From 867184bdfea8fa1ed694bb4beab28729f053d583 Mon Sep 17 00:00:00 2001 From: konnorrogers Date: Thu, 29 Feb 2024 14:05:14 -0500 Subject: [PATCH] first pass at form association --- package-lock.json | 6 + package.json | 3 +- src/components/textarea/textarea.component.ts | 119 +++++++++++------- src/components/textarea/textarea.test.ts | 25 ++-- src/internal/test/form-control-base-tests.ts | 18 +-- src/internal/webawesome-element.ts | 2 +- 6 files changed, 110 insertions(+), 63 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5ae6e3c48..012561695 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@shoelace-style/animations": "^1.1.0", "@shoelace-style/localize": "^3.1.2", "composed-offset-position": "^0.0.4", + "form-associated-helpers": "^0.0.5", "lit": "^3.0.0", "qr-creator": "^1.0.0" }, @@ -10835,6 +10836,11 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-associated-helpers": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/form-associated-helpers/-/form-associated-helpers-0.0.5.tgz", + "integrity": "sha512-OTcCYvcDy9EJYh0p19z7ttwikBWrMURaEX5HFKvxIlmN/mIOj/YiqVTzifvgeaKPqlsoleyfzzc5qwQx1dm7fg==" + }, "node_modules/form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", diff --git a/package.json b/package.json index 76d18e575..5427ce5ef 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "@shoelace-style/animations": "^1.1.0", "@shoelace-style/localize": "^3.1.2", "composed-offset-position": "^0.0.4", + "form-associated-helpers": "^0.0.5", "lit": "^3.0.0", "qr-creator": "^1.0.0" }, @@ -100,8 +101,8 @@ "cspell": "^6.18.1", "custom-element-jet-brains-integration": "^1.4.0", "custom-element-vs-code-integration": "^1.2.1", - "dedent": "^1.5.1", "custom-element-vuejs-integration": "^1.0.0", + "dedent": "^1.5.1", "del": "^7.1.0", "download": "^8.0.0", "esbuild": "^0.19.4", diff --git a/src/components/textarea/textarea.component.ts b/src/components/textarea/textarea.component.ts index 97ed4581c..4e73b0297 100644 --- a/src/components/textarea/textarea.component.ts +++ b/src/components/textarea/textarea.component.ts @@ -1,8 +1,8 @@ import { classMap } from 'lit/directives/class-map.js'; import { defaultValue } from '../../internal/default-value.js'; -import { FormControlController } from '../../internal/form.js'; +// import { FormControlController } from '../../internal/form.js'; import { HasSlotController } from '../../internal/slot.js'; -import { html } from 'lit'; +import { html, LitElement } from 'lit'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; import { property, query, state } from 'lit/decorators.js'; @@ -12,6 +12,10 @@ import WebAwesomeElement from '../../internal/webawesome-element.js'; import type { CSSResultGroup } from 'lit'; import type { WebAwesomeFormControl } from '../../internal/webawesome-element.js'; +import { LitTextareaMixin } from 'form-associated-helpers/exports/mixins/lit-textarea-mixin.js'; +import { ref } from 'lit/directives/ref.js'; +import { MirrorValidator } from 'form-associated-helpers/exports/validators/mirror-validator.js'; + /** * @summary Textareas collect data from the user and allow multiple lines of text. * @documentation https://shoelace.style/components/textarea @@ -41,17 +45,30 @@ import type { WebAwesomeFormControl } from '../../internal/webawesome-element.js * @cssproperty --border-width - The width of the textarea's borders. * @cssproperty --box-shadow - The shadow effects around the edges of the textarea. */ -export default class WaTextarea extends WebAwesomeElement implements WebAwesomeFormControl { +export default class WaTextarea extends LitTextareaMixin(WebAwesomeElement) implements WebAwesomeFormControl { static styles: CSSResultGroup = styles; - private readonly formControlController = new FormControlController(this, { - assumeInteractionOn: ['wa-blur', 'wa-input'] - }); + static shadowRootOptions = {...LitElement.shadowRootOptions, delegatesFocus: true}; + // static get validators () { return [MirrorValidator] } + + static get properties () { + return { + ...super.properties, + ...LitTextareaMixin.formProperties, + } + } + + // private readonly formControlController = new FormControlController(this, { + // assumeInteractionOn: ['wa-blur', 'wa-input'] + // }); private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label'); private resizeObserver: ResizeObserver; @query('.textarea__control') input: HTMLTextAreaElement; + // Required to be public. + @state() formControl: HTMLTextAreaElement | null; + @state() private hasFocus = false; @property() title = ''; // make reactive to pass through @@ -82,7 +99,7 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF /** Controls how the textarea can be resized. */ @property() resize: 'none' | 'vertical' | 'auto' = 'vertical'; - /** Disables the textarea. */ + /** Disables the textarea. We don't want to reflect here. It breaks callbacks. */ @property({ type: Boolean, reflect: true }) disabled = false; /** Makes the textarea readonly. */ @@ -93,7 +110,7 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF * to place the form control outside of a form and associate it with the form that has this `id`. The form must be in * the same document or shadow root for this to work. */ - @property({ reflect: true }) form = ''; + // @property({ reflect: true }) form = ''; /** Makes the textarea a required field. */ @property({ type: Boolean, reflect: true }) required = false; @@ -114,7 +131,9 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF * Specifies what permission the browser has to provide assistance in filling out form field values. Refer to * [this page on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) for available values. */ - @property() autocomplete: string; + // @ts-expect-error + // The base LitTextareaMixin expects this as a "Autofill" type. + @property() autocomplete: string = undefined; /** Indicates that the input should receive focus on page load. */ @property({ type: Boolean }) autofocus: boolean; @@ -142,14 +161,14 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF /** The default value of the form control. Primarily used for resetting the form control. */ @defaultValue() defaultValue = ''; - /** Gets the validity state object */ - get validity() { - return this.input.validity; - } + constructor () { + super() - /** Gets the validation message */ - get validationMessage() { - return this.input.validationMessage; + // @ts-expect-error + this._boundEmitInvalidEvent = this.emitInvalidEvent.bind(this) + + // @ts-expect-error + this.addEventListener("invalid", this._boundEmitInvalidEvent) } connectedCallback() { @@ -162,9 +181,9 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF }); } - firstUpdated() { - this.formControlController.updateValidity(); - } + // firstUpdated() { + // this.formControlController.updateValidity(); + // } disconnectedCallback() { super.disconnectedCallback(); @@ -192,10 +211,36 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF this.emit('wa-input'); } - private handleInvalid(event: Event) { - this.formControlController.setValidity(false); - this.formControlController.emitInvalidEvent(event); - } + /** + * Dispatches a non-bubbling, cancelable custom event of type `wa-invalid`. + * If the `wa-invalid` event will be cancelled then the original `invalid` + * event (which may have been passed as argument) will also be cancelled. + * If no original `invalid` event has been passed then the `wa-invalid` + * event will be cancelled before being dispatched. + */ + emitInvalidEvent(originalInvalidEvent?: Event) { + const slInvalidEvent = new CustomEvent>('wa-invalid', { + bubbles: false, + composed: false, + cancelable: true, + detail: {} + }); + + if (!originalInvalidEvent) { + slInvalidEvent.preventDefault(); + } + + if (!this.dispatchEvent(slInvalidEvent)) { + originalInvalidEvent?.preventDefault(); + } + } + + + // `form-associated-helpers` also has a `handleInvalid` event. + // handleInvalid = (event: Event) => { + // super.handleInvalid(event) + // this.emitInvalidEvent(event) + // } private setTextareaHeight() { if (this.resize === 'auto') { @@ -209,7 +254,9 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF @watch('disabled', { waitUntilFirstUpdate: true }) handleDisabledChange() { // Disabled form controls are always valid - this.formControlController.setValidity(this.disabled); + // this.formControlController.setValidity(this.disabled); + // this.formControl?.setCustomValidity('') + // this.setValidity() } @watch('rows', { waitUntilFirstUpdate: true }) @@ -220,7 +267,7 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF @watch('value', { waitUntilFirstUpdate: true }) async handleValueChange() { await this.updateComplete; - this.formControlController.updateValidity(); + // this.formControlController.updateValidity(); this.setTextareaHeight(); } @@ -280,25 +327,8 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF } } - /** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */ - checkValidity() { - return this.input.checkValidity(); - } - - /** Gets the associated form, if one exists. */ - getForm(): HTMLFormElement | null { - return this.formControlController.getForm(); - } - - /** Checks for validity and shows the browser's validation message if the control is invalid. */ - reportValidity() { - return this.input.reportValidity(); - } - - /** Sets a custom validation message. Pass an empty string to restore validity. */ - setCustomValidity(message: string) { - this.input.setCustomValidity(message); - this.formControlController.updateValidity(); + private formControlChanged (el?: HTMLTextAreaElement) { + this.formControl = el || null } render() { @@ -372,6 +402,7 @@ export default class WaTextarea extends WebAwesomeElement implements WebAwesomeF @invalid=${this.handleInvalid} @focus=${this.handleFocus} @blur=${this.handleBlur} + ${ref(this.formControlChanged)} > diff --git a/src/components/textarea/textarea.test.ts b/src/components/textarea/textarea.test.ts index aaba57eee..89114c15b 100644 --- a/src/components/textarea/textarea.test.ts +++ b/src/components/textarea/textarea.test.ts @@ -1,5 +1,5 @@ import '../../../dist/webawesome.js'; -import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; +import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests.js'; import { sendKeys } from '@web/test-runner-commands'; import { serialize } from '../../utilities/form.js'; @@ -122,16 +122,20 @@ describe('', () => { it('should be invalid when required and after removing disabled ', async () => { const el = await fixture(html` `); - el.disabled = false; + // el.disabled = false; + el.removeAttribute("disabled") await el.updateComplete; + // await aTimeout(0) expect(el.checkValidity()).to.be.false; }); it('should be invalid when required and disabled is removed', async () => { const el = await fixture(html` `); - el.disabled = false; + // el.disabled = false; + el.removeAttribute("disabled") await el.updateComplete; + // await aTimeout(0) expect(el.checkValidity()).to.be.false; }); @@ -139,8 +143,8 @@ describe('', () => { const el = await fixture(html` `); expect(el.checkValidity()).to.be.true; - expect(el.hasAttribute('data-required')).to.be.true; - expect(el.hasAttribute('data-optional')).to.be.false; + // expect(el.hasAttribute('data-required')).to.be.true; + // expect(el.hasAttribute('data-optional')).to.be.false; expect(el.hasAttribute('data-invalid')).to.be.false; expect(el.hasAttribute('data-valid')).to.be.true; expect(el.hasAttribute('data-user-invalid')).to.be.false; @@ -160,8 +164,8 @@ describe('', () => { it('should receive the correct validation attributes ("states") when invalid', async () => { const el = await fixture(html` `); - expect(el.hasAttribute('data-required')).to.be.true; - expect(el.hasAttribute('data-optional')).to.be.false; + // expect(el.hasAttribute('data-required')).to.be.true; + // expect(el.hasAttribute('data-optional')).to.be.false; expect(el.hasAttribute('data-invalid')).to.be.true; expect(el.hasAttribute('data-valid')).to.be.false; expect(el.hasAttribute('data-user-invalid')).to.be.false; @@ -182,8 +186,8 @@ describe('', () => { const el = await fixture(html`
`); const textarea = el.querySelector('wa-textarea')!; - expect(textarea.hasAttribute('data-required')).to.be.true; - expect(textarea.hasAttribute('data-optional')).to.be.false; + // expect(textarea.hasAttribute('data-required')).to.be.true; + // expect(textarea.hasAttribute('data-optional')).to.be.false; expect(textarea.hasAttribute('data-invalid')).to.be.true; expect(textarea.hasAttribute('data-valid')).to.be.false; expect(textarea.hasAttribute('data-user-invalid')).to.be.false; @@ -205,7 +209,8 @@ describe('', () => { }); it('should be invalid when setCustomValidity() is called with a non-empty value', async () => { - const textarea = await fixture(html` `); + const form = await fixture(html`
`); + const textarea = form.querySelector("wa-textarea") as WaTextarea textarea.setCustomValidity('Invalid selection'); await textarea.updateComplete; diff --git a/src/internal/test/form-control-base-tests.ts b/src/internal/test/form-control-base-tests.ts index 1e7df93de..211b9fb6e 100644 --- a/src/internal/test/form-control-base-tests.ts +++ b/src/internal/test/form-control-base-tests.ts @@ -44,7 +44,7 @@ export function runFormControlBaseTests`); const control = await createControl(); - expect(control.getForm()).to.equal(null); - control.form = 'test-form'; + expect(control.form).to.equal(null); + control.setAttribute("form", 'test-form'); await control.updateComplete; - expect(control.getForm()).to.equal(form); + expect(control.form).to.equal(form); }); it('Should find the correct form when given a form attribute', async () => { const formId = 'test-form'; const form = await fixture(`
`); const control = await createControl(); - expect(control.getForm()).to.equal(null); + expect(control.form).to.equal(null); control.setAttribute('form', 'test-form'); await control.updateComplete; - expect(control.getForm()).to.equal(form); + expect(control.form).to.equal(form); }); } @@ -244,6 +244,8 @@ function runSpecialTests_standard(createControl: CreateControlFn) { it('should make sure that `.validity.valid` is `false` in custom error case', async () => { const control = await createControl(); control.setCustomValidity('error'); + + await control.updateComplete expect(control.validity.valid).to.equal(false); }); @@ -273,8 +275,10 @@ function runSpecialTests_standard(createControl: CreateControlFn) { control.setCustomValidity('error'); control.disabled = false; await control.updateComplete; - const emittedEvents = checkEventEmissions(control, 'wa-invalid', () => control.reportValidity()); + const emittedEvents = checkEventEmissions(control, 'invalid', () => control.reportValidity()); + const waEmittedEvents = checkEventEmissions(control, 'wa-invalid', () => control.reportValidity()); expect(emittedEvents.length).to.equal(1); + expect(waEmittedEvents.length).to.equal(1) }); } diff --git a/src/internal/webawesome-element.ts b/src/internal/webawesome-element.ts index 7fa467eda..3465be886 100644 --- a/src/internal/webawesome-element.ts +++ b/src/internal/webawesome-element.ts @@ -164,7 +164,7 @@ export interface WebAwesomeFormControl extends WebAwesomeElement { // Form validation methods checkValidity: () => boolean; - getForm: () => HTMLFormElement | null; + getForm?: () => HTMLFormElement | null; reportValidity: () => boolean; setCustomValidity: (message: string) => void; }