import { ZuiBaseElement } from './zui-base.js'; import { property } from 'lit/decorators.js'; import { updateCustomState } from './utils/update-custom-state.js'; import type { PropertyValues } from 'lit'; const SUPPORTS_FACE = !!window.ElementInternals?.prototype?.setFormValue; if (!SUPPORTS_FACE) { /* eslint-disable-next-line no-console */ console.warn( "Form association isn't supported in this browser. ZUI controls won't participate in native form submissions." ); } // a non-standard event that ZUI mimics, see https://github.com/whatwg/html/issues/9878 const SUPPORTS_VALIDITY_STATE_CHANGE = 'onvaliditystatechange' in HTMLDivElement.prototype; function cloneValidityState(state: ValidityState): ValidityState { return { badInput: state.badInput, patternMismatch: state.patternMismatch, rangeOverflow: state.rangeOverflow, rangeUnderflow: state.rangeUnderflow, stepMismatch: state.stepMismatch, customError: state.customError, typeMismatch: state.typeMismatch, valid: state.valid, valueMissing: state.valueMissing, tooShort: state.tooShort, tooLong: state.tooLong, }; } /** * @attr {string} name - The name of this element that is associated with form submission * @attr {boolean} disabled - Represents whether a user can make changes to this element; if true, the value of this element will be excluded from the form submission * @attr {boolean} readonly - Represents whether a user can make changes to this element; the value of this element will still be included in the form submission * @attr {boolean} autofocus - If true, this element will be focused when connected to the document * * @prop {string} name - The name of this element that is associated with form submission * @prop {boolean} disabled - Represents whether a user can make changes to this element; if true, the value of this element will be excluded from the form submission * @prop {boolean} readOnly - Represents whether a user can make changes to this element; the value of this element will still be included in the form submission * @prop {boolean} autofocus - If true, this element will be focused when connected to the document */ export abstract class ZuiFormAssociatedElement extends ZuiBaseElement { static shadowRootOptions: ShadowRootInit = { ...ZuiBaseElement.shadowRootOptions, delegatesFocus: true }; /** * Necessary workaround to https://issues.chromium.org/issues/389587444 and purely needed for validation */ protected get _focusControlSelector(): string { throw new Error('You must implement a getter for "form control" query in the `_focusControlSelector` property.'); } /** * Tells the browser that this is a form-associated custom element: https://html.spec.whatwg.org/multipage/custom-elements.html#custom-elements-face-example */ protected static get formAssociated() { return true; } get form(): HTMLFormElement | null { // @ts-ignore attachInternals not supported yet if (this.#internals) { // @ts-ignore attachInternals not supported yet return this.#internals.form; } return null; } /** * The name of this element that is associated with form submission */ get name(): string | null { return (this as HTMLElement).getAttribute('name'); } /** * Represents whether a user can make changes to this element; if true, the value of this element will be excluded from the form submission */ @property({ type: Boolean, reflect: true }) disabled = false; /** * Represents whether a user can make changes to this element; the value of this element will still be included in the form submission */ @property({ attribute: 'readonly', type: Boolean, reflect: true }) readOnly = false; /** * If true, this element will be focused when connected to the document */ @property({ type: Boolean, reflect: true }) autofocus = false; /** * Returns true if the element will be validated when the form is submitted */ get willValidate() { return this.#internals?.willValidate ?? false; } /** * Returns a ValidityState object that represents the validity states of an element. * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/ValidityState} */ get validity(): ValidityState { return ( this.#internals?.validity ?? { valid: true, valueMissing: false, typeMismatch: false, patternMismatch: false, tooLong: false, tooShort: false, rangeUnderflow: false, rangeOverflow: false, stepMismatch: false, badInput: false, customError: false, } ); } /** * Returns the error message that would be displayed if the element was to be checked for validity */ get validationMessage() { return this.#internals?.validationMessage ?? ''; } /** * This is required when a consumer utilizes {@link setCustomValidity} */ #priorValidationMessage = ''; #focusControl?: HTMLElement | HTMLInputElement | null = null; #internals?: ElementInternals; #userInteracted = false; /** * TODO(pat): Temporary hack for zui-select-dropdown; focus should be refactored in that component */ protected _deferFocus = false; protected _deferClick = false; protected get _formSubmitButton(): HTMLButtonElement | HTMLInputElement | null { if (this.form) { return this.form.querySelector('button[type="submit"],button:not([type]),input[type="submit"]'); } else { return null; } } constructor() { super(); this.#internals = this.attachInternals?.(); } firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); this.#focusControl = this.shadowRoot?.querySelector(this._focusControlSelector); this.addEventListener('click', () => { if (!this.disabled && !this._deferClick) { this.focus(); } }); // once the form control has received and lost focus, we can consider the user to have interacted with the control // this is necessary to workaround the lack of :user-invalid support in ElementInternals // see https://github.com/whatwg/html/issues/9639 this.addEventListener('blur', () => { this.#broadcastValidityStateChange(true); }); this.addEventListener('invalid', () => { this.#broadcastValidityStateChange(true); }); if (this.autofocus) { this.focus(); } } protected formResetCallback() { throw new Error('You must set a formResetCallback() for each ZUI form custom element.'); } checkValidity() { return this.#internals?.checkValidity() ?? true; } reportValidity() { return this.#internals?.reportValidity() ?? true; } /** * Sets a custom error message to be displayed when the element is checked for validity. Set to empty string to remove the custom error. Note: consumers must clear the custom error; the component will not automatically clear it. * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setCustomValidity | setCustomValidity} * @param message - The error message to be displayed when the element is checked for validity */ setCustomValidity(message: string) { if (message) { // preserve the validity message if the current validity state is not already a custom error if (!this.validity.customError) { this.#priorValidationMessage = this.validationMessage; } this.#internals?.setValidity( { ...cloneValidityState(this.validity), customError: true }, message, this.#focusControl ); } else if (this.#priorValidationMessage) { this.#internals?.setValidity( { ...cloneValidityState(this.validity), customError: false }, this.#priorValidationMessage, this.#focusControl ); } else { this.#internals?.setValidity({}); } this.#broadcastValidityStateChange(); } protected _setFormValue(value: FormValueType) { // native implementation if (Array.isArray(value) || value instanceof FileList) { const values = new FormData(); for (const val of value) { if (val !== null && val !== undefined) { values.append(this.name!, val); } } this.#internals?.setFormValue?.(values); } else { this.#internals?.setFormValue?.(value); } } protected _setValidity(validity: ValidityStateFlags, message: string) { this.#broadcastValidityStateChange(); this.#priorValidationMessage = message; message ? this.#internals?.setValidity?.(validity, message, this.#focusControl) : this.#internals?.setValidity?.({}); } protected _mapValidationMessage( message: string, validityState: ValidityStateFlags, validationMessageMap: ValidationMessageMap ) { for (const entry of Object.entries(validityState)) { if (!entry[1]) { continue; } const key = entry[0] as keyof ValidityStateFlags; const messageKey = validationMessageMap[key]; if (messageKey) { const validationAttribute = `validation-message-${messageKey}`; if (this.hasAttribute(validationAttribute)) { return this.getAttribute(validationAttribute); } } } return message; } #broadcastValidityStateChange(userTriggered?: boolean) { if (userTriggered) { this.#userInteracted = true; updateCustomState(this.#internals, 'add', 'tmp-user-interacted'); } if (this.#userInteracted && !SUPPORTS_VALIDITY_STATE_CHANGE) { this.dispatchEvent(new Event('validitystatechange')); } } } export type FormValueType = string | string[] | File | FileList | null; export type ValidationMessageMap = { [K in keyof ValidityState]?: string; };