From b2af96134d530379c5eddd35f1e13e13c17237bf Mon Sep 17 00:00:00 2001 From: Daniel Freedman Date: Mon, 28 Jun 2021 16:54:13 -0700 Subject: [PATCH] Add support for Form submission to FormElement subclasses - 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 --- CHANGELOG.md | 15 +- packages/base/form-element.ts | 78 +++++++++- packages/base/test/form-element.test.ts | 107 ++++++++++++- packages/checkbox/mwc-checkbox-base.ts | 10 +- packages/checkbox/test/mwc-checkbox.test.ts | 64 +++++++- packages/formfield/mwc-formfield-base.ts | 10 +- packages/radio/mwc-radio-base.ts | 8 +- packages/radio/test/mwc-radio.test.ts | 56 ++++++- packages/select/mwc-select-base.ts | 12 +- packages/select/test/mwc-select.test.ts | 71 ++++++++- packages/slider/mwc-slider-base.ts | 2 + packages/slider/test/mwc-slider.test.ts | 43 +++++- packages/switch/mwc-switch-base.ts | 11 ++ packages/switch/test/mwc-switch.test.ts | 60 +++++++- packages/textarea/test/mwc-textarea.test.ts | 50 +++++- packages/textfield/test/mwc-textfield.test.ts | 145 ++++++++++++------ test/src/util/helpers.ts | 28 +++- 17 files changed, 680 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 219fcba3d79..02e1bf5d9bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` @@ -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 diff --git a/packages/base/form-element.ts b/packages/base/form-element.ts index 6e79ada98f4..6b814d18e26 100644 --- a/packages/base/form-element.ts +++ b/packages/base/form-element.ts @@ -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: @@ -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; - 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(); } } diff --git a/packages/base/test/form-element.test.ts b/packages/base/test/form-element.test.ts index 4f193ed78cd..695479283a3 100644 --- a/packages/base/test/form-element.test.ts +++ b/packages/base/test/form-element.test.ts @@ -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; @@ -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; @@ -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``; + } +} + const testFormElement = html` `; @@ -75,6 +108,9 @@ const testClickFormElement = html` `; +const testFormSubmission = + html`
`; + describe('form-element:', () => { describe('test-form-element', () => { let fixt: TestFixture; @@ -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); + }); + }); }); diff --git a/packages/checkbox/mwc-checkbox-base.ts b/packages/checkbox/mwc-checkbox-base.ts index 3b5518cb70f..15de645bfd2 100644 --- a/packages/checkbox/mwc-checkbox-base.ts +++ b/packages/checkbox/mwc-checkbox-base.ts @@ -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 @@ -186,6 +186,12 @@ export class CheckboxBase extends FormElement { `; } + protected formDataCallback(formData: FormData) { + if (this.checked) { + super.formDataCallback(formData); + } + } + protected handleFocus() { this.focused = true; this.handleRippleFocus(); diff --git a/packages/checkbox/test/mwc-checkbox.test.ts b/packages/checkbox/test/mwc-checkbox.test.ts index 88d19890fed..8a355296e87 100644 --- a/packages/checkbox/test/mwc-checkbox.test.ts +++ b/packages/checkbox/test/mwc-checkbox.test.ts @@ -8,7 +8,7 @@ import {Checkbox} from '@material/mwc-checkbox'; import * as hanbi from 'hanbi'; import {html} from 'lit-html'; -import {fixture, rafPromise, TestFixture} from '../../../test/src/util/helpers'; +import {fixture, rafPromise, TestFixture, simulateFormDataEvent} from '../../../test/src/util/helpers'; interface CheckboxInternals { formElement: HTMLInputElement; @@ -20,6 +20,7 @@ interface CheckboxProps { indeterminate: boolean; disabled: boolean; value: string; + name: string; reduceTouchTarget: boolean; } @@ -37,6 +38,19 @@ const checkbox = (propsInit: Partial) => { `; }; +const checkboxInForm = (propsInit: Partial) => { + return html` +
+ + +
+ `; +}; + describe('mwc-checkbox', () => { let fixt: TestFixture; let element: Checkbox; @@ -58,7 +72,7 @@ describe('mwc-checkbox', () => { expect(element.checked).toEqual(false); expect(element.indeterminate).toEqual(false); expect(element.disabled).toEqual(false); - expect(element.value).toEqual(''); + expect(element.value).toEqual('on'); }); it('element.formElement returns the native checkbox element', async () => { @@ -173,4 +187,50 @@ describe('mwc-checkbox', () => { expect(internals.formElement.value).toEqual('new value 2'); }); }); + + // IE11 can only append to FormData, not inspect it + if (Boolean(FormData.prototype.get)) { + describe('form submission', () => { + let form: HTMLFormElement; + + it('does not submit if not checked', async () => { + fixt = await fixture(checkboxInForm({name: 'foo'})); + element = fixt.root.querySelector('mwc-checkbox')!; + form = fixt.root.querySelector('form')!; + await element.updateComplete; + const formData = simulateFormDataEvent(form); + expect(formData.get('foo')).toBeNull(); + }); + + it('does not submit if disabled', async () => { + fixt = await fixture( + checkboxInForm({checked: true, disabled: true, name: 'foo'})); + element = fixt.root.querySelector('mwc-checkbox')!; + form = fixt.root.querySelector('form')!; + await element.updateComplete; + const formData = simulateFormDataEvent(form); + expect(formData.get('foo')).toBeNull(); + }); + + it('does not submit if name is not provided', async () => { + fixt = await fixture(checkboxInForm({checked: true})); + element = fixt.root.querySelector('mwc-checkbox')!; + form = fixt.root.querySelector('form')!; + await element.updateComplete; + const formData = simulateFormDataEvent(form); + const keys = Array.from(formData.keys()); + expect(keys.length).toEqual(0); + }); + + it('submits under correct conditions', async () => { + fixt = await fixture( + checkboxInForm({name: 'foo', checked: true, value: 'bar'})); + element = fixt.root.querySelector('mwc-checkbox')!; + form = fixt.root.querySelector('form')!; + await element.updateComplete; + const formData = simulateFormDataEvent(form); + expect(formData.get('foo')).toEqual('bar'); + }); + }); + } }); diff --git a/packages/formfield/mwc-formfield-base.ts b/packages/formfield/mwc-formfield-base.ts index 0535203c246..2fd530ba1b7 100644 --- a/packages/formfield/mwc-formfield-base.ts +++ b/packages/formfield/mwc-formfield-base.ts @@ -21,15 +21,7 @@ export class FormfieldBase extends BaseElement { @property({type: String}) @observer(async function(this: FormfieldBase, label: string) { - const input = this.input; - if (input) { - if (input.localName === 'input') { - input.setAttribute('aria-label', label); - } else if (input instanceof FormElement) { - await input.updateComplete; - input.setAriaLabel(label); - } - } + this.input?.setAttribute('aria-label', label); }) label = ''; diff --git a/packages/radio/mwc-radio-base.ts b/packages/radio/mwc-radio-base.ts index 2f289022193..71c4a8171ab 100644 --- a/packages/radio/mwc-radio-base.ts +++ b/packages/radio/mwc-radio-base.ts @@ -88,7 +88,7 @@ export class RadioBase extends FormElement { @observer(function(this: RadioBase, value: string) { this._handleUpdatedValue(value); }) - value = ''; + value = 'on'; _handleUpdatedValue(newValue: string) { // the observer function can't access protected fields (according to @@ -216,6 +216,12 @@ export class RadioBase extends FormElement { this.rippleHandlers.endFocus(); } + protected formDataCallback(formData: FormData) { + if (this.checked) { + super.formDataCallback(formData); + } + } + /** * @soyTemplate * @soyAttributes radioAttributes: input diff --git a/packages/radio/test/mwc-radio.test.ts b/packages/radio/test/mwc-radio.test.ts index e84eff1dbf1..72eaac62057 100644 --- a/packages/radio/test/mwc-radio.test.ts +++ b/packages/radio/test/mwc-radio.test.ts @@ -7,7 +7,7 @@ import {Radio} from '@material/mwc-radio'; import {html} from 'lit-html'; -import {fixture, TestFixture} from '../../../test/src/util/helpers'; +import {fixture, simulateFormDataEvent, TestFixture} from '../../../test/src/util/helpers'; const defaultRadio = html``; @@ -23,6 +23,14 @@ const repeatedRadio = (values: string[]) => { (value) => html``)}`; }; +const radioGroupInForm = html` +
+ + + +
+ `; + describe('mwc-radio', () => { let fixt: TestFixture; let element: Radio; @@ -245,4 +253,50 @@ describe('mwc-radio', () => { expect(a2.checked).toBeFalse(); }); }); + + // IE11 can only append to FormData, not inspect it + if (Boolean(FormData.prototype.get)) { + describe('form submission', () => { + let form: HTMLFormElement; + let a1: Radio; + let a2: Radio; + let b1: Radio; + + beforeEach(async () => { + fixt = await fixture(radioGroupInForm); + form = fixt.root.querySelector('form')!; + [a1, a2, b1] = fixt.root.querySelectorAll('mwc-radio'); + await Promise.all( + [a1.updateComplete, a2.updateComplete, b1.updateComplete]); + }); + + it('does not submit when unchecked', () => { + const formData = simulateFormDataEvent(form); + expect(formData.get('a')).toBeNull(); + expect(formData.get('b')).toBeNull(); + }); + + it('does not submit when disabled', async () => { + a1.checked = true; + a1.disabled = true; + b1.checked = true; + b1.disabled = true; + await a1.updateComplete; + await b1.updateComplete; + const formData = simulateFormDataEvent(form); + expect(formData.get('a')).toBeNull(); + expect(formData.get('b')).toBeNull(); + }); + + it('submits when criteria is met', async () => { + a1.checked = true; + b1.checked = true; + await a1.updateComplete; + await b1.updateComplete; + const formData = simulateFormDataEvent(form); + expect(formData.get('a')).toEqual('a1'); + expect(formData.get('b')).toEqual('b1'); + }); + }); + } }); diff --git a/packages/select/mwc-select-base.ts b/packages/select/mwc-select-base.ts index ab126390a5b..d7b53901b96 100644 --- a/packages/select/mwc-select-base.ts +++ b/packages/select/mwc-select-base.ts @@ -154,6 +154,8 @@ export abstract class SelectBase extends FormElement { }) value = ''; + @property() name = ''; + @state() protected selectedText = ''; @property({type: String}) icon = ''; @@ -259,8 +261,10 @@ export abstract class SelectBase extends FormElement { class="mdc-select ${classMap(classes)}">
`; +const selectInForm = html` +
+ + + Apple + Banana + Cucumber + +
+`; + const isUiInvalid = (element: Select) => { return !!element.shadowRoot!.querySelector('.mdc-select--invalid'); }; @@ -934,4 +945,62 @@ describe('mwc-select:', () => { changeCalls = 0; }); }); + + // IE11 can only append to FormData, not inspect it + if (Boolean(FormData.prototype.get)) { + describe('form submission', () => { + let form: HTMLFormElement; + let element: Select; + let items: ListItem[]; + + beforeEach(async () => { + fixt = await fixture(selectInForm); + form = fixt.root.querySelector('form')!; + element = fixt.root.querySelector('mwc-select')!; + items = Array.from(fixt.root.querySelectorAll('mwc-list-item')); + await Promise.all( + [element.updateComplete, ...(items.map((i) => i.updateComplete))]); + }); + + it('does not submit without a name', async () => { + element.name = ''; + await element.updateComplete; + const formData = simulateFormDataEvent(form); + const keys = Array.from(formData.keys()); + expect(keys.length).toEqual(0); + }); + + it('does not submit when disabled', async () => { + element.disabled = true; + await element.updateComplete; + const formData = simulateFormDataEvent(form); + expect(formData.get('foo')).toBeNull(); + }); + + it('does not submit when there are no items', async () => { + items.forEach((item) => item.remove()); + await element.layout(); + await element.updateComplete; + const formData = simulateFormDataEvent(form); + expect(formData.get('foo')).toBeNull(); + }); + + it('submits selected element value', async () => { + let formData = simulateFormDataEvent(form); + expect(formData.get('foo')) + .withContext('default item') + .toEqual(items[0].value); + element.select(1); + await element.updateComplete; + formData = simulateFormDataEvent(form); + expect(formData.get('foo')) + .withContext('second item') + .toEqual(items[1].value); + }); + + afterEach(() => { + fixt?.remove(); + }); + }); + } }); diff --git a/packages/slider/mwc-slider-base.ts b/packages/slider/mwc-slider-base.ts index 6a30cf6a7a2..48e0dcee013 100644 --- a/packages/slider/mwc-slider-base.ts +++ b/packages/slider/mwc-slider-base.ts @@ -77,6 +77,8 @@ export class SliderBase extends FormElement { }) markers = false; + @property() name = ''; + @state() protected pinMarkerText = ''; @state() protected trackMarkerContainerStyles = {}; @state() protected thumbContainerStyles = {}; diff --git a/packages/slider/test/mwc-slider.test.ts b/packages/slider/test/mwc-slider.test.ts index 8453ec1950a..de613d9ccbc 100644 --- a/packages/slider/test/mwc-slider.test.ts +++ b/packages/slider/test/mwc-slider.test.ts @@ -8,7 +8,7 @@ import {Slider} from '@material/mwc-slider/mwc-slider'; import * as hanbi from 'hanbi'; import {html} from 'lit-html'; -import {fixture, ieSafeKeyboardEvent, rafPromise, TestFixture} from '../../../test/src/util/helpers'; +import {fixture, ieSafeKeyboardEvent, rafPromise, TestFixture, simulateFormDataEvent} from '../../../test/src/util/helpers'; const defaultSliderProps = { min: 0, @@ -34,6 +34,12 @@ const slider = (propsInit: Partial = {}) => { `; }; +const sliderInForm = html` +
+ +
+`; + const afterRender = async (root: ShadowRoot) => { const slider = root.querySelector('mwc-slider')!; await slider.updateComplete; @@ -238,4 +244,39 @@ describe('mwc-slider', () => { expect(element.value).toEqual(-1); }); }); + + // IE11 can only append to FormData, not inspect it + describe('form submission', () => { + let form: HTMLFormElement; + beforeEach(async () => { + fixt = await fixture(sliderInForm); + element = fixt.root.querySelector('mwc-slider')!; + form = fixt.root.querySelector('form')!; + await element.updateComplete; + }); + + it('does not submit without a name', async () => { + element.name = ''; + await element.updateComplete; + const formData = simulateFormDataEvent(form); + const keys = Array.from(formData.keys()); + expect(keys.length).toEqual(0); + }); + + it('does not submit with disabled', async () => { + element.disabled = true; + await element.updateComplete; + const formData = simulateFormDataEvent(form); + expect(formData.get('foo')).toBeNull(); + }); + + it('submits value', async () => { + let formData = simulateFormDataEvent(form); + expect(formData.get('foo').withContext('default value').toEqual(0); + element.value = 100; + await element.updateComplete; + formData = simulateFormDataEvent(form); + expect(formData.get('foo')).withContext('value set to 100').toEqual(100); + }); + }); }); diff --git a/packages/switch/mwc-switch-base.ts b/packages/switch/mwc-switch-base.ts index faa256f7fd4..265db900acc 100644 --- a/packages/switch/mwc-switch-base.ts +++ b/packages/switch/mwc-switch-base.ts @@ -36,6 +36,9 @@ export class SwitchBase extends FormElement { @property({attribute: 'aria-labelledby'}) ariaLabelledBy?: string; + @property() value = 'on'; + @property() name = ''; + @query('.mdc-switch') protected mdcRoot!: HTMLElement; @query('input') protected formElement!: HTMLInputElement; @@ -100,6 +103,12 @@ export class SwitchBase extends FormElement { } } + protected formDataCallback(formData: FormData) { + if (this.checked) { + super.formDataCallback(formData); + } + } + protected render() { return html`
@@ -114,6 +123,8 @@ export class SwitchBase extends FormElement { role="switch" aria-label="${ifDefined(this.ariaLabel)}" aria-labelledby="${ifDefined(this.ariaLabelledBy)}" + name="${this.name}" + .value="${this.value}" @change="${this.changeHandler}" @focus="${this.handleRippleFocus}" @blur="${this.handleRippleBlur}" diff --git a/packages/switch/test/mwc-switch.test.ts b/packages/switch/test/mwc-switch.test.ts index 58effa949df..d4ca809c5d0 100644 --- a/packages/switch/test/mwc-switch.test.ts +++ b/packages/switch/test/mwc-switch.test.ts @@ -7,7 +7,7 @@ import {Switch} from '@material/mwc-switch'; import * as hanbi from 'hanbi'; import {html} from 'lit-html'; -import {fixture, TestFixture} from '../../../test/src/util/helpers'; +import {fixture, TestFixture, simulateFormDataEvent} from '../../../test/src/util/helpers'; interface SwitchProps { checked: boolean; @@ -26,6 +26,12 @@ const switchElement = (propsInit: Partial) => { `; }; +const switchInForm = html` +
+ +
+`; + describe('mwc-switch', () => { let fixt: TestFixture; let element: Switch; @@ -143,4 +149,56 @@ describe('mwc-switch', () => { expect(input.getAttribute('aria-labelledby')).toEqual('foo'); }); }); + + // IE11 can only append to FormData, not inspect it + if (Boolean(FormData.prototype.get)) { + describe('form submission', () => { + let form: HTMLFormElement; + + beforeEach(async () => { + fixt = await fixture(switchInForm); + element = fixt.root.querySelector('mwc-switch')!; + form = fixt.root.querySelector('form')!; + await element.updateComplete; + }); + + it('does not submit without a name', () => { + const formData = simulateFormDataEvent(form); + const keys = Array.from(formData.keys()); + expect(keys.length).toEqual(0); + }); + + it('does not submit when disabled', async () => { + element.name = 'foo'; + element.disabled = true; + await element.updateComplete; + const formData = simulateFormDataEvent(form); + expect(formData.get('foo')).toBeNull(); + }); + + it('does not submit when not checked', async () => { + element.name = 'foo'; + await element.updateComplete; + const formData = simulateFormDataEvent(form); + expect(formData.get('foo')).toBeNull(); + }); + + it('submits "on" by default', async () => { + element.name = 'foo'; + element.checked = true; + await element.updateComplete; + const formData = simulateFormDataEvent(form); + expect(formData.get('foo')).toEqual('on'); + }); + + it('submits given value', async () => { + element.name = 'foo'; + element.value = 'bar'; + element.checked = true; + await element.updateComplete; + const formData = simulateFormDataEvent(form); + expect(formData.get('foo')).toEqual('bar'); + }); + }); + } }); diff --git a/packages/textarea/test/mwc-textarea.test.ts b/packages/textarea/test/mwc-textarea.test.ts index 9d9fd89bcb6..0d5bc51b45c 100644 --- a/packages/textarea/test/mwc-textarea.test.ts +++ b/packages/textarea/test/mwc-textarea.test.ts @@ -7,7 +7,7 @@ import {TextArea} from '@material/mwc-textarea'; import {html} from 'lit-html'; -import {fixture, TestFixture} from '../../../test/src/util/helpers'; +import {fixture, TestFixture, simulateFormDataEvent} from '../../../test/src/util/helpers'; const basic = html` @@ -17,6 +17,12 @@ const withLabel = html` `; +const textareaInForm = html` +
+ +
+`; + describe('mwc-textarea:', () => { let fixt: TestFixture; @@ -73,4 +79,46 @@ describe('mwc-textarea:', () => { } }); }); + + // IE11 can only append to FormData, not inspect it + if (Boolean(FormData.prototype.get)) { + describe('form submission', () => { + let form: HTMLFormElement; + let element: TextArea; + + beforeEach(async () => { + fixt = await fixture(textareaInForm); + element = fixt.root.querySelector('mwc-textarea')!; + form = fixt.root.querySelector('form')!; + await element.updateComplete; + }); + + it('does not submit without a name', async () => { + element.name = ''; + await element.updateComplete; + const formData = simulateFormDataEvent(form); + const keys = Array.from(formData.keys()); + expect(keys.length).toEqual(0); + }); + + it('does not submit if disabled', async () => { + element.disabled = true; + await element.updateComplete; + const formData = simulateFormDataEvent(form); + expect(formData.get('foo')).toBeNull(); + }); + + it('submits empty string by default', async () => { + const formData = simulateFormDataEvent(form); + expect(formData.get('foo')).toEqual(''); + }); + + it('submits given value', async () => { + element.value = 'bar'; + await element.updateComplete; + const formData = simulateFormDataEvent(form); + expect(formData.get('foo')).toEqual('bar'); + }); + }); + } }); diff --git a/packages/textfield/test/mwc-textfield.test.ts b/packages/textfield/test/mwc-textfield.test.ts index 658710e7bbc..65eea0965bb 100644 --- a/packages/textfield/test/mwc-textfield.test.ts +++ b/packages/textfield/test/mwc-textfield.test.ts @@ -10,7 +10,7 @@ import {TextField} from '@material/mwc-textfield'; import {cssClasses} from '@material/textfield/constants'; import {html} from 'lit-html'; -import {fixture, rafPromise, TestFixture} from '../../../test/src/util/helpers'; +import {fixture, rafPromise, simulateFormDataEvent, TestFixture} from '../../../test/src/util/helpers'; const basic = (outlined = false) => html` @@ -59,6 +59,12 @@ const withLabel = html` `; +const textfieldInForm = html` +
+ +
+`; + const isUiInvalid = (element: TextField) => { return !!element.shadowRoot!.querySelector(`.${cssClasses.INVALID}`); }; @@ -620,66 +626,109 @@ describe('mwc-textfield:', () => { expect(input.getAttribute('aria-labelledby')).toBe('label'); }); }); -}); -describe('date type textfield', () => { - // IE 8-1 has no support for input[type=date] - // Feature detection to skip these unit tests in IE, they will always fail - if (window.MSInputMethodContext) { - return; - } + describe('date type textfield', () => { + // IE 8-1 has no support for input[type=date] + // Feature detection to skip these unit tests in IE, they will always fail + if (window.MSInputMethodContext) { + return; + } - // Safari has no support for input[type=date] - // User Agent sniff to skip these unit tests in Safari, they will always fail - if (navigator.userAgent.indexOf('Safari') !== -1) { - return; - } + // Safari has no support for input[type=date] + // User Agent sniff to skip these unit tests in Safari, they will always + // fail + if (navigator.userAgent.indexOf('Safari') !== -1) { + return; + } - let fixt: TestFixture; - let element: TextField; + let fixt: TestFixture; + let element: TextField; - beforeEach(async () => { - fixt = await fixture(asDateType); - element = fixt.root.querySelector('mwc-textfield')!; - await element.updateComplete; - }); + beforeEach(async () => { + fixt = await fixture(asDateType); + element = fixt.root.querySelector('mwc-textfield')!; + await element.updateComplete; + }); - afterEach(() => { - if (fixt) { - fixt.remove(); - } - }); + afterEach(() => { + if (fixt) { + fixt.remove(); + } + }); - it('will be valid with a date-string inside min-max range', async () => { - element.focus(); - element.value = '2020-10-16'; - element.blur(); + it('will be valid with a date-string inside min-max range', async () => { + element.focus(); + element.value = '2020-10-16'; + element.blur(); - await element.updateComplete; + await element.updateComplete; - expect(element.reportValidity()).toBeTrue(); - expect(isUiInvalid(element)).toBeFalse(); - }); + expect(element.reportValidity()).toBeTrue(); + expect(isUiInvalid(element)).toBeFalse(); + }); - it('will be invalid with a date-string before min', async () => { - element.focus(); - element.value = '2019-10-16'; - element.blur(); + it('will be invalid with a date-string before min', async () => { + element.focus(); + element.value = '2019-10-16'; + element.blur(); - await element.updateComplete; + await element.updateComplete; - expect(element.reportValidity()).toBeFalse(); - expect(isUiInvalid(element)).toBeTrue(); - }); + expect(element.reportValidity()).toBeFalse(); + expect(isUiInvalid(element)).toBeTrue(); + }); - it('will be invalid with a date-string after max', async () => { - element.focus(); - element.value = '2021-10-16'; - element.blur(); + it('will be invalid with a date-string after max', async () => { + element.focus(); + element.value = '2021-10-16'; + element.blur(); - await element.updateComplete; + await element.updateComplete; - expect(element.reportValidity()).toBeFalse(); - expect(isUiInvalid(element)).toBeTrue(); + expect(element.reportValidity()).toBeFalse(); + expect(isUiInvalid(element)).toBeTrue(); + }); }); + + // IE11 can only append to FormData, not inspect it + if (Boolean(FormData.prototype.get)) { + describe('form submission', () => { + let form: HTMLFormElement; + let element: TextField; + + beforeEach(async () => { + fixt = await fixture(textfieldInForm); + element = fixt.root.querySelector('mwc-textfield')!; + form = fixt.root.querySelector('form')!; + await element.updateComplete; + }); + + it('does not submit without a name', async () => { + element.name = ''; + await element.updateComplete; + const formData = simulateFormDataEvent(form); + const keys = Array.from(formData.keys()); + expect(keys.length).toEqual(0); + }); + + it('does not submit if disabled', async () => { + element.disabled = true; + await element.updateComplete; + const formData = simulateFormDataEvent(form); + expect(formData.get('foo')).toBeNull(); + }); + + it('submits empty string by default', async () => { + const formData = simulateFormDataEvent(form); + expect(formData.get('foo')).toEqual(''); + }); + + it('submits given value', async () => { + element.value = 'bar'; + await element.updateComplete; + const formData = simulateFormDataEvent(form); + expect(formData.get('foo')).toEqual('bar'); + }); + }); + } }); diff --git a/test/src/util/helpers.ts b/test/src/util/helpers.ts index c61fba43167..663e45307cf 100644 --- a/test/src/util/helpers.ts +++ b/test/src/util/helpers.ts @@ -14,6 +14,9 @@ declare global { interface Window { tachometerResult: undefined|number; } + interface FormDataEvent extends Event { + formData: FormData; + } } @customElement('test-fixture') @@ -177,13 +180,24 @@ export const waitForEvent = (el: Element, ev: string) => new Promise((res) => { }, {once: true}); }); -export const ieSafeKeyboardEvent = (type: string, keycode: number) => { - // IE es5 fix - const init = {detail: 0, bubbles: true, cancelable: true, composed: true}; - const ev = new CustomEvent(type, init); +export const ieSafeKeyboardEvent = + (type: string, keycode: number) => { + // IE es5 fix + const init = {detail: 0, bubbles: true, cancelable: true, composed: true}; + const ev = new CustomEvent(type, init); + + // esc key + (ev as unknown as HasKeyCode).keyCode = keycode; - // esc key - (ev as unknown as HasKeyCode).keyCode = keycode; + return ev; + } - return ev; +export const simulateFormDataEvent = (form: HTMLFormElement): FormData => { + const event = new Event('formdata'); + // new FormData(form) will send a 'formdata' event and coallesce the + // additions, but this only works in Chrome and Firefox + const formData = new FormData(); + (event as FormDataEvent).formData = formData; + form.dispatchEvent(event); + return formData; }