diff --git a/docs/resources/changelog.md b/docs/resources/changelog.md index 09087ab92..99fca4453 100644 --- a/docs/resources/changelog.md +++ b/docs/resources/changelog.md @@ -19,6 +19,8 @@ New versions of Shoelace are released as-needed and generally occur when a criti - Fixed a bug in `` that caused selected colors to be wrong due to incorrect HSV calculations - Fixed a bug in `` that caused the checked button's right border to be incorrect [#1110](https://github.com/shoelace-style/shoelace/issues/1110) - Fixed a bug in `` that caused the animation to stop working correctly in Safari [#1121](https://github.com/shoelace-style/shoelace/issues/1121) +- Refactored the `ShoelaceFormControl` interface to remove the `invalid` property, allowing a more intuitive API for controlling validation internally +- Renamed the internal `FormSubmitController` to `FormControlController` to better reflect what it's used for ## 2.0.0-beta.88 diff --git a/src/components/button/button.ts b/src/components/button/button.ts index c6e948f48..465094ca4 100644 --- a/src/components/button/button.ts +++ b/src/components/button/button.ts @@ -2,7 +2,7 @@ import { customElement, property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { html, literal } from 'lit/static-html.js'; -import { FormSubmitController } from '../../internal/form'; +import { FormControlController } from '../../internal/form'; import ShoelaceElement from '../../internal/shoelace-element'; import { HasSlotController } from '../../internal/slot'; import { watch } from '../../internal/watch'; @@ -39,7 +39,7 @@ import type { CSSResultGroup } from 'lit'; export default class SlButton extends ShoelaceElement implements ShoelaceFormControl { static styles: CSSResultGroup = styles; - private readonly formSubmitController = new FormSubmitController(this, { + private readonly formControlController = new FormControlController(this, { form: input => { // Buttons support a form attribute that points to an arbitrary form, so if this attribute it set we need to query // the form from the same root using its id @@ -141,7 +141,7 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon firstUpdated() { if (this.isButton()) { - this.invalid = !(this.button as HTMLButtonElement).checkValidity(); + this.formControlController.updateValidity(); } } @@ -163,11 +163,11 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon } if (this.type === 'submit') { - this.formSubmitController.submit(this); + this.formControlController.submit(this); } if (this.type === 'reset') { - this.formSubmitController.reset(this); + this.formControlController.reset(this); } } @@ -181,10 +181,9 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon @watch('disabled', { waitUntilFirstUpdate: true }) handleDisabledChange() { - // Disabled form controls are always valid, so we need to recheck validity when the state changes if (this.isButton()) { - this.button.disabled = this.disabled; - this.invalid = !(this.button as HTMLButtonElement).checkValidity(); + // Disabled form controls are always valid + this.formControlController.setValidity(this.disabled); } } @@ -225,7 +224,7 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon setCustomValidity(message: string) { if (this.isButton()) { (this.button as HTMLButtonElement).setCustomValidity(message); - this.invalid = !(this.button as HTMLButtonElement).checkValidity(); + this.formControlController.updateValidity(); } } diff --git a/src/components/checkbox/checkbox.ts b/src/components/checkbox/checkbox.ts index bd5e555db..7e89fbf6a 100644 --- a/src/components/checkbox/checkbox.ts +++ b/src/components/checkbox/checkbox.ts @@ -4,7 +4,7 @@ import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; import { defaultValue } from '../../internal/default-value'; -import { FormSubmitController } from '../../internal/form'; +import { FormControlController } from '../../internal/form'; import ShoelaceElement from '../../internal/shoelace-element'; import { watch } from '../../internal/watch'; import '../icon/icon'; @@ -39,8 +39,7 @@ import type { CSSResultGroup } from 'lit'; export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormControl { static styles: CSSResultGroup = styles; - // @ts-expect-error - Controller is currently unused - private readonly formSubmitController = new FormSubmitController(this, { + private readonly formControlController = new FormControlController(this, { value: (control: SlCheckbox) => (control.checked ? control.value || 'on' : undefined), defaultValue: (control: SlCheckbox) => control.defaultChecked, setValue: (control: SlCheckbox, checked: boolean) => (control.checked = checked) @@ -49,7 +48,6 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC @query('input[type="checkbox"]') input: HTMLInputElement; @state() private hasFocus = false; - @state() invalid = false; @property() title = ''; // make reactive to pass through @@ -81,7 +79,7 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC @defaultValue('checked') defaultChecked = false; firstUpdated() { - this.invalid = !this.checkValidity(); + this.formControlController.updateValidity(); } private handleClick() { @@ -106,16 +104,15 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC @watch('disabled', { waitUntilFirstUpdate: true }) handleDisabledChange() { - // Disabled form controls are always valid, so we need to recheck validity when the state changes - this.input.disabled = this.disabled; - this.invalid = !this.checkValidity(); + // Disabled form controls are always valid + this.formControlController.setValidity(this.disabled); } @watch(['checked', 'indeterminate'], { waitUntilFirstUpdate: true }) handleStateChange() { this.input.checked = this.checked; // force a sync update this.input.indeterminate = this.indeterminate; // force a sync update - this.invalid = !this.checkValidity(); + this.formControlController.updateValidity(); } /** Simulates a click on the checkbox. */ @@ -149,7 +146,7 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC */ setCustomValidity(message: string) { this.input.setCustomValidity(message); - this.invalid = !this.checkValidity(); + this.formControlController.updateValidity(); } render() { diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index fb3b87ed8..bac40c9d9 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -6,7 +6,7 @@ import { ifDefined } from 'lit/directives/if-defined.js'; import { styleMap } from 'lit/directives/style-map.js'; import { defaultValue } from '../../internal/default-value'; import { drag } from '../../internal/drag'; -import { FormSubmitController } from '../../internal/form'; +import { FormControlController } from '../../internal/form'; import { clamp } from '../../internal/math'; import ShoelaceElement from '../../internal/shoelace-element'; import { watch } from '../../internal/watch'; @@ -88,8 +88,7 @@ declare const EyeDropper: EyeDropperConstructor; export default class SlColorPicker extends ShoelaceElement implements ShoelaceFormControl { static styles: CSSResultGroup = styles; - // @ts-expect-error - Controller is currently unused - private readonly formSubmitController = new FormSubmitController(this); + private readonly formControlController = new FormControlController(this); private isSafeValue = false; private lastValueEmitted: string; private readonly localize = new LocalizeController(this); @@ -105,7 +104,6 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo @state() private saturation = 100; @state() private brightness = 100; @state() private alpha = 100; - @state() invalid = false; /** * The current value of the color picker. The value's format will vary based the `format` attribute. To get the value @@ -679,7 +677,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo /** Checks for validity and shows the browser's validation message if the control is invalid. */ reportValidity() { - if (!this.inline && this.input.invalid) { + if (!this.inline && !this.checkValidity()) { // 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 }); @@ -692,7 +690,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo /** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */ setCustomValidity(message: string) { this.input.setCustomValidity(message); - this.invalid = this.input.invalid; + this.formControlController.updateValidity(); } render() { diff --git a/src/components/input/input.ts b/src/components/input/input.ts index e68bc5c3f..2f480ffe9 100644 --- a/src/components/input/input.ts +++ b/src/components/input/input.ts @@ -4,7 +4,7 @@ import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; import { defaultValue } from '../../internal/default-value'; -import { FormSubmitController } from '../../internal/form'; +import { FormControlController } from '../../internal/form'; import ShoelaceElement from '../../internal/shoelace-element'; import { HasSlotController } from '../../internal/slot'; import { watch } from '../../internal/watch'; @@ -63,14 +63,13 @@ const isFirefox = isChromium ? false : navigator.userAgent.includes('Firefox'); export default class SlInput extends ShoelaceElement implements ShoelaceFormControl { static styles: CSSResultGroup = styles; - private readonly formSubmitController = new FormSubmitController(this); + private readonly formControlController = new FormControlController(this); private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label'); private readonly localize = new LocalizeController(this); @query('.input__control') input: HTMLInputElement; @state() private hasFocus = false; - @state() invalid = false; @property() title = ''; // make reactive to pass through /** @@ -220,7 +219,7 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont } firstUpdated() { - this.invalid = !this.checkValidity(); + this.formControlController.updateValidity(); } private handleBlur() { @@ -250,12 +249,12 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont private handleInput() { this.value = this.input.value; - this.invalid = !this.checkValidity(); + this.formControlController.updateValidity(); this.emit('sl-input'); } private handleInvalid() { - this.invalid = true; + this.formControlController.setValidity(false); } private handleKeyDown(event: KeyboardEvent) { @@ -272,7 +271,7 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont // See https://github.com/shoelace-style/shoelace/pull/988 // if (!event.defaultPrevented && !event.isComposing) { - this.formSubmitController.submit(); + this.formControlController.submit(); } }); } @@ -284,9 +283,8 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont @watch('disabled', { waitUntilFirstUpdate: true }) handleDisabledChange() { - // Disabled form controls are always valid, so we need to recheck validity when the state changes - this.input.disabled = this.disabled; - this.invalid = !this.checkValidity(); + // Disabled form controls are always valid + this.formControlController.setValidity(this.disabled); } @watch('step', { waitUntilFirstUpdate: true }) @@ -294,13 +292,13 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont // If step changes, the value may become invalid so we need to recheck after the update. We set the new step // imperatively so we don't have to wait for the next render to report the updated validity. this.input.step = String(this.step); - this.invalid = !this.checkValidity(); + this.formControlController.updateValidity(); } @watch('value', { waitUntilFirstUpdate: true }) async handleValueChange() { await this.updateComplete; - this.invalid = !this.checkValidity(); + this.formControlController.updateValidity(); } /** Sets focus on the input. */ @@ -378,7 +376,7 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont /** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */ setCustomValidity(message: string) { this.input.setCustomValidity(message); - this.invalid = !this.checkValidity(); + this.formControlController.updateValidity(); } render() { @@ -459,7 +457,6 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont enterkeyhint=${ifDefined(this.enterkeyhint)} inputmode=${ifDefined(this.inputmode)} aria-describedby="help-text" - aria-invalid=${this.invalid ? 'true' : 'false'} @change=${this.handleChange} @input=${this.handleInput} @invalid=${this.handleInvalid} diff --git a/src/components/radio-group/radio-group.ts b/src/components/radio-group/radio-group.ts index 56b1e087e..2f7c4c3c7 100644 --- a/src/components/radio-group/radio-group.ts +++ b/src/components/radio-group/radio-group.ts @@ -1,7 +1,7 @@ import { html } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; -import { FormSubmitController } from '../../internal/form'; +import { FormControlController } from '../../internal/form'; import ShoelaceElement from '../../internal/shoelace-element'; import { HasSlotController } from '../../internal/slot'; import { watch } from '../../internal/watch'; @@ -38,7 +38,7 @@ import type { CSSResultGroup } from 'lit'; export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFormControl { static styles: CSSResultGroup = styles; - protected readonly formSubmitController = new FormSubmitController(this, { + protected readonly formControlController = new FormControlController(this, { defaultValue: control => control.defaultValue }); private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label'); @@ -50,7 +50,6 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor @state() private errorMessage = ''; @state() private customErrorMessage = ''; @state() defaultValue = ''; - @state() invalid = false; /** * The radio group's label. Required for proper accessibility. If you need to display HTML, use the `label` slot @@ -76,7 +75,7 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor } firstUpdated() { - this.invalid = !this.validity.valid; + this.formControlController.updateValidity(); } private getAllRadios() { @@ -191,7 +190,7 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor private updateCheckedRadio() { const radios = this.getAllRadios(); radios.forEach(radio => (radio.checked = radio.value === this.value)); - this.invalid = !this.validity.valid; + this.formControlController.setValidity(this.validity.valid); } @watch('value') @@ -212,9 +211,9 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor this.errorMessage = message; if (!message) { - this.invalid = false; + this.formControlController.setValidity(true); } else { - this.invalid = true; + this.formControlController.setValidity(false); this.input.setCustomValidity(message); } } @@ -243,13 +242,13 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor const validity = this.validity; this.errorMessage = this.customErrorMessage || validity.valid ? '' : this.input.validationMessage; - this.invalid = !validity.valid; + this.formControlController.setValidity(validity.valid); if (!validity.valid) { this.showNativeErrorMessage(); } - return !this.invalid; + return validity.valid; } render() { diff --git a/src/components/range/range.ts b/src/components/range/range.ts index c726fddef..6f28d0d8e 100644 --- a/src/components/range/range.ts +++ b/src/components/range/range.ts @@ -4,7 +4,7 @@ import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; import { defaultValue } from '../../internal/default-value'; -import { FormSubmitController } from '../../internal/form'; +import { FormControlController } from '../../internal/form'; import ShoelaceElement from '../../internal/shoelace-element'; import { HasSlotController } from '../../internal/slot'; import { watch } from '../../internal/watch'; @@ -46,8 +46,7 @@ import type { CSSResultGroup } from 'lit'; export default class SlRange extends ShoelaceElement implements ShoelaceFormControl { static styles: CSSResultGroup = styles; - // @ts-expect-error - Controller is currently unused - private readonly formSubmitController = new FormSubmitController(this); + private readonly formControlController = new FormControlController(this); private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label'); private readonly localize = new LocalizeController(this); private resizeObserver: ResizeObserver; @@ -57,7 +56,6 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont @state() private hasFocus = false; @state() private hasTooltip = false; - @state() invalid = false; @property() title = ''; // make reactive to pass through /** The name of the range, submitted as a name/value pair with form data. */ @@ -175,7 +173,7 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont @watch('value', { waitUntilFirstUpdate: true }) handleValueChange() { - this.invalid = !this.checkValidity(); + this.formControlController.updateValidity(); // The value may have constraints, so we set the native control's value and sync it back to ensure it adhere's to // min, max, and step properly @@ -187,9 +185,8 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont @watch('disabled', { waitUntilFirstUpdate: true }) handleDisabledChange() { - // Disabled form controls are always valid, so we need to recheck validity when the state changes - this.input.disabled = this.disabled; - this.invalid = !this.checkValidity(); + // Disabled form controls are always valid + this.formControlController.setValidity(this.disabled); } @watch('hasTooltip', { waitUntilFirstUpdate: true }) @@ -242,7 +239,7 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont /** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */ setCustomValidity(message: string) { this.input.setCustomValidity(message); - this.invalid = !this.checkValidity(); + this.formControlController.updateValidity(); } render() { diff --git a/src/components/select/select.ts b/src/components/select/select.ts index b4a23e229..5420667e4 100644 --- a/src/components/select/select.ts +++ b/src/components/select/select.ts @@ -5,7 +5,7 @@ import { scrollIntoView } from 'src/internal/scroll'; import { animateTo, stopAnimations } from '../../internal/animate'; import { defaultValue } from '../../internal/default-value'; import { waitForEvent } from '../../internal/event'; -import { FormSubmitController } from '../../internal/form'; +import { FormControlController } from '../../internal/form'; import ShoelaceElement from '../../internal/shoelace-element'; import { HasSlotController } from '../../internal/slot'; import { watch } from '../../internal/watch'; @@ -64,8 +64,7 @@ import type { CSSResultGroup } from 'lit'; export default class SlSelect extends ShoelaceElement implements ShoelaceFormControl { static styles: CSSResultGroup = styles; - // @ts-expect-error - Controller is currently unused - private readonly formSubmitController = new FormSubmitController(this); + private readonly formControlController = new FormControlController(this); private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label'); private readonly localize = new LocalizeController(this); private typeToSelectString = ''; @@ -81,7 +80,6 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon @state() displayLabel = ''; @state() currentOption: SlOption; @state() selectedOptions: SlOption[] = []; - @state() invalid = false; /** The name of the select, submitted as a name/value pair with form data. */ @property() name = ''; @@ -512,7 +510,9 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon } // Update validity - this.updateComplete.then(() => (this.invalid = !this.checkValidity())); + this.updateComplete.then(() => { + this.formControlController.updateValidity(); + }); } @watch('disabled', { waitUntilFirstUpdate: true }) @@ -611,7 +611,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon /** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */ setCustomValidity(message: string) { this.valueInput.setCustomValidity(message); - this.invalid = !this.valueInput.checkValidity(); + this.formControlController.updateValidity(); } /** Sets focus on the control. */ diff --git a/src/components/switch/switch.ts b/src/components/switch/switch.ts index f1157ac25..93f59fb6f 100644 --- a/src/components/switch/switch.ts +++ b/src/components/switch/switch.ts @@ -4,7 +4,7 @@ import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; import { defaultValue } from '../../internal/default-value'; -import { FormSubmitController } from '../../internal/form'; +import { FormControlController } from '../../internal/form'; import ShoelaceElement from '../../internal/shoelace-element'; import { watch } from '../../internal/watch'; import styles from './switch.styles'; @@ -37,8 +37,7 @@ import type { CSSResultGroup } from 'lit'; export default class SlSwitch extends ShoelaceElement implements ShoelaceFormControl { static styles: CSSResultGroup = styles; - // @ts-expect-error - Controller is currently unused - private readonly formSubmitController = new FormSubmitController(this, { + private readonly formControlController = new FormControlController(this, { value: (control: SlSwitch) => (control.checked ? control.value || 'on' : undefined), defaultValue: (control: SlSwitch) => control.defaultChecked, setValue: (control: SlSwitch, checked: boolean) => (control.checked = checked) @@ -47,7 +46,6 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon @query('input[type="checkbox"]') input: HTMLInputElement; @state() private hasFocus = false; - @state() invalid = false; @property() title = ''; // make reactive to pass through /** The name of the switch, submitted as a name/value pair with form data. */ @@ -72,7 +70,7 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon @defaultValue('checked') defaultChecked = false; firstUpdated() { - this.invalid = !this.checkValidity(); + this.formControlController.updateValidity(); } private handleBlur() { @@ -113,14 +111,13 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon @watch('checked', { waitUntilFirstUpdate: true }) handleCheckedChange() { this.input.checked = this.checked; // force a sync update - this.invalid = !this.checkValidity(); + this.formControlController.updateValidity(); } @watch('disabled', { waitUntilFirstUpdate: true }) handleDisabledChange() { - // Disabled form controls are always valid, so we need to recheck validity when the state changes - this.input.disabled = this.disabled; - this.invalid = !this.checkValidity(); + // Disabled form controls are always valid + this.formControlController.setValidity(true); } /** Simulates a click on the switch. */ @@ -151,7 +148,7 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon /** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */ setCustomValidity(message: string) { this.input.setCustomValidity(message); - this.invalid = !this.checkValidity(); + this.formControlController.updateValidity(); } render() { diff --git a/src/components/textarea/textarea.ts b/src/components/textarea/textarea.ts index 4528c9fc9..8be8d99ab 100644 --- a/src/components/textarea/textarea.ts +++ b/src/components/textarea/textarea.ts @@ -4,7 +4,7 @@ import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; import { defaultValue } from '../../internal/default-value'; -import { FormSubmitController } from '../../internal/form'; +import { FormControlController } from '../../internal/form'; import ShoelaceElement from '../../internal/shoelace-element'; import { HasSlotController } from '../../internal/slot'; import { watch } from '../../internal/watch'; @@ -37,15 +37,13 @@ import type { CSSResultGroup } from 'lit'; export default class SlTextarea extends ShoelaceElement implements ShoelaceFormControl { static styles: CSSResultGroup = styles; - // @ts-expect-error - Controller is currently unused - private readonly formSubmitController = new FormSubmitController(this); + private readonly formControlController = new FormControlController(this); private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label'); private resizeObserver: ResizeObserver; @query('.textarea__control') input: HTMLTextAreaElement; @state() private hasFocus = false; - @state() invalid = false; @property() title = ''; // make reactive to pass through /** The name of the textarea, submitted as a name/value pair with form data. */ @@ -139,7 +137,7 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC } firstUpdated() { - this.invalid = !this.checkValidity(); + this.formControlController.updateValidity(); } disconnectedCallback() { @@ -179,9 +177,8 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC @watch('disabled', { waitUntilFirstUpdate: true }) handleDisabledChange() { - // Disabled form controls are always valid, so we need to recheck validity when the state changes - this.input.disabled = this.disabled; - this.invalid = !this.checkValidity(); + // Disabled form controls are always valid + this.formControlController.setValidity(this.disabled); } @watch('rows', { waitUntilFirstUpdate: true }) @@ -192,7 +189,7 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC @watch('value', { waitUntilFirstUpdate: true }) async handleValueChange() { await this.updateComplete; - this.invalid = !this.checkValidity(); + this.formControlController.updateValidity(); this.setTextareaHeight(); } @@ -267,7 +264,7 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC /** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */ setCustomValidity(message: string) { this.input.setCustomValidity(message); - this.invalid = !this.checkValidity(); + this.formControlController.updateValidity(); } render() { diff --git a/src/internal/form.ts b/src/internal/form.ts index e79c7fac0..db8c1096b 100644 --- a/src/internal/form.ts +++ b/src/internal/form.ts @@ -21,7 +21,7 @@ const userInteractedControls: WeakMap = new WeakMa // const reportValidityOverloads: WeakMap boolean> = new WeakMap(); -export interface FormSubmitControllerOptions { +export interface FormControlControllerOptions { /** A function that returns the form containing the form control. */ form: (input: ShoelaceFormControl) => HTMLFormElement | null; /** A function that returns the form control's name, which will be submitted with the form data. */ @@ -41,12 +41,12 @@ export interface FormSubmitControllerOptions { setValue: (input: ShoelaceFormControl, value: unknown) => void; } -export class FormSubmitController implements ReactiveController { +export class FormControlController implements ReactiveController { host: ShoelaceFormControl & ReactiveControllerHost; form?: HTMLFormElement | null; - options: FormSubmitControllerOptions; + options: FormControlControllerOptions; - constructor(host: ReactiveControllerHost & ShoelaceFormControl, options?: Partial) { + constructor(host: ReactiveControllerHost & ShoelaceFormControl, options?: Partial) { (this.host = host).addController(this); this.options = { form: input => input.closest('form'), @@ -112,37 +112,12 @@ export class FormSubmitController implements ReactiveController { } hostUpdated() { - // - // We're mapping the following "states" to data attributes. In the future, we can use ElementInternals.states to - // create a similar mapping, but instead of [data-invalid] it will look like :--invalid. - // - // See this RFC for more details: https://github.com/shoelace-style/shoelace/issues/1011 - // - const host = this.host; - const hasInteracted = Boolean(userInteractedControls.get(host)); - const invalid = Boolean(host.invalid); - const required = Boolean(host.required); - - if (this.form?.noValidate) { - // Form validation is disabled, remove the attributes - host.removeAttribute('data-required'); - host.removeAttribute('data-optional'); - host.removeAttribute('data-invalid'); - host.removeAttribute('data-valid'); - host.removeAttribute('data-user-invalid'); - host.removeAttribute('data-user-valid'); - } else { - // Form validation is enabled, set the attributes - host.toggleAttribute('data-required', required); - host.toggleAttribute('data-optional', !required); - host.toggleAttribute('data-invalid', invalid); - host.toggleAttribute('data-valid', !invalid); - host.toggleAttribute('data-user-invalid', invalid && hasInteracted); - host.toggleAttribute('data-user-valid', !invalid && hasInteracted); + if (this.host.hasUpdated) { + this.setValidity(this.host.checkValidity()); } } - handleFormData(event: FormDataEvent) { + private handleFormData(event: FormDataEvent) { const disabled = this.options.disabled(this.host); const name = this.options.name(this.host); const value = this.options.value(this.host); @@ -162,7 +137,7 @@ export class FormSubmitController implements ReactiveController { } } - handleFormSubmit(event: Event) { + private handleFormSubmit(event: Event) { const disabled = this.options.disabled(this.host); const reportValidity = this.options.reportValidity; @@ -179,17 +154,17 @@ export class FormSubmitController implements ReactiveController { } } - handleFormReset() { + private handleFormReset() { this.options.setValue(this.host, this.options.defaultValue(this.host)); this.setUserInteracted(this.host, false); } - async handleUserInput() { + private async handleUserInput() { await this.host.updateComplete; this.setUserInteracted(this.host, true); } - reportFormValidity() { + private reportFormValidity() { // // Shoelace form controls work hard to act like regular form controls. They support the Constraint Validation API // and its associated methods such as setCustomValidity() and reportValidity(). However, the HTMLFormElement also @@ -219,12 +194,12 @@ export class FormSubmitController implements ReactiveController { return true; } - setUserInteracted(el: ShoelaceFormControl, hasInteracted: boolean) { + private setUserInteracted(el: ShoelaceFormControl, hasInteracted: boolean) { userInteractedControls.set(el, hasInteracted); el.requestUpdate(); } - doAction(type: 'submit' | 'reset', invoker?: HTMLInputElement | SlButton) { + private doAction(type: 'submit' | 'reset', invoker?: HTMLInputElement | SlButton) { if (this.form) { const button = document.createElement('button'); button.type = type; @@ -264,4 +239,47 @@ export class FormSubmitController implements ReactiveController { // native submit button into the form, "click" it, then remove it to simulate a standard form submission. this.doAction('submit', invoker); } + + /** + * Synchronously sets the form control's validity. Call this when you know the future validity but need to update + * the host element immediately, i.e. before Lit updates the component in the next update. + */ + setValidity(isValid: boolean) { + const host = this.host; + const hasInteracted = Boolean(userInteractedControls.get(host)); + const required = Boolean(host.required); + + // + // We're mapping the following "states" to data attributes. In the future, we can use ElementInternals.states to + // create a similar mapping, but instead of [data-invalid] it will look like :--invalid. + // + // See this RFC for more details: https://github.com/shoelace-style/shoelace/issues/1011 + // + if (this.form?.noValidate) { + // Form validation is disabled, remove the attributes + host.removeAttribute('data-required'); + host.removeAttribute('data-optional'); + host.removeAttribute('data-invalid'); + host.removeAttribute('data-valid'); + host.removeAttribute('data-user-invalid'); + host.removeAttribute('data-user-valid'); + } else { + // Form validation is enabled, set the attributes + host.toggleAttribute('data-required', required); + host.toggleAttribute('data-optional', !required); + host.toggleAttribute('data-invalid', !isValid); + host.toggleAttribute('data-valid', isValid); + host.toggleAttribute('data-user-invalid', !isValid && hasInteracted); + host.toggleAttribute('data-user-valid', isValid && hasInteracted); + } + } + + /** + * Updates the form control's validity based on the current value of `host.checkValidity()`. 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()); + } } diff --git a/src/internal/shoelace-element.ts b/src/internal/shoelace-element.ts index f6a9e3968..faadbebb4 100644 --- a/src/internal/shoelace-element.ts +++ b/src/internal/shoelace-element.ts @@ -39,9 +39,6 @@ export interface ShoelaceFormControl extends ShoelaceElement { minlength?: number; maxlength?: number; - // Proprietary validation properties (non-attributes) - invalid: boolean; - // Validation methods checkValidity: () => boolean; reportValidity: () => boolean;