Skip to content

Commit

Permalink
Add support for Form submission to FormElement subclasses
Browse files Browse the repository at this point in the history
- Relies on `FormDataEvent`, which is supported by Chrome, Firefox, and Edge.
  - Safari and IE will need a polyfill
- Require `disabled` properties on FormElement subclasses
- Add `setFormData()` function to control what is added to the Form submission
- Set `checkbox` and `radio` `value`  property to `on` by default, to match native controls

Fixes #289

PiperOrigin-RevId: 385248919
  • Loading branch information
dfreedm authored and copybara-github committed Jul 16, 2021
1 parent 2b133cb commit ae4f422
Show file tree
Hide file tree
Showing 17 changed files with 654 additions and 91 deletions.
13 changes: 11 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Switch testing from Mocha to Jasmine
- **BREAKING:** Changed all instances of `static get styles = styles;` to be
an array of `[styles]`
- base
- `base`
- **BREAKING:** Removed `findAssignedElement` from utils, use
`@queryAssignedNodes` lit decorator instead.
- fab
- Remove `setAriaLabel` function, set `aria-label` attribute instead in
`mwc-formfield`
- `checkbox`
- **BREAKING** `value` property defaults to "on" to match native checkbox
- `fab`
- **BREAKING** renderIcon currently doesn't do anything until an internal
google bug is resolved
- `radio`
- **BREAKING** `value` property defaults to "on" to match native radio

### Fixed
- `select`
Expand All @@ -44,6 +50,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
`getUpdateComplete` introduced by lit-element 2.5.0

