diff --git a/docs/getting-started/form-controls.md b/docs/getting-started/form-controls.md index 5edb1bb1..bf206c6b 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 6a5b659c..cc881fa2 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 4ef0f2c8..b301cea5 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 98bb4aff..55816b1c 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 893aa151..a5ea069f 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 4ff02845..2c646c16 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 abd14a53..a65d3dad 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 216a16b7..c72840aa 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 f2e65518..7d4b758a 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 cf5069d7..a0307b56 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 ba1bca93..18ecfc58 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 4e38ce33..5ae39b39 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 0375da6b..558fbc96 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 3ae735ea..099ded92 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 fb9dda60..afc225c8 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 8276b7e7..5cdcc2ae 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 afdcf3ec..f97ef52b 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 c6263488..683fa387 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 11882593..ed9f6cb1 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 9ae39229..7a1443ee 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 2188e9e9..3b885803 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 00000000..adee3981 --- /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 + //