Skip to content

Commit e842f79

Browse files
asynclizcopybara-github
authored andcommitted
feat(textfield): add form association support
PiperOrigin-RevId: 537893731
1 parent 2d02404 commit e842f79

File tree

2 files changed

+119
-37
lines changed

2 files changed

+119
-37
lines changed

textfield/lib/text-field.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {html as staticHtml, StaticValue} from 'lit/static-html.js';
1414
import {ARIAMixinStrict} from '../../internal/aria/aria.js';
1515
import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js';
1616
import {redispatchEvent} from '../../internal/controller/events.js';
17-
import {FormController, getFormValue} from '../../internal/controller/form-controller.js';
1817
import {stringConverter} from '../../internal/controller/string-converter.js';
1918

2019
/**
@@ -46,6 +45,12 @@ export abstract class TextField extends LitElement {
4645
static override shadowRootOptions:
4746
ShadowRootInit = {...LitElement.shadowRootOptions, delegatesFocus: true};
4847

48+
/**
49+
* @nocollapse
50+
* @export
51+
*/
52+
static formAssociated = true;
53+
4954
@property({type: Boolean, reflect: true}) disabled = false;
5055
/**
5156
* 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 {
95100
*/
96101
@property() textDirection = '';
97102

98-
// FormElement
103+
/**
104+
* The associated form element with which this element's value will submit.
105+
*/
99106
get form() {
100-
return this.closest('form');
107+
return this.internals.form;
101108
}
102109

103-
@property({reflect: true, converter: stringConverter}) name = '';
110+
/**
111+
* The labels this element is associated with.
112+
*/
113+
get labels() {
114+
return this.internals.labels;
115+
}
104116

105-
[getFormValue]() {
106-
return this.value;
117+
/**
118+
* The HTML name to use in form submission.
119+
*/
120+
get name() {
121+
return this.getAttribute('name') ?? '';
122+
}
123+
set name(name: string) {
124+
this.setAttribute('name', name);
107125
}
108126

109127
// <input> properties
@@ -276,10 +294,11 @@ export abstract class TextField extends LitElement {
276294
private readonly leadingIcons!: Element[];
277295
@queryAssignedElements({slot: 'trailingicon'})
278296
private readonly trailingIcons!: Element[];
297+
private readonly internals =
298+
(this as HTMLElement /* needed for closure */).attachInternals();
279299

280300
constructor() {
281301
super();
282-
this.addController(new FormController(this));
283302
if (!isServer) {
284303
this.addEventListener('click', this.focus);
285304
this.addEventListener('focusin', this.handleFocusin);
@@ -474,6 +493,7 @@ export abstract class TextField extends LitElement {
474493
// If a property such as `type` changes and causes the internal <input>
475494
// value to change without dispatching an event, re-sync it.
476495
const value = this.getInput().value;
496+
this.internals.setFormValue(value);
477497
if (this.value !== value) {
478498
// Note this is typically inefficient in updated() since it schedules
479499
// another update. However, it is needed for the <input> to fully render
@@ -699,4 +719,14 @@ export abstract class TextField extends LitElement {
699719
this.hasLeadingIcon = this.leadingIcons.length > 0;
700720
this.hasTrailingIcon = this.trailingIcons.length > 0;
701721
}
722+
723+
/** @private */
724+
formResetCallback() {
725+
this.reset();
726+
}
727+
728+
/** @private */
729+
formStateRestoreCallback(state: string) {
730+
this.value = state;
731+
}
702732
}

textfield/lib/text-field_test.ts

Lines changed: 82 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {customElement} from 'lit/decorators.js';
1212
import {literal} from 'lit/static-html.js';
1313

1414
import {Environment} from '../../testing/environment.js';
15+
import {createFormTests} from '../../testing/forms.js';
1516
import {Harness} from '../../testing/harness.js';
1617
import {TextFieldHarness} from '../harness.js';
1718

@@ -634,36 +635,87 @@ describe('TextField', () => {
634635
});
635636
});
636637

637-
describe('form submission', () => {
638-
async function setupFormTest(propsInit: Partial<TestTextField> = {}) {
639-
const template = html`
640-
<form>
641-
<md-test-text-field
642-
?disabled=${propsInit.disabled === true}
643-
.name=${propsInit.name ?? ''}
644-
.value=${propsInit.value ?? ''}>
645-
</md-test-text-field>
646-
</form>`;
647-
return setupTest(template);
648-
}
649-
650-
it('does not submit if disabled', async () => {
651-
const {harness} = await setupFormTest({name: 'foo', disabled: true});
652-
const formData = await harness.submitForm();
653-
expect(formData.get('foo')).toBeNull();
654-
});
655-
656-
it('does not submit if name is not provided', async () => {
657-
const {harness} = await setupFormTest();
658-
const formData = await harness.submitForm();
659-
const keys = Array.from(formData.keys());
660-
expect(keys.length).toEqual(0);
661-
});
662-
663-
it('submits under correct conditions', async () => {
664-
const {harness} = await setupFormTest({name: 'foo', value: 'bar'});
665-
const formData = await harness.submitForm();
666-
expect(formData.get('foo')).toEqual('bar');
638+
describe('forms', () => {
639+
createFormTests({
640+
queryControl: root => root.querySelector('md-test-text-field'),
641+
valueTests: [
642+
{
643+
name: 'unnamed',
644+
render: () =>
645+
html`<md-test-text-field value="Value"></md-test-text-field>`,
646+
assertValue(formData) {
647+
expect(formData)
648+
.withContext('should not add anything to form without a name')
649+
.toHaveSize(0);
650+
}
651+
},
652+
{
653+
name: 'should add empty value',
654+
render: () =>
655+
html`<md-test-text-field name="input"></md-test-text-field>`,
656+
assertValue(formData) {
657+
expect(formData.get('input')).toBe('');
658+
}
659+
},
660+
{
661+
name: 'with value',
662+
render: () =>
663+
html`<md-test-text-field name="input" value="Value"></md-test-text-field>`,
664+
assertValue(formData) {
665+
expect(formData.get('input')).toBe('Value');
666+
}
667+
},
668+
{
669+
name: 'disabled',
670+
render: () =>
671+
html`<md-test-text-field name="input" value="Value" disabled></md-test-text-field>`,
672+
assertValue(formData) {
673+
expect(formData)
674+
.withContext('should not add anything to form when disabled')
675+
.toHaveSize(0);
676+
}
677+
}
678+
],
679+
resetTests: [
680+
{
681+
name: 'reset to empty value',
682+
render: () =>
683+
html`<md-test-text-field name="input"></md-test-text-field>`,
684+
change(textField) {
685+
textField.value = 'Value';
686+
},
687+
assertReset(textField) {
688+
expect(textField.value)
689+
.withContext('textField.value after reset')
690+
.toBe('');
691+
}
692+
},
693+
{
694+
name: 'reset value',
695+
render: () =>
696+
html`<md-test-text-field name="input" value="First"></md-test-text-field>`,
697+
change(textField) {
698+
textField.value = 'Second';
699+
},
700+
assertReset(textField) {
701+
expect(textField.value)
702+
.withContext('textField.value after reset')
703+
.toBe('First');
704+
}
705+
},
706+
],
707+
restoreTests: [
708+
{
709+
name: 'restore value',
710+
render: () =>
711+
html`<md-test-text-field name="input" value="Value"></md-test-text-field>`,
712+
assertRestored(textField) {
713+
expect(textField.value)
714+
.withContext('textField.value after restore')
715+
.toBe('Value');
716+
}
717+
},
718+
]
667719
});
668720
});
669721
});

0 commit comments

Comments
 (0)