From 0d86c2af3702f5dc322362d5058a35734267d463 Mon Sep 17 00:00:00 2001 From: Christophe Eymard Date: Fri, 10 Feb 2023 18:38:47 +0100 Subject: [PATCH 01/10] let popup be anchored to Element and not HTMLElement (#1186) It works with SVG as well, is there a need to be restrictive here ? --- src/components/popup/popup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/popup/popup.ts b/src/components/popup/popup.ts index 0edecc332..c44cac421 100644 --- a/src/components/popup/popup.ts +++ b/src/components/popup/popup.ts @@ -223,7 +223,7 @@ export default class SlPopup extends ShoelaceElement { // Locate the anchor by id const root = this.getRootNode() as Document | ShadowRoot; this.anchorEl = root.getElementById(this.anchor); - } else if (this.anchor instanceof HTMLElement) { + } else if (this.anchor instanceof Element) { // Use the anchor's reference this.anchorEl = this.anchor; } else { From 8493131db54a50e098113d53b4a2be927800a376 Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Fri, 10 Feb 2023 12:42:19 -0500 Subject: [PATCH 02/10] Revert "let popup be anchored to Element and not HTMLElement (#1186)" This reverts commit 0d86c2af3702f5dc322362d5058a35734267d463. --- src/components/popup/popup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/popup/popup.ts b/src/components/popup/popup.ts index c44cac421..0edecc332 100644 --- a/src/components/popup/popup.ts +++ b/src/components/popup/popup.ts @@ -223,7 +223,7 @@ export default class SlPopup extends ShoelaceElement { // Locate the anchor by id const root = this.getRootNode() as Document | ShadowRoot; this.anchorEl = root.getElementById(this.anchor); - } else if (this.anchor instanceof Element) { + } else if (this.anchor instanceof HTMLElement) { // Use the anchor's reference this.anchorEl = this.anchor; } else { From 737b55d78dd8af6294e9bb54d80c8f86c0052cff Mon Sep 17 00:00:00 2001 From: Christophe Eymard Date: Fri, 10 Feb 2023 21:56:57 +0100 Subject: [PATCH 03/10] allow Element as the anchor - now with correct typings --- src/components/popup/popup.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/popup/popup.ts b/src/components/popup/popup.ts index 0edecc332..c9e7f4f49 100644 --- a/src/components/popup/popup.ts +++ b/src/components/popup/popup.ts @@ -38,7 +38,7 @@ import type { CSSResultGroup } from 'lit'; export default class SlPopup extends ShoelaceElement { static styles: CSSResultGroup = styles; - private anchorEl: HTMLElement | null; + private anchorEl: Element | null; private cleanup: ReturnType | undefined; /** A reference to the internal popup container. Useful for animating and styling the popup with JavaScript. */ @@ -223,7 +223,7 @@ export default class SlPopup extends ShoelaceElement { // Locate the anchor by id const root = this.getRootNode() as Document | ShadowRoot; this.anchorEl = root.getElementById(this.anchor); - } else if (this.anchor instanceof HTMLElement) { + } else if (this.anchor instanceof Element) { // Use the anchor's reference this.anchorEl = this.anchor; } else { From 4a28825ea7c81694bc4e2e51ce9708501a4b8d7a Mon Sep 17 00:00:00 2001 From: xdev1 <86223516+xdev1@users.noreply.github.com> Date: Tue, 14 Feb 2023 20:50:06 +0100 Subject: [PATCH 04/10] Added some missing form validation standard features (implemented for #1181) (#1167) * #1163 - added read-only properties 'validity' and 'validationMessage' to all nine form controls * #1163 - added base support for showing form validation messages below the form controls * #1163 - animated validation errors in demo * #1181 - Removed all previous changes that have been validation error specific * Started with 'Inline validation' demo / fixed merge issues / etc. * #1181 - continued work on missing form validation features * #1181 - enhanced validation support for SlColorPicker / some cleanup * #1181 - fixed CSS issues * #1181 - fixed again CSS issues * '1181 - added form validation features finally working * #1181 - bug fixes * #1181 - fixed open issues / added API doc comments * #1181 - updated inline validation demos / removed some legacy code * #1181 - finished invalid form validation example * #1181 - added tests / several bugfixes * #1181 - fixed typos etc. * #1181 - tests * #1181 - tests * #1181 - tests --- docs/getting-started/form-controls.md | 424 +++++++++++++++++- src/components/button/button.test.ts | 28 ++ src/components/button/button.ts | 26 +- src/components/checkbox/checkbox.test.ts | 3 + src/components/checkbox/checkbox.ts | 21 +- .../color-picker/color-picker.test.ts | 3 + src/components/color-picker/color-picker.ts | 49 +- src/components/input/input.test.ts | 7 +- src/components/input/input.ts | 18 +- .../radio-group/radio-group.test.ts | 3 + src/components/radio-group/radio-group.ts | 54 ++- src/components/range/range.test.ts | 3 + src/components/range/range.ts | 18 +- src/components/select/select.test.ts | 3 + src/components/select/select.ts | 21 +- src/components/switch/switch.test.ts | 3 + src/components/switch/switch.ts | 21 +- src/components/textarea/textarea.test.ts | 3 + src/components/textarea/textarea.ts | 21 +- src/internal/form.ts | 63 ++- src/internal/shoelace-element.ts | 4 + src/internal/test/form-control-base-tests.ts | 323 +++++++++++++ 22 files changed, 1080 insertions(+), 39 deletions(-) create mode 100644 src/internal/test/form-control-base-tests.ts diff --git a/docs/getting-started/form-controls.md b/docs/getting-started/form-controls.md index 5edb1bb10..bf206c6b3 100644 --- a/docs/getting-started/form-controls.md +++ b/docs/getting-started/form-controls.md @@ -295,13 +295,15 @@ This example demonstrates custom validation styles using `data-user-invalid` and required > - + Birds Cats Dogs Other + Accept terms and conditions + Submit Reset @@ -316,46 +318,452 @@ This example demonstrates custom validation styles using `data-user-invalid` and ``` +## Inline Form Validation + +You can switch from normal validation mode, where validation messages are presented by browser specific tooltips, to an inline validation mode where the validation messages are displayed below the form fields, normally in red color. +This can be achieved completely in userland with customizations using CSS and JavaScript. +Here's the same example as the previous one, but this time we use inline form validation. + +```html preview + +
+ + + + Birds + Cats + Dogs + Other + + + Accept terms and conditions + + Submit + Reset +
+
+ + + + +``` + +## Inline Form Validation (old version - to be deleted after testing) // TODO!!!! + +```html preview + +
+ + Mrs. + Mr. + Other + + + + + + + USA + Canada + + + + + Please approve that this is really your favorite color + + + + Accept terms and conditions + + Submit + Reset +
+
+ + + + +``` + ## Getting Associated Form Controls At this time, using [`HTMLFormElement.elements`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/elements) will not return Shoelace form controls because the browser is unaware of their status as custom element form controls. Fortunately, Shoelace provides an `elements()` function that does something very similar. However, instead of returning an [`HTMLFormControlsCollection`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormControlsCollection), it returns an array of HTML and Shoelace form controls in the order they appear in the DOM. diff --git a/src/components/button/button.test.ts b/src/components/button/button.test.ts index 6a5b659c5..cc881fa2b 100644 --- a/src/components/button/button.test.ts +++ b/src/components/button/button.test.ts @@ -1,4 +1,5 @@ import { expect, fixture, html, waitUntil } from '@open-wc/testing'; +import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests'; import sinon from 'sinon'; import type SlButton from './button'; @@ -234,4 +235,31 @@ describe('', () => { expect(clickHandler).to.have.been.calledOnce; }); }); + + runFormControlBaseTests({ + tagName: 'sl-button', + variantName: 'type="button"', + + init: (control: SlButton) => { + control.type = 'button'; + } + }); + + runFormControlBaseTests({ + tagName: 'sl-button', + variantName: 'type="submit"', + + init: (control: SlButton) => { + control.type = 'submit'; + } + }); + + runFormControlBaseTests({ + tagName: 'sl-button', + variantName: 'href="xyz"', + + init: (control: SlButton) => { + control.href = 'some-url'; + } + }); }); diff --git a/src/components/button/button.ts b/src/components/button/button.ts index 4ef0f2c86..b301cea57 100644 --- a/src/components/button/button.ts +++ b/src/components/button/button.ts @@ -2,7 +2,7 @@ import '../icon/icon'; import '../spinner/spinner'; import { classMap } from 'lit/directives/class-map.js'; import { customElement, property, query, state } from 'lit/decorators.js'; -import { FormControlController } from '../../internal/form'; +import { FormControlController, validValidityState } from '../../internal/form'; import { HasSlotController } from '../../internal/slot'; import { html, literal } from 'lit/static-html.js'; import { ifDefined } from 'lit/directives/if-defined.js'; @@ -140,6 +140,24 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon /** Used to override the form owner's `target` attribute. */ @property({ attribute: 'formtarget' }) formTarget: '_self' | '_blank' | '_parent' | '_top' | string; + /** Gets the validity state object */ + get validity() { + if (this.isButton()) { + return (this.button as HTMLButtonElement).validity; + } + + return validValidityState; + } + + /** Gets the validation message */ + get validationMessage() { + if (this.isButton()) { + return (this.button as HTMLButtonElement).validationMessage; + } + + return ''; + } + connectedCallback() { super.connectedCallback(); this.handleHostClick = this.handleHostClick.bind(this); @@ -185,6 +203,11 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon } } + private handleInvalid(event: Event) { + this.formControlController.setValidity(false); + this.formControlController.emitSlInvalidEvent(event); + } + private isButton() { return this.href ? false : true; } @@ -290,6 +313,7 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon tabindex=${this.disabled ? '-1' : '0'} @blur=${this.handleBlur} @focus=${this.handleFocus} + @invalid=${this.isButton() ? this.handleInvalid : null} @click=${this.handleClick} > diff --git a/src/components/checkbox/checkbox.test.ts b/src/components/checkbox/checkbox.test.ts index 98bb4aff8..55816b1c9 100644 --- a/src/components/checkbox/checkbox.test.ts +++ b/src/components/checkbox/checkbox.test.ts @@ -1,5 +1,6 @@ import { clickOnElement } from '../../internal/test'; import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; +import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests'; import { sendKeys } from '@web/test-runner-commands'; import sinon from 'sinon'; import type SlCheckbox from './checkbox'; @@ -308,5 +309,7 @@ describe('', () => { expect(indeterminateIcon).to.be.null; }); + + runFormControlBaseTests('sl-checkbox'); }); }); diff --git a/src/components/checkbox/checkbox.ts b/src/components/checkbox/checkbox.ts index 893aa1516..a5ea069fa 100644 --- a/src/components/checkbox/checkbox.ts +++ b/src/components/checkbox/checkbox.ts @@ -26,6 +26,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element'; * @event sl-change - Emitted when the checked state changes. * @event sl-focus - Emitted when the checkbox gains focus. * @event sl-input - Emitted when the checkbox receives input. + * @event sl-invalid - Emitted when `.checkValidity()` or `.reportValidity()` has been called and the returned value is `false`. * * @csspart base - The component's base wrapper. * @csspart control - The square container that wraps the checkbox's checked state. @@ -85,6 +86,16 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC /** Makes the checkbox a required field. */ @property({ type: Boolean, reflect: true }) required = false; + /** Gets the validity state object */ + get validity() { + return this.input.validity; + } + + /** Gets the validation message */ + get validationMessage() { + return this.input.validationMessage; + } + firstUpdated() { this.formControlController.updateValidity(); } @@ -104,6 +115,11 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC this.emit('sl-input'); } + private handleInvalid(event: Event) { + this.formControlController.setValidity(false); + this.formControlController.emitSlInvalidEvent(event); + } + private handleFocus() { this.hasFocus = true; this.emit('sl-focus'); @@ -137,12 +153,12 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC this.input.blur(); } - /** Checks for validity but does not show a validation message. Returns true when valid and false when invalid. */ + /** Checks for validity but does not show a validation message. Returns true when valid and false when invalid. Will emit an `sl-invalid` event in case of negative result (if not disabled). */ checkValidity() { return this.input.checkValidity(); } - /** Checks for validity and shows a validation message if the control is invalid. */ + /** Checks for validity and shows a validation message if the control is invalid. Will emit an `sl-invalid` event in case of negative result (if not disabled). */ reportValidity() { return this.input.reportValidity(); } @@ -189,6 +205,7 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC aria-checked=${this.checked ? 'true' : 'false'} @click=${this.handleClick} @input=${this.handleInput} + @invalid=${this.handleInvalid} @blur=${this.handleBlur} @focus=${this.handleFocus} /> diff --git a/src/components/color-picker/color-picker.test.ts b/src/components/color-picker/color-picker.test.ts index 4ff028450..2c646c16c 100644 --- a/src/components/color-picker/color-picker.test.ts +++ b/src/components/color-picker/color-picker.test.ts @@ -1,5 +1,6 @@ import { aTimeout, expect, fixture, html, oneEvent } from '@open-wc/testing'; import { clickOnElement } from '../../internal/test'; +import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests'; import { sendKeys } from '@web/test-runner-commands'; import { serialize } from '../../utilities/form'; import sinon from 'sinon'; @@ -545,4 +546,6 @@ describe('', () => { // expect(el.hasAttribute('data-user-valid')).to.be.false; }); }); + + runFormControlBaseTests('sl-color-picker'); }); diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index abd14a53c..a65d3dad7 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -49,10 +49,11 @@ declare const EyeDropper: EyeDropperConstructor; * * @slot label - The color picker's form label. Alternatively, you can use the `label` attribute. * - * @event sl-blur Emitted when the color picker loses focus. - * @event sl-change Emitted when the color picker's value changes. - * @event sl-focus Emitted when the color picker receives focus. - * @event sl-input Emitted when the color picker receives input. + * @event sl-blur - Emitted when the color picker loses focus. + * @event sl-change - Emitted when the color picker's value changes. + * @event sl-focus - Emitted when the color picker receives focus. + * @event sl-input - Emitted when the color picker receives input. + * @event sl-invalid - Emitted when `.checkValidity()` or `.reportValidity()` has been called and the returned value is `false`. * * @csspart base - The component's base wrapper. * @csspart trigger - The color picker's dropdown trigger. @@ -174,6 +175,19 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo */ @property({ reflect: true }) form = ''; + /** Makes the color picker a required field. */ + @property({ type: Boolean, reflect: true }) required = false; + + /** Gets the validity state object */ + get validity() { + return this.input.validity; + } + + /** Gets the validation message */ + get validationMessage() { + return this.input.validationMessage; + } + connectedCallback() { super.connectedCallback(); this.handleFocusIn = this.handleFocusIn.bind(this); @@ -188,6 +202,12 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo this.removeEventListener('focusout', this.handleFocusOut); } + firstUpdated() { + this.input.updateComplete.then(() => { + this.formControlController.updateValidity(); + }); + } + private handleCopy() { this.input.select(); document.execCommand('copy'); @@ -444,6 +464,11 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo } } + private handleInputInvalid(event: Event) { + this.formControlController.setValidity(false); + this.formControlController.emitSlInvalidEvent(event); + } + private handleTouchMove(event: TouchEvent) { event.preventDefault(); } @@ -732,18 +757,24 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo } } - /** Checks for validity but does not show the browser's validation message. */ + /** Checks for validity but does not show the browser's validation message. Will emit an `sl-invalid` event in case of negative result (if not disabled). */ checkValidity() { return this.input.checkValidity(); } - /** Checks for validity and shows the browser's validation message if the control is invalid. */ + /** Checks for validity and shows the browser's validation message if the control is invalid. Will emit an `sl-invalid` event in case of negative result (if not disabled). */ reportValidity() { - if (!this.inline && !this.checkValidity()) { + if (!this.inline && !this.validity.valid) { // If the input is inline and invalid, show the dropdown so the browser can focus on it this.dropdown.show(); this.addEventListener('sl-after-show', () => this.input.reportValidity(), { once: true }); - return this.checkValidity(); + + if (!this.disabled) { + // By standards we have to emit a `sl-invalid` event here synchronously. + this.formControlController.emitSlInvalidEvent(); + } + + return false; } return this.input.reportValidity(); @@ -893,11 +924,13 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo autocapitalize="off" spellcheck="false" value=${this.isEmpty ? '' : this.inputValue} + ?required=${this.required} ?disabled=${this.disabled} aria-label=${this.localize.term('currentValue')} @keydown=${this.handleInputKeyDown} @sl-change=${this.handleInputChange} @sl-input=${this.handleInputInput} + @sl-invalid=${this.handleInputInvalid} @sl-blur=${this.stopNestedEventPropagation} @sl-focus=${this.stopNestedEventPropagation} > diff --git a/src/components/input/input.test.ts b/src/components/input/input.test.ts index 216a16b70..c72840aaa 100644 --- a/src/components/input/input.test.ts +++ b/src/components/input/input.test.ts @@ -1,8 +1,9 @@ // eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; import { getFormControls } from '../../../dist/utilities/form.js'; -import { sendKeys } from '@web/test-runner-commands'; -import { serialize } from '../../utilities/form'; // must come from the same module +import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests'; +import { sendKeys } from '@web/test-runner-commands'; // must come from the same module +import { serialize } from '../../utilities/form'; import sinon from 'sinon'; import type SlInput from './input'; @@ -496,4 +497,6 @@ describe('', () => { expect(formControls.map((fc: HTMLInputElement) => fc.value).join('')).to.equal('12345678910'); // eslint-disable-line }); }); + + runFormControlBaseTests('sl-input'); }); diff --git a/src/components/input/input.ts b/src/components/input/input.ts index f2e65518e..7d4b758a7 100644 --- a/src/components/input/input.ts +++ b/src/components/input/input.ts @@ -47,6 +47,7 @@ const isFirefox = isChromium ? false : navigator.userAgent.includes('Firefox'); * @event sl-clear - Emitted when the clear button is activated. * @event sl-focus - Emitted when the control gains focus. * @event sl-input - Emitted when the control receives input. + * @event sl-invalid - Emitted when `.checkValidity()` or `.reportValidity()` has been called and the returned value is `false`. * * @csspart form-control - The form control that wraps the label, input, and help text. * @csspart form-control-label - The label's wrapper. @@ -227,6 +228,16 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont this.value = input.value; } + /** Gets the validity state object */ + get validity() { + return this.input.validity; + } + + /** Gets the validation message */ + get validationMessage() { + return this.input.validationMessage; + } + firstUpdated() { this.formControlController.updateValidity(); } @@ -262,8 +273,9 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont this.emit('sl-input'); } - private handleInvalid() { + private handleInvalid(event: Event) { this.formControlController.setValidity(false); + this.formControlController.emitSlInvalidEvent(event); } private handleKeyDown(event: KeyboardEvent) { @@ -372,12 +384,12 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont } } - /** Checks for validity but does not show the browser's validation message. */ + /** Checks for validity but does not show the browser's validation message. Will emit an `sl-invalid` event in case of negative result (if not disabled). */ checkValidity() { return this.input.checkValidity(); } - /** Checks for validity and shows the browser's validation message if the control is invalid. */ + /** Checks for validity and shows the browser's validation message if the control is invalid. Will emit an `sl-invalid` event in case of negative result (if not disabled). */ reportValidity() { return this.input.reportValidity(); } diff --git a/src/components/radio-group/radio-group.test.ts b/src/components/radio-group/radio-group.test.ts index cf5069d70..a0307b56b 100644 --- a/src/components/radio-group/radio-group.test.ts +++ b/src/components/radio-group/radio-group.test.ts @@ -1,5 +1,6 @@ import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; import { clickOnElement } from '../../internal/test'; +import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests'; import { sendKeys } from '@web/test-runner-commands'; import sinon from 'sinon'; import type SlRadio from '../radio/radio'; @@ -315,4 +316,6 @@ describe('when the value changes', () => { radioGroup.value = '2'; await radioGroup.updateComplete; }); + + runFormControlBaseTests('sl-radio-group'); }); diff --git a/src/components/radio-group/radio-group.ts b/src/components/radio-group/radio-group.ts index ba1bca93d..18ecfc586 100644 --- a/src/components/radio-group/radio-group.ts +++ b/src/components/radio-group/radio-group.ts @@ -1,11 +1,19 @@ import '../button-group/button-group'; import { classMap } from 'lit/directives/class-map.js'; import { customElement, property, query, state } from 'lit/decorators.js'; -import { FormControlController } from '../../internal/form'; + +import { + customErrorValidityState, + FormControlController, + validValidityState, + valueMissingValidityState +} from '../../internal/form'; + import { HasSlotController } from '../../internal/slot'; import { html } from 'lit'; import { watch } from '../../internal/watch'; import ShoelaceElement from '../../internal/shoelace-element'; + import styles from './radio-group.styles'; import type { CSSResultGroup } from 'lit'; import type { ShoelaceFormControl } from '../../internal/shoelace-element'; @@ -26,6 +34,7 @@ import type SlRadioButton from '../radio-button/radio-button'; * * @event sl-change - Emitted when the radio group's selected value changes. * @event sl-input - Emitted when the radio group receives user input. + * @event sl-invalid - Emitted when `.checkValidity()` or `.reportValidity()` has been called and the returned value is `false`. * * @csspart form-control - The form control that wraps the label, input, and help text. * @csspart form-control-label - The label's wrapper. @@ -75,6 +84,34 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor /** Ensures a child radio is checked before allowing the containing form to submit. */ @property({ type: Boolean, reflect: true }) required = false; + /** Gets the validity state object */ + get validity() { + const isRequiredAndEmpty = this.required && !this.value; + const hasCustomValidityMessage = this.customValidityMessage !== ''; + + if (hasCustomValidityMessage) { + return customErrorValidityState; + } else if (isRequiredAndEmpty) { + return valueMissingValidityState; + } + + return validValidityState; + } + + /** Gets the validation message */ + get validationMessage() { + const isRequiredAndEmpty = this.required && !this.value; + const hasCustomValidityMessage = this.customValidityMessage !== ''; + + if (hasCustomValidityMessage) { + return this.customValidityMessage; + } else if (isRequiredAndEmpty) { + return this.validationInput.validationMessage; + } + + return ''; + } + connectedCallback() { super.connectedCallback(); this.defaultValue = this.value; @@ -187,10 +224,15 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor } } + private handleInvalid(event: Event) { + this.formControlController.setValidity(false); + this.formControlController.emitSlInvalidEvent(event); + } + private updateCheckedRadio() { const radios = this.getAllRadios(); radios.forEach(radio => (radio.checked = radio.value === this.value)); - this.formControlController.setValidity(this.checkValidity()); + this.formControlController.setValidity(this.validity.valid); } @watch('value') @@ -200,12 +242,13 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor } } - /** Checks for validity but does not show the browser's validation message. */ + /** Checks for validity but does not show the browser's validation message. Will emit an `sl-invalid` event in case of negative result. */ checkValidity() { const isRequiredAndEmpty = this.required && !this.value; const hasCustomValidityMessage = this.customValidityMessage !== ''; if (isRequiredAndEmpty || hasCustomValidityMessage) { + this.formControlController.emitSlInvalidEvent(); return false; } @@ -220,9 +263,9 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor this.formControlController.updateValidity(); } - /** Checks for validity and shows the browser's validation message if the control is invalid. */ + /** Checks for validity and shows the browser's validation message if the control is invalid. Will emit an `sl-invalid` event in case of negative result. */ reportValidity(): boolean { - const isValid = this.checkValidity(); + const isValid = this.validity.valid; this.errorMessage = this.customValidityMessage || isValid ? '' : this.validationInput.validationMessage; this.formControlController.setValidity(isValid); @@ -289,6 +332,7 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor ?required=${this.required} tabindex="-1" hidden + @invalid=${this.handleInvalid} /> diff --git a/src/components/range/range.test.ts b/src/components/range/range.test.ts index 4e38ce332..5ae39b39a 100644 --- a/src/components/range/range.test.ts +++ b/src/components/range/range.test.ts @@ -1,5 +1,6 @@ import { clickOnElement } from '../../internal/test'; import { expect, fixture, html, oneEvent } from '@open-wc/testing'; +import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests'; import { sendKeys } from '@web/test-runner-commands'; import { serialize } from '../../utilities/form'; import sinon from 'sinon'; @@ -229,4 +230,6 @@ describe('', () => { expect(input.value).to.equal(0); }); }); + + runFormControlBaseTests('sl-range'); }); diff --git a/src/components/range/range.ts b/src/components/range/range.ts index 0375da6b8..558fbc96b 100644 --- a/src/components/range/range.ts +++ b/src/components/range/range.ts @@ -101,6 +101,16 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont /** The default value of the form control. Primarily used for resetting the form control. */ @defaultValue() defaultValue = 0; + /** Gets the validity state object */ + get validity() { + return this.input.validity; + } + + /** Gets the validation message */ + get validationMessage() { + return this.input.validationMessage; + } + connectedCallback() { super.connectedCallback(); this.resizeObserver = new ResizeObserver(() => this.syncRange()); @@ -207,6 +217,11 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont } } + private handleInvalid(event: Event) { + this.formControlController.setValidity(false); + this.formControlController.emitSlInvalidEvent(event); + } + /** Sets focus on the range. */ focus(options?: FocusOptions) { this.input.focus(options); @@ -306,8 +321,9 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont .value=${live(this.value.toString())} aria-describedby="help-text" @change=${this.handleChange} - @input=${this.handleInput} @focus=${this.handleFocus} + @input=${this.handleInput} + @invalid=${this.handleInvalid} @blur=${this.handleBlur} /> ${this.tooltip !== 'none' && !this.disabled diff --git a/src/components/select/select.test.ts b/src/components/select/select.test.ts index 3ae735ea6..099ded92d 100644 --- a/src/components/select/select.test.ts +++ b/src/components/select/select.test.ts @@ -1,5 +1,6 @@ import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; import { clickOnElement } from '../../internal/test'; +import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests'; import { sendKeys } from '@web/test-runner-commands'; import { serialize } from '../../utilities/form'; import sinon from 'sinon'; @@ -548,4 +549,6 @@ describe('', () => { expect(tag.hasAttribute('pill')).to.be.true; }); + + runFormControlBaseTests('sl-select'); }); diff --git a/src/components/select/select.ts b/src/components/select/select.ts index fb9dda609..afc225c8e 100644 --- a/src/components/select/select.ts +++ b/src/components/select/select.ts @@ -46,6 +46,7 @@ import type SlPopup from '../popup/popup'; * @event sl-after-show - Emitted after the select's menu opens and all animations are complete. * @event sl-hide - Emitted when the select's menu closes. * @event sl-after-hide - Emitted after the select's menu closes and all animations are complete. + * @event sl-invalid - Emitted when `.checkValidity()` or `.reportValidity()` has been called and the returned value is `false`. * * @csspart form-control - The form control that wraps the label, input, and help text. * @csspart form-control-label - The label's wrapper. @@ -162,6 +163,16 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon /** The select's required attribute. */ @property({ type: Boolean, reflect: true }) required = false; + /** Gets the validity state object */ + get validity() { + return this.valueInput.validity; + } + + /** Gets the validation message */ + get validationMessage() { + return this.valueInput.validationMessage; + } + connectedCallback() { super.connectedCallback(); this.handleDocumentFocusIn = this.handleDocumentFocusIn.bind(this); @@ -520,6 +531,11 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon }); } + private handleInvalid(event: Event) { + this.formControlController.setValidity(false); + this.formControlController.emitSlInvalidEvent(event); + } + @watch('disabled', { waitUntilFirstUpdate: true }) handleDisabledChange() { // Close the listbox when the control is disabled @@ -603,12 +619,12 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon return waitForEvent(this, 'sl-after-hide'); } - /** Checks for validity but does not show the browser's validation message. */ + /** Checks for validity but does not show the browser's validation message. Will emit an `sl-invalid` event in case of negative result (if not disabled). */ checkValidity() { return this.valueInput.checkValidity(); } - /** Checks for validity and shows the browser's validation message if the control is invalid. */ + /** Checks for validity and shows the browser's validation message if the control is invalid. Will emit an `sl-invalid` event in case of negative result (if not disabled). */ reportValidity() { return this.valueInput.reportValidity(); } @@ -752,6 +768,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon tabindex="-1" aria-hidden="true" @focus=${() => this.focus()} + @invalid=${this.handleInvalid} /> ${hasClearIcon diff --git a/src/components/switch/switch.test.ts b/src/components/switch/switch.test.ts index 8276b7e75..5cdcc2ae8 100644 --- a/src/components/switch/switch.test.ts +++ b/src/components/switch/switch.test.ts @@ -1,4 +1,5 @@ import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; +import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests'; import { sendKeys } from '@web/test-runner-commands'; import sinon from 'sinon'; import type SlSwitch from './switch'; @@ -260,4 +261,6 @@ describe('', () => { expect(switchEl.checked).to.false; }); }); + + runFormControlBaseTests('sl-switch'); }); diff --git a/src/components/switch/switch.ts b/src/components/switch/switch.ts index afdcf3ec9..f97ef52b1 100644 --- a/src/components/switch/switch.ts +++ b/src/components/switch/switch.ts @@ -23,6 +23,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element'; * @event sl-change - Emitted when the control's checked state changes. * @event sl-input - Emitted when the control receives input. * @event sl-focus - Emitted when the control gains focus. + * @event sl-invalid - Emitted when `.checkValidity()` or `.reportValidity()` has been called and the returned value is `false`. * * @csspart base - The component's base wrapper. * @csspart control - The control that houses the switch's thumb. @@ -76,6 +77,16 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon /** Makes the switch a required field. */ @property({ type: Boolean, reflect: true }) required = false; + /** Gets the validity state object */ + get validity() { + return this.input.validity; + } + + /** Gets the validation message */ + get validationMessage() { + return this.input.validationMessage; + } + firstUpdated() { this.formControlController.updateValidity(); } @@ -89,6 +100,11 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon this.emit('sl-input'); } + private handleInvalid(event: Event) { + this.formControlController.setValidity(false); + this.formControlController.emitSlInvalidEvent(event); + } + private handleClick() { this.checked = !this.checked; this.emit('sl-change'); @@ -142,12 +158,12 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon this.input.blur(); } - /** Checks for validity but does not show the browser's validation message. */ + /** Checks for validity but does not show the browser's validation message. Will emit an `sl-invalid` event in case of negative result (if not disabled). */ checkValidity() { return this.input.checkValidity(); } - /** Checks for validity and shows the browser's validation message if the control is invalid. */ + /** Checks for validity and shows the browser's validation message if the control is invalid. Will emit an `sl-invalid` event in case of negative result (if not disabled). */ reportValidity() { return this.input.reportValidity(); } @@ -185,6 +201,7 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon aria-checked=${this.checked ? 'true' : 'false'} @click=${this.handleClick} @input=${this.handleInput} + @invalid=${this.handleInvalid} @blur=${this.handleBlur} @focus=${this.handleFocus} @keydown=${this.handleKeyDown} diff --git a/src/components/textarea/textarea.test.ts b/src/components/textarea/textarea.test.ts index c62634886..683fa387c 100644 --- a/src/components/textarea/textarea.test.ts +++ b/src/components/textarea/textarea.test.ts @@ -1,4 +1,5 @@ import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; +import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests'; import { sendKeys } from '@web/test-runner-commands'; import { serialize } from '../../utilities/form'; import sinon from 'sinon'; @@ -292,4 +293,6 @@ describe('', () => { expect(textarea.spellcheck).to.be.false; }); }); + + runFormControlBaseTests('sl-textarea'); }); diff --git a/src/components/textarea/textarea.ts b/src/components/textarea/textarea.ts index 11882593b..ed9f6cb15 100644 --- a/src/components/textarea/textarea.ts +++ b/src/components/textarea/textarea.ts @@ -25,6 +25,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element'; * @event sl-change - Emitted when an alteration to the control's value is committed by the user. * @event sl-focus - Emitted when the control gains focus. * @event sl-input - Emitted when the control receives input. + * @event sl-invalid - Emitted when `.checkValidity()` or `.reportValidity()` has been called and the returned value is `false`. * * @csspart form-control - The form control that wraps the label, input, and help text. * @csspart form-control-label - The label's wrapper. @@ -135,6 +136,16 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC /** 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; + } + + /** Gets the validation message */ + get validationMessage() { + return this.input.validationMessage; + } + connectedCallback() { super.connectedCallback(); this.resizeObserver = new ResizeObserver(() => this.setTextareaHeight()); @@ -175,6 +186,11 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC this.emit('sl-input'); } + private handleInvalid(event: Event) { + this.formControlController.setValidity(false); + this.formControlController.emitSlInvalidEvent(event); + } + private setTextareaHeight() { if (this.resize === 'auto') { this.input.style.height = 'auto'; @@ -260,12 +276,12 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC } } - /** Checks for validity but does not show the browser's validation message. */ + /** Checks for validity but does not show the browser's validation message. Will emit an `sl-invalid` event in case of negative result (if not disabled). */ checkValidity() { return this.input.checkValidity(); } - /** Checks for validity and shows the browser's validation message if the control is invalid. */ + /** Checks for validity and shows the browser's validation message if the control is invalid. Will emit an `sl-invalid` event in case of negative result (if not disabled). */ reportValidity() { return this.input.reportValidity(); } @@ -344,6 +360,7 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC aria-describedby="help-text" @change=${this.handleChange} @input=${this.handleInput} + @invalid=${this.handleInvalid} @focus=${this.handleFocus} @blur=${this.handleBlur} > diff --git a/src/internal/form.ts b/src/internal/form.ts index 9ae392290..7a1443ee4 100644 --- a/src/internal/form.ts +++ b/src/internal/form.ts @@ -127,7 +127,7 @@ export class FormControlController implements ReactiveController { } if (this.host.hasUpdated) { - this.setValidity(this.host.checkValidity()); + this.setValidity(this.host.validity.valid); } } @@ -341,11 +341,68 @@ export class FormControlController implements ReactiveController { } /** - * Updates the form control's validity based on the current value of `host.checkValidity()`. Call this when anything + * Updates the form control's validity based on the current value of `host.validity.valid`. Call this when anything * that affects constraint validation changes so the component receives the correct validity states. */ updateValidity() { const host = this.host; - this.setValidity(host.checkValidity()); + this.setValidity(host.validity.valid); + } + + /** + * Dispatches a non-bubbling, cancelable custom event of type `sl-invalid`. + * If the `sl-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 `sl-invalid` + * event will be cancelled before being dispatched. + */ + emitSlInvalidEvent(originalInvalidEvent?: Event) { + const slInvalidEvent = new CustomEvent('sl-invalid', { + bubbles: false, + composed: false, + cancelable: true + }); + + if (!originalInvalidEvent) { + slInvalidEvent.preventDefault(); + } + + if (!this.host.dispatchEvent(slInvalidEvent)) { + originalInvalidEvent?.preventDefault(); + } } } + +/* + * Predefined common validity states. + * All of them are read-only. + */ + +// A validity state object that represents `valid` +export const validValidityState: ValidityState = Object.freeze({ + badInput: false, + customError: false, + patternMismatch: false, + rangeOverflow: false, + rangeUnderflow: false, + stepMismatch: false, + tooLong: false, + tooShort: false, + typeMismatch: false, + valid: true, + valueMissing: false +}); + +// A validity state object that represents `value missing` +export const valueMissingValidityState: ValidityState = Object.freeze({ + ...validValidityState, + valid: false, + valueMissing: true +}); + +// A validity state object that represents a custom error +export const customErrorValidityState: ValidityState = Object.freeze({ + ...validValidityState, + valid: false, + customError: true +}); diff --git a/src/internal/shoelace-element.ts b/src/internal/shoelace-element.ts index 2188e9e93..3b8858039 100644 --- a/src/internal/shoelace-element.ts +++ b/src/internal/shoelace-element.ts @@ -40,6 +40,10 @@ export interface ShoelaceFormControl extends ShoelaceElement { minlength?: number; maxlength?: number; + // Validation properties + readonly validity: ValidityState; + readonly validationMessage: string; + // Validation methods checkValidity: () => boolean; reportValidity: () => boolean; diff --git a/src/internal/test/form-control-base-tests.ts b/src/internal/test/form-control-base-tests.ts new file mode 100644 index 000000000..adee3981a --- /dev/null +++ b/src/internal/test/form-control-base-tests.ts @@ -0,0 +1,323 @@ +import { expect, fixture } from '@open-wc/testing'; +import type { ShoelaceFormControl } from '../shoelace-element'; + +// === exports ======================================================= + +export { runFormControlBaseTests }; + +// === types ========================================================= + +type CreateControlFn = () => Promise; + +// === all form control tests ======================================== + +// Runs a set of generic tests for Shoelace form controls +function runFormControlBaseTests( + tagNameOrConfig: + | string + | { + tagName: string; + init?: (control: T) => void; + variantName: string; + } +) { + const isStringArg = typeof tagNameOrConfig === 'string'; + const tagName = isStringArg ? tagNameOrConfig : tagNameOrConfig.tagName; + + // component initialization function or null + const init = + isStringArg || !tagNameOrConfig.init // + ? null + : tagNameOrConfig.init || null; + + // either `` or ` () + const displayName = isStringArg // + ? tagName + : `${tagName} (${tagNameOrConfig.variantName})`; + + // creates a testable form control instance + const createControl = async () => { + const control = await createFormControl(tagName); + init?.(control); + return control; + }; + + runAllValidityTests(tagName, displayName, createControl); +} + +// === all validity tests ============================================ + +// Checks the correct behavior of: +// - `.validity` +// - `.validationMessage`, +// - `.checkValidity()` +// - `.reportValidity()` +// - `.setCustomValidity(msg)` +// +// Applicable for all Shoelace form controls +function runAllValidityTests( + tagName: string, // + displayName: string, + createControl: () => Promise +) { + // will be used later to retrieve meta information about the control + describe(`Form validity base test for ${displayName}`, async () => { + it('should have a property `validity` of type `object`', async () => { + const control = await createControl(); + expect(control).satisfy(() => control.validity !== null && typeof control.validity === 'object'); + }); + + it('should have a property `validationMessage` of type `string`', async () => { + const control = await createControl(); + expect(control).satisfy(() => typeof control.validationMessage === 'string'); + }); + + it('should implement method `checkValidity`', async () => { + const control = await createControl(); + expect(control).satisfies(() => typeof control.checkValidity === 'function'); + }); + + it('should implement method `setCustomValidity`', async () => { + const control = await createControl(); + expect(control).satisfies(() => typeof control.setCustomValidity === 'function'); + }); + + it('should implement method `reportValidity`', async () => { + const control = await createControl(); + expect(control).satisfies(() => typeof control.reportValidity === 'function'); + }); + + it('should be valid initially', async () => { + const control = await createControl(); + expect(control.validity.valid).to.equal(true); + }); + + it('should make sure that calling `.checkValidity()` will return `true` when valid', async () => { + const control = await createControl(); + expect(control.checkValidity()).to.equal(true); + }); + + it('should make sure that calling `.reportValidity()` will return `true` when valid', async () => { + const control = await createControl(); + expect(control.reportValidity()).to.equal(true); + }); + + it('should not emit an `sl-invalid` event when `.checkValidity()` is called while valid', async () => { + const control = await createControl(); + const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.checkValidity()); + expect(emittedEvents.length).to.equal(0); + }); + + it('should not emit an `sl-invalid` event when `.reportValidity()` is called while valid', async () => { + const control = await createControl(); + const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.reportValidity()); + expect(emittedEvents.length).to.equal(0); + }); + + // TODO: As soon as `SlRadioGroup` has a property `disabled` this + // condition can be removed + if (tagName !== 'sl-radio-group') { + it('should not emit an `sl-invalid` event when `.checkValidity()` is called in custom error case while disabled', async () => { + const control = await createControl(); + control.setCustomValidity('error'); + control.disabled = true; + await control.updateComplete; + const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.checkValidity()); + expect(emittedEvents.length).to.equal(0); + }); + + it('should not emit an `sl-invalid` event when `.reportValidity()` is called in custom error case while disabled', async () => { + const control = await createControl(); + control.setCustomValidity('error'); + control.disabled = true; + await control.updateComplete; + const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.reportValidity()); + expect(emittedEvents.length).to.equal(0); + }); + } + + // Run special tests depending on component type + + const mode = getMode(await createControl()); + + if (mode === 'slButtonOfTypeButton') { + runSpecialTests_slButtonOfTypeButton(createControl); + } else if (mode === 'slButtonWithHRef') { + runSpecialTests_slButtonWithHref(createControl); + } else { + runSpecialTests_standard(createControl); + } + }); +} + +// === special tests for ================= + +function runSpecialTests_slButtonOfTypeButton(createControl: CreateControlFn) { + it('should make sure that `.validity.valid` is `false` in custom error case', async () => { + const control = await createControl(); + control.setCustomValidity('error'); + expect(control.validity.valid).to.equal(false); + }); + + it('should make sure that calling `.checkValidity()` will still return `true` when custom error has been set', async () => { + const control = await createControl(); + control.setCustomValidity('error'); + expect(control.checkValidity()).to.equal(true); + }); + + it('should make sure that calling `.reportValidity()` will still return `true` when custom error has been set', async () => { + const control = await createControl(); + control.setCustomValidity('error'); + expect(control.reportValidity()).to.equal(true); + }); + + it('should not emit an `sl-invalid` event when `.checkValidity()` is called in custom error case, and not disabled', async () => { + const control = await createControl(); + control.setCustomValidity('error'); + control.disabled = false; + await control.updateComplete; + const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.checkValidity()); + expect(emittedEvents.length).to.equal(0); + }); + + it('should not emit an `sl-invalid` event when `.reportValidity()` is called in custom error case, and not disabled', async () => { + const control = await createControl(); + control.setCustomValidity('error'); + control.disabled = false; + await control.updateComplete; + const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.reportValidity()); + + expect(emittedEvents.length).to.equal(0); + }); +} + +// === special tests for =================== + +function runSpecialTests_slButtonWithHref(createControl: CreateControlFn) { + it('should make sure that calling `.checkValidity()` will return `true` in custom error case', async () => { + const control = await createControl(); + control.setCustomValidity('error'); + expect(control.checkValidity()).to.equal(true); + }); + + it('should make sure that calling `.reportValidity()` will return `true` in custom error case', async () => { + const control = await createControl(); + control.setCustomValidity('error'); + expect(control.reportValidity()).to.equal(true); + }); + + it('should not emit an `sl-invalid` event when `.checkValidity()` is called in custom error case', async () => { + const control = await createControl(); + control.setCustomValidity('error'); + await control.updateComplete; + const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.checkValidity()); + expect(emittedEvents.length).to.equal(0); + }); + + it('should not emit an `sl-invalid` event when `.reportValidity()` is called in custom error case', async () => { + const control = await createControl(); + control.setCustomValidity('error'); + await control.updateComplete; + const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.reportValidity()); + expect(emittedEvents.length).to.equal(0); + }); +} + +// === special tests for all components with usual behavior ========= + +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'); + expect(control.validity.valid).to.equal(false); + }); + + it('should make sure that calling `.checkValidity()` will return `false` in custom error case', async () => { + const control = await createControl(); + control.setCustomValidity('error'); + expect(control.checkValidity()).to.equal(false); + }); + + it('should make sure that calling `.reportValidity()` will return `false` in custom error case', async () => { + const control = await createControl(); + control.setCustomValidity('error'); + expect(control.reportValidity()).to.equal(false); + }); + + it('should emit an `sl-invalid` event when `.checkValidity()` is called in custom error case and not disabled', async () => { + const control = await createControl(); + control.setCustomValidity('error'); + control.disabled = false; + await control.updateComplete; + const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.checkValidity()); + expect(emittedEvents.length).to.equal(1); + }); + + it('should emit an `sl-invalid` event when `.reportValidity()` is called in custom error case and not disabled', async () => { + const control = await createControl(); + control.setCustomValidity('error'); + control.disabled = false; + await control.updateComplete; + const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.reportValidity()); + expect(emittedEvents.length).to.equal(1); + }); +} + +// === Local helper functions ======================================== + +// Creates a testable Shoelace form control instance +async function createFormControl(tagName: string): Promise { + return await fixture(`<${tagName}>`); +} + +// Runs an action while listening for emitted events of a given type. +// Returns an array of all events of the given type that have been +// been emitted while the action was running. +function checkEventEmissions(control: ShoelaceFormControl, eventType: string, action: () => void): Event[] { + const emittedEvents: Event[] = []; + + const eventHandler = (event: Event) => { + emittedEvents.push(event); + }; + + try { + control.addEventListener(eventType, eventHandler); + action(); + } finally { + control.removeEventListener(eventType, eventHandler); + } + + return emittedEvents; +} + +// Component `sl-button` behaves quite different to the other +// components. To keep things simple we use simple conditions +// here. `sl-button` might stay the only component in Shoelace +// core behaves that way, so we just hard code it here. +function getMode(control: ShoelaceFormControl) { + // shall behave the same way as + //