diff --git a/checkbox/checkbox_test.ts b/checkbox/checkbox_test.ts index f80312ebbe..d73db3969e 100644 --- a/checkbox/checkbox_test.ts +++ b/checkbox/checkbox_test.ts @@ -6,6 +6,9 @@ // import 'jasmine'; (google3-only) +import {html} from 'lit'; + +import {createFormTests} from '../testing/forms.js'; import {createTokenTests} from '../testing/tokens.js'; import {MdCheckbox} from './checkbox.js'; @@ -14,4 +17,138 @@ describe('', () => { describe('.styles', () => { createTokenTests(MdCheckbox.styles); }); + + describe('forms', () => { + createFormTests({ + queryControl: root => root.querySelector('md-checkbox'), + valueTests: [ + { + name: 'unnamed', + render: () => html``, + assertValue(formData) { + expect(formData) + .withContext('should not add anything to form without a name') + .toHaveSize(0); + } + }, + { + name: 'unchecked', + render: () => html``, + assertValue(formData) { + expect(formData) + .withContext('should not add anything to form when unchecked') + .toHaveSize(0); + } + }, + { + name: 'checked default value', + render: () => + html``, + assertValue(formData) { + expect(formData.get('checkbox')).toBe('on'); + } + }, + { + name: 'checked custom value', + render: () => + html``, + assertValue(formData) { + expect(formData.get('checkbox')).toBe('Custom value'); + } + }, + { + name: 'indeterminate', + render: () => + html``, + assertValue(formData) { + expect(formData) + .withContext( + 'should not add anything to form when indeterminate') + .toHaveSize(0); + } + }, + { + name: 'disabled', + render: () => + html``, + assertValue(formData) { + expect(formData) + .withContext('should not add anything to form when disabled') + .toHaveSize(0); + } + } + ], + resetTests: [ + { + name: 'reset to unchecked', + render: () => html``, + change(checkbox) { + checkbox.checked = true; + }, + assertReset(checkbox) { + expect(checkbox.checked) + .withContext('checkbox.checked after reset') + .toBeFalse(); + } + }, + { + name: 'reset to checked', + render: () => + html``, + change(checkbox) { + checkbox.checked = false; + }, + assertReset(checkbox) { + expect(checkbox.checked) + .withContext('checkbox.checked after reset') + .toBeTrue(); + } + }, + { + name: 'reset to indeterminate', + render: () => + html``, + change(checkbox) { + checkbox.indeterminate = false; + }, + assertReset(checkbox) { + expect(checkbox.indeterminate) + .withContext('checkbox.indeterminate should not be reset') + .toBeFalse(); + } + } + ], + restoreTests: [ + { + name: 'restore unchecked', + render: () => html``, + assertRestored(checkbox) { + expect(checkbox.checked) + .withContext('checkbox.checked after restore') + .toBeFalse(); + } + }, + { + name: 'restore checked', + render: () => + html``, + assertRestored(checkbox) { + expect(checkbox.checked) + .withContext('checkbox.checked after restore') + .toBeTrue(); + } + }, + { + name: 'restore indeterminate', + render: () => + html``, + assertRestored(checkbox) { + expect(checkbox.indeterminate) + .withContext('checkbox.indeterminate should not be restored') + .toBeFalse(); + } + } + ] + }); + }); }); diff --git a/checkbox/lib/checkbox.ts b/checkbox/lib/checkbox.ts index 85260b38fe..d7d2ad36ad 100644 --- a/checkbox/lib/checkbox.ts +++ b/checkbox/lib/checkbox.ts @@ -15,8 +15,6 @@ import {when} from 'lit/directives/when.js'; import {ARIAMixinStrict} from '../../aria/aria.js'; import {requestUpdateOnAriaChange} from '../../aria/delegate.js'; import {dispatchActivationClick, isActivationClick, redispatchEvent} from '../../controller/events.js'; -import {FormController, getFormValue} from '../../controller/form-controller.js'; -import {stringConverter} from '../../controller/string-converter.js'; import {ripple} from '../../ripple/directive.js'; import {MdRipple} from '../../ripple/ripple.js'; @@ -28,15 +26,13 @@ export class Checkbox extends LitElement { requestUpdateOnAriaChange(this); } - /** - * @nocollapse - */ + /** @nocollapse */ static formAssociated = true; /** * Whether or not the checkbox is selected. */ - @property({type: Boolean, reflect: true}) checked = false; + @property({type: Boolean}) checked = false; /** * Whether or not the checkbox is disabled. @@ -46,14 +42,14 @@ export class Checkbox extends LitElement { /** * Whether or not the checkbox is invalid. */ - @property({type: Boolean, reflect: true}) error = false; + @property({type: Boolean}) error = false; /** * Whether or not the checkbox is indeterminate. * * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#indeterminate_state_checkboxes */ - @property({type: Boolean, reflect: true}) indeterminate = false; + @property({type: Boolean}) indeterminate = false; /** * The value of the checkbox that is submitted with a form when selected. @@ -65,13 +61,25 @@ export class Checkbox extends LitElement { /** * The HTML name to use in form submission. */ - @property({reflect: true, converter: stringConverter}) name = ''; + 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.closest('form'); + return this.internals.form; + } + + /** + * The labels this element is associated with. + */ + get labels() { + return this.internals.labels; } @state() private prevChecked = false; @@ -80,10 +88,11 @@ export class Checkbox extends LitElement { @queryAsync('md-ripple') private readonly ripple!: Promise; @query('input') private readonly input!: HTMLInputElement|null; @state() private showRipple = false; + private readonly internals = + (this as HTMLElement /* needed for closure */).attachInternals(); constructor() { super(); - this.addController(new FormController(this)); if (!isServer) { this.addEventListener('click', (event: MouseEvent) => { if (!isActivationClick(event)) { @@ -99,10 +108,6 @@ export class Checkbox extends LitElement { this.input?.focus(); } - [getFormValue]() { - return this.checked ? this.value : null; - } - protected override update(changed: PropertyValues) { if (changed.has('checked') || changed.has('disabled') || changed.has('indeterminate')) { @@ -112,6 +117,9 @@ 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); } @@ -176,4 +184,16 @@ export class Checkbox extends LitElement { private readonly renderRipple = () => { // bind to this return html``; }; + + /** @private */ + 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) { + this.checked = state === 'true'; + } } diff --git a/testing/forms.ts b/testing/forms.ts new file mode 100644 index 0000000000..ededddec92 --- /dev/null +++ b/testing/forms.ts @@ -0,0 +1,325 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) + +import {html, render, TemplateResult} from 'lit'; + +/** + * Options for creating form tests. + */ +export interface FormTestsOptions { + /** + * A `querySelector` result that returns the tested element. + * + * @param root The root element to query from. + * @return `root.querySelector('md-component')` + */ + queryControl(root: Element): T|null; + /** + * Tests for `setFormValue`. Tests should render a form element, then + * assert that the form's `FormData` matches the expected value (or lack of + * one). + * + * There must be at least one value test. + */ + valueTests: [ValueTest, ...ValueTest[]]; + /** + * Tests for `formResetCallback`. Tests should render a form element with an + * initial state, change the value of the element, then assert that the + * control was reset to its initial value. + * + * There must be at least one reset test. + */ + resetTests: [ResetTest, ...Array>]; + /** + * Tests for `formStateRestoreCallback`. Tests should render a form element + * with an initial state, then assert that a new control was restored with the + * same state. + * + * There must be at least one restore test. + */ + restoreTests: [RestoreTest, ...Array>]; +} + +/** + * Creates a series of tests that ensure an element works with forms as a form + * associated custom element. + * + * @param options Options for creating tests, including use cases. + */ +export function createFormTests( + options: FormTestsOptions) { + // Patch attachInternals in order to spy on `setFormValue()` for simulating + // form state restoration. + const originalAttachInternals = HTMLElement.prototype.attachInternals; + const INTERNALS = Symbol('internals'); + + interface HTMLElementWithInternals { + [INTERNALS]: SpiedElementInternals; + } + + interface SpiedElementInternals extends ElementInternals { + setFormValue: jasmine.Spy; + } + + function getInternals(element: HTMLElement) { + return (element as unknown as HTMLElementWithInternals)[INTERNALS]; + } + + beforeAll(() => { + HTMLElement.prototype.attachInternals = function(this: HTMLElement) { + const internals = originalAttachInternals.call(this); + spyOn(internals, 'setFormValue').and.callThrough(); + (this as unknown as HTMLElementWithInternals)[INTERNALS] = + internals as SpiedElementInternals; + return internals; + }; + }); + + afterAll(() => { + HTMLElement.prototype.attachInternals = originalAttachInternals; + }); + + let root: HTMLElement|undefined; + + beforeEach(() => { + root = document.createElement('div'); + document.body.appendChild(root); + }); + + afterEach(() => { + root?.remove(); + }); + + async function setupTest(content = options.valueTests[0].render()) { + if (!root) { + throw new Error('root was not set up correctly.'); + } + + render(html`
${content}
`, root); + const form = root.querySelector('form'); + if (!form) { + throw new Error('Could not query rendered
'); + } + + const control = + options.queryControl(root) as (T & ExpectedFormAssociatedElement) | + null; + if (!control) { + throw new Error('`queryControl` must return an element.'); + } + + await control?.updateComplete; + return {form, control}; + } + + it('should have `static formAssociated = true;`', async () => { + const {control} = await setupTest(); + + expect(control.constructor.formAssociated) + .withContext('control.constructor.formAssociated') + .toBeTrue(); + }); + + it('should return associated form for `form` property', async () => { + const {form, control} = await setupTest(); + expect(control.form).withContext('control.form').toBe(form); + }); + + it('should return null for `form` when not part of a ', async () => { + const {form, control} = await setupTest(); + form.parentElement?.append(control); + expect(control.form).withContext('control.form').toBeNull(); + }); + + it('should return associated labels for `labels` property', async () => { + const {form, control} = await setupTest(); + const labelFor = document.createElement('label'); + const labelParent = document.createElement('label'); + labelFor.htmlFor = 'control'; + control.id = 'control'; + form.append(labelFor); + labelParent.appendChild(control); + form.appendChild(labelParent); + + expect(control.labels) + .withContext('control.labels') + .toBeInstanceOf(NodeList); + const labels = Array.from(control.labels); + expect(labels) + .withContext('should contain parent label element') + .toContain(labelParent); + expect(labels) + .withContext('should contain label element with for attribute') + .toContain(labelFor); + }); + + it('should return empty NodeList for `labels` when not part of a ', + async () => { + const {form, control} = await setupTest(); + form.parentElement?.append(control); + expect(control.labels) + .withContext('control.labels') + .toBeInstanceOf(NodeList); + expect(control.labels.length) + .withContext('control.labels.length') + .toBe(0); + }); + + it('should have a name property that reflects to the name attribute', + async () => { + const {control} = await setupTest(); + control.name = 'control'; + await control?.updateComplete; + expect(control.getAttribute('name')) + .withContext('"name" reflected attribute') + .toBe('control'); + }); + + it('should not add a form value without a name', async () => { + const {form, control} = await setupTest(); + control.name = ''; + await control?.updateComplete; + const data = new FormData(form); + expect(data).withContext('data should be empty').toHaveSize(0); + }); + + for (const valueTest of options.valueTests) { + it(`should pass the "${valueTest.name}" value test`, async () => { + const {form} = await setupTest(valueTest.render()); + valueTest.assertValue(new FormData(form)); + }); + } + + for (const resetTest of options.resetTests) { + it(`it should pass the "${resetTest.name}" reset test`, async () => { + const {form, control} = await setupTest(resetTest.render()); + resetTest.change(control); + form.reset(); + resetTest.assertReset(control); + }); + } + + for (const restoreTest of options.restoreTests) { + it(`it should pass the "${restoreTest.name}" restore test`, async () => { + const {form} = await setupTest(restoreTest.render()); + const controls = + Array.from(form.elements) as ExpectedFormAssociatedElement[]; + for (const control of controls) { + // Simulate restoring a new set of controls. For each control, we + // grab its value and state from its internals. Then, we remove it from + // the form, add a new control, and simulate restoring the state and + // value for that control. + const [value, state] = + getInternals(control).setFormValue.calls.mostRecent()?.args ?? [null, null]; + + const newControl = document.createElement(control.tagName) as + ExpectedFormAssociatedElement; + control.remove(); + form.appendChild(newControl); + let restoreState: FormState|null|FormData = state ?? value; + if (restoreState instanceof FormData) { + restoreState = restoreState.entries(); + } + + newControl?.formStateRestoreCallback(restoreState, 'restore'); + await newControl?.updateComplete; + } + + const control = options.queryControl(form); + if (!control) { + throw new Error('`queryControl` must return an element.'); + } + + restoreTest.assertRestored(control); + }); + } +} + +/** + * The expected interface of a Form Associated Custom Element. Used for type + * checking in this file only. + */ +interface ExpectedFormAssociatedElement extends HTMLElement { + new(): ExpectedFormAssociatedElement; + constructor: (new() => ExpectedFormAssociatedElement)& + {readonly formAssociated: true}; + prototype: ExpectedFormAssociatedElement; + form: HTMLFormElement|null; + labels: NodeList; + name: string; + formStateRestoreCallback( + state: FormState|null, reason: 'restore'|'autocomplete'): void; + updateComplete?: Promise; +} + +/** + * `formStateRestoreCallback` type for `state`. May be a string, `File`, + * `FormData` entries, or null. + */ +type FormState = + FormDataEntryValue|IterableIterator<[string, FormDataEntryValue]>; + +/** + * A test for `FormData` values. + */ +interface ValueTest { + /** + * The name of the test. + */ + name: string; + /** + * Renders a form element with or without a value for form submission. + */ + render(): TemplateResult; + /** + * Asserts that the form's `FormData` contains or does not contain the form + * element's value. + */ + assertValue(formData: FormData): void; +} + +/** + * A test for `formResetCallback`. + */ +interface ResetTest { + /** + * The name of the test. + */ + name: string; + /** + * Renders a form element with some initial state. + */ + render(): TemplateResult; + /** + * Changes the state of a form element. + */ + change(control: T): void; + /** + * Asserts that the control was reset to its initial state. + */ + assertReset(control: T): void; +} + +/** + * A test form `formStateRestoreCallback`. + */ +interface RestoreTest { + /** + * The name of the test. + */ + name: string; + /** + * Renders a form element with some initial state. + */ + render(): TemplateResult; + /** + * Asserts that the newly created control was restored to the original + * control's state. + */ + assertRestored(control: T): void; +}