Skip to content

Commit 9dc8613

Browse files
asynclizcopybara-github
authored andcommitted
feat(radio): add full form association support
PiperOrigin-RevId: 532652986
1 parent 921a905 commit 9dc8613

File tree

2 files changed

+155
-92
lines changed

2 files changed

+155
-92
lines changed

radio/lib/radio.ts

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {when} from 'lit/directives/when.js';
1414
import {ARIAMixinStrict} from '../../aria/aria.js';
1515
import {requestUpdateOnAriaChange} from '../../aria/delegate.js';
1616
import {dispatchActivationClick, isActivationClick, redispatchEvent} from '../../controller/events.js';
17-
import {FormController, getFormValue} from '../../controller/form-controller.js';
1817
import {ripple} from '../../ripple/directive.js';
1918
import {MdRipple} from '../../ripple/ripple.js';
2019

@@ -33,15 +32,13 @@ export class Radio extends LitElement {
3332
static override shadowRootOptions:
3433
ShadowRootInit = {...LitElement.shadowRootOptions, delegatesFocus: true};
3534

36-
/**
37-
* @nocollapse
38-
*/
35+
/** @nocollapse */
3936
static formAssociated = true;
4037

4138
/**
4239
* Whether or not the radio is selected.
4340
*/
44-
@property({type: Boolean, reflect: true})
41+
@property({type: Boolean})
4542
get checked() {
4643
return this[CHECKED];
4744
}
@@ -52,6 +49,8 @@ export class Radio extends LitElement {
5249
}
5350

5451
this[CHECKED] = checked;
52+
const state = String(checked);
53+
this.internals.setFormValue(this.checked ? this.value : null, state);
5554
this.requestUpdate('checked', wasChecked);
5655
this.selectionController.handleCheckedChange();
5756
}
@@ -71,23 +70,36 @@ export class Radio extends LitElement {
7170
/**
7271
* The HTML name to use in form submission.
7372
*/
74-
@property({reflect: true}) name = '';
73+
get name() {
74+
return this.getAttribute('name') ?? '';
75+
}
76+
set name(name: string) {
77+
this.setAttribute('name', name);
78+
}
7579

7680
/**
7781
* The associated form element with which this element's value will submit.
7882
*/
7983
get form() {
80-
return this.closest('form');
84+
return this.internals.form;
85+
}
86+
87+
/**
88+
* The labels this element is associated with.
89+
*/
90+
get labels() {
91+
return this.internals.labels;
8192
}
8293

8394
@query('input') private readonly input!: HTMLInputElement|null;
8495
@queryAsync('md-ripple') private readonly ripple!: Promise<MdRipple|null>;
8596
private readonly selectionController = new SingleSelectionController(this);
8697
@state() private showRipple = false;
98+
private readonly internals =
99+
(this as HTMLElement /* needed for closure */).attachInternals();
87100

88101
constructor() {
89102
super();
90-
this.addController(new FormController(this));
91103
this.addController(this.selectionController);
92104
if (!isServer) {
93105
this.addEventListener('click', (event: Event) => {
@@ -100,10 +112,6 @@ export class Radio extends LitElement {
100112
}
101113
}
102114

103-
[getFormValue]() {
104-
return this.checked ? this.value : null;
105-
}
106-
107115
override focus() {
108116
this.input?.focus();
109117
}
@@ -154,4 +162,16 @@ export class Radio extends LitElement {
154162
private readonly renderRipple = () => {
155163
return html`<md-ripple unbounded ?disabled=${this.disabled}></md-ripple>`;
156164
};
165+
166+
/** @private */
167+
formResetCallback() {
168+
// The checked property does not reflect, so the original attribute set by
169+
// the user is used to determine the default value.
170+
this.checked = this.hasAttribute('checked');
171+
}
172+
173+
/** @private */
174+
formStateRestoreCallback(state: string) {
175+
this.checked = state === 'true';
176+
}
157177
}

radio/radio_test.ts

Lines changed: 123 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import {html} from 'lit';
88

99
import {Environment} from '../testing/environment.js';
10+
import {createFormTests} from '../testing/forms.js';
1011
import {createTokenTests} from '../testing/tokens.js';
1112

1213
import {RadioHarness} from './harness.js';
@@ -378,86 +379,6 @@ describe('<md-radio>', () => {
378379
});
379380
});
380381

381-
describe('form submission', () => {
382-
async function setupFormTest() {
383-
const root = env.render(html`
384-
<form>
385-
<md-radio id="first" name="a" value="first"></md-radio>
386-
<md-radio id="disabled" name="a" value="disabled" disabled></md-radio>
387-
<md-radio id="unNamed" value="unnamed"></md-radio>
388-
<md-radio id="ownGroup" name="b" value="ownGroup"></md-radio>
389-
<md-radio id="last" name="a" value="last"></md-radio>
390-
</form>`);
391-
await env.waitForStability();
392-
const harnesses = new Map<string, RadioHarness>();
393-
Array.from(root.querySelectorAll('md-radio')).forEach((el: MdRadio) => {
394-
harnesses.set(el.id, new RadioHarness(el));
395-
});
396-
return harnesses;
397-
}
398-
399-
it('does not submit if not checked', async () => {
400-
const harness = (await setupFormTest()).get('first')!;
401-
const formData = await harness.submitForm();
402-
const keys = Array.from(formData.keys());
403-
expect(keys.length).toEqual(0);
404-
});
405-
406-
it('does not submit if disabled', async () => {
407-
const harness = (await setupFormTest()).get('disabled')!;
408-
expect(harness.element.disabled).toBeTrue();
409-
harness.element.checked = true;
410-
const formData = await harness.submitForm();
411-
const keys = Array.from(formData.keys());
412-
expect(keys.length).toEqual(0);
413-
});
414-
415-
it('does not submit if name is not provided', async () => {
416-
const harness = (await setupFormTest()).get('unNamed')!;
417-
expect(harness.element.name).toBe('');
418-
const formData = await harness.submitForm();
419-
const keys = Array.from(formData.keys());
420-
expect(keys.length).toEqual(0);
421-
});
422-
423-
it('submits under correct conditions', async () => {
424-
const harness = (await setupFormTest()).get('first')!;
425-
harness.element.checked = true;
426-
const formData = await harness.submitForm();
427-
const {name, value} = harness.element;
428-
const keys = Array.from(formData.keys());
429-
expect(keys.length).toEqual(1);
430-
expect(formData.get(name)).toEqual(value);
431-
});
432-
433-
it('submits changes to group value under correct conditions', async () => {
434-
const harnesses = await setupFormTest();
435-
const first = harnesses.get('first')!;
436-
const last = harnesses.get('last')!;
437-
const ownGroup = harnesses.get('ownGroup')!;
438-
439-
// check first and submit
440-
first.element.checked = true;
441-
let formData = await first.submitForm();
442-
expect(Array.from(formData.keys()).length).toEqual(1);
443-
expect(formData.get(first.element.name)).toEqual(first.element.value);
444-
445-
// check last and submit
446-
last.element.checked = true;
447-
formData = await last.submitForm();
448-
expect(Array.from(formData.keys()).length).toEqual(1);
449-
expect(formData.get(last.element.name)).toEqual(last.element.value);
450-
451-
// check ownGroup and submit
452-
ownGroup.element.checked = true;
453-
formData = await ownGroup.submitForm();
454-
expect(Array.from(formData.keys()).length).toEqual(2);
455-
expect(formData.get(last.element.name)).toEqual(last.element.value);
456-
expect(formData.get(ownGroup.element.name))
457-
.toEqual(ownGroup.element.value);
458-
});
459-
});
460-
461382
describe('label activation', () => {
462383
async function setupLabelTest() {
463384
const root = env.render(html`
@@ -484,4 +405,126 @@ describe('<md-radio>', () => {
484405
expect(radio2.checked).toBeTrue();
485406
});
486407
});
408+
409+
describe('forms', () => {
410+
createFormTests({
411+
queryControl: root => root.querySelector('md-radio'),
412+
valueTests: [
413+
{
414+
name: 'unnamed',
415+
render: () => html`
416+
<md-radio value="One" checked></md-radio>
417+
<md-radio value="Two"></md-radio>
418+
`,
419+
assertValue(formData) {
420+
expect(formData)
421+
.withContext('should not add anything to form without a name')
422+
.toHaveSize(0);
423+
}
424+
},
425+
{
426+
name: 'unchecked',
427+
render: () => html`
428+
<md-radio name="radio" value="One"></md-radio>
429+
<md-radio name="radio" value="Two"></md-radio>
430+
`,
431+
assertValue(formData) {
432+
expect(formData)
433+
.withContext('should not add anything to form when unchecked')
434+
.toHaveSize(0);
435+
}
436+
},
437+
{
438+
name: 'checked first value',
439+
render: () => html`
440+
<md-radio name="radio" value="One" checked></md-radio>
441+
<md-radio name="radio" value="Two"></md-radio>
442+
`,
443+
assertValue(formData) {
444+
expect(formData.get('radio')).toBe('One');
445+
}
446+
},
447+
{
448+
name: 'checked second value',
449+
render: () => html`
450+
<md-radio name="radio" value="One"></md-radio>
451+
<md-radio name="radio" value="Two" checked></md-radio>
452+
`,
453+
assertValue(formData) {
454+
expect(formData.get('radio')).toBe('Two');
455+
}
456+
},
457+
{
458+
name: 'disabled',
459+
render: () => html`
460+
<md-radio name="radio" value="One" checked disabled></md-radio>
461+
<md-radio name="radio" value="Two" disabled></md-radio>
462+
`,
463+
assertValue(formData) {
464+
expect(formData)
465+
.withContext('should not add anything to form when disabled')
466+
.toHaveSize(0);
467+
}
468+
}
469+
],
470+
resetTests: [
471+
{
472+
name: 'reset to unchecked',
473+
render: () => html`
474+
<md-radio name="radio" value="One"></md-radio>
475+
<md-radio name="radio" value="Two"></md-radio>
476+
`,
477+
change(radio) {
478+
radio.checked = true;
479+
},
480+
assertReset(radio) {
481+
expect(radio.checked)
482+
.withContext('radio.checked after reset')
483+
.toBeFalse();
484+
}
485+
},
486+
{
487+
name: 'reset to checked',
488+
render: () => html`
489+
<md-radio name="radio" value="One" checked></md-radio>
490+
<md-radio name="radio" value="Two"></md-radio>
491+
`,
492+
change(radio) {
493+
radio.checked = false;
494+
},
495+
assertReset(radio) {
496+
expect(radio.checked)
497+
.withContext('radio.checked after reset')
498+
.toBeTrue();
499+
}
500+
},
501+
],
502+
restoreTests: [
503+
{
504+
name: 'restore unchecked',
505+
render: () => html`
506+
<md-radio name="radio" value="One"></md-radio>
507+
<md-radio name="radio" value="Two"></md-radio>
508+
`,
509+
assertRestored(radio) {
510+
expect(radio.checked)
511+
.withContext('radio.checked after restore')
512+
.toBeFalse();
513+
}
514+
},
515+
{
516+
name: 'restore checked',
517+
render: () => html`
518+
<md-radio name="radio" value="One" checked></md-radio>
519+
<md-radio name="radio" value="Two"></md-radio>
520+
`,
521+
assertRestored(radio) {
522+
expect(radio.checked)
523+
.withContext('radio.checked after restore')
524+
.toBeTrue();
525+
}
526+
},
527+
]
528+
});
529+
});
487530
});

0 commit comments

Comments
 (0)