/** * @license * Copyright 2023 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import {LitElement, isServer} from 'lit'; import {ConstraintValidation} from './constraint-validation.js'; import {WithElementInternals, internals} from './element-internals.js'; import {MixinBase, MixinReturn} from './mixin.js'; /** * A constraint validation element that has a callback for when the element * should report validity styles and error messages to the user. * * This is commonly used in text-field-like controls that display error styles * and error messages. */ export interface OnReportValidity extends ConstraintValidation { /** * A callback that is invoked when validity should be reported. Components * that can display their own error state can use this and update their * styles. * * If an invalid event is provided, the element is invalid. If `null`, the * element is valid. * * The invalid event's `preventDefault()` may be called to stop the platform * popup from displaying. * * @param invalidEvent The `invalid` event dispatched when an element is * invalid, or `null` if the element is valid. */ [onReportValidity](invalidEvent: Event | null): void; // `mixinOnReportValidity()` implements this optional method. If overriden, // call `super.formAssociatedCallback(form)`. // (inherit jsdoc from `FormAssociated`) formAssociatedCallback(form: HTMLFormElement | null): void; } /** * A symbol property used for a callback when validity has been reported. */ export const onReportValidity = Symbol('onReportValidity'); // Private symbol members, used to avoid name clashing. const privateCleanupFormListeners = Symbol('privateCleanupFormListeners'); const privateDoNotReportInvalid = Symbol('privateDoNotReportInvalid'); const privateIsSelfReportingValidity = Symbol('privateIsSelfReportingValidity'); const privateCallOnReportValidity = Symbol('privateCallOnReportValidity'); /** * Mixes in a callback for constraint validation when validity should be * styled and reported to the user. * * This is commonly used in text-field-like controls that display error styles * and error messages. * * @example * ```ts * const baseClass = mixinOnReportValidity( * mixinConstraintValidation( * mixinFormAssociated(mixinElementInternals(LitElement)), * ), * ); * * class MyField extends baseClass { * \@property({type: Boolean}) error = false; * \@property() errorMessage = ''; * * [onReportValidity](invalidEvent: Event | null) { * this.error = !!invalidEvent; * this.errorMessage = this.validationMessage; * * // Optionally prevent platform popup from displaying * invalidEvent?.preventDefault(); * } * } * ``` * * @param base The class to mix functionality into. * @return The provided class with `OnReportValidity` mixed in. */ export function mixinOnReportValidity< T extends MixinBase, >(base: T): MixinReturn { abstract class OnReportValidityElement extends base implements OnReportValidity { /** * Used to clean up event listeners when a new form is associated. */ [privateCleanupFormListeners] = new AbortController(); /** * Used to determine if an invalid event should report validity. Invalid * events from `checkValidity()` do not trigger reporting. */ [privateDoNotReportInvalid] = false; /** * Used to determine if the control is reporting validity from itself, or * if a `
` is causing the validity report. Forms have different * control focusing behavior. */ [privateIsSelfReportingValidity] = false; // Mixins must have a constructor with `...args: any[]` // tslint:disable-next-line:no-any constructor(...args: any[]) { super(...args); if (isServer) { return; } this.addEventListener( 'invalid', (invalidEvent) => { // Listen for invalid events dispatched by a `` when it tries to // submit and the element is invalid. We ignore events dispatched when // calling `checkValidity()` as well as untrusted events, since the // `reportValidity()` and ``-dispatched events are always // trusted. if (this[privateDoNotReportInvalid] || !invalidEvent.isTrusted) { return; } this.addEventListener( 'invalid', () => { // A normal bubbling phase event listener. By adding it here, we // ensure it's the last event listener that is called during the // bubbling phase. this[privateCallOnReportValidity](invalidEvent); }, {once: true}, ); }, { // Listen during the capture phase, which will happen before the // bubbling phase. That way, we can add a final event listener that // will run after other event listeners, and we can check if it was // default prevented. This works because invalid does not bubble. capture: true, }, ); } override checkValidity() { this[privateDoNotReportInvalid] = true; const valid = super.checkValidity(); this[privateDoNotReportInvalid] = false; return valid; } override reportValidity() { this[privateIsSelfReportingValidity] = true; const valid = super.reportValidity(); // Constructor's invalid listener will handle reporting invalid events. if (valid) { this[privateCallOnReportValidity](null); } this[privateIsSelfReportingValidity] = false; return valid; } [privateCallOnReportValidity](invalidEvent: Event | null) { // Since invalid events do not bubble to parent listeners, and because // our invalid listeners are added lazily after other listeners, we can // reliably read `defaultPrevented` synchronously without worrying // about waiting for another listener that could cancel it. const wasCanceled = invalidEvent?.defaultPrevented; if (wasCanceled) { return; } this[onReportValidity](invalidEvent); // If an implementation calls invalidEvent.preventDefault() to stop the // platform popup from displaying, focusing is also prevented, so we need // to manually focus. const implementationCanceledFocus = !wasCanceled && invalidEvent?.defaultPrevented; if (!implementationCanceledFocus) { return; } // The control should be focused when: // - `control.reportValidity()` is called (self-reporting). // - a form is reporting validity for its controls and this is the first // invalid control. if ( this[privateIsSelfReportingValidity] || isFirstInvalidControlInForm(this[internals].form, this) ) { this.focus(); } } [onReportValidity](invalidEvent: Event | null) { throw new Error('Implement [onReportValidity]'); } override formAssociatedCallback(form: HTMLFormElement | null) { // can't use super.formAssociatedCallback?.() due to closure if (super.formAssociatedCallback) { super.formAssociatedCallback(form); } // Clean up previous form listeners. this[privateCleanupFormListeners].abort(); if (!form) { return; } this[privateCleanupFormListeners] = new AbortController(); // Add a listener that fires when the form runs constraint validation and // the control is valid, so that it may remove its error styles. // // This happens on `form.reportValidity()` and `form.requestSubmit()` // (both when the submit fails and passes). addFormReportValidListener( this, form, () => { this[privateCallOnReportValidity](null); }, this[privateCleanupFormListeners].signal, ); } } return OnReportValidityElement; } /** * Add a listener that fires when a form runs constraint validation on a control * and it is valid. This is needed to clear previously invalid styles. * * @param control The control of the form to listen for valid events. * @param form The control's form that can run constraint validation. * @param onControlValid A listener that is called when the form runs constraint * validation and the control is valid. * @param cleanup A cleanup signal to remove the listener. */ function addFormReportValidListener( control: Element, form: HTMLFormElement, onControlValid: () => void, cleanup: AbortSignal, ) { const validateHooks = getFormValidateHooks(form); // When a form validates its controls, check if an invalid event is dispatched // on the control. If it is not, then inform the control to report its valid // state. let controlFiredInvalid = false; let cleanupInvalidListener: AbortController | undefined; let isNextSubmitFromHook = false; validateHooks.addEventListener( 'before', () => { isNextSubmitFromHook = true; cleanupInvalidListener = new AbortController(); controlFiredInvalid = false; control.addEventListener( 'invalid', () => { controlFiredInvalid = true; }, { signal: cleanupInvalidListener.signal, }, ); }, {signal: cleanup}, ); validateHooks.addEventListener( 'after', () => { isNextSubmitFromHook = false; cleanupInvalidListener?.abort(); if (controlFiredInvalid) { return; } onControlValid(); }, {signal: cleanup}, ); // The above hooks handle imperatively submitting the form, but not // declaratively submitting the form. This happens when: // 1. A non-custom element `