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 `name`, `value`, and `disabled` properties on FormElement subclasses
- Add `formDataCallback()` function to control what is added to the Form submission
- Set `checkbox`, `radio`, and `switch` `value`  property to `on` by default, to match native controls

Fixes #289

PiperOrigin-RevId: 381974666
  • Loading branch information
dfreedm authored and copybara-github committed Jul 2, 2021
1 parent 134f24b commit b2af961
Show file tree
Hide file tree
Showing 17 changed files with 680 additions and 90 deletions.
15 changes: 13 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,20 @@ 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`
- `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`
- `value` property defaults to "on" to match native radio
- `switch`
- `value` property defaults to "on" to match checkbox and radio

### Fixed
- `select`
Expand All @@ -33,6 +41,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`, `switch`, `textfield`
- 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
78 changes: 70 additions & 8 deletions packages/base/form-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,19 @@ export {
CustomEventListener,
EventType,
RippleInterface,
SpecificEventListener,
SpecificEventListener
};

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

interface HTMLElementEventMap {
formdata: FormDataEvent;
}
}

/** @soyCompatible */
export abstract class FormElement extends BaseElement {
static shadowRootOptions:
Expand All @@ -26,23 +36,75 @@ export abstract class FormElement extends BaseElement {
*
* Define in your component with the `@query` decorator
*/
protected abstract formElement: HTMLElement;
protected abstract formElement: HTMLInputElement;

/**
* Name of the component for form submission
*/
abstract name: string;
/**
* Value of the component for form submission
*/
abstract value: string;
abstract disabled: boolean;

/**
* 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) => {
this.formDataCallback(ev.formData);
};

protected findFormElement(): HTMLFormElement|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;
}

/**
* This callback is called when the containing form element is submitting.
* Override this callback to change what information is submitted with the
* form
*/
protected formDataCallback(formData: FormData) {
if (!this.disabled && this.name) {
formData.append(this.name, this.value);
}
}

setAriaLabel(label: string) {
connectedCallback() {
super.connectedCallback();
if (!this.shadowRoot) {
return;
}
this.containingForm = this.findFormElement();
this.containingForm?.addEventListener('formdata', this.formDataListener);
}

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

click() {
if (this.formElement) {
this.formElement.setAttribute('aria-label', label);
this.formElement.focus();
this.formElement.click();
}
}

Expand Down
107 changes: 102 additions & 5 deletions packages/base/test/form-element.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,26 @@
*/

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, TestFixture, simulateFormDataEvent} from '../../../test/src/util/helpers';

interface FormElementInternals {
containingForm: HTMLFormElement|null;
}

interface FormDataEvent extends Event {
formData: FormData;
}

@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;
name = '';
value = '';

protected mdcFoundation = undefined;
protected mdcFoundationClass = undefined;
Expand All @@ -34,8 +44,11 @@ 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;
name = '';
value = '';

protected mdcFoundation = undefined;
protected mdcFoundationClass = undefined;
Expand Down Expand Up @@ -67,6 +80,26 @@ 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 {};
}

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 @@ -75,6 +108,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 @@ -189,4 +225,65 @@ 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', () => {
expect((component as unknown as FormElementInternals).containingForm)
.toEqual(form);
});

it('resets containingForm on disconnect', () => {
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 @@ -25,9 +25,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 @@ -186,6 +186,12 @@ export class CheckboxBase extends FormElement {
</div>`;
}

protected formDataCallback(formData: FormData) {
if (this.checked) {
super.formDataCallback(formData);
}
}

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

0 comments on commit b2af961

Please sign in to comment.