feat(controller): add FormController for form support

PiperOrigin-RevId: 418809280
This commit is contained in:
Liz Mitchell 2021-12-29 09:45:59 -08:00 committed by Copybara-Service
parent dc8f8c117b
commit f8411edaf5
5 changed files with 283 additions and 48 deletions

View File

@ -0,0 +1,99 @@
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {ReactiveController, ReactiveControllerHost} from 'lit';
import {bound} from '../decorators/bound';
/**
* An element that `FormController` may use.
*/
export interface FormElement extends ReactiveControllerHost, HTMLElement {
/**
* The `<form>` 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 `<form>` support to an element.
*/
export class FormController implements ReactiveController {
private form?: HTMLFormElement|null;
/**
* Creates a new `FormController` for the given element.
*
* @param element The element to add `<form>` 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);
}
@bound
private 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);
}
}

View File

@ -0,0 +1,146 @@
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import 'jasmine';
import {html, LitElement, TemplateResult} from 'lit';
import {customElement, property} from 'lit/decorators';
import {Environment} from '../../testing/environment';
import {FormController, FormElement, getFormValue} from '../form-controller';
function submitForm(form: HTMLFormElement) {
return new Promise<FormData>(resolve => {
const submitListener = (event: SubmitEvent) => {
event.preventDefault();
form.removeEventListener('submit', submitListener);
const data = new FormData(form);
resolve(data);
return false;
};
form.addEventListener('submit', submitListener);
form.requestSubmit();
});
}
declare global {
interface HTMLElementTagNameMap {
'my-form-element': MyFormElement;
'my-form-data-element': MyFormDataElement;
}
}
@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;
}
}
describe('FormController', () => {
const env = new Environment();
async function setupTest(template: TemplateResult) {
const root = env.render(html`
<form>${template}</form>
`);
await env.waitForStability();
return root.querySelector('form')!;
}
it('should add element\'s name/value pair to the form', async () => {
const form = await setupTest(html`
<my-form-element name="element" value="foo"></my-form-element>
`);
const data = await submitForm(form);
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 form = await setupTest(html`
<my-form-element name="element" value="foo"></my-form-element>
`);
form.removeChild(form.querySelector('my-form-element')!);
const data = await 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 form = await setupTest(html`
<my-form-element name="element" value="foo" disabled></my-form-element>
`);
const data = await submitForm(form);
expect(data.has('element'))
.withContext('should not add disabled element to data')
.toBeFalse();
});
it('should not add data when value is null', async () => {
const form = await setupTest(html`
<my-form-element name="element"></my-form-element>
`);
const data = await submitForm(form);
expect(data.has('element'))
.withContext('should not add null value to data')
.toBeFalse();
});
it('should add all entries if element returns FormData', async () => {
const form = await setupTest(html`
<my-form-data-element value="foo"></my-form-data-element>
`);
const data = await submitForm(form);
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');
});
});

View File

@ -6,7 +6,7 @@
import {MDCObserverFoundation} from '@material/base/observer-foundation';
import {MDCSwitchState} from './state';
import {MDCSwitchAdapter} from './state';
/**
* `MDCSwitchFoundation` provides a state-only foundation for a switch
@ -15,8 +15,9 @@ import {MDCSwitchState} from './state';
* State observers and event handler entrypoints update a component's adapter's
* state with the logic needed for switch to function.
*/
export class MDCSwitchFoundation extends MDCObserverFoundation<MDCSwitchState> {
constructor(adapter: MDCSwitchState) {
export class MDCSwitchFoundation extends
MDCObserverFoundation<MDCSwitchAdapter> {
constructor(adapter: MDCSwitchAdapter) {
super(adapter);
this.handleClick = this.handleClick.bind(this);
}
@ -25,7 +26,7 @@ export class MDCSwitchFoundation extends MDCObserverFoundation<MDCSwitchState> {
* Initializes the foundation and starts observing state changes.
*/
override init() {
this.observe(this.adapter, {
this.observe(this.adapter.state, {
disabled: this.stopProcessingIfDisabled,
processing: this.stopProcessingIfDisabled,
});
@ -36,16 +37,16 @@ export class MDCSwitchFoundation extends MDCObserverFoundation<MDCSwitchState> {
* selected state.
*/
handleClick() {
if (this.adapter.disabled) {
if (this.adapter.state.disabled) {
return;
}
this.adapter.selected = !this.adapter.selected;
this.adapter.state.selected = !this.adapter.state.selected;
}
protected stopProcessingIfDisabled() {
if (this.adapter.disabled) {
this.adapter.processing = false;
if (this.adapter.state.disabled) {
this.adapter.state.processing = false;
}
}
}

View File

@ -7,23 +7,26 @@
import '@material/mwc-ripple/mwc-ripple';
import {ariaProperty as legacyAriaProperty} from '@material/mwc-base/aria-property';
import {FormElement} from '@material/mwc-base/form-element';
import {Ripple} from '@material/mwc-ripple/mwc-ripple';
import {RippleHandlers} from '@material/mwc-ripple/ripple-handlers';
import {html, TemplateResult} from 'lit';
import {eventOptions, property, query, queryAsync, state} from 'lit/decorators';
import {html, LitElement, TemplateResult} from 'lit';
import {eventOptions, property, queryAsync, state} from 'lit/decorators';
import {ClassInfo, classMap} from 'lit/directives/class-map';
import {ifDefined} from 'lit/directives/if-defined';
import {FormController, getFormValue} from '../../controller/form-controller';
import {ariaProperty} from '../../decorators/aria-property';
import {MDCSwitchFoundation} from './foundation';
import {MDCSwitchAdapter, MDCSwitchState} from './state';
import {MDCSwitchState} from './state';
/** @soyCompatible */
export class Switch extends FormElement implements MDCSwitchState {
export class Switch extends LitElement implements MDCSwitchState {
static override shadowRootOptions:
ShadowRootInit = {mode: 'open', delegatesFocus: true};
// MDCSwitchState
@property({type: Boolean}) override disabled = false;
@property({type: Boolean, reflect: true}) disabled = false;
@property({type: Boolean}) processing = false;
@property({type: Boolean}) selected = false;
@ -40,7 +43,7 @@ export class Switch extends FormElement implements MDCSwitchState {
ariaLabelledBy = '';
// Ripple
@queryAsync('mwc-ripple') override readonly ripple!: Promise<Ripple|null>;
@queryAsync('mwc-ripple') readonly ripple!: Promise<Ripple|null>;
@state() protected shouldRenderRipple = false;
protected rippleHandlers = new RippleHandlers(() => {
@ -48,30 +51,26 @@ export class Switch extends FormElement implements MDCSwitchState {
return this.ripple;
});
// FormElement
// FormController
get form() {
return this.closest('form');
}
@property({type: String, reflect: true}) name = '';
@property({type: String}) value = 'on';
@query('input') protected readonly formElement!: HTMLElement;
protected setFormData(formData: FormData) {
if (this.name && this.selected) {
formData.append(this.name, this.value);
}
[getFormValue]() {
return this.selected ? this.value : null;
}
// BaseElement
@query('.mdc-switch') protected readonly mdcRoot!: HTMLElement;
protected readonly mdcFoundationClass = MDCSwitchFoundation;
protected mdcFoundation?: MDCSwitchFoundation;
protected mdcFoundation = new MDCSwitchFoundation({state: this});
constructor() {
super();
this.addController(new FormController(this));
}
override click() {
// Switch uses a hidden input as its form element, but a different <button>
// for interaction. It overrides click() from FormElement to avoid clicking
// the hidden input.
if (!this.disabled) {
this.mdcRoot?.focus();
this.mdcRoot?.click();
}
this.mdcFoundation?.handleClick();
super.click();
}
/** @soyTemplate */
@ -201,8 +200,4 @@ export class Switch extends FormElement implements MDCSwitchState {
protected handlePointerLeave() {
this.rippleHandlers.endHover();
}
protected createAdapter(): MDCSwitchAdapter {
return {state: this};
}
}

View File

@ -242,23 +242,17 @@ describe('mwc-switch', () => {
});
describe('click()', () => {
it('should focus and click root element', () => {
const root = toggle.shadowRoot!.querySelector('button')!;
spyOn(root, 'focus');
spyOn(root, 'click');
it('should toggle element', () => {
expect(toggle.selected).withContext('is false by default').toBeFalse();
toggle.click();
expect(root.focus).toHaveBeenCalledTimes(1);
expect(root.click).toHaveBeenCalledTimes(1);
expect(toggle.selected).withContext('should toggle selected').toBeTrue();
});
it('should do nothing if disabled', () => {
const root = toggle.shadowRoot!.querySelector('button')!;
spyOn(root, 'focus');
spyOn(root, 'click');
expect(toggle.selected).withContext('is false by default').toBeFalse();
toggle.disabled = true;
toggle.click();
expect(root.focus).not.toHaveBeenCalled();
expect(root.click).not.toHaveBeenCalled();
expect(toggle.selected).withContext('should remain false').toBeFalse();
});
it('should not focus or click hidden input form element', () => {