From 9dc86130671ad9d6151f397be4d5ea53212be515 Mon Sep 17 00:00:00 2001 From: Elizabeth Mitchell Date: Tue, 16 May 2023 20:27:58 -0700 Subject: [PATCH] feat(radio): add full form association support PiperOrigin-RevId: 532652986 --- radio/lib/radio.ts | 44 +++++++--- radio/radio_test.ts | 203 +++++++++++++++++++++++++++----------------- 2 files changed, 155 insertions(+), 92 deletions(-) diff --git a/radio/lib/radio.ts b/radio/lib/radio.ts index 47a42dd9a3..f06e41fd44 100644 --- a/radio/lib/radio.ts +++ b/radio/lib/radio.ts @@ -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; 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``; }; + + /** @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'; + } } diff --git a/radio/radio_test.ts b/radio/radio_test.ts index 2fd274145e..c615c50729 100644 --- a/radio/radio_test.ts +++ b/radio/radio_test.ts @@ -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('', () => { }); }); - describe('form submission', () => { - async function setupFormTest() { - const root = env.render(html` -
- - - - - -
`); - await env.waitForStability(); - const harnesses = new Map(); - 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('', () => { expect(radio2.checked).toBeTrue(); }); }); + + describe('forms', () => { + createFormTests({ + queryControl: root => root.querySelector('md-radio'), + valueTests: [ + { + name: 'unnamed', + render: () => html` + + + `, + assertValue(formData) { + expect(formData) + .withContext('should not add anything to form without a name') + .toHaveSize(0); + } + }, + { + name: 'unchecked', + render: () => html` + + + `, + assertValue(formData) { + expect(formData) + .withContext('should not add anything to form when unchecked') + .toHaveSize(0); + } + }, + { + name: 'checked first value', + render: () => html` + + + `, + assertValue(formData) { + expect(formData.get('radio')).toBe('One'); + } + }, + { + name: 'checked second value', + render: () => html` + + + `, + assertValue(formData) { + expect(formData.get('radio')).toBe('Two'); + } + }, + { + name: 'disabled', + render: () => html` + + + `, + assertValue(formData) { + expect(formData) + .withContext('should not add anything to form when disabled') + .toHaveSize(0); + } + } + ], + resetTests: [ + { + name: 'reset to unchecked', + render: () => html` + + + `, + change(radio) { + radio.checked = true; + }, + assertReset(radio) { + expect(radio.checked) + .withContext('radio.checked after reset') + .toBeFalse(); + } + }, + { + name: 'reset to checked', + render: () => html` + + + `, + change(radio) { + radio.checked = false; + }, + assertReset(radio) { + expect(radio.checked) + .withContext('radio.checked after reset') + .toBeTrue(); + } + }, + ], + restoreTests: [ + { + name: 'restore unchecked', + render: () => html` + + + `, + assertRestored(radio) { + expect(radio.checked) + .withContext('radio.checked after restore') + .toBeFalse(); + } + }, + { + name: 'restore checked', + render: () => html` + + + `, + assertRestored(radio) { + expect(radio.checked) + .withContext('radio.checked after restore') + .toBeTrue(); + } + }, + ] + }); + }); });