From 1f55d4111fe4da6644b1e32e745b5d74306b6da1 Mon Sep 17 00:00:00 2001 From: Elizabeth Mitchell Date: Tue, 31 Oct 2023 14:08:08 -0700 Subject: [PATCH] chore(behaviors): update form controls to use shared mixins PiperOrigin-RevId: 578297611 --- checkbox/internal/checkbox.ts | 93 ++++---- internal/aria/aria.ts | 6 +- internal/controller/form-submitter.ts | 4 +- labs/behaviors/element-internals.ts | 30 ++- labs/behaviors/form-associated.ts | 302 ++++++++++++++++++++++++ labs/behaviors/form-associated_test.ts | 309 +++++++++++++++++++++++++ labs/behaviors/mixin.ts | 1 - radio/internal/radio.ts | 82 +++---- select/internal/select.ts | 85 +++---- slider/internal/slider.ts | 88 +++---- switch/internal/switch.ts | 99 +++----- textfield/internal/text-field.ts | 93 ++++---- 12 files changed, 865 insertions(+), 327 deletions(-) create mode 100644 labs/behaviors/form-associated.ts create mode 100644 labs/behaviors/form-associated_test.ts diff --git a/checkbox/internal/checkbox.ts b/checkbox/internal/checkbox.ts index 941b88b494..c05629207d 100644 --- a/checkbox/internal/checkbox.ts +++ b/checkbox/internal/checkbox.ts @@ -18,11 +18,25 @@ import { isActivationClick, redispatchEvent, } from '../../internal/controller/events.js'; +import { + internals, + mixinElementInternals, +} from '../../labs/behaviors/element-internals.js'; +import { + getFormState, + getFormValue, + mixinFormAssociated, +} from '../../labs/behaviors/form-associated.js'; + +// Separate variable needed for closure. +const checkboxBaseClass = mixinFormAssociated( + mixinElementInternals(LitElement), +); /** * A checkbox component. */ -export class Checkbox extends LitElement { +export class Checkbox extends checkboxBaseClass { static { requestUpdateOnAriaChange(Checkbox); } @@ -33,19 +47,11 @@ export class Checkbox extends LitElement { delegatesFocus: true, }; - /** @nocollapse */ - static readonly formAssociated = true; - /** * Whether or not the checkbox is selected. */ @property({type: Boolean}) checked = false; - /** - * Whether or not the checkbox is disabled. - */ - @property({type: Boolean, reflect: true}) disabled = false; - /** * Whether or not the checkbox is indeterminate. * @@ -68,30 +74,6 @@ export class Checkbox extends LitElement { */ @property() value = 'on'; - /** - * The HTML name to use in form submission. - */ - get name() { - return this.getAttribute('name') ?? ''; - } - set name(name: string) { - this.setAttribute('name', name); - } - - /** - * The associated form element with which this element's value will submit. - */ - get form() { - return this.internals.form; - } - - /** - * The labels this element is associated with. - */ - get labels() { - return this.internals.labels; - } - /** * Returns a ValidityState object that represents the validity states of the * checkbox. @@ -103,7 +85,7 @@ export class Checkbox extends LitElement { */ get validity() { this.syncValidity(); - return this.internals.validity; + return this[internals].validity; } /** @@ -113,7 +95,7 @@ export class Checkbox extends LitElement { */ get validationMessage() { this.syncValidity(); - return this.internals.validationMessage; + return this[internals].validationMessage; } /** @@ -124,7 +106,7 @@ export class Checkbox extends LitElement { */ get willValidate() { this.syncValidity(); - return this.internals.willValidate; + return this[internals].willValidate; } @state() private prevChecked = false; @@ -132,10 +114,8 @@ export class Checkbox extends LitElement { @state() private prevIndeterminate = false; @query('input') private readonly input!: HTMLInputElement | null; // Needed for Safari, see https://bugs.webkit.org/show_bug.cgi?id=261432 - // Replace with this.internals.validity.customError when resolved. + // Replace with this[internals].validity.customError when resolved. private hasCustomValidityError = false; - // Cast needed for closure - private readonly internals = (this as HTMLElement).attachInternals(); constructor() { super(); @@ -162,7 +142,7 @@ export class Checkbox extends LitElement { */ checkValidity() { this.syncValidity(); - return this.internals.checkValidity(); + return this[internals].checkValidity(); } /** @@ -180,7 +160,7 @@ export class Checkbox extends LitElement { */ reportValidity() { this.syncValidity(); - return this.internals.reportValidity(); + return this[internals].reportValidity(); } /** @@ -196,7 +176,7 @@ export class Checkbox extends LitElement { */ setCustomValidity(error: string) { this.hasCustomValidityError = !!error; - this.internals.setValidity({customError: !!error}, error, this.getInput()); + this[internals].setValidity({customError: !!error}, error, this.getInput()); } protected override update(changed: PropertyValues) { @@ -211,9 +191,6 @@ export class Checkbox extends LitElement { changed.get('indeterminate') ?? this.indeterminate; } - const shouldAddFormValue = this.checked && !this.indeterminate; - const state = String(this.checked); - this.internals.setFormValue(shouldAddFormValue ? this.value : null, state); super.update(changed); } @@ -285,12 +262,12 @@ export class Checkbox extends LitElement { // validity. We do this to re-use native `` validation messages. const input = this.getInput(); if (this.hasCustomValidityError) { - input.setCustomValidity(this.internals.validationMessage); + input.setCustomValidity(this[internals].validationMessage); } else { input.setCustomValidity(''); } - this.internals.setValidity( + this[internals].setValidity( input.validity, input.validationMessage, this.getInput(), @@ -314,15 +291,29 @@ export class Checkbox extends LitElement { return this.input!; } - /** @private */ - formResetCallback() { + // Writable mixin properties for lit-html binding, needed for lit-analyzer + declare disabled: boolean; + declare name: string; + + override [getFormValue]() { + if (!this.checked || this.indeterminate) { + return null; + } + + return this.value; + } + + override [getFormState]() { + return String(this.checked); + } + + override formResetCallback() { // The checked property does not reflect, so the original attribute set by // the user is used to determine the default value. this.checked = this.hasAttribute('checked'); } - /** @private */ - formStateRestoreCallback(state: string) { + override formStateRestoreCallback(state: string) { this.checked = state === 'true'; } } diff --git a/internal/aria/aria.ts b/internal/aria/aria.ts index b5c1be4542..e223d55857 100644 --- a/internal/aria/aria.ts +++ b/internal/aria/aria.ts @@ -319,12 +319,12 @@ export function polyfillARIAMixin(ctor: typeof ReactiveElement) { * Polyfills an element and its `ElementInternals` to support `ARIAMixin` * properties on internals. This is needed for Firefox. * - * `setupHostAria()` must be called for the element class. + * `polyfillARIAMixin()` must be called for the element class. * * @example * class XButton extends LitElement { * static { - * setupHostAria(XButton); + * polyfillARIAMixin(XButton); * } * * private internals = @@ -345,7 +345,7 @@ export function polyfillElementInternalsAria( } if (!('role' in host)) { - throw new Error('Missing setupHostAria()'); + throw new Error('Missing polyfillARIAMixin()'); } let firstConnectedCallbacks: Array<{ diff --git a/internal/controller/form-submitter.ts b/internal/controller/form-submitter.ts index bb99101ca5..14730e9638 100644 --- a/internal/controller/form-submitter.ts +++ b/internal/controller/form-submitter.ts @@ -66,7 +66,7 @@ type FormSubmitterConstructor = * * @example * ```ts - * class MyElement extends LitElement { + * class MyElement extends mixinElementInternals(LitElement) { * static { * setupFormSubmitter(MyElement); * } @@ -74,8 +74,6 @@ type FormSubmitterConstructor = * static formAssociated = true; * * type: FormSubmitterType = 'submit'; - * - * [internals] = this.attachInternals(); * } * ``` * diff --git a/labs/behaviors/element-internals.ts b/labs/behaviors/element-internals.ts index f583367c63..49468e77bf 100644 --- a/labs/behaviors/element-internals.ts +++ b/labs/behaviors/element-internals.ts @@ -6,6 +6,10 @@ import {LitElement} from 'lit'; +import { + polyfillARIAMixin, + polyfillElementInternalsAria, +} from '../../internal/aria/aria.js'; import {MixinBase, MixinReturn} from './mixin.js'; /** @@ -38,6 +42,9 @@ export interface WithElementInternals { [internals]: ElementInternals; } +// Private symbols +const privateInternals = Symbol('privateInternals'); + /** * Mixes in an attached `ElementInternals` instance. * @@ -54,8 +61,27 @@ export function mixinElementInternals>( extends base implements WithElementInternals { - // Cast needed for closure - [internals] = (this as HTMLElement).attachInternals(); + static { + polyfillARIAMixin( + WithElementInternalsElement as unknown as typeof LitElement, + ); + } + + get [internals]() { + // Create internals in getter so that it can be used in methods called on + // construction in `ReactiveElement`, such as `requestUpdate()`. + if (!this[privateInternals]) { + // Cast needed for closure + this[privateInternals] = polyfillElementInternalsAria( + this, + (this as HTMLElement).attachInternals(), + ); + } + + return this[privateInternals]; + } + + [privateInternals]?: ElementInternals; } return WithElementInternalsElement; diff --git a/labs/behaviors/form-associated.ts b/labs/behaviors/form-associated.ts new file mode 100644 index 0000000000..69b4f67cd9 --- /dev/null +++ b/labs/behaviors/form-associated.ts @@ -0,0 +1,302 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {LitElement, PropertyDeclaration} from 'lit'; +import {property} from 'lit/decorators.js'; + +import {internals, WithElementInternals} from './element-internals.js'; +import {MixinBase, MixinReturn} from './mixin.js'; + +/** + * A form-associated element. + * + * IMPORTANT: Requires declares for lit-analyzer + * @example + * ```ts + * const base = mixinFormAssociated(mixinElementInternals(LitElement)); + * class MyControl extends base { + * // Writable mixin properties for lit-html binding, needed for lit-analyzer + * declare disabled: boolean; + * declare name: string; + * } + * ``` + */ +export interface FormAssociated { + /** + * The associated form element with which this element's value will submit. + */ + readonly form: HTMLFormElement | null; + + /** + * The labels this element is associated with. + */ + readonly labels: NodeList; + + /** + * The HTML name to use in form submission. + */ + name: string; + + /** + * Whether or not the element is disabled. + */ + disabled: boolean; + + /** + * Gets the current form value of a component. + * + * @return The current form value. + */ + [getFormValue](): FormValue | null; + + /** + * Gets the current form state of a component. Defaults to the component's + * `[formValue]`. + * + * Use this when the state of an element is different from its value, such as + * checkboxes (internal boolean state and a user string value). + * + * @return The current form state, defaults to the form value. + */ + [getFormState](): FormValue | null; + + /** + * A callback for when a form component should be disabled or enabled. This + * can be called in a variety of situations, such as disabled `
`s. + * + * @param disabled Whether or not the form control should be disabled. + */ + formDisabledCallback(disabled: boolean): void; + + /** + * A callback for when the form requests to reset its value. Typically, the + * default value that is reset is represented in the attribute of an element. + * + * This means the attribute used for the value should not update as the value + * changes. For example, a checkbox should not change its default `checked` + * attribute when selected. Ensure form values do not reflect. + */ + formResetCallback(): void; + + /** + * A callback for when the form restores the state of a component. For + * example, when a page is reloaded or forms are autofilled. + * + * @param state The state to restore, or null to reset the form control's + * value. + * @param reason The reason state was restored, either `'restore'` or + * `'autocomplete'`. + */ + formStateRestoreCallback( + state: FormRestoreState | null, + reason: FormRestoreReason, + ): void; + + /** + * An optional callback for when the associated form changes. + * + * @param form The new associated form, or `null` if there is none. + */ + formAssociatedCallback?(form: HTMLFormElement | null): void; +} + +/** + * The constructor of a `FormAssociated` element. + */ +export interface FormAssociatedConstructor { + /** + * Indicates that an element is participating in form association. + */ + readonly formAssociated: true; +} + +/** + * A symbol property to retrieve the form value for an element. + */ +export const getFormValue = Symbol('getFormValue'); + +/** + * A symbol property to retrieve the form state for an element. + */ +export const getFormState = Symbol('getFormState'); + +/** + * Mixes in form-associated behavior for a class. This allows an element to add + * values to `
` elements. + * + * Implementing classes should provide a `[formValue]` to return the current + * value of the element, as well as reset and restore callbacks. + * + * @example + * ```ts + * const base = mixinFormAssociated(mixinElementInternals(LitElement)); + * + * class MyControl extends base { + * \@property() + * value = ''; + * + * override [getFormValue]() { + * return this.value; + * } + * + * override formResetCallback() { + * const defaultValue = this.getAttribute('value'); + * this.value = defaultValue; + * } + * + * override formStateRestoreCallback(state: string) { + * this.value = state; + * } + * } + * ``` + * + * Elements may optionally provide a `[formState]` if their values do not + * represent the state of the component. + * + * @example + * ```ts + * const base = mixinFormAssociated(mixinElementInternals(LitElement)); + * + * class MyCheckbox extends base { + * \@property() + * value = 'on'; + * + * \@property({type: Boolean}) + * checked = false; + * + * override [getFormValue]() { + * return this.checked ? this.value : null; + * } + * + * override [getFormState]() { + * return String(this.checked); + * } + * + * override formResetCallback() { + * const defaultValue = this.hasAttribute('checked'); + * this.checked = defaultValue; + * } + * + * override formStateRestoreCallback(state: string) { + * this.checked = Boolean(state); + * } + * } + * ``` + * + * IMPORTANT: Requires declares for lit-analyzer + * @example + * ```ts + * const base = mixinFormAssociated(mixinElementInternals(LitElement)); + * class MyControl extends base { + * // Writable mixin properties for lit-html binding, needed for lit-analyzer + * declare disabled: boolean; + * declare name: string; + * } + * ``` + * + * @param base The class to mix functionality into. The base class must use + * `mixinElementInternals()`. + * @return The provided class with `FormAssociated` mixed in. + */ +export function mixinFormAssociated< + T extends MixinBase, +>(base: T): MixinReturn { + abstract class FormAssociatedElement extends base implements FormAssociated { + /** @nocollapse */ + static readonly formAssociated = true; + + get form() { + return this[internals].form; + } + + get labels() { + return this[internals].labels; + } + + // name attribute must be set synchronously + @property({reflect: true}) + get name() { + return this.getAttribute('name') ?? ''; + } + set name(name: string) { + const prev = this.name; + // Setting name to null or empty string does not remove the attribute. + this.setAttribute('name', name); + // Explicit requestUpdate needed for Lit 2.0 + this.requestUpdate('name', prev); + } + + // disabled attribute must be set synchronously + @property({type: Boolean, reflect: true}) + get disabled() { + return this.hasAttribute('disabled'); + } + set disabled(disabled: boolean) { + const prev = this.disabled; + this.toggleAttribute('disabled', disabled); + // Explicit requestUpdate needed for Lit 2.0 + this.requestUpdate('disabled', prev); + } + + override requestUpdate( + name?: PropertyKey, + oldValue?: unknown, + options?: PropertyDeclaration, + ) { + super.requestUpdate(name, oldValue, options); + // If any properties change, update the form value, which may have changed + // as well. + // Update the form value synchronously in `requestUpdate()` rather than + // `update()` or `updated()`, which are async. This is necessary to ensure + // that form data is updated in time for synchronous event listeners. + this[internals].setFormValue(this[getFormValue](), this[getFormState]()); + } + + [getFormValue](): FormValue | null { + // Closure does not allow abstract symbol members, so a default + // implementation is needed. + throw new Error('Implement [getFormValue]'); + } + + [getFormState](): FormValue | null { + return this[getFormValue](); + } + + formDisabledCallback(disabled: boolean) { + this.disabled = disabled; + } + + abstract formResetCallback(): void; + + abstract formStateRestoreCallback( + state: FormRestoreState | null, + reason: FormRestoreReason, + ): void; + } + + return FormAssociatedElement; +} + +/** + * A value that can be provided for form submission and state. + */ +export type FormValue = File | string | FormData; + +/** + * A value to be restored for a component's form value. If a component's form + * state is a `FormData` object, its entry list of name and values will be + * provided. + */ +export type FormRestoreState = + | File + | string + | Array<[string, FormDataEntryValue]>; + +/** + * The reason a form component is being restored for, either `'restore'` for + * browser restoration or `'autocomplete'` for restoring user values. + */ +export type FormRestoreReason = 'restore' | 'autocomplete'; diff --git a/labs/behaviors/form-associated_test.ts b/labs/behaviors/form-associated_test.ts new file mode 100644 index 0000000000..013c5017d6 --- /dev/null +++ b/labs/behaviors/form-associated_test.ts @@ -0,0 +1,309 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) + +import {LitElement, html, render} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; + +import {Environment} from '../../testing/environment.js'; + +import {internals, mixinElementInternals} from './element-internals.js'; +import { + getFormState, + getFormValue, + mixinFormAssociated, +} from './form-associated.js'; + +describe('mixinFormAssociated()', () => { + @customElement('test-form-associated') + class TestFormAssociated extends mixinFormAssociated( + mixinElementInternals(LitElement), + ) { + @property() value = ''; + get state(): 'populated' | 'empty' { + return this.value ? 'populated' : 'empty'; + } + + useCustomGetFormStateImpl = true; + + override [getFormValue]() { + return this.value; + } + + override [getFormState]() { + if (this.useCustomGetFormStateImpl) { + return this.state; + } + + return super[getFormState](); + } + + // These behaviors are specific to individual components + override formResetCallback() {} + override formStateRestoreCallback() {} + } + + const env = new Environment(); + + // Form APIs should be synchronous + function setupTestWithoutForm() { + const root = env.render( + html``, + ); + + const element = root.querySelector( + 'test-form-associated', + ) as TestFormAssociated; + + return {root, element}; + } + + function setupTestWithForm() { + const root = env.render( + html``, + ); + + const form = root.querySelector('form')!; + const element = root.querySelector( + 'test-form-associated', + ) as TestFormAssociated; + + return {root, form, element}; + } + + it('should add `static formAssociated = true`', () => { + const {element} = setupTestWithoutForm(); + expect( + (element.constructor as typeof TestFormAssociated).formAssociated, + ).toBeTrue(); + }); + + describe('.form', () => { + it('should return null when not a child of a form', () => { + const {element} = setupTestWithoutForm(); + expect(element.form).toBeNull(); + }); + + it('should return parent form element', () => { + const {form, element} = setupTestWithForm(); + expect(element.form).toBe(form); + }); + }); + + describe('labels', () => { + it('should return empty NodeList when no labels associated', () => { + const {element} = setupTestWithoutForm(); + expect(element.labels).toBeInstanceOf(NodeList); + expect(element.labels).toHaveSize(0); + }); + + it('should return parent labels', () => { + const {root, element} = setupTestWithoutForm(); + render(html``, root); + const label = root.querySelector('label'); + if (!label) { + throw new Error('