diff --git a/internal/controller/form-controller.ts b/internal/controller/form-controller.ts deleted file mode 100644 index bb3005af4c..0000000000 --- a/internal/controller/form-controller.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * @license - * Copyright 2021 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {ReactiveController, ReactiveControllerHost} from 'lit'; - -declare global { - interface Window { - ShadyDOM?: {inUse: boolean;}; - } -} - -/** - * An element that `FormController` may use. - */ -export interface FormElement extends ReactiveControllerHost, HTMLElement { - /** - * The `
` that this element is associated with. - */ - readonly form: HTMLFormElement|null; - /** - * The name of the element in the form. This property should reflect to a - * `name` attribute. - */ - name: string; - /** - * Whether or not this element is disabled. If present, this property should - * reflect to a `disabled` attribute. - */ - disabled?: boolean; - /** - * A function that retrieves the current form value for this element. - * - * @return The current form value, or `null` if there is no value. - */ - [getFormValue](): string|File|FormData|null; -} - -/** - * A unique symbol key for `FormController` elements to implement their - * `getFormValue()` function. - */ -export const getFormValue = Symbol('getFormValue'); - -/** - * A `ReactiveController` that adds `` support to an element. - * - * Elements should also set `static formAssociated = true` which - * provides platform support for forms. When an element is form associated, - * it can be activated via clicks on associated label elements. It is the - * responsibility of the element to process this click and perform any necessary - * activation tasks, for example focusing and clicking on an internal element. - * - */ -export class FormController implements ReactiveController { - private form?: HTMLFormElement|null; - - /** - * Creates a new `FormController` for the given element. - * - * @param element The element to add `` support to. - */ - constructor(private readonly element: FormElement) {} - - hostConnected() { - // If the component internals are not in Shadow DOM, subscribing to form - // data events could lead to duplicated data, which may not work correctly - // on the server side. - if (!this.element.shadowRoot || window.ShadyDOM?.inUse) { - return; - } - - // Preserve a reference to the form, since on hostDisconnected it may be - // null if the child was removed. - this.form = this.element.form; - this.form?.addEventListener('formdata', this.formDataListener); - } - - hostDisconnected() { - this.form?.removeEventListener('formdata', this.formDataListener); - } - - private readonly formDataListener = (event: FormDataEvent) => { - if (this.element.disabled) { - // Check for truthiness since some elements may not support disabling. - return; - } - - const value = this.element[getFormValue](); - // If given a `FormData` instance, append all values to the form. This - // allows elements to customize what is added beyond a single name/value - // pair. - if (value instanceof FormData) { - for (const [key, dataValue] of value) { - event.formData.append(key, dataValue); - } - return; - } - - // Do not associate the value with the form if there is no value or no name. - if (value === null || !this.element.name) { - return; - } - - event.formData.append(this.element.name, value); - }; -} diff --git a/internal/controller/form-controller_test.ts b/internal/controller/form-controller_test.ts deleted file mode 100644 index 26ce29f77f..0000000000 --- a/internal/controller/form-controller_test.ts +++ /dev/null @@ -1,331 +0,0 @@ -/** - * @license - * Copyright 2021 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -// import 'jasmine'; (google3-only) - -import {html, LitElement, TemplateResult} from 'lit'; -import {customElement, property, query} from 'lit/decorators.js'; - -import {Environment} from '../../testing/environment.js'; -import {Harness} from '../../testing/harness.js'; - -import {dispatchActivationClick, isActivationClick} from './events.js'; -import {FormController, FormElement, getFormValue} from './form-controller.js'; - -declare global { - interface HTMLElementTagNameMap { - 'my-form-element': MyFormElement; - 'my-form-data-element': MyFormDataElement; - 'my-checked-form-element': MyCheckedFormElement; - 'my-checked-form-associated-element': MyCheckedFormAssociatedElement; - } -} - -@customElement('my-form-element') -class MyFormElement extends LitElement implements FormElement { - get form() { - return this.closest('form'); - } - @property({type: Boolean}) disabled = false; - @property() name = ''; - @property() value = ''; - [getFormValue](): string|null|FormData { - return this.value ? this.value : null; - } - - constructor() { - super(); - this.addController(new FormController(this)); - } -} - -@customElement('my-form-data-element') -class MyFormDataElement extends MyFormElement { - override[getFormValue]() { - const data = new FormData(); - data.append('element-value', this.value); - data.append('element-foo', 'foo'); - return data; - } -} - -@customElement('my-checked-form-element') -class MyCheckedFormElement extends MyFormElement { - @property({type: Boolean}) checked = false; - - @query('#checked') checkedEl!: HTMLDivElement|null; - - constructor() { - super(); - this.setupActivationClickHandler(); - } - - setupActivationClickHandler() { - this.addEventListener('click', (event: MouseEvent) => { - if (!isActivationClick(event) || !this.checkedEl) { - return; - } - this.checkedEl.focus(); - dispatchActivationClick(this.checkedEl); - }); - } - - // Note, due to the following Firefox issue, it's important that the - // element contain native "interactive content" like a button or input, - // see https://bugzilla.mozilla.org/show_bug.cgi?id=1804576. - // - protected override render() { - return html``; - } - - override focus() { - this.checkedEl?.focus(); - } -} - -@customElement('my-checked-form-associated-element') -class MyCheckedFormAssociatedElement extends MyCheckedFormElement { - static formAssociated = true; -} - -class CheckedFormElementHarness extends Harness { - protected override async getInteractiveElement() { - await this.element.updateComplete; - return this.element.checkedEl!; - } -} - -describe('FormController', () => { - const env = new Environment(); - - async function setupTest(template: TemplateResult) { - const root = env.render(html` - ${template} - `); - - await env.waitForStability(); - const element = - root.querySelector('form')!.firstElementChild! as HTMLElement; - return new Harness(element); - } - - it('should add element\'s name/value pair to the form', async () => { - const harness = await setupTest(html` - - `); - const data = await harness.submitForm(); - expect(data.has('element')) - .withContext('should add name to data') - .toBeTrue(); - expect(data.get('element')) - .withContext('should add value to data') - .toBe('foo'); - }); - - it('should add form associated element\'s name/value pair to the form', - async () => { - const harness = await setupTest(html` - - `); - - const data = await harness.submitForm(); - expect(data.has('element')) - .withContext('should add name to data') - .toBeTrue(); - expect(data.get('element')) - .withContext('should add value to data') - .toBe('foo'); - }); - - it('should not add data when disconnected', async () => { - const harness = await setupTest(html` - - `); - - const form = harness.element.form!; - harness.element.remove(); - expect(harness.element.form).toBeNull(); - const data = await harness.submitForm(form); - expect(data.has('element')) - .withContext('should not add disconnected element to data') - .toBeFalse(); - }); - - it('should not add data when element is disabled', async () => { - const harness = await setupTest(html` - - `); - - const data = await harness.submitForm(); - expect(data.has('element')) - .withContext('should not add disabled element to data') - .toBeFalse(); - }); - - it('should not add data when value is null', async () => { - const harness = await setupTest(html` - - `); - - const data = await harness.submitForm(); - expect(data.has('element')) - .withContext('should not add null value to data') - .toBeFalse(); - }); - - it('should add all entries if element returns FormData', async () => { - const harness = await setupTest(html` - - `); - - const data = await harness.submitForm(); - expect(data.has('element-value')) - .withContext('should add element-value data') - .toBe(true); - expect(data.has('element-foo')) - .withContext('should add element-value data') - .toBe(true); - expect(data.get('element-value')) - .withContext('element-value should match data value') - .toBe('foo'); - expect(data.get('element-foo')) - .withContext('element-foo should match "foo"') - .toBe('foo'); - }); - - describe('label activation', () => { - const setupLabelTest = async ( - template: TemplateResult, - harnessTag = 'my-checked-form-associated-element') => { - const root = env.render(html` -
${template}
- `); - await env.waitForStability(); - const label = new Harness(root.querySelector('label')!); - const face = new CheckedFormElementHarness( - root.querySelector(harnessTag)!); - return {label, face}; - }; - - it('should activate via click event', async () => { - const {face} = await setupLabelTest(html` - - `); - expect(face.element.checked).toBeFalse(); - await face.clickWithMouse(); - expect(face.element.checked).toBeTrue(); - await face.clickWithMouse(); - expect(face.element.checked).toBeFalse(); - }); - - it('should activate via click method', async () => { - const {face} = await setupLabelTest(html` - - `); - expect(face.element.checked).toBeFalse(); - face.element.click(); - await env.waitForStability(); - expect(face.element.checked).toBeTrue(); - face.element.click(); - await env.waitForStability(); - expect(face.element.checked).toBeFalse(); - }); - - it('should activate form associated elements via surrounding label', - async () => { - const {label, face} = await setupLabelTest(html` - `); - expect(face.element.checked).toBeFalse(); - await face.clickWithMouse(); - expect(face.element.checked).toBeTrue(); - await label.clickWithMouse(); - expect(face.element.checked).toBeFalse(); - await label.clickWithMouse(); - expect(face.element.checked).toBeTrue(); - }); - - it('should not generate extra clicks when activated', async () => { - const {label, face} = await setupLabelTest(html` - `); - expect(face.element.checked).toBeFalse(); - const clickListener = jasmine.createSpy('clickListener'); - face.element.addEventListener('click', clickListener); - await face.clickWithMouse(); - expect(clickListener).toHaveBeenCalledTimes(1); - face.element.click(); - await env.waitForStability(); - expect(clickListener).toHaveBeenCalledTimes(2); - await label.clickWithMouse(); - expect(clickListener).toHaveBeenCalledTimes(3); - }); - - it('should activate form associated elements via label with matching `for`', - async () => { - const {label, face} = await setupLabelTest(html` - - - `); - expect(face.element.checked).toBeFalse(); - await face.clickWithMouse(); - expect(face.element.checked).toBeTrue(); - await label.clickWithMouse(); - expect(face.element.checked).toBeFalse(); - await label.clickWithMouse(); - expect(face.element.checked).toBeTrue(); - - // Disconnect `for` and check that face is not activated. - label.element.setAttribute('for', ''); - await label.clickWithMouse(); - expect(face.element.checked).toBeTrue(); - }); - - it('should *not* activate disabled form associated elements via label', - async () => { - const {label, face} = await setupLabelTest(html` - `); - expect(face.element.checked).toBeFalse(); - await label.clickWithMouse(); - expect(face.element.checked).toBeFalse(); - }); - - it('should *not* activate non-form associated elements via label', - async () => { - const {label, face} = await setupLabelTest( - html` - `, - `my-checked-form-element`); - expect(face.element.checked).toBeFalse(); - await face.clickWithMouse(); - expect(face.element.checked).toBeTrue(); - await label.clickWithMouse(); - expect(face.element.checked).toBeTrue(); - }); - }); -});