diff --git a/button/internal/button.ts b/button/internal/button.ts index 47c5ed4968..c1d88321ec 100644 --- a/button/internal/button.ts +++ b/button/internal/button.ts @@ -16,24 +16,19 @@ import { dispatchActivationClick, isActivationClick, } from '../../internal/events/form-label-activation.js'; -import { - internals, - mixinElementInternals, -} from '../../labs/behaviors/element-internals.js'; +import {mixinElementInternals} from '../../labs/behaviors/element-internals.js'; +import {mixinFormAssociated} from '../../labs/behaviors/form-associated.js'; import {mixinFormSubmitter} from '../../labs/behaviors/form-submitter.js'; // Separate variable needed for closure. const buttonBaseClass = mixinDelegatesAria( - mixinFormSubmitter(mixinElementInternals(LitElement)), + mixinFormSubmitter(mixinFormAssociated(mixinElementInternals(LitElement))), ); /** * A button component. */ export abstract class Button extends buttonBaseClass { - /** @nocollapse */ - static readonly formAssociated = true; - /** @nocollapse */ static override shadowRootOptions: ShadowRootInit = { mode: 'open', @@ -43,7 +38,7 @@ export abstract class Button extends buttonBaseClass { /** * Whether or not the button is disabled. */ - @property({type: Boolean, reflect: true}) disabled = false; + declare disabled: boolean; // for jsdoc until lit-analyzer is updated /** * Whether or not the button is "soft-disabled" (disabled but still @@ -89,13 +84,6 @@ export abstract class Button extends buttonBaseClass { @property({type: Boolean, attribute: 'has-icon', reflect: true}) hasIcon = false; - /** - * The associated form element with which this element's value will submit. - */ - get form() { - return this[internals].form; - } - @query('.button') private readonly buttonElement!: HTMLElement | null; @queryAssignedElements({slot: 'icon', flatten: true}) diff --git a/iconbutton/internal/icon-button.ts b/iconbutton/internal/icon-button.ts index a486982361..33aa305d81 100644 --- a/iconbutton/internal/icon-button.ts +++ b/iconbutton/internal/icon-button.ts @@ -19,17 +19,15 @@ import { afterDispatch, setupDispatchHooks, } from '../../internal/events/dispatch-hooks.js'; -import { - internals, - mixinElementInternals, -} from '../../labs/behaviors/element-internals.js'; +import {mixinElementInternals} from '../../labs/behaviors/element-internals.js'; +import {mixinFormAssociated} from '../../labs/behaviors/form-associated.js'; import {mixinFormSubmitter} from '../../labs/behaviors/form-submitter.js'; type LinkTarget = '_blank' | '_parent' | '_self' | '_top'; // Separate variable needed for closure. const iconButtonBaseClass = mixinDelegatesAria( - mixinFormSubmitter(mixinElementInternals(LitElement)), + mixinFormSubmitter(mixinFormAssociated(mixinElementInternals(LitElement))), ); /** @@ -40,9 +38,6 @@ const iconButtonBaseClass = mixinDelegatesAria( * @fires change {Event} Dispatched when a toggle button toggles --bubbles */ export class IconButton extends iconButtonBaseClass { - /** @nocollapse */ - static readonly formAssociated = true; - /** @nocollapse */ static override shadowRootOptions: ShadowRootInit = { mode: 'open', @@ -52,7 +47,7 @@ export class IconButton extends iconButtonBaseClass { /** * Disables the icon button and makes it non-interactive. */ - @property({type: Boolean, reflect: true}) disabled = false; + declare disabled: boolean; // for jsdoc until lit-analyzer is updated /** * "Soft-disables" the icon button (disabled but still focusable). @@ -105,20 +100,6 @@ export class IconButton extends iconButtonBaseClass { */ @property({type: Boolean, reflect: true}) selected = false; - /** - * 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; - } - @state() private flipIcon = isRtl(this, this.flipIconInRtl); constructor() { diff --git a/labs/behaviors/form-associated.ts b/labs/behaviors/form-associated.ts index 75f5efed34..fdad2d795d 100644 --- a/labs/behaviors/form-associated.ts +++ b/labs/behaviors/form-associated.ts @@ -46,7 +46,8 @@ export interface FormAssociated { disabled: boolean; /** - * Gets the current form value of a component. + * Gets the current form value of a component. Defaults to the component's + * `value` attribute. * * @return The current form value. */ @@ -54,7 +55,7 @@ export interface FormAssociated { /** * Gets the current form state of a component. Defaults to the component's - * `[formValue]`. + * `[getFormValue]()` result. * * Use this when the state of an element is different from its value, such as * checkboxes (internal boolean state and a user string value). @@ -72,25 +73,26 @@ export interface FormAssociated { 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. + * An optional 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; + 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. + * An optional 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( + formStateRestoreCallback?( state: FormRestoreState | null, reason: FormRestoreReason, ): void; @@ -238,7 +240,9 @@ export function mixinFormAssociated< return this.hasAttribute('disabled'); } set disabled(disabled: boolean) { - this.toggleAttribute('disabled', disabled); + // Coerce `disabled` in `Boolean()` to ensure that setting to `null` or + // `undefined` sets the attribute to `false`. + this.toggleAttribute('disabled', Boolean(disabled)); // We don't need to call `requestUpdate()` since it's called synchronously // in `attributeChangedCallback()`. } @@ -282,9 +286,7 @@ export function mixinFormAssociated< } [getFormValue](): FormValue | null { - // Closure does not allow abstract symbol members, so a default - // implementation is needed. - throw new Error('Implement [getFormValue]'); + return this.getAttribute('value'); } [getFormState](): FormValue | null { @@ -294,13 +296,6 @@ export function mixinFormAssociated< formDisabledCallback(disabled: boolean) { this.disabled = disabled; } - - abstract formResetCallback(): void; - - abstract formStateRestoreCallback( - state: FormRestoreState | null, - reason: FormRestoreReason, - ): void; } return FormAssociatedElement; diff --git a/labs/behaviors/form-associated_test.ts b/labs/behaviors/form-associated_test.ts index 013c5017d6..dea0ff0eb0 100644 --- a/labs/behaviors/form-associated_test.ts +++ b/labs/behaviors/form-associated_test.ts @@ -217,27 +217,20 @@ describe('mixinFormAssociated()', () => { }); describe('[getFormValue]()', () => { - it('should throw an error if not implemented', () => { - expect(() => { - @customElement('test-bad-form-associated') - class TestBadFormAssociated extends mixinFormAssociated( - mixinElementInternals(LitElement), - ) { - override requestUpdate( - ...args: Parameters - ) { - // Suppress errors that will occur async when the element is - // initialized. This is harder to test in jasmine, so we explicitly - // call the getFormValue function to test the error. - try { - super.requestUpdate(...args); - } catch {} - } - } - - const element = new TestBadFormAssociated(); - element[getFormValue](); - }).toThrowError(/getFormValue/); + it('should return the value attribute by default', () => { + @customElement('test-default-value-form-associated') + class TestDefaultValueFormAssociated extends mixinFormAssociated( + mixinElementInternals(LitElement), + ) {} + + const element = new TestDefaultValueFormAssociated(); + expect(element[getFormValue]()) + .withContext('[getFormValue]() return with no value attribute') + .toBeNull(); + element.setAttribute('value', 'value'); + expect(element[getFormValue]()) + .withContext('[getFormValue]() return with value attribute') + .toBe('value'); }); it('should not add form data without a name', () => {