From c3ad8683421fd0572d467c37b2dcbe47d451ea7e Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 7 Jan 2025 12:31:15 -0500 Subject: [PATCH] Separate `WebAwesomeFormAssociatedElement` (and friends) into a separate file --- src/components/button/button.ts | 2 +- src/components/checkbox/checkbox.ts | 2 +- src/components/color-picker/color-picker.ts | 2 +- src/components/icon-button/icon-button.ts | 2 +- src/components/input/input.ts | 2 +- src/components/radio-button/radio-button.ts | 2 +- src/components/radio-group/radio-group.ts | 2 +- src/components/radio/radio.ts | 2 +- src/components/select/select.ts | 2 +- src/components/slider/slider.ts | 2 +- src/components/switch/switch.ts | 2 +- src/components/textarea/textarea.ts | 2 +- src/internal/test/form-control-base-tests.ts | 2 +- .../validators/custom-error-validator.ts | 2 +- src/internal/validators/mirror-validator.ts | 2 +- src/internal/validators/required-validator.ts | 2 +- src/internal/webawesome-element.ts | 383 ----------------- .../webawesome-formassociated-element.ts | 386 ++++++++++++++++++ 18 files changed, 402 insertions(+), 399 deletions(-) create mode 100644 src/internal/webawesome-formassociated-element.ts diff --git a/src/components/button/button.ts b/src/components/button/button.ts index 24cfffff0..dc1e7cabc 100644 --- a/src/components/button/button.ts +++ b/src/components/button/button.ts @@ -7,7 +7,7 @@ import { WaFocusEvent } from '../../events/focus.js'; import { WaInvalidEvent } from '../../events/invalid.js'; import { MirrorValidator } from '../../internal/validators/mirror-validator.js'; import { watch } from '../../internal/watch.js'; -import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js'; +import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js'; import nativeStyles from '../../styles/native/button.css'; import appearanceStyles from '../../styles/utilities/appearance.css'; import sizeStyles from '../../styles/utilities/size.css'; diff --git a/src/components/checkbox/checkbox.ts b/src/components/checkbox/checkbox.ts index f69dd19fe..db6718de2 100644 --- a/src/components/checkbox/checkbox.ts +++ b/src/components/checkbox/checkbox.ts @@ -11,7 +11,7 @@ import { WaInputEvent } from '../../events/input.js'; import { HasSlotController } from '../../internal/slot.js'; import { RequiredValidator } from '../../internal/validators/required-validator.js'; import { watch } from '../../internal/watch.js'; -import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js'; +import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js'; import nativeStyles from '../../styles/native/checkbox.css'; import formControlStyles from '../../styles/shadow/form-control.css'; import sizeStyles from '../../styles/utilities/size.css'; diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index 4dfb99d7e..dcc5a0ea8 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -15,7 +15,7 @@ import { clamp } from '../../internal/math.js'; import { HasSlotController } from '../../internal/slot.js'; import { RequiredValidator } from '../../internal/validators/required-validator.js'; import { watch } from '../../internal/watch.js'; -import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js'; +import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js'; import formControlStyles from '../../styles/shadow/form-control.css'; import sizeStyles from '../../styles/utilities/size.css'; import visuallyHidden from '../../styles/utilities/visually-hidden.css'; diff --git a/src/components/icon-button/icon-button.ts b/src/components/icon-button/icon-button.ts index ffde70379..82813b18d 100644 --- a/src/components/icon-button/icon-button.ts +++ b/src/components/icon-button/icon-button.ts @@ -4,7 +4,7 @@ import { ifDefined } from 'lit/directives/if-defined.js'; import { html, literal } from 'lit/static-html.js'; import { WaBlurEvent } from '../../events/blur.js'; import { WaFocusEvent } from '../../events/focus.js'; -import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js'; +import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js'; import '../icon/icon.js'; import styles from './icon-button.css'; diff --git a/src/components/input/input.ts b/src/components/input/input.ts index c97cb607c..f2189a737 100644 --- a/src/components/input/input.ts +++ b/src/components/input/input.ts @@ -11,7 +11,7 @@ import { WaInputEvent } from '../../events/input.js'; import { HasSlotController } from '../../internal/slot.js'; import { MirrorValidator } from '../../internal/validators/mirror-validator.js'; import { watch } from '../../internal/watch.js'; -import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js'; +import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js'; import nativeStyles from '../../styles/native/input.css'; import formControlStyles from '../../styles/shadow/form-control.css'; import appearanceStyles from '../../styles/utilities/appearance.css'; diff --git a/src/components/radio-button/radio-button.ts b/src/components/radio-button/radio-button.ts index 331bc438c..03240230f 100644 --- a/src/components/radio-button/radio-button.ts +++ b/src/components/radio-button/radio-button.ts @@ -6,7 +6,7 @@ import { WaBlurEvent } from '../../events/blur.js'; import { WaFocusEvent } from '../../events/focus.js'; import { HasSlotController } from '../../internal/slot.js'; import { watch } from '../../internal/watch.js'; -import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js'; +import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js'; import nativeStyles from '../../styles/native/button.css'; import sizeStyles from '../../styles/utilities/size.css'; import variantStyles from '../../styles/utilities/variants.css'; diff --git a/src/components/radio-group/radio-group.ts b/src/components/radio-group/radio-group.ts index bc18ee951..812a1f34a 100644 --- a/src/components/radio-group/radio-group.ts +++ b/src/components/radio-group/radio-group.ts @@ -7,7 +7,7 @@ import { uniqueId } from '../../internal/math.js'; import { HasSlotController } from '../../internal/slot.js'; import { RequiredValidator } from '../../internal/validators/required-validator.js'; import { watch } from '../../internal/watch.js'; -import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js'; +import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js'; import formControlStyles from '../../styles/shadow/form-control.css'; import buttonGroupStyles from '../../styles/utilities/button-group.css'; import sizeStyles from '../../styles/utilities/size.css'; diff --git a/src/components/radio/radio.ts b/src/components/radio/radio.ts index 334cad676..eeaa286b0 100644 --- a/src/components/radio/radio.ts +++ b/src/components/radio/radio.ts @@ -4,7 +4,7 @@ import { classMap } from 'lit/directives/class-map.js'; import { WaBlurEvent } from '../../events/blur.js'; import { WaFocusEvent } from '../../events/focus.js'; import { watch } from '../../internal/watch.js'; -import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js'; +import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js'; import nativeStyles from '../../styles/native/radio.css'; import sizeStyles from '../../styles/utilities/size.css'; import '../icon/icon.js'; diff --git a/src/components/select/select.ts b/src/components/select/select.ts index 969cf8757..5eb198f5a 100644 --- a/src/components/select/select.ts +++ b/src/components/select/select.ts @@ -19,7 +19,7 @@ import { scrollIntoView } from '../../internal/scroll.js'; import { HasSlotController } from '../../internal/slot.js'; import { RequiredValidator } from '../../internal/validators/required-validator.js'; import { watch } from '../../internal/watch.js'; -import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js'; +import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js'; import nativeStyles from '../../styles/native/select.css'; import formControlStyles from '../../styles/shadow/form-control.css'; import appearanceStyles from '../../styles/utilities/appearance.css'; diff --git a/src/components/slider/slider.ts b/src/components/slider/slider.ts index 68e4ec7dc..59ea6cb38 100644 --- a/src/components/slider/slider.ts +++ b/src/components/slider/slider.ts @@ -10,7 +10,7 @@ import { WaInputEvent } from '../../events/input.js'; import { HasSlotController } from '../../internal/slot.js'; import { MirrorValidator } from '../../internal/validators/mirror-validator.js'; import { watch } from '../../internal/watch.js'; -import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js'; +import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js'; import sliderStyles from '../../styles/native/slider.css'; import formControlStyles from '../../styles/shadow/form-control.css'; import { LocalizeController } from '../../utilities/localize.js'; diff --git a/src/components/switch/switch.ts b/src/components/switch/switch.ts index 54b451efa..bb2834364 100644 --- a/src/components/switch/switch.ts +++ b/src/components/switch/switch.ts @@ -11,7 +11,7 @@ import { WaInputEvent } from '../../events/input.js'; import { HasSlotController } from '../../internal/slot.js'; import { MirrorValidator } from '../../internal/validators/mirror-validator.js'; import { watch } from '../../internal/watch.js'; -import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js'; +import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js'; import formControlStyles from '../../styles/shadow/form-control.css'; import sizeStyles from '../../styles/utilities/size.css'; import styles from './switch.css'; diff --git a/src/components/textarea/textarea.ts b/src/components/textarea/textarea.ts index 56f99efe3..e483c5f42 100644 --- a/src/components/textarea/textarea.ts +++ b/src/components/textarea/textarea.ts @@ -10,7 +10,7 @@ import { WaInputEvent } from '../../events/input.js'; import { HasSlotController } from '../../internal/slot.js'; import { MirrorValidator } from '../../internal/validators/mirror-validator.js'; import { watch } from '../../internal/watch.js'; -import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-element.js'; +import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-formassociated-element.js'; import nativeStyles from '../../styles/native/input.css'; import formControlStyles from '../../styles/shadow/form-control.css'; import appearanceStyles from '../../styles/utilities/appearance.css'; diff --git a/src/internal/test/form-control-base-tests.ts b/src/internal/test/form-control-base-tests.ts index 9ff8f7893..ce7d72eb5 100644 --- a/src/internal/test/form-control-base-tests.ts +++ b/src/internal/test/form-control-base-tests.ts @@ -2,7 +2,7 @@ import { aTimeout, expect } from '@open-wc/testing'; import { html, type TemplateResult } from 'lit'; import { html as staticHTML, unsafeStatic } from 'lit/static-html.js'; import { clickOnElement } from '../test.js'; -import type { WebAwesomeFormControl } from '../webawesome-element.js'; +import type { WebAwesomeFormControl } from '../webawesome-formassociated-element.js'; import type { clientFixture, hydratedFixture } from './fixture.js'; import { fixtures } from './fixture.js'; diff --git a/src/internal/validators/custom-error-validator.ts b/src/internal/validators/custom-error-validator.ts index 108e58cb9..22b01871e 100644 --- a/src/internal/validators/custom-error-validator.ts +++ b/src/internal/validators/custom-error-validator.ts @@ -1,4 +1,4 @@ -import type { Validator } from '../webawesome-element.js'; +import type { Validator } from '../webawesome-formassociated-element.js'; /** * This validator is for if you have an exact copy of your element in the shadow DOM. Rather than needing diff --git a/src/internal/validators/mirror-validator.ts b/src/internal/validators/mirror-validator.ts index e5daaa89e..e6fbd73af 100644 --- a/src/internal/validators/mirror-validator.ts +++ b/src/internal/validators/mirror-validator.ts @@ -1,4 +1,4 @@ -import type { Validator } from '../webawesome-element.js'; +import type { Validator } from '../webawesome-formassociated-element.js'; /** * This validator is for if you have an exact copy of your element in the shadow DOM. Rather than needing diff --git a/src/internal/validators/required-validator.ts b/src/internal/validators/required-validator.ts index dd0300b33..6b240a164 100644 --- a/src/internal/validators/required-validator.ts +++ b/src/internal/validators/required-validator.ts @@ -1,4 +1,4 @@ -import type { Validator } from '../webawesome-element.js'; +import type { Validator } from '../webawesome-formassociated-element.js'; export interface RequiredValidatorOptions { /** This is a cheap way for us to get translation strings for the user without having proper translations. */ diff --git a/src/internal/webawesome-element.ts b/src/internal/webawesome-element.ts index acb223a51..12e85ee23 100644 --- a/src/internal/webawesome-element.ts +++ b/src/internal/webawesome-element.ts @@ -1,9 +1,7 @@ import type { CSSResult, CSSResultGroup, PropertyValues } from 'lit'; import { LitElement, isServer, unsafeCSS } from 'lit'; import { property } from 'lit/decorators.js'; -import { WaInvalidEvent } from '../events/invalid.js'; import componentStyles from '../styles/shadow/component.css'; -import { CustomErrorValidator } from './validators/custom-error-validator.js'; export default class WebAwesomeElement extends LitElement { constructor() { @@ -151,384 +149,3 @@ export default class WebAwesomeElement extends LitElement { return this.hasStatesSupport() ? this.internals.states.has(state) : false; } } - -export interface Validator { - observedAttributes?: string[]; - checkValidity: (element: T) => { - message: string; - isValid: boolean; - invalidKeys: Exclude[]; - }; - message?: string | ((element: T) => string); -} - -export interface WebAwesomeFormControl extends WebAwesomeElement { - // Form attributes - name: null | string; - disabled?: boolean; - defaultValue?: unknown; - defaultChecked?: boolean; - checked?: boolean; - defaultSelected?: boolean; - selected?: boolean; - form?: string | null; - - value?: unknown; - - // Constraint validation attributes - pattern?: string; - min?: number | string | Date; - max?: number | string | Date; - step?: number | 'any'; - required?: boolean; - minlength?: number; - maxlength?: number; - - // Form validation properties - readonly validity: ValidityState; - readonly validationMessage: string; - - // Form validation methods - checkValidity: () => boolean; - getForm: () => HTMLFormElement | null; - reportValidity: () => boolean; - setCustomValidity: (message: string) => void; - - // Form properties - hasInteracted: boolean; - valueHasChanged?: boolean; - - /** Convenience API for `setCustomValidity()` */ - customError: null | string; -} - -// setFormValue omitted so that we can use `setValue` -export class WebAwesomeFormAssociatedElement - extends WebAwesomeElement - implements Omit, WebAwesomeFormControl -{ - static formAssociated = true; - - /** - * Validators are static because they have `observedAttributes`, essentially attributes to "watch" - * for changes. Whenever these attributes change, we want to be notified and update the validator. - */ - static get validators(): Validator[] { - return [CustomErrorValidator()]; - } - - // Append all Validator "observedAttributes" into the "observedAttributes" so they can run. - static get observedAttributes() { - const parentAttrs = new Set(super.observedAttributes || []); - - for (const validator of this.validators) { - if (!validator.observedAttributes) { - continue; - } - - for (const attr of validator.observedAttributes) { - parentAttrs.add(attr); - } - } - - return [...parentAttrs]; - } - - // Form attributes - /** The name of the input, submitted as a name/value pair with form data. */ - @property({ reflect: true }) name: string | null = null; - - /** Disables the form control. */ - @property({ type: Boolean }) disabled = false; - - required: boolean = false; - - assumeInteractionOn: string[] = ['wa-input']; - - // Additional - input?: (HTMLElement & { value: unknown }) | HTMLInputElement | HTMLTextAreaElement; - - validators: Validator[] = []; - - // Should these be private? - @property({ state: true, attribute: false }) valueHasChanged: boolean = false; - @property({ state: true, attribute: false }) hasInteracted: boolean = false; - - // This works around a limitation in Safari. It is a hacky way for us to preserve custom errors generated by the user. - @property({ attribute: 'custom-error', reflect: true }) customError: string | null = null; - - private emittedEvents: string[] = []; - - constructor() { - super(); - - if (!isServer) { - // eslint-disable-next-line - this.addEventListener('invalid', this.emitInvalid); - } - } - states: CustomStateSet; - - connectedCallback() { - super.connectedCallback(); - this.updateValidity(); - - // Lazily evaluate after the constructor to allow people to override the `assumeInteractionOn` - this.assumeInteractionOn.forEach(event => { - this.addEventListener(event, this.handleInteraction); - }); - } - - firstUpdated(...args: Parameters) { - super.firstUpdated(...args); - this.updateValidity(); - } - - emitInvalid = (e: Event) => { - if (e.target !== this) return; - - // An "invalid" event counts as interacted, this is usually triggered by a button "submitting" - this.hasInteracted = true; - this.dispatchEvent(new WaInvalidEvent()); - }; - - protected willUpdate(changedProperties: Parameters[0]) { - if (!isServer && changedProperties.has('customError')) { - // We use null because it we really don't want it to show up in the attributes because `custom-error` does reflect - if (!this.customError) { - this.customError = null; - } - this.setCustomValidity(this.customError || ''); - } - - if (changedProperties.has('value') || changedProperties.has('disabled')) { - // @ts-expect-error Some components will use an accessors, other use a property, so we dont want to limit them. - const value = this.value as unknown; - - // Accounts for the snowflake case on `` - if (Array.isArray(value)) { - if (this.name) { - const formData = new FormData(); - for (const val of value) { - formData.append(this.name, val as string); - } - this.setValue(formData, formData); - } - } else { - this.setValue(value as FormData | string | File | null, value as FormData | string | File | null); - } - } - - if (changedProperties.has('disabled')) { - this.toggleCustomState('disabled', this.disabled); - - if (this.hasAttribute('disabled') || (!isServer && !this.matches(':disabled'))) { - this.toggleAttribute('disabled', this.disabled); - } - } - - this.updateValidity(); - super.willUpdate(changedProperties); - } - - private handleInteraction = (event: Event) => { - const emittedEvents = this.emittedEvents; - if (!emittedEvents.includes(event.type)) { - emittedEvents.push(event.type); - } - - // Mark it as user-interacted as soon as all associated events have been emitted - if (emittedEvents.length === this.assumeInteractionOn?.length) { - this.hasInteracted = true; - } - }; - - get labels() { - return this.internals.labels; - } - - getForm() { - return this.internals.form; - } - - @property({ attribute: false, state: true, type: Object }) - get validity() { - return this.internals.validity; - } - - // Not sure if this supports `novalidate`. Will need to test. - get willValidate() { - return this.internals.willValidate; - } - - get validationMessage() { - return this.internals.validationMessage; - } - - checkValidity() { - this.updateValidity(); - return this.internals.checkValidity(); - } - - reportValidity() { - this.updateValidity(); - // This seems reasonable. `reportValidity()` is kind of like "we expect you to have interacted" - this.hasInteracted = true; - return this.internals.reportValidity(); - } - - /** - * Override this to change where constraint validation popups are anchored. - */ - get validationTarget(): undefined | HTMLElement { - return (this.input || undefined) as undefined | HTMLElement; - } - - setValidity(...args: Parameters) { - const flags = args[0]; - const message = args[1]; - let anchor = args[2]; - - if (!anchor) { - anchor = this.validationTarget; - } - - this.internals.setValidity(flags, message, anchor || undefined); - this.requestUpdate('validity'); - this.setCustomStates(); - } - - setCustomStates() { - const required = Boolean(this.required); - const isValid = this.internals.validity.valid; - const hasInteracted = this.hasInteracted; - - this.toggleCustomState('required', required); - this.toggleCustomState('optional', !required); - this.toggleCustomState('invalid', !isValid); - this.toggleCustomState('valid', isValid); - this.toggleCustomState('user-invalid', !isValid && hasInteracted); - this.toggleCustomState('user-valid', isValid && hasInteracted); - } - - /** - * Do not use this when creating a "Validator". This is intended for end users of components. - * We track manually defined custom errors so we don't clear them on accident in our validators. - * - */ - setCustomValidity(message: string) { - if (!message) { - // We use null because it we really don't want it to show up in the attributes because `custom-error` does reflect - this.customError = null; - this.setValidity({}); - return; - } - - this.customError = message; - this.setValidity({ customError: true }, message, this.validationTarget); - } - - formResetCallback() { - this.resetValidity(); - this.hasInteracted = false; - this.valueHasChanged = false; - this.emittedEvents = []; - this.updateValidity(); - } - - formDisabledCallback(isDisabled: boolean) { - this.disabled = isDisabled; - - this.updateValidity(); - } - - /** - * Called when the browser is trying to restore element’s state to state in which case reason is "restore", or when - * the browser is trying to fulfill autofill on behalf of user in which case reason is "autocomplete". In the case of - * "restore", state is a string, File, or FormData object previously set as the second argument to setFormValue. - */ - formStateRestoreCallback(state: string | File | FormData | null, reason: 'autocomplete' | 'restore') { - // @ts-expect-error We purposely do not have a value property. It makes things hard to extend. - this.value = state; - - if (reason === 'restore') { - this.resetValidity(); - } - - this.updateValidity(); - } - - setValue(...args: Parameters) { - const [value, state] = args; - - this.internals.setFormValue(value, state); - } - - get allValidators() { - const staticValidators = (this.constructor as typeof WebAwesomeFormAssociatedElement).validators || []; - - const validators = this.validators || []; - return [...staticValidators, ...validators]; - } - - /** - * Reset validity is a way of removing manual custom errors and native validation. - */ - resetValidity() { - this.setCustomValidity(''); - this.setValidity({}); - } - - updateValidity() { - if ( - this.disabled || - this.hasAttribute('disabled') || - !this.willValidate // - ) { - this.resetValidity(); - - return; - } - - const validators = this.allValidators; - - if (!validators?.length) { - // If there's no validators, we do nothing. We also don't want to mess with custom errors, so we just stop here. - return; - } - - type ValidityKey = { -readonly [P in keyof ValidityState]: ValidityState[P] }; - - const flags: Partial = { - // Don't trust custom errors from the Browser. Safari breaks the spec. - customError: Boolean(this.customError), - }; - - const formControl = this.validationTarget || this.input || undefined; - - let finalMessage = ''; - - for (const validator of validators) { - const { isValid, message, invalidKeys } = validator.checkValidity(this); - - if (isValid) { - continue; - } - - if (!finalMessage) { - finalMessage = message; - } - - if (invalidKeys?.length >= 0) { - (invalidKeys as (keyof ValidityState)[]).forEach(str => (flags[str] = true)); - } - } - - // This is a workaround for preserving custom errors - if (!finalMessage) { - finalMessage = this.validationMessage; - } - - this.setValidity(flags, finalMessage, formControl); - } -} diff --git a/src/internal/webawesome-formassociated-element.ts b/src/internal/webawesome-formassociated-element.ts new file mode 100644 index 000000000..b687b5920 --- /dev/null +++ b/src/internal/webawesome-formassociated-element.ts @@ -0,0 +1,386 @@ +import { LitElement, isServer } from 'lit'; +import { property } from 'lit/decorators.js'; +import { WaInvalidEvent } from '../events/invalid.js'; +import { CustomErrorValidator } from './validators/custom-error-validator.js'; +import WebAwesomeElement from './webawesome-element.js'; + +export interface Validator { + observedAttributes?: string[]; + checkValidity: (element: T) => { + message: string; + isValid: boolean; + invalidKeys: Exclude[]; + }; + message?: string | ((element: T) => string); +} + +export interface WebAwesomeFormControl extends WebAwesomeElement { + // Form attributes + name: null | string; + disabled?: boolean; + defaultValue?: unknown; + defaultChecked?: boolean; + checked?: boolean; + defaultSelected?: boolean; + selected?: boolean; + form?: string | null; + + value?: unknown; + + // Constraint validation attributes + pattern?: string; + min?: number | string | Date; + max?: number | string | Date; + step?: number | 'any'; + required?: boolean; + minlength?: number; + maxlength?: number; + + // Form validation properties + readonly validity: ValidityState; + readonly validationMessage: string; + + // Form validation methods + checkValidity: () => boolean; + getForm: () => HTMLFormElement | null; + reportValidity: () => boolean; + setCustomValidity: (message: string) => void; + + // Form properties + hasInteracted: boolean; + valueHasChanged?: boolean; + + /** Convenience API for `setCustomValidity()` */ + customError: null | string; +} + +// setFormValue omitted so that we can use `setValue` +export class WebAwesomeFormAssociatedElement + extends WebAwesomeElement + implements Omit, WebAwesomeFormControl +{ + static formAssociated = true; + + /** + * Validators are static because they have `observedAttributes`, essentially attributes to "watch" + * for changes. Whenever these attributes change, we want to be notified and update the validator. + */ + static get validators(): Validator[] { + return [CustomErrorValidator()]; + } + + // Append all Validator "observedAttributes" into the "observedAttributes" so they can run. + static get observedAttributes() { + const parentAttrs = new Set(super.observedAttributes || []); + + for (const validator of this.validators) { + if (!validator.observedAttributes) { + continue; + } + + for (const attr of validator.observedAttributes) { + parentAttrs.add(attr); + } + } + + return [...parentAttrs]; + } + + // Form attributes + /** The name of the input, submitted as a name/value pair with form data. */ + @property({ reflect: true }) name: string | null = null; + + /** Disables the form control. */ + @property({ type: Boolean }) disabled = false; + + required: boolean = false; + + assumeInteractionOn: string[] = ['wa-input']; + + // Additional + input?: (HTMLElement & { value: unknown }) | HTMLInputElement | HTMLTextAreaElement; + + validators: Validator[] = []; + + // Should these be private? + @property({ state: true, attribute: false }) valueHasChanged: boolean = false; + @property({ state: true, attribute: false }) hasInteracted: boolean = false; + + // This works around a limitation in Safari. It is a hacky way for us to preserve custom errors generated by the user. + @property({ attribute: 'custom-error', reflect: true }) customError: string | null = null; + + private emittedEvents: string[] = []; + + constructor() { + super(); + + if (!isServer) { + // eslint-disable-next-line + this.addEventListener('invalid', this.emitInvalid); + } + } + states: CustomStateSet; + + connectedCallback() { + super.connectedCallback(); + this.updateValidity(); + + // Lazily evaluate after the constructor to allow people to override the `assumeInteractionOn` + this.assumeInteractionOn.forEach(event => { + this.addEventListener(event, this.handleInteraction); + }); + } + + firstUpdated(...args: Parameters) { + super.firstUpdated(...args); + this.updateValidity(); + } + + emitInvalid = (e: Event) => { + if (e.target !== this) return; + + // An "invalid" event counts as interacted, this is usually triggered by a button "submitting" + this.hasInteracted = true; + this.dispatchEvent(new WaInvalidEvent()); + }; + + protected willUpdate(changedProperties: Parameters[0]) { + if (!isServer && changedProperties.has('customError')) { + // We use null because it we really don't want it to show up in the attributes because `custom-error` does reflect + if (!this.customError) { + this.customError = null; + } + this.setCustomValidity(this.customError || ''); + } + + if (changedProperties.has('value') || changedProperties.has('disabled')) { + // @ts-expect-error Some components will use an accessors, other use a property, so we dont want to limit them. + const value = this.value as unknown; + + // Accounts for the snowflake case on `` + if (Array.isArray(value)) { + if (this.name) { + const formData = new FormData(); + for (const val of value) { + formData.append(this.name, val as string); + } + this.setValue(formData, formData); + } + } else { + this.setValue(value as FormData | string | File | null, value as FormData | string | File | null); + } + } + + if (changedProperties.has('disabled')) { + this.toggleCustomState('disabled', this.disabled); + + if (this.hasAttribute('disabled') || (!isServer && !this.matches(':disabled'))) { + this.toggleAttribute('disabled', this.disabled); + } + } + + this.updateValidity(); + super.willUpdate(changedProperties); + } + + private handleInteraction = (event: Event) => { + const emittedEvents = this.emittedEvents; + if (!emittedEvents.includes(event.type)) { + emittedEvents.push(event.type); + } + + // Mark it as user-interacted as soon as all associated events have been emitted + if (emittedEvents.length === this.assumeInteractionOn?.length) { + this.hasInteracted = true; + } + }; + + get labels() { + return this.internals.labels; + } + + getForm() { + return this.internals.form; + } + + @property({ attribute: false, state: true, type: Object }) + get validity() { + return this.internals.validity; + } + + // Not sure if this supports `novalidate`. Will need to test. + get willValidate() { + return this.internals.willValidate; + } + + get validationMessage() { + return this.internals.validationMessage; + } + + checkValidity() { + this.updateValidity(); + return this.internals.checkValidity(); + } + + reportValidity() { + this.updateValidity(); + // This seems reasonable. `reportValidity()` is kind of like "we expect you to have interacted" + this.hasInteracted = true; + return this.internals.reportValidity(); + } + + /** + * Override this to change where constraint validation popups are anchored. + */ + get validationTarget(): undefined | HTMLElement { + return (this.input || undefined) as undefined | HTMLElement; + } + + setValidity(...args: Parameters) { + const flags = args[0]; + const message = args[1]; + let anchor = args[2]; + + if (!anchor) { + anchor = this.validationTarget; + } + + this.internals.setValidity(flags, message, anchor || undefined); + this.requestUpdate('validity'); + this.setCustomStates(); + } + + setCustomStates() { + const required = Boolean(this.required); + const isValid = this.internals.validity.valid; + const hasInteracted = this.hasInteracted; + + this.toggleCustomState('required', required); + this.toggleCustomState('optional', !required); + this.toggleCustomState('invalid', !isValid); + this.toggleCustomState('valid', isValid); + this.toggleCustomState('user-invalid', !isValid && hasInteracted); + this.toggleCustomState('user-valid', isValid && hasInteracted); + } + + /** + * Do not use this when creating a "Validator". This is intended for end users of components. + * We track manually defined custom errors so we don't clear them on accident in our validators. + * + */ + setCustomValidity(message: string) { + if (!message) { + // We use null because it we really don't want it to show up in the attributes because `custom-error` does reflect + this.customError = null; + this.setValidity({}); + return; + } + + this.customError = message; + this.setValidity({ customError: true }, message, this.validationTarget); + } + + formResetCallback() { + this.resetValidity(); + this.hasInteracted = false; + this.valueHasChanged = false; + this.emittedEvents = []; + this.updateValidity(); + } + + formDisabledCallback(isDisabled: boolean) { + this.disabled = isDisabled; + + this.updateValidity(); + } + + /** + * Called when the browser is trying to restore element’s state to state in which case reason is "restore", or when + * the browser is trying to fulfill autofill on behalf of user in which case reason is "autocomplete". In the case of + * "restore", state is a string, File, or FormData object previously set as the second argument to setFormValue. + */ + formStateRestoreCallback(state: string | File | FormData | null, reason: 'autocomplete' | 'restore') { + // @ts-expect-error We purposely do not have a value property. It makes things hard to extend. + this.value = state; + + if (reason === 'restore') { + this.resetValidity(); + } + + this.updateValidity(); + } + + setValue(...args: Parameters) { + const [value, state] = args; + + this.internals.setFormValue(value, state); + } + + get allValidators() { + const staticValidators = (this.constructor as typeof WebAwesomeFormAssociatedElement).validators || []; + + const validators = this.validators || []; + return [...staticValidators, ...validators]; + } + + /** + * Reset validity is a way of removing manual custom errors and native validation. + */ + resetValidity() { + this.setCustomValidity(''); + this.setValidity({}); + } + + updateValidity() { + if ( + this.disabled || + this.hasAttribute('disabled') || + !this.willValidate // + ) { + this.resetValidity(); + + return; + } + + const validators = this.allValidators; + + if (!validators?.length) { + // If there's no validators, we do nothing. We also don't want to mess with custom errors, so we just stop here. + return; + } + + type ValidityKey = { -readonly [P in keyof ValidityState]: ValidityState[P] }; + + const flags: Partial = { + // Don't trust custom errors from the Browser. Safari breaks the spec. + customError: Boolean(this.customError), + }; + + const formControl = this.validationTarget || this.input || undefined; + + let finalMessage = ''; + + for (const validator of validators) { + const { isValid, message, invalidKeys } = validator.checkValidity(this); + + if (isValid) { + continue; + } + + if (!finalMessage) { + finalMessage = message; + } + + if (invalidKeys?.length >= 0) { + (invalidKeys as (keyof ValidityState)[]).forEach(str => (flags[str] = true)); + } + } + + // This is a workaround for preserving custom errors + if (!finalMessage) { + finalMessage = this.validationMessage; + } + + this.setValidity(flags, finalMessage, formControl); + } +}