From 8d3d693f12d3a0d2a727917720843feb7cd3a18b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Murat=20=C3=87orlu?= <127687+muratcorlu@users.noreply.github.com> Date: Wed, 28 Sep 2022 09:27:54 +0200 Subject: [PATCH] feat(input): elementinternals implementation for input component (#220) * feat(input): input as native form elements * chore(input): add element-internals-polyfill to support safari * refactor(input): validation state with elementinternals * refactor(input): elementinternals for input * feat(input): elementinternals implementation for input Co-authored-by: Levent Anil Ozen --- package-lock.json | 96 +++++++++++------------ package.json | 3 + src/components/button/bl-button.ts | 6 ++ src/components/input/bl-input.css | 8 +- src/components/input/bl-input.stories.mdx | 33 +++++++- src/components/input/bl-input.test.ts | 65 ++++++++++++++- src/components/input/bl-input.ts | 91 +++++++++++++-------- src/utilities/form-control.test.ts | 41 ++++++++++ src/utilities/form-control.ts | 20 +++++ 9 files changed, 274 insertions(+), 89 deletions(-) create mode 100644 src/utilities/form-control.test.ts create mode 100644 src/utilities/form-control.ts diff --git a/package-lock.json b/package-lock.json index aea5f140..8976e6d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,9 @@ "@floating-ui/dom": "^0.5.4", "@fontsource/rubik": "^4.5.9", "@lit-labs/react": "^1.0.7", + "@open-wc/form-control": "^0.4.1", + "@open-wc/form-helpers": "^0.1.2", + "element-internals-polyfill": "^1.1.11", "lit": "^2.2.3" }, "devDependencies": { @@ -3411,6 +3414,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@open-wc/form-control": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@open-wc/form-control/-/form-control-0.4.1.tgz", + "integrity": "sha512-9mUrlKgB9No56LtcOeG9sTy16r9aQsOR/9fKh3r9xtkAgaU6tK9nlI8u9z/6CHsvMeyumxuWjE5sypajVN7qBA==" + }, + "node_modules/@open-wc/form-helpers": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@open-wc/form-helpers/-/form-helpers-0.1.2.tgz", + "integrity": "sha512-nENxFIlvk5l/jjEmWjO8xpSKQmv9HT2E1QY++/pY5GsjsTxBOYRhiG6BSyjysLBb7hBvLWfCL05qefzie6Juqw==" + }, "node_modules/@open-wc/scoped-elements": { "version": "2.0.1", "dev": true, @@ -12889,6 +12902,11 @@ "dev": true, "license": "ISC" }, + "node_modules/element-internals-polyfill": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/element-internals-polyfill/-/element-internals-polyfill-1.1.11.tgz", + "integrity": "sha512-+izpja9BOt31/LK/p/sjyN5x0Vu6STkwnBju5e9X3yIARrzgOz83M9QZE0Kn42v4Z7dKHhXG4AIYPwXvkzkEyQ==" + }, "node_modules/element-resize-detector": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/element-resize-detector/-/element-resize-detector-1.2.4.tgz", @@ -13221,22 +13239,6 @@ "esbuild-windows-arm64": "0.14.50" } }, - "node_modules/esbuild-darwin-arm64": { - "version": "0.14.50", - "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.50.tgz", - "integrity": "sha512-36nNs5OjKIb/Q50Sgp8+rYW/PqirRiFN0NFc9hEvgPzNJxeJedktXwzfJSln4EcRFRh5Vz4IlqFRScp+aiBBzA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/esbuild-plugin-lit-css": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/esbuild-plugin-lit-css/-/esbuild-plugin-lit-css-2.0.0.tgz", @@ -13562,27 +13564,22 @@ } }, "node_modules/espree": { - "version": "9.3.3", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.3.3.tgz", - "integrity": "sha512-ORs1Rt/uQTqUKjDdGCyrtYxbazf5umATSf/K4qxjmZHORR6HJk+2s/2Pqe+Kk49HHINC/xNIrGfgh8sZcll0ng==", + "version": "9.3.1", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.8.0", - "acorn-jsx": "^5.3.2", + "acorn": "^8.7.0", + "acorn-jsx": "^5.3.1", "eslint-visitor-keys": "^3.3.0" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" } }, "node_modules/espree/node_modules/acorn": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", - "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", + "version": "8.7.0", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -26689,10 +26686,9 @@ "license": "MIT" }, "node_modules/tslib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", - "dev": true + "version": "2.3.1", + "dev": true, + "license": "0BSD" }, "node_modules/tsscmp": { "version": "1.0.6", @@ -31386,6 +31382,16 @@ "version": "1.3.0", "dev": true }, + "@open-wc/form-control": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@open-wc/form-control/-/form-control-0.4.1.tgz", + "integrity": "sha512-9mUrlKgB9No56LtcOeG9sTy16r9aQsOR/9fKh3r9xtkAgaU6tK9nlI8u9z/6CHsvMeyumxuWjE5sypajVN7qBA==" + }, + "@open-wc/form-helpers": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@open-wc/form-helpers/-/form-helpers-0.1.2.tgz", + "integrity": "sha512-nENxFIlvk5l/jjEmWjO8xpSKQmv9HT2E1QY++/pY5GsjsTxBOYRhiG6BSyjysLBb7hBvLWfCL05qefzie6Juqw==" + }, "@open-wc/scoped-elements": { "version": "2.0.1", "dev": true, @@ -38428,6 +38434,11 @@ "version": "1.4.144", "dev": true }, + "element-internals-polyfill": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/element-internals-polyfill/-/element-internals-polyfill-1.1.11.tgz", + "integrity": "sha512-+izpja9BOt31/LK/p/sjyN5x0Vu6STkwnBju5e9X3yIARrzgOz83M9QZE0Kn42v4Z7dKHhXG4AIYPwXvkzkEyQ==" + }, "element-resize-detector": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/element-resize-detector/-/element-resize-detector-1.2.4.tgz", @@ -38702,13 +38713,6 @@ "esbuild-windows-arm64": "0.14.50" } }, - "esbuild-darwin-arm64": { - "version": "0.14.50", - "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.50.tgz", - "integrity": "sha512-36nNs5OjKIb/Q50Sgp8+rYW/PqirRiFN0NFc9hEvgPzNJxeJedktXwzfJSln4EcRFRh5Vz4IlqFRScp+aiBBzA==", - "dev": true, - "optional": true - }, "esbuild-plugin-lit-css": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/esbuild-plugin-lit-css/-/esbuild-plugin-lit-css-2.0.0.tgz", @@ -38927,20 +38931,16 @@ "dev": true }, "espree": { - "version": "9.3.3", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.3.3.tgz", - "integrity": "sha512-ORs1Rt/uQTqUKjDdGCyrtYxbazf5umATSf/K4qxjmZHORR6HJk+2s/2Pqe+Kk49HHINC/xNIrGfgh8sZcll0ng==", + "version": "9.3.1", "dev": true, "requires": { - "acorn": "^8.8.0", - "acorn-jsx": "^5.3.2", + "acorn": "^8.7.0", + "acorn-jsx": "^5.3.1", "eslint-visitor-keys": "^3.3.0" }, "dependencies": { "acorn": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", - "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", + "version": "8.7.0", "dev": true }, "eslint-visitor-keys": { @@ -48629,9 +48629,7 @@ "dev": true }, "tslib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "version": "2.3.1", "dev": true }, "tsscmp": { diff --git a/package.json b/package.json index 92d36dd8..2cfd8c64 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,9 @@ "@floating-ui/dom": "^0.5.4", "@fontsource/rubik": "^4.5.9", "@lit-labs/react": "^1.0.7", + "@open-wc/form-control": "^0.4.1", + "@open-wc/form-helpers": "^0.1.2", + "element-internals-polyfill": "^1.1.11", "lit": "^2.2.3" }, "engines": { diff --git a/src/components/button/bl-button.ts b/src/components/button/bl-button.ts index 52089e5d..8a43fdb9 100644 --- a/src/components/button/bl-button.ts +++ b/src/components/button/bl-button.ts @@ -72,6 +72,12 @@ export default class BlButton extends LitElement { @property({ type: String }) target?: TargetType = '_self'; + /** + * Sets the type of the button. Set `submit` to use button as the submitter of parent form. + */ + @property({ type: String }) + type: 'submit' | null; + /** * Fires when button clicked */ diff --git a/src/components/input/bl-input.css b/src/components/input/bl-input.css index 61abe0b1..28e1dc91 100644 --- a/src/components/input/bl-input.css +++ b/src/components/input/bl-input.css @@ -37,14 +37,14 @@ input:focus { --bl-input-border-color: var(--bl-color-primary); } -input:focus ~ bl-icon { - --bl-input-icon-color: var(--bl-color-primary); -} - :host([label-fixed]) bl-icon { top: calc(var(--bl-input-padding-vertical) + var(--bl-size-m)); } +input:focus ~ bl-icon { + --bl-input-icon-color: var(--bl-color-primary); +} + :host ::placeholder { color: var(--bl-color-content-tertiary); } diff --git a/src/components/input/bl-input.stories.mdx b/src/components/input/bl-input.stories.mdx index 3676b313..dc7b39c9 100644 --- a/src/components/input/bl-input.stories.mdx +++ b/src/components/input/bl-input.stories.mdx @@ -99,7 +99,7 @@ export const LabelStylesTemplate = args => html` Input component is the component for taking text input from user. -Inline styles in examples are only for **demo purposes**. Use regular CSS classes or tag selectors to set styles. +Inline styles in examples are only for **demo purposes**. Use regular CSS classes or tag selectors to set styles. ## Basic Usage @@ -135,6 +135,9 @@ If you want to use always it on top of the input, then you can use `label-fixed` {SingleInputTemplate.bind({})} + + {SingleInputTemplate.bind({})} + ## Input Help Text @@ -200,6 +203,34 @@ Inputs have 2 size options: `medium` and `large`. `medium` size is default and i +## Using within a form + +Input component uses [ElementInternals](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals) to associate with it's parent form automatically. When you use `bl-input` within a form with a `name` attribute, input's value will be automatically set parent form's FormData. Check the example below: + +```html +
+ + + + +
+ + +``` + +When you run this example and submit the form, you'll see key/value pairs of the inputs in the console. + +If user presses `Enter` key in an input inside a form, this will trigger submit of the form. This behaviour mimics the native input behaviour. + ## Reference diff --git a/src/components/input/bl-input.test.ts b/src/components/input/bl-input.test.ts index 206d42bf..4c2f542e 100644 --- a/src/components/input/bl-input.test.ts +++ b/src/components/input/bl-input.test.ts @@ -1,4 +1,4 @@ -import { assert, expect, fixture, oneEvent, html } from '@open-wc/testing'; +import { assert, expect, fixture, oneEvent, html, elementUpdated } from '@open-wc/testing'; import BlInput from './bl-input'; describe('bl-input', () => { @@ -73,6 +73,11 @@ describe('bl-input', () => { const el = await fixture( html`` ); + + el.reportValidity(); + + await elementUpdated(el); + const errorMessageElement = ( el.shadowRoot?.querySelector('.invalid-text') ); @@ -86,6 +91,9 @@ describe('bl-input', () => { it('should show error when reportValidity method called', async () => { const el = await fixture(html``); el.reportValidity(); + + await elementUpdated(el); + expect(el.validity.valid).to.be.false; const errorMessageElement = ( el.shadowRoot?.querySelector('.invalid-text') @@ -125,4 +133,59 @@ describe('bl-input', () => { expect(ev.detail).to.be.equal('some value'); }); }); + + describe('form integration', () => { + it('should show errors when parent form is submitted', async () => { + const form = await fixture(html`
+ +
`); + + const blInput = form.querySelector('bl-input'); + + form.addEventListener('submit', e => e.preventDefault()); + + form.dispatchEvent(new SubmitEvent('submit', {cancelable: true})); + + await elementUpdated(form); + + const errorMessageElement = ( + blInput?.shadowRoot?.querySelector('.invalid-text') + ); + + expect(blInput?.validity.valid).to.be.false; + + expect(errorMessageElement).to.exist; + + }); + + it('should submit parent form when pressed Enter key', async () => { + const form = await fixture(html`
+ + +
`); + + const blInput = form.querySelector('bl-input'); + + await elementUpdated(form); + + const submitEvent = new Promise(resolve => { + function listener(ev: SubmitEvent) { + ev.preventDefault(); + resolve(ev); + form.removeEventListener('submit', listener); + } + form.addEventListener('submit', listener); + }); + + const enterEvent = new KeyboardEvent('keydown', { + code: 'Enter', + cancelable: true + }); + + blInput?.dispatchEvent(enterEvent); + + const ev = await submitEvent; + expect(ev).to.exist; + }); + }); }); diff --git a/src/components/input/bl-input.ts b/src/components/input/bl-input.ts index 9fe9788a..ebbafbb7 100644 --- a/src/components/input/bl-input.ts +++ b/src/components/input/bl-input.ts @@ -2,8 +2,12 @@ import { CSSResultGroup, html, LitElement, TemplateResult } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; +import { FormControlMixin } from '@open-wc/form-control'; +import { submit } from '@open-wc/form-helpers'; import { live } from 'lit/directives/live.js'; import { event, EventDispatcher } from '../../utilities/event'; +import { innerInputValidators } from '../../utilities/form-control'; +import 'element-internals-polyfill'; import '../icon/bl-icon'; import style from './bl-input.css'; @@ -14,12 +18,15 @@ export type InputSize = 'medium' | 'large'; * @summary Baklava Input component */ @customElement('bl-input') -export default class BlInput extends LitElement { +export default class BlInput extends FormControlMixin(LitElement) { static get styles(): CSSResultGroup { return [style]; } - @query('input') private input: HTMLInputElement; + static formControlValidators = innerInputValidators; + + @query('input') + validationTarget: HTMLInputElement; /** * Type of the input. It's used to set `type` attribute of native input inside. Only `text` and `number` is supported for now. @@ -122,57 +129,73 @@ export default class BlInput extends LitElement { @event('bl-input') private onInput: EventDispatcher; /** - * Current validity state of input + * Fires when the value of an input element has been changed. */ - validity: ValidityState; + @event('bl-invalid') private onInvalid: EventDispatcher; - /** - * Runs input validation - */ - reportValidity() { - this._dirty = true; - this.input.checkValidity(); + connectedCallback(): void { + super.connectedCallback(); + this.addEventListener('keydown', this.onKeydown); + this.addEventListener('invalid', this.onError); + + this.internals.form?.addEventListener('submit', () => { + this.reportValidity(); + }); } - @state() private _dirty = false; + disconnectedCallback(): void { + super.disconnectedCallback(); + this.removeEventListener('keydown', this.onKeydown); + this.removeEventListener('invalid', this.onError); + } - private get dirty(): boolean { - return this._dirty; + private onKeydown = (event: KeyboardEvent): void => { + if (event.code === 'Enter' && this.form) { + submit(this.form); + } } - private get hasValue(): boolean { - return this.input?.value.length > 0; + private onError = (): void => { + this.onInvalid(this.internals.validity); } - private get _invalidText() { - return this.customInvalidText || this.input?.validationMessage; + @state() private dirty = false; + + validityCallback(): string | void { + return this.customInvalidText || this.validationTarget?.validationMessage; } - private get _invalidState() { - return this.input && !this.input?.validity.valid; + reportValidity() { + this.dirty = true; + return this.checkValidity(); } - private inputHandler() { - this.validity = this.input?.validity; - this.value = this.input.value; - this.onInput(this.input.value); + valueChangedCallback(value: string): void { + this.value = value; } - private changeHandler() { - this._dirty = true; - this.onChange(this.input.value); + private inputHandler(event: Event) { + const value = (event.target as HTMLInputElement).value; + + this.setValue(value); + this.onInput(value); + } + + private changeHandler(event: Event) { + const value = (event.target as HTMLInputElement).value; + + this.dirty = true; + this.setValue(value); + this.onChange(value); } firstUpdated() { - this.validity = this.input?.validity; - if (this._invalidState) { - this.requestUpdate(); - } + this.setValue(this.value); } render(): TemplateResult { - const invalidMessage = this._invalidState - ? html`

${this._invalidText}

` + const invalidMessage = !this.checkValidity() + ? html`

${this.validationMessage}

` : ``; const helpMessage = this.helpText ? html`

${this.helpText}

` : ``; const icon = this.icon @@ -182,8 +205,8 @@ export default class BlInput extends LitElement { const classes = { 'dirty': this.dirty, - 'has-icon': this.icon || (this.dirty && this._invalidState), - 'has-value': this.hasValue, + 'has-icon': this.icon || (this.dirty && !this.checkValidity()), + 'has-value': this.value !== null && this.value !== '', }; return html` diff --git a/src/utilities/form-control.test.ts b/src/utilities/form-control.test.ts new file mode 100644 index 00000000..9287ec42 --- /dev/null +++ b/src/utilities/form-control.test.ts @@ -0,0 +1,41 @@ +import { elementUpdated, expect, fixture, fixtureCleanup } from "@open-wc/testing"; +import { html, LitElement } from "lit"; +import { customElement, query } from "lit/decorators.js"; +import { innerInputValidators } from "./form-control" + +@customElement('my-valid-input') +class MyValidInput extends LitElement { + validationTarget: HTMLInputElement; +} + + +@customElement('my-invalid-input') +class MyInvalidInput extends LitElement { + @query('input') + validationTarget: HTMLInputElement; + + render() { + return html`` + } +} + +describe('Form Control Validators', () => { + afterEach(fixtureCleanup); + + it('should return true if validationTarget is not present', async () => { + + const el = await fixture(html``); + + expect(innerInputValidators.every(validator => validator.isValid(el))).to.be.true; + }); + + it('should return correct value if validationTarget present', async () => { + const el = await fixture(html``); + + await elementUpdated(el); + + expect(innerInputValidators.every(validator => validator.isValid(el))).to.be.false; + expect(innerInputValidators.find(validator => !validator.isValid(el))?.key).to.eq('valueMissing'); + + }); +}); diff --git a/src/utilities/form-control.ts b/src/utilities/form-control.ts new file mode 100644 index 00000000..f6cd2b3b --- /dev/null +++ b/src/utilities/form-control.ts @@ -0,0 +1,20 @@ +const validityStates: Array = [ + 'valueMissing', + 'typeMismatch', + 'tooLong', + 'tooShort', + 'rangeUnderflow', + 'rangeOverflow', + 'badInput', + 'customError', +]; + +export const innerInputValidators = validityStates.map(key => ({ + key, + isValid(instance: HTMLElement & { validationTarget: HTMLInputElement }) { + if (instance.validationTarget) { + return !instance.validationTarget.validity[key]; + } + return true; + }, +}));