From e842f793115b3b65a650d9cb49caf02575a69a91 Mon Sep 17 00:00:00 2001 From: Elizabeth Mitchell Date: Mon, 5 Jun 2023 09:11:59 -0700 Subject: [PATCH] feat(textfield): add form association support PiperOrigin-RevId: 537893731 --- textfield/lib/text-field.ts | 44 ++++++++++-- textfield/lib/text-field_test.ts | 112 ++++++++++++++++++++++--------- 2 files changed, 119 insertions(+), 37 deletions(-) diff --git a/textfield/lib/text-field.ts b/textfield/lib/text-field.ts index 8744ca1e25..25d0346c81 100644 --- a/textfield/lib/text-field.ts +++ b/textfield/lib/text-field.ts @@ -14,7 +14,6 @@ import {html as staticHtml, StaticValue} from 'lit/static-html.js'; import {ARIAMixinStrict} from '../../internal/aria/aria.js'; import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js'; import {redispatchEvent} from '../../internal/controller/events.js'; -import {FormController, getFormValue} from '../../internal/controller/form-controller.js'; import {stringConverter} from '../../internal/controller/string-converter.js'; /** @@ -46,6 +45,12 @@ export abstract class TextField extends LitElement { static override shadowRootOptions: ShadowRootInit = {...LitElement.shadowRootOptions, delegatesFocus: true}; + /** + * @nocollapse + * @export + */ + static formAssociated = true; + @property({type: Boolean, reflect: true}) disabled = false; /** * Gets or sets whether or not the text field is in a visually invalid state. @@ -95,15 +100,28 @@ export abstract class TextField extends LitElement { */ @property() textDirection = ''; - // FormElement + /** + * The associated form element with which this element's value will submit. + */ get form() { - return this.closest('form'); + return this.internals.form; } - @property({reflect: true, converter: stringConverter}) name = ''; + /** + * The labels this element is associated with. + */ + get labels() { + return this.internals.labels; + } - [getFormValue]() { - return this.value; + /** + * The HTML name to use in form submission. + */ + get name() { + return this.getAttribute('name') ?? ''; + } + set name(name: string) { + this.setAttribute('name', name); } // properties @@ -276,10 +294,11 @@ export abstract class TextField extends LitElement { private readonly leadingIcons!: Element[]; @queryAssignedElements({slot: 'trailingicon'}) private readonly trailingIcons!: Element[]; + private readonly internals = + (this as HTMLElement /* needed for closure */).attachInternals(); constructor() { super(); - this.addController(new FormController(this)); if (!isServer) { this.addEventListener('click', this.focus); this.addEventListener('focusin', this.handleFocusin); @@ -474,6 +493,7 @@ export abstract class TextField extends LitElement { // If a property such as `type` changes and causes the internal // value to change without dispatching an event, re-sync it. const value = this.getInput().value; + this.internals.setFormValue(value); if (this.value !== value) { // Note this is typically inefficient in updated() since it schedules // another update. However, it is needed for the to fully render @@ -699,4 +719,14 @@ export abstract class TextField extends LitElement { this.hasLeadingIcon = this.leadingIcons.length > 0; this.hasTrailingIcon = this.trailingIcons.length > 0; } + + /** @private */ + formResetCallback() { + this.reset(); + } + + /** @private */ + formStateRestoreCallback(state: string) { + this.value = state; + } } diff --git a/textfield/lib/text-field_test.ts b/textfield/lib/text-field_test.ts index 0a98e8e741..aa0e4d8477 100644 --- a/textfield/lib/text-field_test.ts +++ b/textfield/lib/text-field_test.ts @@ -12,6 +12,7 @@ import {customElement} from 'lit/decorators.js'; import {literal} from 'lit/static-html.js'; import {Environment} from '../../testing/environment.js'; +import {createFormTests} from '../../testing/forms.js'; import {Harness} from '../../testing/harness.js'; import {TextFieldHarness} from '../harness.js'; @@ -634,36 +635,87 @@ describe('TextField', () => { }); }); - describe('form submission', () => { - async function setupFormTest(propsInit: Partial = {}) { - const template = html` -
- - -
`; - return setupTest(template); - } - - it('does not submit if disabled', async () => { - const {harness} = await setupFormTest({name: 'foo', disabled: true}); - const formData = await harness.submitForm(); - expect(formData.get('foo')).toBeNull(); - }); - - it('does not submit if name is not provided', async () => { - const {harness} = await setupFormTest(); - const formData = await harness.submitForm(); - const keys = Array.from(formData.keys()); - expect(keys.length).toEqual(0); - }); - - it('submits under correct conditions', async () => { - const {harness} = await setupFormTest({name: 'foo', value: 'bar'}); - const formData = await harness.submitForm(); - expect(formData.get('foo')).toEqual('bar'); + describe('forms', () => { + createFormTests({ + queryControl: root => root.querySelector('md-test-text-field'), + valueTests: [ + { + name: 'unnamed', + render: () => + html``, + assertValue(formData) { + expect(formData) + .withContext('should not add anything to form without a name') + .toHaveSize(0); + } + }, + { + name: 'should add empty value', + render: () => + html``, + assertValue(formData) { + expect(formData.get('input')).toBe(''); + } + }, + { + name: 'with value', + render: () => + html``, + assertValue(formData) { + expect(formData.get('input')).toBe('Value'); + } + }, + { + name: 'disabled', + render: () => + html``, + assertValue(formData) { + expect(formData) + .withContext('should not add anything to form when disabled') + .toHaveSize(0); + } + } + ], + resetTests: [ + { + name: 'reset to empty value', + render: () => + html``, + change(textField) { + textField.value = 'Value'; + }, + assertReset(textField) { + expect(textField.value) + .withContext('textField.value after reset') + .toBe(''); + } + }, + { + name: 'reset value', + render: () => + html``, + change(textField) { + textField.value = 'Second'; + }, + assertReset(textField) { + expect(textField.value) + .withContext('textField.value after reset') + .toBe('First'); + } + }, + ], + restoreTests: [ + { + name: 'restore value', + render: () => + html``, + assertRestored(textField) { + expect(textField.value) + .withContext('textField.value after restore') + .toBe('Value'); + } + }, + ] }); }); });