feat(radio): add full form association support

PiperOrigin-RevId: 532652986
This commit is contained in:
Elizabeth Mitchell 2023-05-16 20:27:58 -07:00 committed by Copybara-Service
parent 921a905758
commit 9dc8613067
2 changed files with 155 additions and 92 deletions

View File

@ -14,7 +14,6 @@ import {when} from 'lit/directives/when.js';
import {ARIAMixinStrict} from '../../aria/aria.js';
import {requestUpdateOnAriaChange} from '../../aria/delegate.js';
import {dispatchActivationClick, isActivationClick, redispatchEvent} from '../../controller/events.js';
import {FormController, getFormValue} from '../../controller/form-controller.js';
import {ripple} from '../../ripple/directive.js';
import {MdRipple} from '../../ripple/ripple.js';
@ -33,15 +32,13 @@ export class Radio extends LitElement {
static override shadowRootOptions:
ShadowRootInit = {...LitElement.shadowRootOptions, delegatesFocus: true};
/**
* @nocollapse
*/
/** @nocollapse */
static formAssociated = true;
/**
* Whether or not the radio is selected.
*/
@property({type: Boolean, reflect: true})
@property({type: Boolean})
get checked() {
return this[CHECKED];
}
@ -52,6 +49,8 @@ export class Radio extends LitElement {
}
this[CHECKED] = checked;
const state = String(checked);
this.internals.setFormValue(this.checked ? this.value : null, state);
this.requestUpdate('checked', wasChecked);
this.selectionController.handleCheckedChange();
}
@ -71,23 +70,36 @@ export class Radio extends LitElement {
/**
* The HTML name to use in form submission.
*/
@property({reflect: true}) name = '';
get name() {
return this.getAttribute('name') ?? '';
}
set name(name: string) {
this.setAttribute('name', name);
}
/**
* The associated form element with which this element's value will submit.
*/
get form() {
return this.closest('form');
return this.internals.form;
}
/**
* The labels this element is associated with.
*/
get labels() {
return this.internals.labels;
}
@query('input') private readonly input!: HTMLInputElement|null;
@queryAsync('md-ripple') private readonly ripple!: Promise<MdRipple|null>;
private readonly selectionController = new SingleSelectionController(this);
@state() private showRipple = false;
private readonly internals =
(this as HTMLElement /* needed for closure */).attachInternals();
constructor() {
super();
this.addController(new FormController(this));
this.addController(this.selectionController);
if (!isServer) {
this.addEventListener('click', (event: Event) => {
@ -100,10 +112,6 @@ export class Radio extends LitElement {
}
}
[getFormValue]() {
return this.checked ? this.value : null;
}
override focus() {
this.input?.focus();
}
@ -154,4 +162,16 @@ export class Radio extends LitElement {
private readonly renderRipple = () => {
return html`<md-ripple unbounded ?disabled=${this.disabled}></md-ripple>`;
};
/** @private */
formResetCallback() {
// The checked property does not reflect, so the original attribute set by
// the user is used to determine the default value.
this.checked = this.hasAttribute('checked');
}
/** @private */
formStateRestoreCallback(state: string) {
this.checked = state === 'true';
}
}

View File

@ -7,6 +7,7 @@
import {html} from 'lit';
import {Environment} from '../testing/environment.js';
import {createFormTests} from '../testing/forms.js';
import {createTokenTests} from '../testing/tokens.js';
import {RadioHarness} from './harness.js';
@ -378,86 +379,6 @@ describe('<md-radio>', () => {
});
});
describe('form submission', () => {
async function setupFormTest() {
const root = env.render(html`
<form>
<md-radio id="first" name="a" value="first"></md-radio>
<md-radio id="disabled" name="a" value="disabled" disabled></md-radio>
<md-radio id="unNamed" value="unnamed"></md-radio>
<md-radio id="ownGroup" name="b" value="ownGroup"></md-radio>
<md-radio id="last" name="a" value="last"></md-radio>
</form>`);
await env.waitForStability();
const harnesses = new Map<string, RadioHarness>();
Array.from(root.querySelectorAll('md-radio')).forEach((el: MdRadio) => {
harnesses.set(el.id, new RadioHarness(el));
});
return harnesses;
}
it('does not submit if not checked', async () => {
const harness = (await setupFormTest()).get('first')!;
const formData = await harness.submitForm();
const keys = Array.from(formData.keys());
expect(keys.length).toEqual(0);
});
it('does not submit if disabled', async () => {
const harness = (await setupFormTest()).get('disabled')!;
expect(harness.element.disabled).toBeTrue();
harness.element.checked = true;
const formData = await harness.submitForm();
const keys = Array.from(formData.keys());
expect(keys.length).toEqual(0);
});
it('does not submit if name is not provided', async () => {
const harness = (await setupFormTest()).get('unNamed')!;
expect(harness.element.name).toBe('');
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()).get('first')!;
harness.element.checked = true;
const formData = await harness.submitForm();
const {name, value} = harness.element;
const keys = Array.from(formData.keys());
expect(keys.length).toEqual(1);
expect(formData.get(name)).toEqual(value);
});
it('submits changes to group value under correct conditions', async () => {
const harnesses = await setupFormTest();
const first = harnesses.get('first')!;
const last = harnesses.get('last')!;
const ownGroup = harnesses.get('ownGroup')!;
// check first and submit
first.element.checked = true;
let formData = await first.submitForm();
expect(Array.from(formData.keys()).length).toEqual(1);
expect(formData.get(first.element.name)).toEqual(first.element.value);
// check last and submit
last.element.checked = true;
formData = await last.submitForm();
expect(Array.from(formData.keys()).length).toEqual(1);
expect(formData.get(last.element.name)).toEqual(last.element.value);
// check ownGroup and submit
ownGroup.element.checked = true;
formData = await ownGroup.submitForm();
expect(Array.from(formData.keys()).length).toEqual(2);
expect(formData.get(last.element.name)).toEqual(last.element.value);
expect(formData.get(ownGroup.element.name))
.toEqual(ownGroup.element.value);
});
});
describe('label activation', () => {
async function setupLabelTest() {
const root = env.render(html`
@ -484,4 +405,126 @@ describe('<md-radio>', () => {
expect(radio2.checked).toBeTrue();
});
});
describe('forms', () => {
createFormTests({
queryControl: root => root.querySelector('md-radio'),
valueTests: [
{
name: 'unnamed',
render: () => html`
<md-radio value="One" checked></md-radio>
<md-radio value="Two"></md-radio>
`,
assertValue(formData) {
expect(formData)
.withContext('should not add anything to form without a name')
.toHaveSize(0);
}
},
{
name: 'unchecked',
render: () => html`
<md-radio name="radio" value="One"></md-radio>
<md-radio name="radio" value="Two"></md-radio>
`,
assertValue(formData) {
expect(formData)
.withContext('should not add anything to form when unchecked')
.toHaveSize(0);
}
},
{
name: 'checked first value',
render: () => html`
<md-radio name="radio" value="One" checked></md-radio>
<md-radio name="radio" value="Two"></md-radio>
`,
assertValue(formData) {
expect(formData.get('radio')).toBe('One');
}
},
{
name: 'checked second value',
render: () => html`
<md-radio name="radio" value="One"></md-radio>
<md-radio name="radio" value="Two" checked></md-radio>
`,
assertValue(formData) {
expect(formData.get('radio')).toBe('Two');
}
},
{
name: 'disabled',
render: () => html`
<md-radio name="radio" value="One" checked disabled></md-radio>
<md-radio name="radio" value="Two" disabled></md-radio>
`,
assertValue(formData) {
expect(formData)
.withContext('should not add anything to form when disabled')
.toHaveSize(0);
}
}
],
resetTests: [
{
name: 'reset to unchecked',
render: () => html`
<md-radio name="radio" value="One"></md-radio>
<md-radio name="radio" value="Two"></md-radio>
`,
change(radio) {
radio.checked = true;
},
assertReset(radio) {
expect(radio.checked)
.withContext('radio.checked after reset')
.toBeFalse();
}
},
{
name: 'reset to checked',
render: () => html`
<md-radio name="radio" value="One" checked></md-radio>
<md-radio name="radio" value="Two"></md-radio>
`,
change(radio) {
radio.checked = false;
},
assertReset(radio) {
expect(radio.checked)
.withContext('radio.checked after reset')
.toBeTrue();
}
},
],
restoreTests: [
{
name: 'restore unchecked',
render: () => html`
<md-radio name="radio" value="One"></md-radio>
<md-radio name="radio" value="Two"></md-radio>
`,
assertRestored(radio) {
expect(radio.checked)
.withContext('radio.checked after restore')
.toBeFalse();
}
},
{
name: 'restore checked',
render: () => html`
<md-radio name="radio" value="One" checked></md-radio>
<md-radio name="radio" value="Two"></md-radio>
`,
assertRestored(radio) {
expect(radio.checked)
.withContext('radio.checked after restore')
.toBeTrue();
}
},
]
});
});
});