├── .changeset ├── README.md └── config.json ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── pr.yml │ └── release.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── LICENSE.md ├── README.md ├── husky.config.js ├── nx.json ├── package-lock.json ├── package.json ├── packages ├── form-control │ ├── LICENSE.md │ ├── README.md │ ├── demo │ │ ├── async-validator-demo.ts │ │ ├── complex-demo.ts │ │ ├── index.ts │ │ ├── page.css │ │ ├── page.ts │ │ ├── switch.style.ts │ │ └── switch.ts │ ├── index.html │ ├── index.ts │ ├── package.json │ ├── src │ │ ├── FormControlMixin.ts │ │ ├── index.ts │ │ ├── types.ts │ │ └── validators.ts │ ├── tests │ │ ├── asyncValidators.test.ts │ │ ├── delayedValidationTarget.test.ts │ │ ├── lit.test.ts │ │ ├── validation.test.ts │ │ ├── validators.test.ts │ │ └── value.test.ts │ ├── tsconfig.base.json │ ├── tsconfig.build.json │ ├── tsconfig.json │ ├── web-dev-server.config.mjs │ └── web-test-runner.config.mjs └── form-helpers │ ├── LICENSE.md │ ├── README.md │ ├── index.ts │ ├── package.json │ ├── src │ └── index.ts │ ├── tests │ ├── formValues.test.ts │ └── submit.test.ts │ ├── tsconfig.json │ └── web-test-runner.config.mjs └── tsconfig.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.6.3/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "linked": [], 6 | "access": "public", 7 | "baseBranch": "main", 8 | "updateInternalDependencies": "patch", 9 | "ignore": [] 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | insert_final_newline = false 13 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage/ 3 | dist 4 | tsc-dist 5 | stats.html 6 | /packages/**/test-node/**/snapshots 7 | /packages/demoing-storybook/storybook-static/**/* 8 | /packages/**/demo/**/* 9 | /packages/dev-server-hmr/src/patches/**/* 10 | /packages/testing/plugins/**/* 11 | _site/ 12 | _site-dev 13 | docs/_merged_assets/ 14 | docs/_merged_data/ 15 | docs/_merged_includes/ 16 | **/tests/* 17 | *.js 18 | *.d.ts 19 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['simple-import-sort', '@typescript-eslint'], 5 | extends: [ 6 | 'eslint:recommended', 7 | 'plugin:@typescript-eslint/eslint-recommended', 8 | 'plugin:@typescript-eslint/recommended' 9 | ], 10 | rules: { 11 | 'lit/no-useless-template-literals': 'off', 12 | 'consistent-return': 'off', 13 | 'max-classes-per-file': 'off', 14 | 'no-prototype-builtins': 'off' 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: PR 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | build-lint-test: 7 | # Prevents changesets action from creating a PR on forks 8 | if: github.repository == 'open-wc/form-participation' 9 | name: Build, Lint, Test 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout Repo 13 | uses: actions/checkout@master 14 | with: 15 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits 16 | fetch-depth: 0 17 | 18 | - name: Setup Node 20.x 19 | uses: actions/setup-node@master 20 | with: 21 | node-version: 20.x 22 | registry-url: 'https://registry.npmjs.org' 23 | 24 | - name: Install Dependencies 25 | run: npm ci 26 | 27 | - name: Build packages 28 | run: npm run build 29 | 30 | - name: Lint 31 | run: npm run lint:ci 32 | 33 | - name: Test 34 | run: npm test 35 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | # Prevents changesets action from creating a PR on forks 11 | if: github.repository == 'open-wc/form-participation' 12 | name: Release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Repo 16 | uses: actions/checkout@master 17 | with: 18 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits 19 | fetch-depth: 0 20 | 21 | - name: Setup Node 20.x 22 | uses: actions/setup-node@master 23 | with: 24 | node-version: 20.x 25 | registry-url: 'https://registry.npmjs.org' 26 | 27 | - name: Install Dependencies 28 | run: npm ci 29 | 30 | - name: Build packages 31 | run: npm run build 32 | 33 | - name: Lint 34 | run: npm run lint:ci 35 | 36 | - name: Test 37 | run: npm test 38 | 39 | - name: Version 40 | run: npm run changeset version 41 | 42 | - name: Publish 43 | run: npm run changeset publish 44 | env: 45 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 46 | 47 | - name: Prep Commit Back 48 | # only add modifications (changelog, package.json) and deletions (changeset .md) 49 | run: | 50 | git config user.email "you@example.com" 51 | git config user.name "Open Web Components" 52 | git add -u 53 | git commit -m "[skip ci] Automated version bump" --author "Open WC <>" 54 | git push origin main 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## editors 2 | /.idea 3 | /.vscode 4 | 5 | ## system files 6 | .DS_Store 7 | 8 | ## code coverage folders 9 | coverage/ 10 | 11 | ## generated types 12 | packages/*/types 13 | tsconfig.tsbuildinfo 14 | 15 | ## npm 16 | node_modules 17 | !packages/es-dev-server/test/**/node_modules 18 | !packages/scoped-elements/demo/**/node_modules 19 | npm-debug.log 20 | yarn-error.log 21 | 22 | ## lerna 23 | lerna-debug.log 24 | 25 | ## temp folders 26 | /.tmp/ 27 | 28 | ## build hp 29 | /_site/ 30 | /_site-dev/ 31 | 32 | ## lock files in packages we do not need to save 33 | packages/*/yarn.lock 34 | 35 | ## build output 36 | lib 37 | dist 38 | build-stats.json 39 | stats.html 40 | .rpt2_cache 41 | storybook-static 42 | /packages/testing/plugins 43 | packages/**/*.js 44 | packages/**/*.js.map 45 | packages/**/*.d.ts 46 | packages/**/*.ts.map 47 | **/*.tsbuildinfo 48 | 49 | 50 | ## browserstack 51 | local.log 52 | 53 | ## generated codelabs 54 | docs/.vuepress/public/codelabs 55 | 56 | # Local Netlify folder 57 | .netlify 58 | 59 | ## Rocket ignore files (need to be the full relative path to the folders) 60 | docs/_merged_data/ 61 | docs/_merged_assets/ 62 | docs/_merged_includes/ 63 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage/ 3 | lib 4 | CHANGELOG.md 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "embeddedLanguageFormatting": "off", 3 | "htmlWhitespaceSensitivity": "strict", 4 | "printWidth": 120, 5 | "singleQuote": true, 6 | "trailingComma": "none" 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 open-wc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Form Participation 2 | 3 | The ability to create custom form elements that extend the behavior and UI/UX of native form elements is an area that has a huge need for standardization. The packages in this monorepo enable web component authors seeking to create custom element inputs a standardized approach to do so. 4 | 5 | ### Packages 6 | 7 | - [`@open-wc/form-control`](./packages/form-control) : A `FormControlMixin` that enables creating a web component that functions like a native form element in a standardized way 8 | - [`@open-wc/form-helpers`](./packages/form-helpers) Form control related utilities such as implicit submit and form value parsing. 9 | -------------------------------------------------------------------------------- /husky.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | hooks: { 3 | 'pre-commit': 'lint-staged' 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "nx/presets/npm.json", 3 | "targetDetails": { 4 | "build": { 5 | "dependsOn": [ 6 | "^build" 7 | ] 8 | } 9 | }, 10 | "tasksRunnerOptions": { 11 | "default": { 12 | "runner": "nx/tasks-runners/default", 13 | "options": { 14 | "cacheableOperations": [ 15 | "build", 16 | "test" 17 | ] 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@open-wc/form-participation-root", 3 | "version": "1.0.0", 4 | "private": true, 5 | "main": "index.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "build": "nx run-many --target build", 11 | "changeset": "changeset", 12 | "lint": "eslint --fix .", 13 | "lint:ci": "eslint .", 14 | "test": "nx run-many --target test --parallel 1" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/open-wc/form-participation.git" 19 | }, 20 | "author": "Caleb D. Williams ", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/open-wc/form-participation/issues" 24 | }, 25 | "homepage": "https://github.com/open-wc/form-participation#readme", 26 | "devDependencies": { 27 | "@changesets/cli": "^2.18.1", 28 | "@nx/devkit": "^16.9.1", 29 | "@nx/workspace": "^16.9.1", 30 | "@open-wc/eslint-config": "^7.0.0", 31 | "@open-wc/testing": "^3.0.3", 32 | "@types/sinon": "^10.0.6", 33 | "@typescript-eslint/eslint-plugin": "^5.9.1", 34 | "@typescript-eslint/parser": "^5.9.1", 35 | "@web/dev-server": "^0.1.28", 36 | "@web/dev-server-esbuild": "^0.2.16", 37 | "@web/test-runner": "^0.13.22", 38 | "@web/test-runner-commands": "^0.5.13", 39 | "@web/test-runner-playwright": "^0.8.8", 40 | "construct-style-sheets-polyfill": "^3.0.5", 41 | "element-internals-polyfill": "^1.1.19", 42 | "eslint": "^7.32.0", 43 | "eslint-config-prettier": "^8.3.0", 44 | "eslint-plugin-simple-import-sort": "^7.0.0", 45 | "husky": "^7.0.4", 46 | "lint-staged": "^12.1.2", 47 | "lit": "^2.0.2", 48 | "nx": "^16.9.1", 49 | "prettier": "^2.5.1", 50 | "sinon": "^12.0.1", 51 | "typescript": "^4.5.4" 52 | }, 53 | "workspaces": [ 54 | "packages/*" 55 | ], 56 | "lint-staged": { 57 | "**/*": [ 58 | "eslint --fix", 59 | "prettier --write" 60 | ] 61 | }, 62 | "resolutions": { 63 | "source-map": "^0.8.0-beta.0" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/form-control/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 open-wc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /packages/form-control/README.md: -------------------------------------------------------------------------------- 1 | # @open-wc/form-control 2 | 3 | A standardized mixin for creating form-associated custom elements using a standardized validation function pattern. 4 | 5 | ## Install 6 | 7 | ```sh 8 | # npm 9 | npm install @open-wc/form-control 10 | 11 | # yarn 12 | yarn add @open-wc/form-control 13 | ``` 14 | 15 | ## Usage 16 | 17 | After importing, create a web component class that extends the mixin, and provide your desired base class as the input to `FormControlMixin`. 18 | 19 | ### Public API 20 | 21 | The `FormControlMixin` adds several methods to the element's prototype 22 | 23 | #### setValue 24 | 25 | `setValue(value: FormData) => void` 26 | 27 | The `setValue` method takes an argument of `FormValue` which is equal to `string | FormData | FileData | null`. This the value passed into this method will be attached to the element's parent form using the element's `name` attribute. 28 | 29 | A common use case for this would look something like the following 30 | 31 | ```typescript 32 | import { FormControlMixin } from '@open-wc/form-control'; 33 | 34 | class CustomFormControl extends FormControlMixin(HTMLElement) { 35 | private _value: string; 36 | 37 | set value(newValue: string) { 38 | this._value = newValue; 39 | this.setValue(newValue); 40 | } 41 | 42 | get value(): string { 43 | return this._value; 44 | } 45 | } 46 | ``` 47 | 48 | The above example—using `HTMLElement` as the mixed class—will now respond to changes to the element's value property by attaching the element's value to its associated form. 49 | 50 | Using `LitElement` the above example might look like: 51 | 52 | ```typescript 53 | import { LitElement } from 'lit'; 54 | import { property } from 'lit/decorators.js'; 55 | import { FormControlMixin } from '@open-wc/form-control'; 56 | 57 | export class CustomControlLit extends FormControlMixin(LitElement) { 58 | @property() 59 | value: string = ''; 60 | 61 | updated(changedProperties: Map): void { 62 | if (changedProperties.has('value')) { 63 | this.setValue(this.value); 64 | } 65 | } 66 | } 67 | ``` 68 | 69 | #### shouldFormValueUpdate 70 | 71 | `shouldFormValueUpdate() => boolean` 72 | 73 | The `shouldFormValueUpdate` method is called internally before a call to `ElementInternals.prototype.setFormValue`. If the method returns `true` the value passed ot `setValue` will be added to the form; otherwise an empty value will be passed. This is useful for emulating behavior like that of a radio or checkbox. 74 | 75 | ```typescript 76 | import { FormControlMixin } from '@open-wc/form-control'; 77 | 78 | export class CustomControl extends FormControlMixin(HTMLElement) { 79 | _checked = false; 80 | _value = ''; 81 | 82 | set checked(newChecked: boolean) { 83 | this._checked = newChecked; 84 | this.setValue(this.value); 85 | } 86 | 87 | get checked(): boolean { 88 | return this._checked; 89 | } 90 | 91 | set value(newValue: string) { 92 | this._value = newValue; 93 | this.setValue(newValue); 94 | } 95 | 96 | get value(): string { 97 | return this._value; 98 | } 99 | 100 | shouldFormValueUpdate(): boolean { 101 | return this.checked; 102 | } 103 | } 104 | ``` 105 | 106 | For `LitElement` this example might look like 107 | 108 | ```typescript 109 | import { LitElement } from 'lit'; 110 | import { property } from 'lit/decorators.js'; 111 | import { FormControlMixin } from '@open-wc/form-control'; 112 | 113 | export class CustomControlWithLit extends FormControlMixin(LitElement) { 114 | @property({ type: Boolean }) 115 | checked = false; 116 | 117 | @property() 118 | value = ''; 119 | 120 | shouldFormValueUpdate(): boolean { 121 | return this.checked; 122 | } 123 | 124 | updated(changedValues: Map): void { 125 | if (changedProperties.has('checked') || changedProperties.has('value')) { 126 | this.setValue(this.value); 127 | } 128 | } 129 | } 130 | ``` 131 | 132 | Both of the above examples will update the element's form value when either the `checked` or `value` properties are set. 133 | 134 | #### resetFormControl 135 | 136 | `resetFormControl() => void` 137 | 138 | The `resetFormControl` lifecycle method is called when a control's form is reset either via a button or the `HTMLFormElement.prototype.reset` method. This is a place to clean up the form control's values and settings. For example, if creating a custom checkbox or radio button, you might use this method to restore the element's `checked` state. 139 | 140 | ```typescript 141 | import { FormControlMixin } from '@open-wc/form-control'; 142 | 143 | export class CustomControl extends FormControlMixin(HTMLElement) { 144 | /** Built on from the exampels in setValue and shouldFormValueUpdate */ 145 | 146 | resetFormControl(): void { 147 | this.checked = this.hasAttribute('checked'); 148 | } 149 | } 150 | ``` 151 | 152 | #### validityCallback 153 | 154 | `validityCallback(key: keyof ValidityState) => string | void` 155 | 156 | The `validityCallback` is used to override the controls' validity message for a given Validator key. This has the highest level of priority when setting a validationMessage, so use this method wisely. 157 | 158 | To use this method you must also call the the `FormControlMixin`'s validation API. The following example will be the same for both `HTMLElement` and `LitElement` and assumes the built-in `requiredValidator` is used. 159 | 160 | ```typescript 161 | import { FormControlMixin, requiredValidator } from '@open-wc/form-control'; 162 | 163 | export class CustomControl extends FormControlMixin(HTMLElement) { 164 | static formControlValidators = [requiredValidator]; 165 | 166 | validityCallback(key: keyof ValidityState): boolean { 167 | if (key === 'valueMissing') { 168 | return 'This is a custom error message for valueMissing errors'; 169 | } 170 | } 171 | } 172 | ``` 173 | 174 | #### validationTarget 175 | 176 | The `validationTarget` is required when using the validation API should be an element inside the custom element's shadow root that is capable of receiving focus. Per the DOM spec (and accessibility best practices) the first validation target in source order will receive focus whenever a form is submitted or the element's `requestValidity` method is called. 177 | 178 | In the event a control becomes invalid, this item will be focused on form submit for accessibility purposes. Failure to do so will cause an error to throw. 179 | 180 | This can be a getter or a property: 181 | 182 | ```typescript 183 | import { FormControlMixin, requiredValidator } from '@open-wc/form-control'; 184 | 185 | const template = ` 186 | `; 187 | 188 | export class CustomControl extends FormControlMixin(HTMLElement) { 189 | static formControlValidators = [requiredValidator]; 190 | 191 | constructor() { 192 | super(); 193 | const root = this.attachShadow({ mode: 'open' }); 194 | root.append(template.contents.cloneNode(true)); 195 | } 196 | 197 | get validationTarget(): HTMLInputElment { 198 | return this.shadowRoot.querySelector('input'); 199 | } 200 | } 201 | ``` 202 | 203 | or in Lit you will likely want to use the `query` decorator 204 | 205 | ```typescript 206 | import { html, LitElement, TemplateResult } from 'lit'; 207 | import { query } from 'lit/decorators.js'; 208 | import { FormControlMixin, requiredValidator } from '@open-wc/form-control'; 209 | 210 | export class CustomControl extends FormControlMixin(LitElement) { 211 | static formControlValidators = [requiredValidator]; 212 | 213 | @query('input') 214 | validationTarget: HTMLInputElement; 215 | 216 | render(): TemplateResult { 217 | return html` 218 | `; 219 | } 220 | } 221 | ``` 222 | 223 | #### validationMessageCallback 224 | 225 | `validationMessageCallback(validitionMessage: string): void` 226 | 227 | The `validationMessageCallback` is an opinionated method that is called when the form control mixin believes a displayed validation message should be changed. This will will clear the message on the host focus and on value changes but will re-introduce the message whenever the element becomes blurred again. 228 | 229 | ```typescript 230 | import { FormControlMixin, requiredValidator } from '@open-wc/form-control'; 231 | 232 | export class CustomControl extends FormControlMixin(HTMLElement) { 233 | static formControlValidators = [requiredValidator]; 234 | 235 | constructor() { 236 | super(); 237 | const root = this.attachShadow({ mode: 'open' }); 238 | const span = document.createElement('span'); 239 | root.append(span); 240 | } 241 | 242 | validationMessageCallback(message: string): void { 243 | this.shadowRoot.querySelector('span').innerText = message; 244 | } 245 | } 246 | ``` 247 | 248 | This is a partial example but would attach a message to a span element whenever the mixin assumes it should be attached. This might not meet all use cases. Other access to the validationMessage can be accessed directly on `this.internals.validationMessage`. 249 | 250 | A more complete example in Lit might look something like 251 | 252 | ```typescript 253 | import { css, html, LitElement, TemplateResult } from 'lit'; 254 | import { property, query } from 'lit/decorators.js'; 255 | import { live } from 'lit/directives/live.js'; 256 | import { FormControlMixin, requiredValidator } from '@open-wc/form-control'; 257 | 258 | export class CustomControl extends FormControlMixin(LitElement) { 259 | static formControlValidator = [requiredValidator]; 260 | static styles = css` 261 | /** Custom styles here potentially for a design system */ 262 | `; 263 | 264 | @property({ type: Boolean, reflect: true }) 265 | required = false; 266 | 267 | @property() 268 | value = ''; 269 | 270 | @property() 271 | validationMessage = ''; 272 | 273 | @query('input') 274 | validationTarget: HTMLInputElement; 275 | 276 | render(): TemplateResult { 277 | return html` 278 | 279 | 287 | ${this.validationMessage}`; 288 | } 289 | 290 | validationMessageCallback(message: string): void { 291 | this.validationMessage = message; 292 | } 293 | 294 | updated(changedProperties: Map): void { 295 | if (changedProperties.has('value')) { 296 | this.setValue(this.value); 297 | } 298 | } 299 | 300 | private _onChange(event: Event & { target: HTMLInputElement}): void { 301 | this.value = event.target.value; 302 | } 303 | } 304 | ``` 305 | 306 | ### ElementInternals 307 | 308 | This library makes use of [ElementInternals](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals) features. As of the time of writing `ElementInternals` features are fully supported in Chrome, partially supported in Firefox and being strongly considered by Webkit. 309 | 310 | In order to make these features work in all browsers you will need to include the [element-internals-polyfill](https://www.npmjs.com/package/element-internals-polyfill). Refer to the `element-internals-polyfill` documentation for installation and usage instructions. 311 | 312 | ## Validation 313 | 314 | The `FormControlMixin` includes an API for constraint validations and a set of common validators for validity states like `required`, `minlength`, `maxlength` and `pattern`. 315 | 316 | ```typescript 317 | import { LitElement, html } from 'lit'; 318 | import { customElement, query, property } from 'lit/decorators.js' 319 | import { live } from 'lit/directives/live.js'; 320 | 321 | import { FormControlMixin, requiredValidator } from '@open-wc/form-control'; 322 | 323 | @customElement('demo-form-control') 324 | class DemoFormControl extends FormControlMixin(LitElement) { 325 | static formControlValidators = [requiredValidator]; 326 | 327 | @property({ type: Boolean, reflect: true }) 328 | required = false; 329 | 330 | @property({ type: String }) 331 | value = ''; 332 | 333 | render() { 334 | return html` 335 | 336 | 341 | `; 342 | } 343 | 344 | updated(changedProperties: Map): void { 345 | if (changedProperties.has('value')) { 346 | this.setValue(this.value); 347 | } 348 | } 349 | 350 | #onInput({ target }: { target: HTMLInputElement }): void { 351 | this.value = target.value; 352 | } 353 | } 354 | ``` 355 | 356 | Including the `requiredValidator` adds a validation function attached to the `valueMissing` validity state to the component instance. 357 | 358 | > Note, this does require the element's prototype to actually have a `required` property defined. 359 | 360 | ### Validators 361 | 362 | This package contains a few standardized validators, though more could be added for various unconsidered use cases. So far, there are validators for: 363 | 364 | - **required** (valueMissing) : fails when the element's `value` is falsy while the element's `required` property equals `true` 365 | - **minlength** (rangeUnderflow) : fails if the length of the element's value is less than the defined `minLength` 366 | - **maxlength** (rangeOverflow) : fails if the length of the element's value is greater than the defined `maxLength` 367 | - **programmatic** (customError) : Allows setting a completely custom error state and message as a string. 368 | 369 | If you have an idea for another standardized validator, please [Submit an issue](/../../issues) (preferred so that we can discuss) or [Send a PR](/../../pulls) with your ideas. 370 | 371 | ### Creating a custom validator 372 | 373 | It is possible to create a custom validator object using the `Validator` interface: 374 | 375 | ```typescript 376 | export interface Validator { 377 | attribute?: string; 378 | key?: string; 379 | message: string | ((instance: any, value: any) => string); 380 | isValid(instance: HTMLElement, value: any): boolean; 381 | } 382 | ``` 383 | 384 | | Property | Type | Required | Description | 385 | | --------- | --------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 386 | | attribute | `string` | true | If defined, adds the specified attribute to the element's `observedAttributes` and the validator will run when the provided attribute changed | 387 | | key | `string` | - | String name of one of the fields in the `ValidityState` object to override on validator change. If `key` is not set, it is assumed to be `customError`. | 388 | | message | `string \| ((instance: any, value: any) => string)` | true | When set to a string, the `message` will equal the string passed in. If set to a function, the validation message will be the returned value from the callback. The message callback takes two arguments, the element instance and the control's form value (not the element's value property) | 389 | | isValid | `(instance: any, value: any) => boolean` | true | When `isValid` returns `true`, the validator is considered to be in a valid state. When the `isValid` callback returns `false` the validator is considered to be in an invalid state. | 390 | 391 | #### Example custom validator 392 | 393 | So, a validator that would key off an `error` attribute to attach a programatic validation to an input might look like this: 394 | 395 | ```typescript 396 | export const programaticValidator: Validator = { 397 | attribute: 'error', 398 | message(instance: HTMLElement & { error: string }): string { 399 | return instance.error; 400 | }, 401 | isValid(instance: HTMLElement & { error: string }): boolean { 402 | return !instance.error; 403 | } 404 | }; 405 | ``` 406 | 407 | Validators come in two varieties: synchronous and asynchronous. The most common pattern for validators are synchronous. This means that the `isValid` method directly returns the validity of the object in real time. Asynchronous validators, on the other hand return a `Promise` indicating the validity state of the validator where a `Promise` value has no effect on the validity status. 408 | 409 | Because its possible for an asynchronous validator to run multiple times in rapid succession, the `Validator.isValid` method provides a third argument called an [abort signal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). This signal will be aborted during the next validatation run. This allows developers to respond to frequent validation requests and cancel any long-running validatons. 410 | 411 | Let's look at an naive example of an async validator: 412 | 413 | ```typescript 414 | import { AsyncValidator, FormValue } from '@open-wc/form-control'; 415 | 416 | const sleepValidator: AsyncValidator = { 417 | message: 'Hello world', 418 | isValid(instance: AsyncValidatorDemo, value: FormValue, signal: AbortSignal): Promise { 419 | if (signal.aborted) { 420 | return Promise.resolve(); 421 | } 422 | 423 | return new Promise((resolve) => { 424 | const id = setTimeout(() => { 425 | resolve(value === 'foo'); 426 | }, 2000); 427 | 428 | signal.addEventListener('abort', () => { 429 | clearTimeout(id); 430 | console.log(`abort for value ${value}`); 431 | resolve(); 432 | }); 433 | }); 434 | } 435 | } 436 | ``` 437 | 438 | Here we can see the `isValid` method returns a `Promise` object that evaluates the `value` and returns sets the control to valid if and only if the value is exactly equal to the string `'foo'`. This validator creates some asynchronous behavior by utilizing the `setTimeout` function which will wait at minimum two seconds before finally validating the control. 439 | 440 | When the `signal` dispatches an abort event, the validator cancels its the timeout, logs some information to the developer about the validator not running for the current value and resolves. This will only happen when the validation cycle is kicked off again before all validators in the chain finish. 441 | 442 | For more information on `AbortController` and `AbortSignal`, see this post from [@samthor](https://twitter.com/samthor) titled [_AbortController is your friend_](https://whistlr.info/2022/abortcontroller-is-your-friend/). 443 | 444 | ### Validating a control as a group 445 | 446 | It is possible to evaluate the validity of a set of controls as a group (similar to a radio button) where if one control in the group doesn't meet some criteria the validation fails. To enable this behavior, you need to set the components static property `formControlValidationGroup` to `true`. The following example emulates how the native `required` property interacts with `input[type="radio"]`. 447 | 448 | ```typescript 449 | import { FormControlMixin } from '@open-wc/form-control'; 450 | import { LitElement } from 'lit'; 451 | import { customElement } from 'lit/decorators.js'; 452 | 453 | @customElement('fc-radio') 454 | class FcRadio extends FormControlMixin(LitElement) { 455 | /** Enable group validation behavior */ 456 | static formControlValidationGroup = true; 457 | 458 | /** Custom validator logic */ 459 | static formControlValidators = [ 460 | { 461 | attribute: 'required', 462 | key: 'valueMissing', 463 | message: 'Please select an item', 464 | isValid(instance, value) { 465 | const rootNode = instance.getRootNode(); 466 | const selector = `${instance.localName}[name="${instance.getAttribute('name')}"]`; 467 | const group = Array.from(rootNode.querySelectorAll(selector)); 468 | const isChecked = group.some(instance => instance.checked); 469 | const isRequired = group.some(instance => instance.required); 470 | 471 | if (isRequired && !isChecked) { 472 | return false; 473 | } 474 | 475 | return true; 476 | } 477 | } 478 | ]; 479 | } 480 | ``` -------------------------------------------------------------------------------- /packages/form-control/demo/async-validator-demo.ts: -------------------------------------------------------------------------------- 1 | import { css, LitElement, html, TemplateResult, PropertyValues } from 'lit'; 2 | import { customElement, property, query } from 'lit/decorators.js'; 3 | import { live } from 'lit/directives/live.js'; 4 | import { AsyncValidator, FormControlMixin, FormValue, requiredValidator } from '../src'; 5 | 6 | const sleepValidator: AsyncValidator = { 7 | message: 'Hello world', 8 | isValid(instance: AsyncValidatorDemo, value: FormValue, signal: AbortSignal): Promise { 9 | if (signal.aborted) { 10 | return Promise.resolve(); 11 | } 12 | 13 | return new Promise((resolve) => { 14 | const id = setTimeout(() => { 15 | resolve(value === 'foo'); 16 | console.log(`Evaluated ${value} for validity update`); 17 | }, 2000); 18 | 19 | signal.addEventListener('abort', () => { 20 | clearTimeout(id); 21 | console.log(`abort for value ${value}`); 22 | resolve(); 23 | }); 24 | }); 25 | } 26 | }; 27 | 28 | const onBlurValidator: AsyncValidator = { 29 | key: 'badInput', 30 | message: 'Length must be a multiple of two', 31 | isValid(instance: AsyncValidatorDemo, value: string, signal: AbortSignal): Promise { 32 | if (signal.aborted) { 33 | return Promise.resolve(); 34 | } 35 | 36 | return new Promise(resolve => { 37 | instance.validationTarget?.addEventListener('blur', () => { 38 | resolve(value!.length % 2 === 0); 39 | }, { signal }); 40 | }); 41 | } 42 | }; 43 | 44 | @customElement('async-validator') 45 | export class AsyncValidatorDemo extends FormControlMixin(LitElement) { 46 | static styles = css` 47 | :host { 48 | display: block; 49 | } 50 | :host(:invalid) input { 51 | background: tomato; 52 | }`; 53 | 54 | static formControlValidators = [requiredValidator, sleepValidator]; 55 | 56 | @property({ type: Boolean, reflect: true }) 57 | required = false; 58 | 59 | @property() 60 | value = ''; 61 | 62 | @query('input') 63 | validationTarget!: HTMLInputElement; 64 | 65 | render(): TemplateResult { 66 | return html``; 71 | } 72 | 73 | formResetCallback(): void { 74 | this.value = ''; 75 | } 76 | 77 | private _onInput(event: KeyboardEvent & { target: HTMLInputElement }): void { 78 | this.value = event.target.value; 79 | } 80 | 81 | protected updated(changed: PropertyValues): void { 82 | if (changed.has('value')) { 83 | this.setValue(this.value); 84 | } 85 | } 86 | 87 | async valueChangedCallback(value: FormValue): Promise { 88 | await this.validationComplete; 89 | console.log('validations complete', value); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /packages/form-control/demo/complex-demo.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from 'lit'; 2 | import { customElement, property, query } from 'lit/decorators.js'; 3 | import { FormControlMixin, FormValue, maxLengthValidator, minLengthValidator, patternValidator, programmaticValidator, requiredValidator } from '../src'; 4 | import { submit } from '@open-wc/form-helpers'; 5 | import { live } from 'lit/directives/live.js'; 6 | 7 | export const commonSheet = css`:host { 8 | display: flex; 9 | flex-flow: column; 10 | font-family: Helvetica, Arial, sans-serif; 11 | font-size: 16px; 12 | gap: 4px; 13 | } 14 | label { 15 | font-weight: 600; 16 | } 17 | span { 18 | font-size: 14px; 19 | } 20 | input { 21 | border-radius: 4px; 22 | border: 1px solid #121212; 23 | font-size: 16px; 24 | padding: 4px; 25 | } 26 | /** Default invalid state */ 27 | :host(:--show-error) input { 28 | border-color: red; 29 | } 30 | :host(:--show-error) span { 31 | color: red; 32 | } 33 | 34 | /** Polyfilled invalid state */ 35 | :host([state--show-error]) input { 36 | border-color: red; 37 | } 38 | :host([state--show-error]) span { 39 | color: red; 40 | } 41 | `; 42 | 43 | abstract class ComplexFormControl extends FormControlMixin(LitElement) { 44 | static get formControlValidators() { 45 | return [ 46 | requiredValidator, 47 | programmaticValidator, 48 | maxLengthValidator, 49 | minLengthValidator, 50 | patternValidator 51 | ]; 52 | } 53 | 54 | @property({ type: Boolean, reflect: true }) 55 | required = false; 56 | 57 | @property({ type: Number, attribute: 'minlength' }) 58 | minLength: number|null = null; 59 | 60 | @property({ type: Number, attribute: 'maxlength' }) 61 | maxLength: number|null = null; 62 | 63 | @property({ type: String, reflect: true }) 64 | pattern: string|null = null; 65 | 66 | @property({ reflect: false }) 67 | validationMessage = ''; 68 | 69 | @property() 70 | value = ''; 71 | 72 | constructor() { 73 | super(); 74 | this.addEventListener('keydown', this.onKeydown); 75 | this.addEventListener('invalid', this.onInvalid); 76 | } 77 | 78 | disconnectedCallback(): void { 79 | super.disconnectedCallback(); 80 | this.removeEventListener('keydown', this.onKeydown); 81 | this.removeEventListener('invalid', this.onInvalid); 82 | } 83 | 84 | private onInvalid = (event: Event): void => { 85 | event.preventDefault(); 86 | this.validationTarget!.focus(); 87 | } 88 | 89 | private onKeydown = (event: KeyboardEvent): void => { 90 | if (event.code === 'Enter') { 91 | if (this.form) { 92 | submit(this.form); 93 | } 94 | } 95 | } 96 | 97 | validationMessageCallback(message: string): void { 98 | this.validationMessage = message; 99 | } 100 | 101 | protected updated(changed: Map): void { 102 | if (changed.has('value')) { 103 | this.setValue(this.value); 104 | } 105 | } 106 | } 107 | 108 | @customElement('complex-demo') 109 | export class ComplexDemo extends ComplexFormControl { 110 | static styles = commonSheet; 111 | 112 | @query('input') 113 | validationTarget!: HTMLInputElement; 114 | 115 | render() { 116 | return html` 117 | 125 | ${this.showError ? this.validationMessage : 'Value must end with the string "lit"'}`; 126 | } 127 | 128 | onInput({ target }: Event & { target: HTMLInputElement }) { 129 | this.value = target.value; 130 | } 131 | 132 | updated(changed: Map): void { 133 | if (changed.has('value')) { 134 | this.setValue(this.value); 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /packages/form-control/demo/index.ts: -------------------------------------------------------------------------------- 1 | import './complex-demo'; 2 | import './switch'; 3 | import './async-validator-demo'; 4 | import { css, html, LitElement } from 'lit'; 5 | import { customElement, property, query } from 'lit/decorators.js'; 6 | import { FormControlMixin, Validator } from '../src'; 7 | 8 | @customElement('test-el') 9 | class TestEl extends FormControlMixin(LitElement) { 10 | static styles = css`:host {display:block}` 11 | static formControlValidators: Validator[] = [{ 12 | key: 'customError', 13 | message: 'Oops', 14 | isValid(instance: TestEl, value: string): boolean { 15 | console.log(value === 'foo') 16 | return value === 'foo' 17 | } 18 | }]; 19 | 20 | static shadowRootOptions: ShadowRootInit = { 21 | mode: 'open', 22 | delegatesFocus: true 23 | } 24 | 25 | @query('input') 26 | validationTarget!: HTMLInputElement; 27 | 28 | @property() 29 | error = '' 30 | 31 | firstUpdated() { 32 | this.setValue(''); 33 | this.tabIndex = 0; 34 | } 35 | 36 | render() { 37 | return html`${this.error}` 38 | } 39 | 40 | onInput(event: Event & { target: HTMLInputElement }) { 41 | this.setValue(event.target.value); 42 | } 43 | 44 | validationMessageCallback(message: string): void { 45 | this.error = message; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/form-control/demo/page.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: ButtonFace; 3 | } 4 | form { 5 | background: #ffffff; 6 | border-radius: 4px; 7 | display: flex; 8 | flex-flow: column; 9 | gap: 24px; 10 | margin: 16px auto; 11 | padding: 16px; 12 | max-width: 400px; 13 | } 14 | form h1 { 15 | font-family: Helvetica, Arial, sans-serif; 16 | margin: 0; 17 | } 18 | form button { 19 | font-size: 16px; 20 | padding: 4px 8px; 21 | } 22 | form .buttons { 23 | align-self: flex-end; 24 | display: flex; 25 | gap: 8px; 26 | } 27 | .form-field { 28 | align-items: center; 29 | display: flex; 30 | font-family: Arial, Helvetica, sans-serif; 31 | gap: 8px; 32 | } 33 | -------------------------------------------------------------------------------- /packages/form-control/demo/page.ts: -------------------------------------------------------------------------------- 1 | import 'element-internals-polyfill'; 2 | 3 | const form = document.getElementById('form') as HTMLFormElement; 4 | const litControl = document.querySelector('lit-control'); 5 | 6 | form!.addEventListener('submit', (event: Event) => { 7 | event.preventDefault(); 8 | const data = new FormData(event.target as HTMLFormElement); 9 | console.log({ 10 | switch: data.get('switch'), 11 | complex: data.get('complex-demo'), 12 | async: data.get('async') 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /packages/form-control/demo/switch.style.ts: -------------------------------------------------------------------------------- 1 | import 'construct-style-sheets-polyfill'; 2 | 3 | const sheet = new CSSStyleSheet(); 4 | sheet.replace(` 5 | :host { 6 | background: ButtonFace; 7 | border: 1px solid #343434; 8 | border-radius: 999999px; 9 | cursor: pointer; 10 | display: inline-block; 11 | height: 24px; 12 | position: relative; 13 | transition: 0.1s ease-in color; 14 | width: 48px; 15 | } 16 | :host::after { 17 | aspect-ratio: 1; 18 | background: #ffffff; 19 | border: 1px solid #343434; 20 | border-radius: 50%; 21 | content: ""; 22 | display: block; 23 | height: calc(100% - 4px); 24 | transition: 0.1s ease-in all; 25 | position: absolute; 26 | top: 1px; 27 | left: 1px; 28 | } 29 | :host(:not(:--checked):hover), :host(:not(:--checked):focus) { 30 | background: #cccccc; 31 | } 32 | :host(:not([state--checked]):hover), :host(:not([state--checked]):focus) { 33 | background: #cccccc; 34 | } 35 | :host(:not(:--checked):active) { 36 | background: #bbbbbb; 37 | } 38 | :host(:not([state--checked]):active) { 39 | background: #bbbbbb; 40 | } 41 | :host(:hover)::after, :host(:focus)::after { 42 | background: #f6f6f6; 43 | } 44 | :host(:active)::after { 45 | background: #eeeeee; 46 | } 47 | :host(:--checked) { 48 | background: ForestGreen; 49 | } 50 | :host([state--checked]) { 51 | background: ForestGreen; 52 | } 53 | :host(:--checked:hover) { 54 | background: Green; 55 | } 56 | :host([state--checked]:hover) { 57 | background: Green; 58 | } 59 | :host(:--checked:focus) { 60 | background: Green; 61 | } 62 | :host([state--checked]:focus) { 63 | background: Green; 64 | } 65 | :host(:--checked:active) { 66 | background: DarkGreen; 67 | } 68 | :host([state--checked]:active) { 69 | background: DarkGreen; 70 | } 71 | :host(:--checked)::after { 72 | left: calc(100% - 24px); 73 | } 74 | :host([state--checked])::after { 75 | left: calc(100% - 24px); 76 | } 77 | @media (prefers-reduced-motion: reduce) { 78 | :host::after { 79 | transition: none; 80 | } 81 | }`); 82 | export default sheet; 83 | -------------------------------------------------------------------------------- /packages/form-control/demo/switch.ts: -------------------------------------------------------------------------------- 1 | import { LitElement } from 'lit'; 2 | import { customElement, property } from 'lit/decorators.js'; 3 | import { FormControlMixin } from '../src'; 4 | import styles from './switch.style'; 5 | 6 | @customElement('demo-switch') 7 | export class DemoSwitch extends FormControlMixin(LitElement) { 8 | static styles: CSSStyleSheet = styles; 9 | 10 | @property({ type: Boolean, reflect: false }) 11 | checked = false; 12 | 13 | @property({ type: String }) 14 | value: string = ''; 15 | 16 | protected firstUpdated(_changedProperties: Map): void { 17 | this.checked = this.hasAttribute('checked'); 18 | this.addEventListener('click', this.#onClick); 19 | this.addEventListener('keypress', this.#onKeypress); 20 | this.setAttribute('role', 'switch'); 21 | this.internals.ariaChecked = this.checked.toString(); 22 | this.setAttribute('tabindex', '0'); 23 | } 24 | 25 | #onClick = (): void => { 26 | const changeEvent = new Event('change', { 27 | bubbles: true 28 | }); 29 | this.checked = !this.checked; 30 | this.internals.ariaChecked = this.checked.toString(); 31 | this.dispatchEvent(changeEvent); 32 | }; 33 | 34 | #onKeypress = (event: KeyboardEvent): void => { 35 | if (['Enter', 'Space'].includes(event.code)) { 36 | this.#onClick(); 37 | } 38 | }; 39 | 40 | shouldFormValueUpdate(): boolean { 41 | return this.checked === true; 42 | } 43 | 44 | resetFormControl(): void { 45 | this.checked = this.hasAttribute('checked'); 46 | } 47 | 48 | protected updated(changed: Map): void { 49 | if (changed.has('value') || changed.has('checked')) { 50 | this.setValue(this.value); 51 | } 52 | if (changed.has('checked')) { 53 | this.internals.states[this.checked ? 'add' : 'delete']('--checked'); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/form-control/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | FormControl demo page 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |

