Skip to content

Commit

Permalink
feat(radio): add full form association support
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 532652986
  • Loading branch information
asyncLiz authored and copybara-github committed May 17, 2023
1 parent 921a905 commit 9dc8613
Show file tree
Hide file tree
Showing 2 changed files with 155 additions and 92 deletions.
44 changes: 32 additions & 12 deletions radio/lib/radio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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];
}
Expand All @@ -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();
}
Expand All @@ -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) => {
Expand All @@ -100,10 +112,6 @@ export class Radio extends LitElement {
}
}

[getFormValue]() {
return this.checked ? this.value : null;
}

override focus() {
this.input?.focus();
}
Expand Down Expand Up @@ -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';
}
}
203 changes: 123 additions & 80 deletions radio/radio_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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`
Expand All @@ -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();
}
},
]
});
});
});

0 comments on commit 9dc8613

Please sign in to comment.