### Added
- `base`, `checkbox`, `radio`, `select`, `slider`, `textfield`, `textarea`
- Added support for form submission to FormElement subclasses
- This requires browser support for [FormDataEvent](https://developer.mozilla.org/en-US/docs/Web/API/FormDataEvent#browser_compatibility)

## [v0.21.0] - 2021-04-30

Expand Down
82 changes: 74 additions & 8 deletions packages/base/form-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
// tslint:disable:strip-private-property-underscore


import {property} from 'lit-element';

import {addHasRemoveClass, BaseElement, CustomEventListener, EventType, SpecificEventListener} from './base-element';
import {RippleInterface} from './utils';

Expand All @@ -17,9 +19,29 @@ export {
CustomEventListener,
EventType,
RippleInterface,
SpecificEventListener,
SpecificEventListener
};

declare global {
interface FormDataEvent extends Event {
formData: FormData;
}

interface HTMLElementEventMap {
formdata: FormDataEvent;
}
}

// TODO(b/193921953): Remove when lit-html typing for ShadyDOM works internally
interface WindowWithShadyDOM {
// tslint:disable-next-line:enforce-name-casing
ShadyDOM?: {inUse: boolean};
}

// ShadyDOM should submit <input> elements in component internals
const USING_SHADY_DOM =
(window as unknown as WindowWithShadyDOM).ShadyDOM?.inUse ?? false;

/** @soyCompatible */
export abstract class FormElement extends BaseElement {
static shadowRootOptions:
Expand All @@ -32,21 +54,65 @@ export abstract class FormElement extends BaseElement {
*/
protected abstract formElement: HTMLElement;

/**
* Disabled state for the component. When `disabled` is set to `true`, the
* component will not be added to form submission.
*/
@property({type: Boolean}) disabled = false;

/**
* Implement ripple getter for Ripple integration with mwc-formfield
*/
readonly ripple?: Promise<RippleInterface|null>;

click() {
if (this.formElement) {
this.formElement.focus();
this.formElement.click();
/**
* Form element that contains this element
*/
protected containingForm: HTMLFormElement|null = null;
protected formDataListener = (ev: FormDataEvent) => {
if (!this.disabled) {
this.setFormData(ev.formData);
}
};

protected findFormElement(): HTMLFormElement|null {
// 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.shadowRoot || USING_SHADY_DOM) {
return null;
}
const root = this.getRootNode() as HTMLElement;
const forms = root.querySelectorAll('form');
for (const form of Array.from(forms)) {
if (form.contains(this)) {
return form;
}
}
return null;
}

setAriaLabel(label: string) {
if (this.formElement) {
this.formElement.setAttribute('aria-label', label);
/**
* Implement this callback to submit form data
*/
protected abstract setFormData(formData: FormData): void;

connectedCallback() {
super.connectedCallback();
this.containingForm = this.findFormElement();
this.containingForm?.addEventListener('formdata', this.formDataListener);
}

disconnectedCallback() {
super.disconnectedCallback();
this.containingForm?.removeEventListener('formdata', this.formDataListener);
this.containingForm = null;
}

click() {
if (this.formElement && !this.disabled) {
this.formElement.focus();
this.formElement.click();
}
}

Expand Down
115 changes: 110 additions & 5 deletions packages/base/test/form-element.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,29 @@


import {FormElement} from '@material/mwc-base/form-element';
import {customElement, LitElement, query} from 'lit-element';
import {customElement, LitElement, property, query} from 'lit-element';
import {html} from 'lit-html';

import {fixture, TestFixture} from '../../../test/src/util/helpers';
import {fixture, simulateFormDataEvent, TestFixture} from '../../../test/src/util/helpers';

interface FormElementInternals {
containingForm: HTMLFormElement|null;
}

@customElement('test-form-element')
class TestFormElement extends FormElement {
@query('#root') protected mdcRoot!: HTMLElement;
@query('#input') protected formElement!: HTMLElement;
@query('#input') protected formElement!: HTMLInputElement;
disabled = false;

protected mdcFoundation = undefined;
protected mdcFoundationClass = undefined;
protected createAdapter() {
return {};
}

protected setFormData(_fd: FormData) {}

render() {
return html`
<label id="root" for="input">
Expand All @@ -38,8 +44,9 @@ class TestFormElement extends FormElement {
@customElement('custom-click-form-element')
class CustomClickFormElement extends FormElement {
@query('#root') protected mdcRoot!: HTMLElement;
@query('#indirect') indirectFormElement!: HTMLElement;
@query('#direct') protected formElement!: HTMLElement;
@query('#indirect') indirectFormElement!: HTMLInputElement;
@query('#direct') protected formElement!: HTMLInputElement;
disabled = false;

protected mdcFoundation = undefined;
protected mdcFoundationClass = undefined;
Expand All @@ -57,6 +64,8 @@ class CustomClickFormElement extends FormElement {
}
}

protected setFormData(_fd: FormData) {}

render() {
return html`
<section id="root">
Expand All @@ -71,6 +80,32 @@ class CustomClickFormElement extends FormElement {
}
}

@customElement('form-submission')
class FormSubmission extends FormElement {
@property() name = 'foo';
@property() value = 'bar';
@property({type: Boolean}) disabled = false;
@query('input') protected formElement!: HTMLInputElement;
@query('input') protected mdcRoot!: HTMLElement;

protected mdcFoundation = undefined;
protected mdcFoundationClass = undefined;
protected createAdapter() {
return {};
}

protected setFormData(formData: FormData) {
if (this.name) {
formData.append(this.name, this.value);
}
}

render() {
return html`<input value="${this.value}" ?disabled="${
this.disabled}" name="${this.name}"></input>`;
}
}

const testFormElement = html`
<test-form-element></test-form-element>
`;
Expand All @@ -79,6 +114,9 @@ const testClickFormElement = html`
<custom-click-form-element></custom-click-form-element>
`;

const testFormSubmission =
html`<form><form-submission></form-submission></form>`;

describe('form-element:', () => {
describe('test-form-element', () => {
let fixt: TestFixture;
Expand Down Expand Up @@ -193,4 +231,71 @@ describe('form-element:', () => {
expect(component.shadowRoot.activeElement).not.toEqual(formElement);
});
});

describe('form submission', () => {
let fixt: TestFixture;
let component: FormSubmission;
let form: HTMLFormElement;

// IE11 can only append to FormData, not inspect it
const canInspectFormData = Boolean(FormData.prototype.get);

beforeEach(async () => {
fixt = await fixture(testFormSubmission);
component = fixt.root.querySelector('form-submission')!;
form = fixt.root.querySelector('form')!;

await component.updateComplete;
});

afterEach(() => {
fixt?.remove();
});

it('finds the form element container', () => {
if (!canInspectFormData) {
return;
}
expect((component as unknown as FormElementInternals).containingForm)
.toEqual(form);
});

it('resets containingForm on disconnect', () => {
if (!canInspectFormData) {
return;
}
component.remove();
expect((component as unknown as FormElementInternals).containingForm)
.toBeNull();
});

it('reports name and value on "formdata" event', () => {
if (!canInspectFormData) {
return;
}
const formData = simulateFormDataEvent(form);
expect(formData.get('foo')).toEqual('bar');
});

it('does not report when the component is disabled', async () => {
if (!canInspectFormData) {
return;
}
component.disabled = true;
await component.updateComplete;
const formData = simulateFormDataEvent(form);
expect(formData.get('foo')).toBeNull();
});

it('does not report when name is not set', async () => {
if (!canInspectFormData) {
return;
}
component.name = '';
await component.updateComplete;
const formData = simulateFormDataEvent(form);
const keys = Array.from(formData.keys());
expect(keys.length).toEqual(0);
});
});
});
10 changes: 8 additions & 2 deletions packages/checkbox/mwc-checkbox-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ export class CheckboxBase extends FormElement {

@property({type: Boolean, reflect: true}) disabled = false;

@property({type: String, reflect: true}) name?: string;
@property({type: String, reflect: true}) name = '';

@property({type: String}) value = '';
@property({type: String}) value = 'on';

/** @soyPrefixAttribute */
@ariaProperty
Expand Down Expand Up @@ -190,6 +190,12 @@ export class CheckboxBase extends FormElement {
</div>`;
}

protected setFormData(formData: FormData) {
if (this.name && this.checked) {
formData.append(this.name, this.value);
}
}

protected handleFocus() {
this.focused = true;
this.handleRippleFocus();
Expand Down
Loading

0 comments on commit ae4f422

Please sign in to comment.