FormControlMixin demo

13 | 14 | 19 | Example of how to implement a complex control 20 | 21 | 22 |
23 | 24 | 25 |
26 | 27 |
28 | 29 |
30 | 31 |
32 | 33 | 34 |
35 |
36 | 37 | 38 | 39 | 40 |
41 | 42 | 43 |
44 | 45 | 46 | -------------------------------------------------------------------------------- /packages/form-control/index.ts: -------------------------------------------------------------------------------- 1 | export * from './src/index.js'; 2 | -------------------------------------------------------------------------------- /packages/form-control/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@open-wc/form-control", 3 | "version": "1.0.0", 4 | "description": "Base class for creating form-participating custom elements", 5 | "main": "index.js", 6 | "module": "index.js", 7 | "type": "module", 8 | "exports": { 9 | ".": "./index.js", 10 | "./FormControlMixin.js": "./src/FormControlMixin.js", 11 | "./validators.js": "./src/validators.js", 12 | "./types": "./src/types.js" 13 | }, 14 | "files": [ 15 | "src/*.js", 16 | "src/*.d.ts", 17 | "src/*.js.map", 18 | "index.js", 19 | "*.d.ts", 20 | "*.js.map" 21 | ], 22 | "scripts": { 23 | "build": "tsc --project tsconfig.build.json", 24 | "build:watch": "tsc --project tsconfig.build.json --watch", 25 | "start": "web-dev-server --node-resolve --watch", 26 | "test": "web-test-runner tests/*.test.ts --node-resolve --coverage", 27 | "test:playwright": "web-test-runner tests/*.test.ts --node-resolve --playwright --browsers chromium firefox webkit" 28 | }, 29 | "contributors": [ 30 | "Caleb D. Williams ", 31 | "Michael Warren " 32 | ], 33 | "license": "MIT", 34 | "sideEffects": false 35 | } 36 | -------------------------------------------------------------------------------- /packages/form-control/src/FormControlMixin.ts: -------------------------------------------------------------------------------- 1 | import { Constructor, CustomValidityState, FormControlInterface, FormValue, IControlHost, validationMessageCallback, Validator } from './types.js'; 2 | 3 | export function FormControlMixin< 4 | TBase extends Constructor & { observedAttributes?: string [] } 5 | >(SuperClass: TBase) { 6 | class FormControl extends SuperClass { 7 | /** Wires up control instances to be form associated */ 8 | static get formAssociated(): boolean { 9 | return true; 10 | } 11 | 12 | /** 13 | * A list of Validator objects that will be evaluated when a control's form 14 | * value is modified or optionally when a given attribute changes. 15 | * 16 | * When a Validator's callback returns false, the entire form control will 17 | * be set to an invalid state. 18 | */ 19 | declare static formControlValidators: Validator[]; 20 | 21 | /** 22 | * If set to true the control described should be evaluated and validated 23 | * as part of a group. Like a radio, if any member of the group's validity 24 | * changes the the other members should update as well. 25 | */ 26 | declare static formControlValidationGroup: boolean; 27 | 28 | private static get validators(): Validator[] { 29 | return this.formControlValidators || []; 30 | } 31 | 32 | /** 33 | * Allows the FormControl instance to respond to Validator attributes. 34 | * For instance, if a given Validator has a `required` attribute, that 35 | * validator will be evaluated whenever the host's required attribute 36 | * is updated. 37 | */ 38 | static get observedAttributes(): string[] { 39 | const validatorAttributes = this.validators.map((validator) => validator.attribute).flat(); 40 | 41 | const observedAttributes = super.observedAttributes || []; 42 | 43 | /** Make sure there are no duplicates inside the attributes list */ 44 | const attributeSet = new Set([...observedAttributes, ...validatorAttributes]); 45 | return [...attributeSet] as string[]; 46 | } 47 | 48 | /** 49 | * Return the validator associated with a given attribute. If no 50 | * Validator is associated with the attribute, it will return null. 51 | */ 52 | static getValidator(attribute: string): Validator | null { 53 | return this.validators.find((validator) => validator.attribute === attribute) || null; 54 | } 55 | 56 | /** 57 | * Get all validators that are set to react to a given attribute 58 | * @param {string} attribute - The attribute that has changed 59 | * @returns {Validator[]} 60 | */ 61 | static getValidators(attribute: string): Validator[] | null { 62 | return this.validators.filter(validator => { 63 | if (validator.attribute === attribute || validator.attribute?.includes(attribute)) { 64 | return true; 65 | } 66 | }); 67 | } 68 | 69 | /** The ElementInternals instance for the control. */ 70 | internals = this.attachInternals(); 71 | 72 | /** 73 | * Keep track of if the control has focus 74 | * @private 75 | */ 76 | #focused = false; 77 | 78 | /** 79 | * Exists to control when an error should be displayed 80 | * @private 81 | */ 82 | #forceError = false; 83 | 84 | /** 85 | * Toggles to true whenever the element has been focused. This property 86 | * will reset whenever the control's formResetCallback is called. 87 | * @private 88 | */ 89 | #touched = false; 90 | 91 | /** An internal abort controller for cancelling pending async validation */ 92 | #abortController?: AbortController; 93 | #previousAbortController?: AbortController; 94 | 95 | /** 96 | * Used for tracking if a validation target has been set to manage focus 97 | * when the control's validity is reported 98 | */ 99 | #awaitingValidationTarget = true; 100 | 101 | /** All of the controls within a root with a matching local name and form name */ 102 | get #formValidationGroup(): NodeListOf { 103 | const rootNode = this.getRootNode() as HTMLElement; 104 | const selector = `${this.localName}[name="${this.getAttribute('name')}"]`; 105 | return rootNode.querySelectorAll(selector); 106 | } 107 | 108 | /** 109 | * Acts as a cache for the current value so the value can be re-evaluated 110 | * whenever an attribute changes or on some other event. 111 | */ 112 | #value: FormValue = ''; 113 | 114 | /** 115 | * Set this[touched] and this[focused] 116 | * to true when the element is focused 117 | * @private 118 | */ 119 | #onFocus = (): void => { 120 | this.#touched = true; 121 | this.#focused = true; 122 | this.#shouldShowError(); 123 | }; 124 | 125 | /** 126 | * Reset this[focused] on blur 127 | * @private 128 | */ 129 | #onBlur = (): void => { 130 | this.#focused = false; 131 | 132 | this.#runValidators(this.shouldFormValueUpdate() ? this.#value : ''); 133 | 134 | /** 135 | * Set forceError to ensure error messages persist until 136 | * the value is changed. 137 | */ 138 | if (!this.validity.valid && this.#touched) { 139 | this.#forceError = true; 140 | } 141 | const showError = this.#shouldShowError(); 142 | if (this.validationMessageCallback) { 143 | this.validationMessageCallback(showError ? this.internals.validationMessage : ''); 144 | } 145 | }; 146 | 147 | /** 148 | * For the show error state on invalid 149 | * @private 150 | */ 151 | #onInvalid = (): void => { 152 | if (this.#awaitingValidationTarget && this.validationTarget) { 153 | this.internals.setValidity( 154 | this.validity, 155 | this.validationMessage, 156 | this.validationTarget 157 | ); 158 | this.#awaitingValidationTarget = false; 159 | } 160 | this.#touched = true; 161 | this.#forceError = true; 162 | this.#shouldShowError(); 163 | this?.validationMessageCallback?.(this.showError ? this.internals.validationMessage : ''); 164 | }; 165 | 166 | /** Return a reference to the control's form */ 167 | get form(): HTMLFormElement { 168 | return this.internals.form; 169 | } 170 | 171 | /** 172 | * Will return true if it is recommended that the control shows an internal 173 | * error. If using this property, it is wise to listen for 'invalid' events 174 | * on the element host and call preventDefault on the event. Doing this will 175 | * prevent browsers from showing a validation popup. 176 | */ 177 | get showError(): boolean { 178 | return this.#shouldShowError(); 179 | } 180 | 181 | /** 182 | * Forward the internals checkValidity method 183 | * will return the valid state of the control. 184 | */ 185 | checkValidity(): boolean { 186 | return this.internals.checkValidity(); 187 | } 188 | 189 | /** The element's validity state */ 190 | get validity(): ValidityState { 191 | return this.internals.validity; 192 | } 193 | 194 | /** 195 | * The validation message shown by a given Validator object. If the control 196 | * is in a valid state this should be falsy. 197 | */ 198 | get validationMessage(): string { 199 | return this.internals.validationMessage; 200 | } 201 | 202 | /* eslint-disable @typescript-eslint/no-explicit-any */ 203 | constructor(...args: any[]) { 204 | super(...args); 205 | this.addEventListener?.('focus', this.#onFocus); 206 | this.addEventListener?.('blur', this.#onBlur); 207 | this.addEventListener?.('invalid', this.#onInvalid); 208 | this.setValue(null); 209 | } 210 | 211 | attributeChangedCallback(name: string, oldValue: string, newValue: string): void { 212 | super.attributeChangedCallback?.(name, oldValue, newValue); 213 | 214 | /** 215 | * Check to see if a Validator is associated with the changed attribute. 216 | * If one exists, call control's validate function which will perform 217 | * control validation. 218 | */ 219 | const proto = this.constructor as typeof FormControl; 220 | const validators = proto.getValidators(name); 221 | 222 | if (validators?.length && this.validationTarget) { 223 | this.setValue(this.#value); 224 | } 225 | } 226 | 227 | /** PUBLIC LIFECYCLE METHODS */ 228 | 229 | /** 230 | * Sets the control's form value if the call to `shouldFormValueUpdate` 231 | * returns `true`. 232 | * @param value {FormValue} - The value to pass to the form 233 | */ 234 | setValue(value: FormValue): void { 235 | this.#forceError = false; 236 | this.validationMessageCallback?.(''); 237 | this.#value = value; 238 | const valueShouldUpdate = this.shouldFormValueUpdate(); 239 | const valueToUpdate = valueShouldUpdate ? value : null; 240 | this.internals.setFormValue(valueToUpdate as string); 241 | this.#runValidators(valueToUpdate); 242 | if (this.valueChangedCallback) { 243 | this.valueChangedCallback(valueToUpdate); 244 | } 245 | this.#shouldShowError(); 246 | } 247 | 248 | /** 249 | * This method can be overridden to determine if the control's form value 250 | * should be set on a call to `setValue`. An example of when a user might want 251 | * to skip this step is when implementing checkbox-like behavior, first checking 252 | * to see if `this.checked` is set to a truthy value. By default this returns 253 | * `true`. 254 | */ 255 | shouldFormValueUpdate(): boolean { 256 | return true; 257 | } 258 | 259 | /** Save a reference to the validation complete resolver */ 260 | #validationCompleteResolver?: (value: void | PromiseLike) => void; 261 | 262 | /** When true validation will be pending */ 263 | #isValidationPending = false; 264 | 265 | #validationComplete = Promise.resolve(); 266 | 267 | /** A promise that will resolve when all pending validations are complete */ 268 | get validationComplete(): Promise { 269 | return new Promise(resolve => resolve(this.#validationComplete)); 270 | } 271 | 272 | /** DECLARED INSTANCE METHODS AND PROPERTIES*/ 273 | 274 | /** 275 | * Resets a form control to its initial state 276 | */ 277 | declare resetFormControl: () => void; 278 | 279 | /** 280 | * This method is used to override the controls' validity message 281 | * for a given Validator key. This has the highest level of priority when 282 | * setting a validationMessage, so use this method wisely. 283 | * 284 | * The returned value will be used as the validationMessage for the given key. 285 | * @param validationKey {string} - The key that has returned invalid 286 | */ 287 | declare validityCallback: (validationKey: string) => string | void; 288 | 289 | /** 290 | * Called when the control's validationMessage should be changed 291 | * @param message { string } - The new validation message 292 | */ 293 | declare validationMessageCallback: (message: string) => void; 294 | 295 | /** 296 | * A callback for when the controls' form value changes. The value 297 | * passed to this function should not be confused with the control's 298 | * value property, this is the value that will appear on the form. 299 | * 300 | * In cases where `checked` did not exist on the control's prototype 301 | * upon initialization, this value and the value property will be identical; 302 | * in cases where `checked` is present upon initialization, this will be 303 | * effectively `this.checked && this.value`. 304 | */ 305 | declare valueChangedCallback: (value: FormValue) => void; 306 | 307 | /** 308 | * The element that will receive focus when the control's validity 309 | * state is reported either by a form submission or via API 310 | * 311 | * We use declare since this is optional and we don't particularly 312 | * care how the consuming component implements this (as a field, member 313 | * or getter/setter) 314 | */ 315 | declare validationTarget: HTMLElement | null; 316 | 317 | /** PRIVATE LIFECYCLE METHODS */ 318 | 319 | /** 320 | * Check to see if an error should be shown. This method will also 321 | * update the internals state object with the --show-error state 322 | * if necessary. 323 | * @private 324 | */ 325 | #shouldShowError(): boolean { 326 | if (this.hasAttribute('disabled')) { 327 | return false; 328 | } 329 | 330 | const showError = this.#forceError || (this.#touched && !this.validity.valid && !this.#focused); 331 | 332 | /** 333 | * At the time of writing Firefox doesn't support states 334 | * TODO: Remove when check for states when fully support is in place 335 | */ 336 | if (showError && this.internals.states) { 337 | this.internals.states.add('--show-error'); 338 | } else if (this.internals.states) { 339 | this.internals.states.delete('--show-error'); 340 | } 341 | 342 | return showError; 343 | } 344 | 345 | #runValidators(value: FormValue): void { 346 | const proto = this.constructor as typeof FormControl; 347 | const validity: CustomValidityState = {}; 348 | const validators = proto.validators; 349 | const asyncValidators: Promise[] = []; 350 | const hasAsyncValidators = validators.some((validator) => validator.isValid instanceof Promise) 351 | 352 | if (!this.#isValidationPending) { 353 | this.#validationComplete = new Promise(resolve => { 354 | this.#validationCompleteResolver = resolve 355 | }); 356 | this.#isValidationPending = true; 357 | } 358 | 359 | /** 360 | * If an abort controller exists from a previous validation step 361 | * notify still-running async validators that we are requesting they 362 | * discontinue any work. 363 | */ 364 | if (this.#abortController) { 365 | this.#abortController.abort(); 366 | this.#previousAbortController = this.#abortController; 367 | } 368 | 369 | /** 370 | * Create a new abort controller and replace the instance reference 371 | * so we can clean it up for next time 372 | */ 373 | const abortController = new AbortController(); 374 | this.#abortController = abortController; 375 | let validationMessage: string | undefined = undefined; 376 | 377 | /** Track to see if any validity key has changed */ 378 | let hasChange = false; 379 | 380 | if (!validators.length) { 381 | return; 382 | } 383 | 384 | validators.forEach(validator => { 385 | const key = validator.key || 'customError'; 386 | const isValid = validator.isValid(this, value, abortController.signal); 387 | const isAsyncValidator = isValid instanceof Promise; 388 | 389 | if (isAsyncValidator) { 390 | asyncValidators.push(isValid); 391 | 392 | isValid.then(isValidatorValid => { 393 | if (isValidatorValid === undefined || isValidatorValid === null) { 394 | return; 395 | } 396 | /** Invert the validity state to correspond to the ValidityState API */ 397 | validity[key] = !isValidatorValid; 398 | 399 | validationMessage = this.#getValidatorMessageForValue(validator, value); 400 | this.#setValidityWithOptionalTarget(validity, validationMessage); 401 | }); 402 | } else { 403 | /** Invert the validity state to correspond to the ValidityState API */ 404 | validity[key] = !isValid; 405 | 406 | if (this.validity[key] !== !isValid) { 407 | hasChange = true; 408 | } 409 | 410 | // only update the validationMessage for the first invalid scenario 411 | // so that earlier invalid validators dont get their messages overwritten by later ones 412 | // in the validators array 413 | if (!isValid && !validationMessage) { 414 | validationMessage = this.#getValidatorMessageForValue(validator, value); 415 | } 416 | } 417 | }); 418 | 419 | /** Once all the async validators have settled, resolve validationComplete */ 420 | Promise.allSettled(asyncValidators) 421 | .then(() => { 422 | /** Don't resolve validations if the signal is aborted */ 423 | if (!abortController?.signal.aborted) { 424 | this.#isValidationPending = false; 425 | this.#validationCompleteResolver?.(); 426 | } 427 | }); 428 | 429 | /** 430 | * If async validators are present: 431 | * Only run updates when a sync validator has a change. This is to prevent 432 | * situations where running sync validators can override async validators 433 | * that are still in progress 434 | * 435 | * If async validators are not present, always update validity 436 | */ 437 | if (hasChange || !hasAsyncValidators) { 438 | this.#setValidityWithOptionalTarget(validity, validationMessage); 439 | } 440 | } 441 | 442 | /** 443 | * If the validationTarget is not set, the user can decide how they would 444 | * prefer to handle focus when the field is validated. 445 | */ 446 | #setValidityWithOptionalTarget(validity: Partial, validationMessage: string|undefined): void { 447 | if (this.validationTarget) { 448 | this.internals.setValidity(validity, validationMessage, this.validationTarget); 449 | this.#awaitingValidationTarget = false; 450 | } else { 451 | this.internals.setValidity(validity, validationMessage); 452 | 453 | if (this.internals.validity.valid) { 454 | return; 455 | } 456 | 457 | /** 458 | * Sets mark the component as awaiting a validation target 459 | * if the element dispatches an invalid event, the #onInvalid listener 460 | * will check to see if the validation target has been set since this call 461 | * has run. This useful in cases like Lit's use of the query 462 | * decorator for setting the validationTarget or any scenario 463 | * where the validationTarget isn't available upon construction 464 | */ 465 | this.#awaitingValidationTarget = true; 466 | } 467 | } 468 | 469 | /** Process the validator message attribute */ 470 | #getValidatorMessageForValue(validator: Validator, value: FormValue): string { 471 | /** If the validity callback exists and returns, use that as the result */ 472 | if (this.validityCallback) { 473 | const message = this.validityCallback(validator.key || 'customError'); 474 | 475 | if (message) { 476 | return message; 477 | } 478 | } 479 | 480 | if (validator.message instanceof Function) { 481 | return (validator.message as validationMessageCallback)(this, value); 482 | } else { 483 | return validator.message as string; 484 | } 485 | } 486 | 487 | /** Reset control state when the form is reset */ 488 | formResetCallback() { 489 | this.#touched = false; 490 | this.#forceError = false; 491 | this.#shouldShowError(); 492 | this.resetFormControl?.(); 493 | 494 | this.validationMessageCallback?.( 495 | this.#shouldShowError() ? this.validationMessage : '' 496 | ); 497 | } 498 | } 499 | 500 | return FormControl as Constructor & TBase; 501 | } 502 | -------------------------------------------------------------------------------- /packages/form-control/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './FormControlMixin.js'; 2 | export * from './types.js'; 3 | export * from './validators.js'; 4 | -------------------------------------------------------------------------------- /packages/form-control/src/types.ts: -------------------------------------------------------------------------------- 1 | import { IElementInternals } from "element-internals-polyfill"; 2 | 3 | /** Generic constructor type */ 4 | /* eslint-disable @typescript-eslint/no-explicit-any */ 5 | export type Constructor> = new (...args: any[]) => T; 6 | 7 | /** Union type for form values */ 8 | export type FormValue = File|FormData|string|null; 9 | 10 | /** Validation message callback */ 11 | export type validationMessageCallback = (instance: any, value: FormValue) => string; 12 | 13 | /** Interface of exported FormControl behavior */ 14 | export interface FormControlInterface { 15 | validationTarget?: HTMLElement | null; 16 | readonly form: HTMLFormElement; 17 | readonly internals: ElementInternals & IElementInternals; 18 | readonly showError: boolean; 19 | readonly validationMessage: string; 20 | readonly validity: ValidityState; 21 | readonly validationComplete: Promise; 22 | connectedCallback(): void; 23 | checkValidity(): boolean; 24 | formResetCallback(): void; 25 | resetFormControl?(): void; 26 | // validateAsync(validator: AsyncValidator): Promise; 27 | valueChangedCallback?(value: FormValue): void | Promise; 28 | validityCallback(validationKey: string): string | void; 29 | validationMessageCallback(message: string): void; 30 | setValue(value: FormValue): void; 31 | shouldFormValueUpdate?(): boolean; 32 | } 33 | 34 | /** 35 | * Generic Validator shape. These objects 36 | * are used to create Validation behaviors on FormControl 37 | * instances. 38 | */ 39 | export interface ValidatorBase { 40 | /** 41 | * If present, the FormControl object will be re-run 42 | * when this attribute changes. Some validators won't need this 43 | * like a validator that ensures a given value can be cast 44 | * to a number. 45 | * 46 | * If an array of attribute names are provided, the attribute will 47 | * respond to changes for any of the listed attributes. 48 | */ 49 | attribute?: string | string[]; 50 | 51 | /** 52 | * This key determines which field on the control's validity 53 | * object will be toggled when a given Validator is run. This 54 | * property must exist on the global constraint validation 55 | * (ValidityState) object. Defaults to `customError`. 56 | */ 57 | key?: keyof ValidityState; 58 | 59 | /** 60 | * When a control becomes invalid, this property will be set 61 | * as the control's validityMessage. If the property is of type 62 | * string it will be used outright. If it is a function, the 63 | * returned string will be used as the validation message. 64 | * 65 | * One thing to be concerned with is that overriding a given 66 | * Validator's message property via reference will affect 67 | * all controls that use that validator. If a user wants to change 68 | * the default message, it is best to clone the validator and 69 | * change the message that way. 70 | * 71 | * Validation messages can also be changed by using the 72 | * FormControl.prototype.validityCallback, which takes a given 73 | * ValidityState key as an argument and must return a validationMessage 74 | * for the given instance. 75 | */ 76 | message: string | validationMessageCallback; 77 | } 78 | 79 | export interface SyncValidator extends ValidatorBase { 80 | /** 81 | * Callback for a given validator. Takes the FormControl instance 82 | * and the form control value as arguments and returns a 83 | * boolean to evaluate for that Validator. 84 | * @param instance {FormControlInterface} - The FormControl instance 85 | * @param value {FormValue} - The form control value 86 | * @returns {boolean} - The validity of a given Validator 87 | */ 88 | isValid(instance: HTMLElement, value: FormValue): boolean; 89 | } 90 | 91 | export interface AsyncValidator extends ValidatorBase { 92 | /** 93 | * Callback for a given validator. Takes the FormControl instance 94 | * and the form control value as arguments and returns a 95 | * boolean to evaluate for that Validator as a promise. 96 | * @param instance {FormControlInterface} - The FormControl instance 97 | * @param value {FormValue} - The form control value 98 | * @returns {Promise} - The validity of a given Validator 99 | */ 100 | isValid(instance: HTMLElement, value: FormValue, signal: AbortSignal): Promise; 101 | } 102 | 103 | export type Validator = SyncValidator|AsyncValidator; 104 | 105 | /** Generic type to allow usage of HTMLElement lifecycle methods */ 106 | export interface IControlHost { 107 | attributeChangedCallback?(name: string, oldValue: string, newValue: string): void; 108 | connectedCallback?(): void; 109 | disconnectedCallback?(): void; 110 | checked?: boolean; 111 | disabled?: boolean; 112 | } 113 | 114 | export type CustomValidityState = Partial>; 115 | -------------------------------------------------------------------------------- /packages/form-control/src/validators.ts: -------------------------------------------------------------------------------- 1 | import { Validator } from './index.js'; 2 | import { FormControlInterface, FormValue } from './types.js'; 3 | 4 | export const requiredValidator: Validator = { 5 | attribute: 'required', 6 | key: 'valueMissing', 7 | message: 'Please fill out this field', 8 | isValid(instance: HTMLElement & { required: boolean }, value: FormValue): boolean { 9 | let valid = true; 10 | 11 | if ((instance.hasAttribute('required') || instance.required) && !value) { 12 | valid = false; 13 | } 14 | 15 | return valid; 16 | } 17 | }; 18 | 19 | export const programmaticValidator: Validator = { 20 | attribute: 'error', 21 | message(instance: HTMLElement & { error: string }): string { 22 | return instance.error; 23 | }, 24 | isValid(instance: HTMLElement & { error: string }): boolean { 25 | return !instance.error; 26 | } 27 | }; 28 | 29 | export const minLengthValidator: Validator = { 30 | attribute: 'minlength', 31 | key: 'tooShort', 32 | message(instance: FormControlInterface & { minLength: number }, value: FormValue): string { 33 | const _value = value as string || ''; 34 | return `Please use at least ${instance.minLength} characters (you are currently using ${_value.length} characters).`; 35 | }, 36 | isValid(instance: HTMLElement & { minLength: number }, value: string): boolean { 37 | /** If no value is provided, this validator should return true */ 38 | if (!value) { 39 | return true; 40 | } 41 | 42 | if (!!value && instance.minLength > value.length) { 43 | return false; 44 | } 45 | 46 | return true; 47 | } 48 | }; 49 | 50 | export const maxLengthValidator: Validator = { 51 | attribute: 'maxlength', 52 | key: 'tooLong', 53 | message( 54 | instance: FormControlInterface & { maxLength: number }, 55 | value: FormValue 56 | ): string { 57 | const _value = value as string || ''; 58 | return `Please use no more than ${instance.maxLength} characters (you are currently using ${_value.length} characters).`; 59 | }, 60 | isValid( 61 | instance: HTMLElement & { maxLength: number }, 62 | value: string 63 | ): boolean { 64 | /** If maxLength isn't set, this is valid */ 65 | if (!instance.maxLength) { 66 | return true; 67 | } 68 | 69 | if (!!value && instance.maxLength < value.length) { 70 | return false; 71 | } 72 | 73 | return true; 74 | } 75 | }; 76 | 77 | export const patternValidator: Validator = { 78 | attribute: 'pattern', 79 | key: 'patternMismatch', 80 | message: 'Please match the requested format', 81 | isValid(instance: HTMLElement & { pattern: string }, value: string): boolean { 82 | /** If no value is provided, this validator should return true */ 83 | if (!value || !instance.pattern) { 84 | return true; 85 | } 86 | 87 | const regExp = new RegExp(instance.pattern); 88 | return !!regExp.exec(value); 89 | } 90 | }; 91 | -------------------------------------------------------------------------------- /packages/form-control/tests/asyncValidators.test.ts: -------------------------------------------------------------------------------- 1 | import { aTimeout, expect, fixture, fixtureCleanup, html } from '@open-wc/testing'; 2 | import { 3 | AsyncValidator, 4 | FormControlMixin, 5 | FormValue, 6 | Validator 7 | } from '../src'; 8 | 9 | let abortCount = 0; 10 | 11 | describe('The FormControlMixin using HTMLElement', () => { 12 | let form: HTMLFormElement; 13 | let el: AsyncValidatorEl; 14 | 15 | beforeEach(async () => { 16 | form = await fixture(html` 17 |
18 | 21 |
22 | `); 23 | 24 | el = form.querySelector('async-validator-el')!; 25 | }); 26 | 27 | afterEach(() => { 28 | fixtureCleanup(); 29 | abortCount = 0; 30 | }); 31 | 32 | it('will process the element as initially invalid as the element resolves', async () => { 33 | expect(el.validity.valid).to.be.true; 34 | await aTimeout(100); 35 | expect(el.validity.valid).to.be.false; 36 | }); 37 | 38 | it('validationComplete will resolve when validators are complete', async () => { 39 | expect(el.validity.valid).to.be.true; 40 | await el.validationComplete; 41 | expect(el.validity.valid).to.be.false; 42 | }); 43 | 44 | it('will become valid after success criteria is met', async () => { 45 | expect(el.validity.valid).to.be.true; 46 | await el.validationComplete; 47 | expect(el.validity.valid).to.be.false; 48 | el.value = 'foo'; 49 | await el.validationComplete; 50 | expect(el.validity.valid).to.be.true; 51 | }); 52 | 53 | it('will cancel validations using the abort signal', async () => { 54 | expect(el.validity.valid).to.be.true; 55 | await el.validationComplete; 56 | expect(el.validity.valid).to.be.false; 57 | el.value = 'f'; 58 | el.value = 'fo'; 59 | expect(abortCount).to.equal(2); // It will abort the initial set as well as 'f' 60 | }); 61 | }); 62 | 63 | const sleepValidator: AsyncValidator = { 64 | message: 'Hello world', 65 | isValid(instance: AsyncValidatorEl, value: FormValue, signal: AbortSignal): Promise { 66 | let id: ReturnType; 67 | 68 | return new Promise(resolve => { 69 | signal.addEventListener('abort', () => { 70 | clearTimeout(id); 71 | console.log(`abort for value ${value}`); 72 | abortCount += 1; 73 | resolve(); 74 | }); 75 | 76 | id = setTimeout(() => { 77 | resolve(value === 'foo'); 78 | }, 100); 79 | }); 80 | } 81 | } 82 | 83 | export class NativeFormControl extends FormControlMixin(HTMLElement) {} 84 | export class AsyncValidatorEl extends NativeFormControl { 85 | static get formControlValidators(): Validator[] { 86 | return [sleepValidator]; 87 | } 88 | 89 | constructor() { 90 | super(); 91 | const root = this.attachShadow({ mode: 'open' }); 92 | this.validationTarget = document.createElement('button'); 93 | root.append(this.validationTarget); 94 | } 95 | 96 | private _value = ''; 97 | 98 | get value() { 99 | return this._value; 100 | } 101 | 102 | set value(value: string) { 103 | this._value = value; 104 | this.setValue(value); 105 | } 106 | } 107 | 108 | window.customElements.define('async-validator-el', AsyncValidatorEl); 109 | -------------------------------------------------------------------------------- /packages/form-control/tests/delayedValidationTarget.test.ts: -------------------------------------------------------------------------------- 1 | import { aTimeout, expect, fixture, fixtureCleanup, html } from '@open-wc/testing'; 2 | import { FormControlMixin, Validator } from '../src'; 3 | 4 | describe('The FormControlMixin using HTMLElement', () => { 5 | let form: HTMLFormElement; 6 | let el: DelayedTarget | NoTarget; 7 | 8 | describe('the no validationTarget scenario', () => { 9 | beforeEach(async () => { 10 | form = await fixture(html` 11 |
12 | 15 |
16 | `); 17 | 18 | el = form.querySelector('no-target')!; 19 | }); 20 | 21 | afterEach(fixtureCleanup); 22 | 23 | it('not set the validation target', async () => { 24 | expect(el.validationTarget).to.be.undefined; 25 | expect(el.validity.valid).to.be.false; 26 | await aTimeout(500); 27 | form.requestSubmit(); 28 | expect(document.activeElement?.shadowRoot?.activeElement).to.be.undefined; 29 | }); 30 | }); 31 | }); 32 | 33 | export class NativeFormControl extends FormControlMixin(HTMLElement) {} 34 | export class DelayedTarget extends NativeFormControl { 35 | static get formControlValidators(): Validator[] { 36 | return [ 37 | { 38 | key: 'customError', 39 | message: 'always invalid', 40 | isValid(): boolean { 41 | return false; 42 | } 43 | } 44 | ]; 45 | } 46 | 47 | constructor() { 48 | super(); 49 | const root = this.attachShadow({ mode: 'open' }); 50 | const validationTarget = document.createElement('div'); 51 | validationTarget.style.height = '100px'; 52 | validationTarget.style.width = '100px'; 53 | validationTarget.contentEditable = 'true'; 54 | validationTarget.tabIndex = 0; 55 | root.append(validationTarget); 56 | } 57 | 58 | connectedCallback(): void { 59 | this.tabIndex = 0; 60 | setTimeout(() => { 61 | this.validationTarget = this.shadowRoot?.querySelector('div'); 62 | }); 63 | } 64 | } 65 | 66 | export class NoTarget extends NativeFormControl { 67 | static get formControlValidators(): Validator[] { 68 | return [ 69 | { 70 | key: 'customError', 71 | message: 'always invalid', 72 | isValid(): boolean { 73 | return false; 74 | } 75 | } 76 | ]; 77 | } 78 | 79 | constructor() { 80 | super(); 81 | this.attachShadow({ mode: 'open' }); 82 | } 83 | 84 | private _value: string|null = null; 85 | 86 | get value(): string|null { 87 | return this._value; 88 | } 89 | 90 | set value(_value: string|null) { 91 | this._value = _value; 92 | this.setValue(_value); 93 | } 94 | } 95 | 96 | window.customElements.define('delayed-target', DelayedTarget); 97 | window.customElements.define('no-target', NoTarget); 98 | -------------------------------------------------------------------------------- /packages/form-control/tests/lit.test.ts: -------------------------------------------------------------------------------- 1 | import { elementUpdated, expect, fixture, fixtureCleanup, html } from '@open-wc/testing'; 2 | import { LitElement, TemplateResult } from 'lit'; 3 | import { customElement, property, query } from 'lit/decorators.js'; 4 | import { live } from 'lit/directives/live.js'; 5 | import { sendKeys } from '@web/test-runner-commands'; 6 | import { FormControlMixin, requiredValidator, Validator } from '../src'; 7 | 8 | describe('The FormControlMixin using LitElement', () => { 9 | let form: HTMLFormElement; 10 | let el: LitControl; 11 | 12 | beforeEach(async () => { 13 | form = await fixture(html` 14 |
15 | 16 |
17 | `); 18 | el = form.querySelector('lit-control')!; 19 | }); 20 | 21 | afterEach(fixtureCleanup); 22 | 23 | it('respects the lit update cycle', async () => { 24 | el.focus(); 25 | el.validationTarget?.focus(); 26 | await sendKeys({ type: 'Hello world' }); 27 | await elementUpdated(el); 28 | const data = new FormData(form); 29 | expect(el.value).to.equal('Hello world'); 30 | expect(data.get(el.name)).to.equal(el.value); 31 | }); 32 | 33 | it('will validate with lit', async () => { 34 | expect(el.validity.valid).to.be.true; 35 | el.required = true; 36 | await elementUpdated(el); 37 | expect(el.validity.valid).to.be.false; 38 | expect(el.validity.valueMissing).to.be.true; 39 | expect(el.internals.validationMessage).to.equal('value missing'); 40 | }); 41 | }); 42 | 43 | @customElement('lit-control') 44 | export class LitControl extends FormControlMixin(LitElement) { 45 | static get formControlValidators(): Validator[] { 46 | return [requiredValidator]; 47 | } 48 | 49 | @property({ type: String }) 50 | name = ''; 51 | 52 | @property({ type: Boolean, reflect: true }) 53 | required = false; 54 | 55 | @property({ type: String, reflect: false }) 56 | value = ''; 57 | 58 | @query('input') 59 | validationTarget?: HTMLElement | null | undefined; 60 | 61 | protected firstUpdated(_changedProperties: Map): void { 62 | this.tabIndex = 0; 63 | } 64 | 65 | render(): TemplateResult { 66 | return html``; 71 | } 72 | 73 | #onInput(event: KeyboardEvent & { target: HTMLInputElement }) { 74 | this.value = event.target.value; 75 | } 76 | 77 | validityCallback(validationKey: string): string | void { 78 | return 'value missing'; 79 | } 80 | 81 | updated(_changedProperties: Map): void { 82 | if (_changedProperties.has('value')) { 83 | this.setValue(this.value); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /packages/form-control/tests/validation.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, fixture, fixtureCleanup, html } from '@open-wc/testing'; 2 | import sinon, { SinonSpy } from 'sinon'; 3 | import { FormControlMixin, FormValue } from '../src'; 4 | import { Validator } from '../src/types'; 5 | 6 | let callCount = 0; 7 | const noopValidator: Validator = { 8 | key: 'customError', 9 | message: 'No op', 10 | isValid(instance: HTMLElement, value: FormValue) { 11 | callCount += 1; 12 | return value === 'valid'; 13 | } 14 | }; 15 | 16 | const multiAttributeValidator: Validator = { 17 | attribute: ['foo', 'bar'], 18 | message: 'foo', 19 | isValid() { 20 | return true; 21 | } 22 | }; 23 | 24 | const noopValidatorWithAttribute: Validator = { 25 | attribute: 'noop', 26 | ...noopValidator 27 | }; 28 | 29 | describe('The FormControlMixin using HTMLElement', () => { 30 | let form: HTMLFormElement; 31 | let noopEl: NoopValidatorEl | NoopValidatorAttr; 32 | let isValidSpy = sinon.spy(noopValidator, 'isValid'); 33 | 34 | describe('validator with no attributes', () => { 35 | beforeEach(async () => { 36 | form = await fixture(html` 37 |
38 | 41 |
42 | `); 43 | 44 | noopEl = form.querySelector('no-op-validator-el')!; 45 | }); 46 | 47 | afterEach(fixtureCleanup); 48 | afterEach(() => { 49 | callCount = 0; 50 | isValidSpy.restore(); 51 | }); 52 | 53 | it('has access to the validators array', async () => { 54 | expect(NoopValidatorEl.formControlValidators.length).to.equal(1); 55 | }); 56 | 57 | it('will default to invalid', async () => { 58 | expect(noopEl.validity.valid).to.be.false; 59 | expect(isValidSpy.called).to.be.true; 60 | expect(isValidSpy.callCount).to.equal(1); 61 | }); 62 | 63 | it('call the validationMessageCallback on invalid', async () => { 64 | const validationMessageCallbackSpy = sinon.spy(noopEl, 'validationMessageCallback'); 65 | expect(noopEl.validity.valid).to.be.false; 66 | noopEl.dispatchEvent(new Event('invalid')); 67 | expect(validationMessageCallbackSpy.called).to.be.true; 68 | validationMessageCallbackSpy.restore(); 69 | }); 70 | 71 | it('will call the callback after every entry', async () => { 72 | expect(noopEl.validity.valid).to.be.false; 73 | expect(callCount).to.equal(1); 74 | noopEl.value = 'valid'; 75 | expect(callCount).to.equal(2); 76 | }); 77 | 78 | it('can toggle the validity to true', async () => { 79 | expect(noopEl.validity.valid).to.be.false; 80 | noopEl.value = 'valid'; 81 | expect(noopEl.validity.valid).to.be.true; 82 | }); 83 | 84 | it('will toggle showError on focus state', async () => { 85 | expect(noopEl.validity.valid).to.be.false; 86 | expect(noopEl.showError).to.be.false; 87 | noopEl.focus(); 88 | noopEl.blur(); 89 | expect(noopEl.showError, 'showError should be true').to.be.true; 90 | }); 91 | 92 | it('will always recommend against showing error if disabled', async () => { 93 | expect(noopEl.validity.valid).to.be.false; 94 | expect(noopEl.showError).to.be.false; 95 | noopEl.focus(); 96 | noopEl.blur(); 97 | expect(noopEl.showError, 'showError should be true').to.be.true; 98 | noopEl.toggleAttribute('disabled', true); 99 | expect(noopEl.showError, 'showError should be true').to.be.false; 100 | }); 101 | 102 | it('will recommend showing error on invalid events', async () => { 103 | expect(noopEl.validity.valid).to.be.false; 104 | expect(noopEl.showError).to.be.false; 105 | noopEl.dispatchEvent(new Event('invalid')); 106 | expect(noopEl.showError).to.be.true; 107 | }); 108 | 109 | it('has a checkValidity method', async () => { 110 | expect(noopEl.validity.valid).to.be.false; 111 | expect(noopEl.checkValidity()).to.equal(noopEl.validity.valid); 112 | noopEl.value = 'valid'; 113 | expect(noopEl.validity.valid).to.be.true; 114 | expect(noopEl.checkValidity()).to.equal(noopEl.validity.valid); 115 | }); 116 | }); 117 | 118 | describe('validator with attributes', () => { 119 | beforeEach(async () => { 120 | form = await fixture(html` 121 |
122 | 125 |
126 | `); 127 | 128 | noopEl = form.querySelector('no-op-validator-attr')!; 129 | }); 130 | 131 | afterEach(fixtureCleanup); 132 | afterEach(() => { 133 | callCount = 0; 134 | }); 135 | 136 | it('will add the attribute to the observed attributes', async () => { 137 | const constructor = noopEl.constructor as unknown as NoopValidatorAttr & { observedAttributes: string[] }; 138 | expect(constructor.observedAttributes).to.deep.equal(['noop']); 139 | }); 140 | 141 | it('will call the validator on attribute change', async () => { 142 | expect(callCount).to.equal(1); 143 | noopEl.toggleAttribute('noop', true); 144 | expect(callCount).to.equal(2); 145 | }); 146 | }); 147 | 148 | describe('Multi-attribute validators', () => { 149 | let callbackSpy: SinonSpy; 150 | 151 | beforeEach(() => { 152 | callbackSpy = sinon.spy(multiAttributeValidator, 'isValid'); 153 | }); 154 | 155 | afterEach(() => { 156 | callbackSpy.restore(); 157 | }); 158 | 159 | it('will be evaluated on each attribute change', async () => { 160 | const el = new MultiAttributeValidator(); 161 | /** Called when the element is constructed */ 162 | expect(callbackSpy.callCount).to.equal(1); 163 | 164 | /** Called when the first attribute changes */ 165 | el.setAttribute('foo', 'foo'); 166 | expect(callbackSpy.callCount).to.equal(2); 167 | 168 | /** Called when the first attribute changes */ 169 | el.setAttribute('bar', 'bar'); 170 | expect(callbackSpy.callCount).to.equal(3); 171 | }) 172 | }); 173 | 174 | // describe('validators in a group', () => { 175 | // it('will validate as a group', async () => { 176 | // const form = await fixture(html`
177 | // 178 | // 179 | //
`); 180 | 181 | // let [el1, el2] = form.querySelectorAll('no-op-validator-attr-group'); 182 | // el1.value = 'foo'; 183 | // el2.value = 'bar'; 184 | 185 | // expect(el1.validity.valid).to.be.false; 186 | // expect(el2.validity.valid).to.be.false; 187 | // }); 188 | // }); 189 | }); 190 | 191 | export class MultiAttributeValidator extends FormControlMixin(HTMLElement) { 192 | static get formControlValidators() { 193 | return [multiAttributeValidator]; 194 | } 195 | 196 | constructor() { 197 | super(); 198 | const root = this.attachShadow({ mode: 'open' }); 199 | const btn = document.createElement('button'); 200 | root.append(btn); 201 | this.validationTarget = btn; 202 | } 203 | } 204 | export class NativeFormControl extends FormControlMixin(HTMLElement) {} 205 | export class NoopValidatorEl extends NativeFormControl { 206 | static get formControlValidators() { 207 | return [noopValidator]; 208 | } 209 | 210 | _value: string|null = ''; 211 | message = ''; 212 | 213 | constructor() { 214 | super(); 215 | const root = this.attachShadow({ mode: 'open' }); 216 | const validationTarget = document.createElement('div'); 217 | validationTarget.tabIndex = 0; 218 | root.append(validationTarget); 219 | } 220 | 221 | connectedCallback(): void { 222 | this.setAttribute('tabindex', '0'); 223 | } 224 | 225 | get validationTarget(): HTMLDivElement { 226 | return this.shadowRoot?.querySelector('div')!; 227 | } 228 | 229 | get value() { 230 | return this._value; 231 | } 232 | 233 | set value(_value) { 234 | this._value = _value; 235 | this.setValue(_value); 236 | } 237 | 238 | validationMessageCallback(message: string): void { 239 | return; 240 | } 241 | } 242 | 243 | export class NoopValidatorAttr extends NoopValidatorEl { 244 | static get formControlValidators() { 245 | return [noopValidatorWithAttribute]; 246 | } 247 | } 248 | 249 | window.customElements.define('no-op-validator-el', NoopValidatorEl); 250 | window.customElements.define('no-op-validator-attr', NoopValidatorAttr); 251 | window.customElements.define('multi-attribute-validator-el', MultiAttributeValidator); 252 | -------------------------------------------------------------------------------- /packages/form-control/tests/validators.test.ts: -------------------------------------------------------------------------------- 1 | import { aTimeout, expect, fixture, fixtureCleanup, html } from '@open-wc/testing'; 2 | import { 3 | FormControlMixin, 4 | maxLengthValidator, 5 | minLengthValidator, 6 | patternValidator, 7 | programmaticValidator, 8 | requiredValidator, 9 | validationMessageCallback, 10 | Validator 11 | } from '../src'; 12 | 13 | describe('The FormControlMixin using HTMLElement', () => { 14 | let form: HTMLFormElement; 15 | let el: ValidatedEl; 16 | 17 | beforeEach(async () => { 18 | form = await fixture(html` 19 |
20 | 23 |
24 | `); 25 | 26 | el = form.querySelector('validated-el')!; 27 | }); 28 | 29 | afterEach(fixtureCleanup); 30 | 31 | describe('requiredValidator', () => { 32 | it('will not affect validity if the required attribute is missing', async () => { 33 | expect(el.validity.valid).to.be.true; 34 | }); 35 | 36 | it('will invalidate the control if required and no value', async () => { 37 | expect(el.validity.valid).to.be.true; 38 | expect(el.validity.valueMissing).to.be.false; 39 | el.toggleAttribute('required', true); 40 | expect(el.validity.valid).to.be.false; 41 | expect(el.validity.valueMissing).to.be.true; 42 | expect(el.internals.validationMessage).to.equal('Please fill out this field'); 43 | }); 44 | 45 | it('will respond to value setting', async () => { 46 | expect(el.validity.valid).to.be.true; 47 | expect(el.validity.valueMissing).to.be.false; 48 | el.toggleAttribute('required', true); 49 | expect(el.validity.valid).to.be.false; 50 | expect(el.validity.valueMissing).to.be.true; 51 | el.value = 'foo'; 52 | expect(el.validity.valid).to.be.true; 53 | expect(el.validity.valueMissing).to.be.false; 54 | }); 55 | }); 56 | 57 | describe('programmaticValidator', () => { 58 | it('will not have an effect unless error is set', () => { 59 | expect(el.validity.valid).to.be.true; 60 | }); 61 | 62 | it('will respond to changes to the error property', () => { 63 | expect(el.validity.valid).to.be.true; 64 | expect(el.validity.customError).to.be.false; 65 | el.error = 'Foo bar'; 66 | expect(el.validity.valid).to.be.false; 67 | expect(el.validity.customError).to.be.true; 68 | expect(el.internals.validationMessage).to.equal('Foo bar'); 69 | }); 70 | }); 71 | 72 | describe('minLengthValidator', () => { 73 | it('will not affect the element unless minLength is set', async () => { 74 | expect(el.validity.valid).to.be.true; 75 | expect(el.validity.tooShort).to.be.false; 76 | }); 77 | 78 | it('will invalidate element when value length is less than minLength', async () => { 79 | expect(el.validity.valid).to.be.true; 80 | expect(el.validity.tooShort).to.be.false; 81 | el.minLength = 3; 82 | el.value = 'ab'; 83 | expect(el.validity.valid).to.be.false; 84 | expect(el.validity.tooShort).to.be.true; 85 | expect(el.internals.validationMessage).to.equal( 86 | 'Please use at least 3 characters (you are currently using 2 characters).' 87 | ); 88 | }); 89 | 90 | it('will validate element when value length is equal to minLength', async () => { 91 | expect(el.validity.valid).to.be.true; 92 | expect(el.validity.tooShort).to.be.false; 93 | el.minLength = 3; 94 | el.value = 'abc'; 95 | expect(el.validity.valid).to.be.true; 96 | expect(el.validity.tooShort).to.be.false; 97 | }); 98 | 99 | it('will validate element when value length is greater than minLength', async () => { 100 | expect(el.validity.valid).to.be.true; 101 | expect(el.validity.tooShort).to.be.false; 102 | el.minLength = 3; 103 | el.value = 'abcd'; 104 | expect(el.validity.valid).to.be.true; 105 | expect(el.validity.tooShort).to.be.false; 106 | }); 107 | }); 108 | 109 | /** maxLengthValidator */ 110 | describe('maxLengthValidator', () => { 111 | it('will not affect the element unless maxLength is set', async () => { 112 | expect(el.validity.valid).to.be.true; 113 | expect(el.validity.tooLong).to.be.false; 114 | }); 115 | 116 | it('will invalidate controls where value is longer than maxLength', async () => { 117 | expect(el.validity.valid).to.be.true; 118 | expect(el.validity.tooLong).to.be.false; 119 | el.maxLength = 3; 120 | el.value = 'abcd'; 121 | expect(el.validity.valid).to.be.false; 122 | expect(el.validity.tooLong).to.be.true; 123 | expect(el.internals.validationMessage).to.equal( 124 | 'Please use no more than 3 characters (you are currently using 4 characters).' 125 | ); 126 | }); 127 | 128 | it('will validate controls where value is equal to maxLength', async () => { 129 | expect(el.validity.valid).to.be.true; 130 | expect(el.validity.tooLong).to.be.false; 131 | el.maxLength = 3; 132 | el.value = 'abcd'; 133 | expect(el.validity.valid).to.be.false; 134 | expect(el.validity.tooLong).to.be.true; 135 | expect(el.internals.validationMessage).to.equal( 136 | 'Please use no more than 3 characters (you are currently using 4 characters).' 137 | ); 138 | el.value = 'abc'; 139 | expect(el.validity.valid).to.be.true; 140 | expect(el.validity.tooLong).to.be.false; 141 | }); 142 | 143 | it('will validate controls where value is less than maxLength', async () => { 144 | expect(el.validity.valid).to.be.true; 145 | expect(el.validity.tooLong).to.be.false; 146 | el.maxLength = 3; 147 | el.value = 'abcd'; 148 | expect(el.validity.valid).to.be.false; 149 | expect(el.validity.tooLong).to.be.true; 150 | expect(el.internals.validationMessage).to.equal( 151 | 'Please use no more than 3 characters (you are currently using 4 characters).' 152 | ); 153 | el.value = 'ab'; 154 | expect(el.validity.valid).to.be.true; 155 | expect(el.validity.tooLong).to.be.false; 156 | }); 157 | }); 158 | 159 | describe('patternValidator', () => { 160 | it('will have no affect if pattern is not set', async () => { 161 | expect(el.validity.valid).to.be.true; 162 | expect(el.validity.patternMismatch).to.be.false; 163 | }); 164 | 165 | it('will invalidate the control if the pattern is set and does not match', async () => { 166 | expect(el.validity.valid).to.be.true; 167 | expect(el.validity.patternMismatch).to.be.false; 168 | el.pattern = 'abc'; 169 | el.value = 'def'; 170 | expect(el.validity.valid).to.be.false; 171 | expect(el.validity.patternMismatch).to.be.true; 172 | expect(el.internals.validationMessage).to.equal('Please match the requested format'); 173 | }); 174 | 175 | it('will validate the control if the pattern is set and does match', async () => { 176 | expect(el.validity.valid).to.be.true; 177 | expect(el.validity.patternMismatch).to.be.false; 178 | el.pattern = 'abc'; 179 | el.value = 'def'; 180 | expect(el.validity.valid).to.be.false; 181 | expect(el.validity.patternMismatch).to.be.true; 182 | expect(el.internals.validationMessage).to.equal('Please match the requested format'); 183 | el.value = 'abc'; 184 | expect(el.validity.valid).to.be.true; 185 | expect(el.validity.patternMismatch).to.be.false; 186 | }); 187 | }); 188 | 189 | /** maxLengthValidator */ 190 | describe('maxLengthValidator', () => { 191 | it('will not affect the element unless maxLength is set', async () => { 192 | expect(el.validity.valid).to.be.true; 193 | expect(el.validity.tooLong).to.be.false; 194 | }); 195 | 196 | it('will invalidate controls where value is longer than maxLength', async () => { 197 | expect(el.validity.valid).to.be.true; 198 | expect(el.validity.tooLong).to.be.false; 199 | el.maxLength = 3; 200 | el.value = 'abcd'; 201 | expect(el.validity.valid).to.be.false; 202 | expect(el.validity.tooLong).to.be.true; 203 | expect(el.internals.validationMessage).to.equal( 204 | 'Please use no more than 3 characters (you are currently using 4 characters).' 205 | ); 206 | }); 207 | 208 | it('will validate controls where value is equal to maxLength', async () => { 209 | expect(el.validity.valid).to.be.true; 210 | expect(el.validity.tooLong).to.be.false; 211 | el.maxLength = 3; 212 | el.value = 'abcd'; 213 | expect(el.validity.valid).to.be.false; 214 | expect(el.validity.tooLong).to.be.true; 215 | expect(el.internals.validationMessage).to.equal( 216 | 'Please use no more than 3 characters (you are currently using 4 characters).' 217 | ); 218 | el.value = 'abc'; 219 | expect(el.validity.valid).to.be.true; 220 | expect(el.validity.tooLong).to.be.false; 221 | }); 222 | 223 | it('will validate controls where value is less than maxLength', async () => { 224 | expect(el.validity.valid).to.be.true; 225 | expect(el.validity.tooLong).to.be.false; 226 | el.maxLength = 3; 227 | el.value = 'abcd'; 228 | expect(el.validity.valid).to.be.false; 229 | expect(el.validity.tooLong).to.be.true; 230 | expect(el.internals.validationMessage).to.equal( 231 | 'Please use no more than 3 characters (you are currently using 4 characters).' 232 | ); 233 | el.value = 'ab'; 234 | expect(el.validity.valid).to.be.true; 235 | expect(el.validity.tooLong).to.be.false; 236 | }); 237 | }); 238 | 239 | /** iterative validation message */ 240 | it('should have only the first invalid message when multiple validators are invalid', async () => { 241 | el.pattern = '^[A-Z]$'; 242 | el.maxLength = 5; 243 | el.value = 'aBCDEF'; 244 | 245 | // expect the error message to be the maxLength validato message because it is the first one 246 | // in the array that will return invalid 247 | expect(el.validationMessage).to.equal((maxLengthValidator.message as validationMessageCallback)(el, el.value)); 248 | expect(el.validity.tooLong).to.be.true; 249 | expect(el.validity.patternMismatch).to.be.true; 250 | 251 | 252 | // change the value so that the maxLength validator returns valid 253 | el.value = 'aBCDE'; 254 | 255 | // expect the error message to be the pattern validator message because it now the first one that will be invalid 256 | expect(el.validationMessage).to.equal(patternValidator.message); 257 | }); 258 | 259 | }); 260 | 261 | export class NativeFormControl extends FormControlMixin(HTMLElement) {} 262 | export class ValidatedEl extends NativeFormControl { 263 | static get formControlValidators(): Validator[] { 264 | return [maxLengthValidator, minLengthValidator, patternValidator, programmaticValidator, requiredValidator]; 265 | } 266 | 267 | _error: string | null = null; 268 | 269 | _maxLength: number | null = null; 270 | 271 | _minLength: number | null = null; 272 | 273 | _pattern: string | null = null; 274 | 275 | _required = false; 276 | 277 | _value: string|null = null; 278 | 279 | get error(): string | null { 280 | return this._error; 281 | } 282 | 283 | set error(error: string | null) { 284 | this._error = error; 285 | if (error) { 286 | this.setAttribute('error', error); 287 | } else { 288 | this.removeAttribute('error'); 289 | } 290 | } 291 | 292 | get maxLength(): number | null { 293 | return this._maxLength; 294 | } 295 | 296 | set maxLength(maxLength: number | null) { 297 | this._maxLength = maxLength; 298 | if (maxLength) { 299 | this.setAttribute('maxlength', maxLength.toString()); 300 | } else { 301 | this.removeAttribute('maxlength'); 302 | } 303 | } 304 | 305 | get minLength(): number | null { 306 | return this._minLength; 307 | } 308 | 309 | set minLength(minLength: number | null) { 310 | this._minLength = minLength; 311 | if (minLength) { 312 | this.setAttribute('minlength', minLength.toString()); 313 | } else { 314 | this.removeAttribute('minlength'); 315 | } 316 | } 317 | 318 | get pattern(): string | null { 319 | return this._pattern; 320 | } 321 | 322 | set pattern(pattern: string | null) { 323 | this._pattern = pattern; 324 | if (pattern) { 325 | this.setAttribute('pattern', pattern); 326 | } else { 327 | this.removeAttribute('pattern'); 328 | } 329 | } 330 | 331 | get required() { 332 | return this._required; 333 | } 334 | 335 | set required(required: boolean) { 336 | this._required = required; 337 | this.toggleAttribute('required', required); 338 | } 339 | 340 | get value(): string|null { 341 | return this._value; 342 | } 343 | 344 | set value(_value) { 345 | this._value = _value; 346 | this.setValue(_value); 347 | } 348 | 349 | constructor() { 350 | super(); 351 | const root = this.attachShadow({ mode: 'open' }); 352 | const validationTarget = document.createElement('div'); 353 | validationTarget.tabIndex = 0; 354 | root.append(validationTarget); 355 | } 356 | 357 | get validationTarget(): HTMLDivElement { 358 | return this.shadowRoot?.querySelector('div')!; 359 | } 360 | } 361 | 362 | window.customElements.define('validated-el', ValidatedEl); 363 | -------------------------------------------------------------------------------- /packages/form-control/tests/value.test.ts: -------------------------------------------------------------------------------- 1 | import { aTimeout, expect, fixture, fixtureCleanup, html, should } from '@open-wc/testing'; 2 | import sinon from 'sinon'; 3 | import { FormControlMixin } from '../src'; 4 | 5 | 6 | describe('The FormControlMixin using HTMLElement', () => { 7 | let form: HTMLFormElement; 8 | let el: NativeControlDemo; 9 | let shouldUpdateEl: ShouldUpdateDemo; 10 | 11 | beforeEach(async () => { 12 | form = await fixture(html`
13 | 14 | 15 |
`); 16 | el = form.querySelector('native-control-demo')!; 17 | shouldUpdateEl = form.querySelector('should-update-demo')!; 18 | }); 19 | 20 | afterEach(fixtureCleanup); 21 | 22 | it('will keep track of the parent form', async () => { 23 | expect(el.form).to.equal(form); 24 | }); 25 | 26 | it('can reset an element value on non-checked controls', async () => { 27 | const spy = sinon.spy(el, 'resetFormControl'); 28 | el.value = 'foo'; 29 | let data = new FormData(form); 30 | expect(data.get('control')).to.equal('foo'); 31 | form.reset(); 32 | data = new FormData(form); 33 | expect(spy.called).to.be.true; 34 | expect(data.get('control')).to.equal(''); 35 | spy.restore(); 36 | }); 37 | 38 | it('will call and evaluate shouldFormaValueUpdate', async () => { 39 | const spy = sinon.spy(shouldUpdateEl, 'shouldFormValueUpdate'); 40 | let data = new FormData(form); 41 | shouldUpdateEl.value = 'abc'; 42 | expect(spy.lastCall.returnValue).to.be.false; 43 | expect(data.get('should-update')).to.equal(null); 44 | shouldUpdateEl.checked = true; 45 | expect(spy.called).to.be.true; 46 | expect(spy.lastCall.returnValue).to.be.true; 47 | data = new FormData(form); 48 | expect(data.get('should-update')).to.equal('abc'); 49 | spy.restore(); 50 | }) 51 | }); 52 | 53 | const FormControl = FormControlMixin(HTMLElement); 54 | class NativeControlDemo extends FormControl { 55 | #value = ''; 56 | validationTarget = document.createElement('input'); 57 | 58 | constructor() { 59 | super(); 60 | this.setValue(''); 61 | 62 | const root = this.attachShadow({ mode: 'open' }); 63 | root.append(this.validationTarget); 64 | } 65 | 66 | get value(): string { 67 | return this.#value; 68 | } 69 | 70 | set value(value: string) { 71 | this.setValue(value); 72 | this.#value = value; 73 | } 74 | 75 | resetFormControl(): void { 76 | this.value = ''; 77 | } 78 | } 79 | 80 | class ShouldUpdateDemo extends NativeControlDemo { 81 | private _checked = false; 82 | 83 | get checked() { 84 | return this._checked; 85 | } 86 | 87 | set checked(checked: boolean) { 88 | this._checked = checked; 89 | this.setValue(this.checked ? this.value : ''); 90 | } 91 | 92 | shouldFormValueUpdate(): boolean { 93 | return this.checked; 94 | } 95 | } 96 | 97 | customElements.define('native-control-demo', NativeControlDemo); 98 | customElements.define('should-update-demo', ShouldUpdateDemo); 99 | -------------------------------------------------------------------------------- /packages/form-control/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/form-control/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["./index.ts", "./src/*.ts"], 4 | "exclude": [ 5 | "dist", 6 | "types", 7 | "demo", 8 | "tests" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /packages/form-control/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "experimentalDecorators": true 6 | }, 7 | "include": ["src/*.ts", "*.js", "tests/*.ts", "demo/*.ts"], 8 | "exclude": ["dist", "types"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/form-control/web-dev-server.config.mjs: -------------------------------------------------------------------------------- 1 | import { esbuildPlugin } from '@web/dev-server-esbuild'; 2 | 3 | export default { 4 | plugins: [esbuildPlugin({ ts: true, target: 'auto' })] 5 | }; 6 | -------------------------------------------------------------------------------- /packages/form-control/web-test-runner.config.mjs: -------------------------------------------------------------------------------- 1 | import { esbuildPlugin } from '@web/dev-server-esbuild'; 2 | import { sendKeysPlugin } from '@web/test-runner-commands/plugins'; 3 | 4 | export default { 5 | plugins: [esbuildPlugin({ ts: true, target: 'auto' }), sendKeysPlugin()] 6 | }; 7 | -------------------------------------------------------------------------------- /packages/form-helpers/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 open-wc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /packages/form-helpers/README.md: -------------------------------------------------------------------------------- 1 | # @open-wc/form-helpers 2 | 3 | A collection of form control related utilities for working with forms. 4 | 5 | ## Install 6 | 7 | ```sh 8 | # npm 9 | npm install @open-wc/form-helpers 10 | 11 | # yarn 12 | yarn add @open-wc/form-helpers 13 | ``` 14 | 15 | ### Implicit form submit 16 | 17 | The `submit` helper is a useful helper for firing the forms `submit` event – as a preventable event – only when the form's validity reports back as truthy (meaning the form has all valid values in its inputs) and calling the provided form's `submit()` method if the submit event is not `defaultPrevented`. 18 | 19 | It is perhaps best used to add implicit form submission to inputs in a form when the `Enter` key is pressed so that any input can submit a form. Such a feature can be useful for search inputs with no submit button. 20 | 21 | > This helper is somewhat of a stop gap method until Safari implements [HTMLFormElement.requestSubmit()](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/requestSubmit) 22 | 23 | ```html 24 |
25 | 26 |
27 | ``` 28 | 29 | ```js 30 | import { submit } from '@open-wc/form-helpers'; 31 | 32 | let submitted = false; 33 | const form = document.querySelector('#someForm'); 34 | const input = document.querySelector('input'); 35 | 36 | input.addEventListener( 'keypress', ($event) => { 37 | if($event.code === 'Enter') { 38 | submit(form); // submit event is emitted, and form's submit() method is called if the `submit` event is not `defaultPrevented` 39 | console.log(submitted) // submitHandler will not be called if the input doesn't have a value AND is required 40 | } 41 | }); 42 | 43 | function submitHandler(event) { 44 | // the event is not prevented, so the form will be submitted 45 | submitted = true; 46 | }; 47 | ``` 48 | 49 | Please note that `submit` helper respects form's `novalidate` attribute and doesn't call `reportValidity()` if it presents. So with the example below, submit handler will ignore form validation and trigger the submit event. 50 | 51 | ```html 52 |
53 | 54 |
55 | ``` 56 | 57 | 58 | ### Parse form values 59 | 60 | The `formValues` helper is a useful function for parsing out the values of a form's inputs in an object format. 61 | 62 | ```js 63 | import { formValues } from '@open-wc/form-helpers'; 64 | ``` 65 | 66 | Given a form like: 67 | 68 | ```html 69 |
70 | 71 | 72 | 73 | 74 |
75 | ``` 76 | 77 | parsing the form's values can be performed as such: 78 | 79 | ```js 80 | import { formValues } from '@open-wc/form-helpers'; 81 | 82 | const form = document.querySelector('form'); 83 | 84 | console.log(formValues(form)) 85 | 86 | // Output: 87 | // { 88 | // foo: 'one', 89 | // bar: 'two', 90 | // baz: ['1', '2'] 91 | // } 92 | ``` 93 | 94 | ### Parse form object 95 | 96 | The `parseFormObject` helper enables deeper nesting and organization of inputs in a form by inspecting the `name` attribute on each input element and analyzing according to dot notation. 97 | 98 | ```js 99 | import { parseFormAsObject } from '@open-wc/form-helpers'; 100 | ``` 101 | 102 | Given a form like 103 | 104 | ```html 105 |
106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 |
114 | ``` 115 | 116 | parsing the form values as a deeply nested object can be perfomed as such: 117 | 118 | ```js 119 | import { parseFormAsObject } from '@open-wc/form-helpers'; 120 | 121 | const form = document.querySelector('form'); 122 | 123 | console.log(parseFormAsObject(form)) 124 | 125 | // Output: 126 | // { 127 | // one: { 128 | // a: 'a', 129 | // b: 'b', 130 | // }, 131 | // two: '2', 132 | // foo: { 133 | // bar: { 134 | // baz: 'baz', 135 | // qux: 'qux' 136 | // } 137 | // }, 138 | // three: ['three', 'tres'] 139 | // } 140 | ``` 141 | -------------------------------------------------------------------------------- /packages/form-helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './src/index.js'; 2 | -------------------------------------------------------------------------------- /packages/form-helpers/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@open-wc/form-helpers", 3 | "version": "1.0.0", 4 | "description": "Helpers for working with forms", 5 | "main": "index.js", 6 | "module": "index.js", 7 | "type": "module", 8 | "exports": { 9 | ".": "./index.js" 10 | }, 11 | "files": [ 12 | "src/*.js", 13 | "src/*.d.ts", 14 | "src/*.js.map", 15 | "index.js", 16 | "*.d.ts", 17 | "*.js.map" 18 | ], 19 | "scripts": { 20 | "build": "tsc", 21 | "start": "web-dev-server --node-resolve --watch", 22 | "test": "web-test-runner tests/*.test.ts --node-resolve --coverage", 23 | "test:playwright": "web-test-runner tests/*.test.ts --node-resolve --playwright --browsers chromium firefox webkit" 24 | }, 25 | "contributors": [ 26 | "Caleb D. Williams ", 27 | "Michael Warren " 28 | ], 29 | "license": "MIT", 30 | "sideEffects": false 31 | } 32 | -------------------------------------------------------------------------------- /packages/form-helpers/src/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | type Constructor> = new (...args: any[]) => T; 3 | 4 | /** 5 | * Backwards compatibility with jsdom < v21. In jsdom < 21 SubmitEvent is not implemented. 6 | */ 7 | const PolyfilledSubmitEvent: Constructor = globalThis.SubmitEvent = typeof globalThis.SubmitEvent !== 'undefined' ? SubmitEvent : Event as unknown as Constructor; 8 | 9 | 10 | /** 11 | * Implicitly submit a form by first validating all controls. If the form 12 | * is valid, issue a submit event and if that event is not prevented, manually 13 | * call the form's submit method. 14 | * 15 | * @param form {HTMLFormElement} - A form to implicitly submit 16 | */ 17 | export const submit = (form: HTMLFormElement): void => { 18 | if (!form.noValidate && !form.reportValidity()) { 19 | return; 20 | } else { 21 | const submitEvent = new PolyfilledSubmitEvent('submit', { 22 | bubbles: true, 23 | cancelable: true 24 | }); 25 | form.dispatchEvent(submitEvent); 26 | if (!submitEvent.defaultPrevented) { 27 | form.submit(); 28 | } 29 | } 30 | }; 31 | 32 | export type FormValue = string|FormData|File|FormValue[]; 33 | 34 | /** 35 | * Parse a form and return a set of values based on the name/value pair. 36 | * If multiple controls of a similar name exist, return an array for those values; 37 | * otherwise return a single value. 38 | * 39 | * @param form {HTMLFormElement} - The form to parse for values 40 | * @returns {Record} - An object representing the form's current values 41 | */ 42 | export const formValues = (form: HTMLFormElement): Record => { 43 | const formData = new FormData(form); 44 | const values: Record = {}; 45 | 46 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 47 | // @ts-ignore This does exist in all browsers. TypeScript is wrong 48 | for (const [key, value] of formData.entries()) { 49 | if (!values.hasOwnProperty(key)) { 50 | values[key] = value; 51 | } else if (Array.isArray(values[key])) { 52 | const pointer = values[key] as FormValue[]; 53 | pointer.push(value); 54 | } else { 55 | values[key] = [values[key], value]; 56 | } 57 | } 58 | 59 | return values; 60 | }; 61 | 62 | /** 63 | * This method takes a form and parses it as an object. If any form control has a `.` 64 | * in its name, this utility will evaluate that name as a deep key for an object; 65 | * in other words, if a form has a named control, `name.first` and another, `name.last` 66 | * it will report back a nested object, name, with first and last properties 67 | * representing those controls' values. 68 | * 69 | * This can be useful when you have a complex model that you are attempting to represent 70 | * in declaratively in HTML. 71 | * 72 | * @param form {HTMLFormElement} - The form to grab values from 73 | * @returns {Object} - An object representation of the form 74 | */ 75 | export const parseFormAsObject = (form: HTMLFormElement): Record => { 76 | const data = formValues(form); 77 | const output: Record = {}; 78 | 79 | Object.entries(data).forEach(([key, value]) => { 80 | /** If the key has a '.', parse it as an object */ 81 | if (key.includes('.')) { 82 | const path = key.split('.'); 83 | const destination: string | undefined = path.pop(); 84 | let pointer = output; 85 | 86 | while (path.length) { 87 | const key = path.shift(); 88 | pointer[key as string] = pointer[key as string] || ({} as FormValue); 89 | pointer = pointer[key as string] as unknown as Record; 90 | } 91 | 92 | pointer[destination as string] = value; 93 | } else { 94 | output[key] = data[key]; 95 | } 96 | }); 97 | 98 | return output; 99 | }; 100 | -------------------------------------------------------------------------------- /packages/form-helpers/tests/formValues.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, fixture, fixtureCleanup, html } from '@open-wc/testing'; 2 | import { formValues, parseFormAsObject } from '../src'; 3 | 4 | describe('The form values helper', () => { 5 | let form: HTMLFormElement; 6 | 7 | beforeEach(async () => { 8 | form = await fixture(html`
9 | 10 | 11 | 12 | 13 |
`); 14 | }); 15 | 16 | afterEach(fixtureCleanup); 17 | 18 | it('will return an object that reflects the form state', async () => { 19 | const data = formValues(form); 20 | expect(data).to.deep.equal({ 21 | foo: 'one', 22 | bar: 'two', 23 | baz: ['1', '2'] 24 | }); 25 | }); 26 | }); 27 | 28 | describe('the form as object helper', () => { 29 | let form: HTMLFormElement; 30 | 31 | beforeEach(async () => { 32 | form = await fixture(html`
33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 |
`); 43 | }); 44 | 45 | it('will parse the form values as an object', async () => { 46 | const data = parseFormAsObject(form); 47 | expect(data).to.deep.equal({ 48 | one: { 49 | a: 'a', 50 | b: 'b' 51 | }, 52 | two: '2', 53 | foo: { 54 | bar: { 55 | baz: 'baz', 56 | qux: 'qux' 57 | } 58 | }, 59 | three: ['three', 'tres'], 60 | tests: { 61 | are: ['helpful', 'frustrating'] 62 | } 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /packages/form-helpers/tests/submit.test.ts: -------------------------------------------------------------------------------- 1 | import { aTimeout, expect, fixture, fixtureCleanup, html } from '@open-wc/testing'; 2 | import * as sinon from 'sinon'; 3 | import { submit } from '../src'; 4 | 5 | let submitted = false; 6 | const submitCallbackPrevented = (event: Event) => { 7 | event.preventDefault(); 8 | submitted = true; 9 | }; 10 | const submitCallback = () => { 11 | submitted = true; 12 | }; 13 | 14 | describe('The submit form helper', () => { 15 | let form: HTMLFormElement; 16 | let formSubmitStub: sinon.SinonSpy; 17 | 18 | beforeEach(async () => { 19 | form = await fixture(html`
20 | 21 |
`); 22 | formSubmitStub = sinon.stub(form, 'submit').callsFake(() => {}); 23 | submitted = false; 24 | }); 25 | 26 | afterEach(() => { 27 | sinon.restore(); 28 | fixtureCleanup(); 29 | }); 30 | 31 | // it('will submit a form that is valid', async () => { 32 | // submit(form); 33 | // await aTimeout(0); 34 | // expect(submitted).to.be.true; 35 | // expect(formSubmitStub.callCount).to.equal(1); 36 | // }); 37 | 38 | it('will not fire the submit event for a form that is invalid', async () => { 39 | const input = form.querySelector('input')!; 40 | input.required = true; 41 | submit(form); 42 | expect(submitted).to.be.false; 43 | }); 44 | 45 | it('will not submit a form that is invalid', async () => { 46 | const input = form.querySelector('input')!; 47 | input.required = true; 48 | submit(form); 49 | 50 | expect(formSubmitStub.callCount).to.equal(0); 51 | }); 52 | 53 | it('will not submit a form when the submit event is `defaultPrevented`', async () => { 54 | form = await fixture(html`
55 | 56 |
`); 57 | 58 | formSubmitStub = sinon.stub(form, 'submit').callsFake(() => { return false; }); 59 | 60 | submit(form); 61 | 62 | expect(formSubmitStub.callCount).to.equal(0); 63 | }); 64 | 65 | it('will emit an event that bubbles', async () => { 66 | const onSubmit = (event: SubmitEvent) => { 67 | event.preventDefault(); 68 | expect(event.bubbles).to.be.true; 69 | } 70 | form = await fixture(html`
`); 71 | submit(form); 72 | }); 73 | 74 | describe('novalidate', () => { 75 | beforeEach(async () => { 76 | form = await fixture(html`
77 | 78 |
`); 79 | }); 80 | 81 | it('will respect novalidate attribute', async () => { 82 | submit(form); 83 | expect(submitted).to.be.true; 84 | }) 85 | }) 86 | }); 87 | -------------------------------------------------------------------------------- /packages/form-helpers/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["./index.ts", "./src"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/form-helpers/web-test-runner.config.mjs: -------------------------------------------------------------------------------- 1 | import { esbuildPlugin } from '@web/dev-server-esbuild'; 2 | import { sendKeysPlugin } from '@web/test-runner-commands/plugins'; 3 | 4 | export default { 5 | plugins: [esbuildPlugin({ ts: true, target: 'auto' }), sendKeysPlugin()] 6 | }; 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "declaration": true, 5 | "declarationMap": true, 6 | "experimentalDecorators": true, 7 | "lib": ["DOM", "ES2020"], 8 | "sourceMap": true, 9 | "inlineSources": true, 10 | "downlevelIteration": true, 11 | "strict": true, 12 | "moduleResolution": "node", 13 | "typeRoots": ["@types", "./types-global", "./types"], 14 | "types": ["mocha", "chai"], 15 | "allowSyntheticDefaultImports": true, 16 | "esModuleInterop": true, 17 | "forceConsistentCasingInFileNames": true 18 | }, 19 | "references": [ 20 | { 21 | "path": "./packages/form-control/tsconfig.json" 22 | } 23 | ] 24 | } 25 | --------------------------------------------------------------------------------