mirror of
https://github.com/material-components/material-web.git
synced 2024-11-13 10:25:18 +03:00
feat(controller): add FormController for form support
PiperOrigin-RevId: 418809280
This commit is contained in:
parent
dc8f8c117b
commit
f8411edaf5
99
components/controller/form-controller.ts
Normal file
99
components/controller/form-controller.ts
Normal 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);
|
||||
}
|
||||
}
|
146
components/controller/test/form-controller.test.ts
Normal file
146
components/controller/test/form-controller.test.ts
Normal 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');
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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};
|
||||
}
|
||||
}
|
||||
|
@ -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', () => {
|
||||
|
Loading…
Reference in New Issue
Block a user