From 91c24255c243a97d01a873854046ad3a57033352 Mon Sep 17 00:00:00 2001 From: Material Web Team Date: Tue, 20 Dec 2022 17:19:18 -0800 Subject: [PATCH] fix(radio): Radio supports form association and label activation by using FormController and setting `formAssociated`. PiperOrigin-RevId: 496788453 --- radio/lib/radio.ts | 41 ++++++++++++- radio/md-radio_test.ts | 127 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 158 insertions(+), 10 deletions(-) diff --git a/radio/lib/radio.ts b/radio/lib/radio.ts index 6e9d9b9f46..df569e7686 100644 --- a/radio/lib/radio.ts +++ b/radio/lib/radio.ts @@ -15,6 +15,8 @@ import {property, query, queryAsync, state} from 'lit/decorators.js'; import {classMap} from 'lit/directives/class-map.js'; import {when} from 'lit/directives/when.js'; +import {dispatchActivationClick, isActivationClick} from '../../controller/events.js'; +import {FormController, getFormValue} from '../../controller/form-controller.js'; import {ariaProperty} from '../../decorators/aria-property.js'; import {pointerPress, shouldShowStrongFocus} from '../../focus/strong-focus.js'; import {ripple} from '../../ripple/directive.js'; @@ -27,6 +29,11 @@ import {SingleSelectionController} from './single-selection-controller.js'; * @soyCompatible */ export class Radio extends LitElement { + static override shadowRootOptions: + ShadowRootInit = {...LitElement.shadowRootOptions, delegatesFocus: true}; + + static formAssociated = true; + @property({type: Boolean, reflect: true}) get checked(): boolean { return this._checked; @@ -70,9 +77,15 @@ export class Radio extends LitElement { @property({type: Boolean}) disabled = false; + /** + * The element value to use in form submission when checked. + */ @property({type: String}) value = 'on'; - @property({type: String}) name = ''; + /** + * The HTML name to use in form submission. + */ + @property({type: String, reflect: true}) name = ''; /** * Touch target extends beyond visual boundary of a component by default. @@ -91,6 +104,13 @@ export class Radio extends LitElement { @property({attribute: 'data-aria-label', noAccessor: true}) override ariaLabel!: string; + /** + * The associated form element with which this element's value will submit. + */ + get form() { + return this.closest('form'); + } + @state() private focused = false; @query('input') private readonly input!: HTMLInputElement|null; @queryAsync('md-ripple') private readonly ripple!: Promise; @@ -98,9 +118,24 @@ export class Radio extends LitElement { @state() private showFocusRing = false; @state() private showRipple = false; - override click() { + constructor() { + super(); + this.addController(new FormController(this)); + this.addEventListener('click', (event: Event) => { + if (!isActivationClick(event)) { + return; + } + this.focus(); + dispatchActivationClick(this.input!); + }); + } + + [getFormValue]() { + return this.checked ? this.value : null; + } + + override focus() { this.input?.focus(); - this.input?.click(); } override connectedCallback() { diff --git a/radio/md-radio_test.ts b/radio/md-radio_test.ts index 6e4aadb681..7a01c72187 100644 --- a/radio/md-radio_test.ts +++ b/radio/md-radio_test.ts @@ -45,6 +45,16 @@ const repeatedRadio = (values: string[]) => { describe('md-radio', () => { const env = new Environment(); + // Note, this would be better in the harness, but waiting in the test setup + // can be flakey without access to the test `env`. + async function simulateKeyDown(element: HTMLElement, key: string) { + const event = new KeyboardEvent('keydown', {key, bubbles: true}); + element.dispatchEvent(event); + // TODO(https://bugzilla.mozilla.org/show_bug.cgi?id=1804576) + // Remove delay when issue addressed. + await env.waitForStability(); + } + let element: MdRadio; let harness: RadioHarness; @@ -119,9 +129,7 @@ describe('md-radio', () => { const a2 = root.querySelectorAll('md-radio')[1]; expect(a2.checked).toBeTrue(); - const eventRight = - new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true}); - a2.dispatchEvent(eventRight); + await simulateKeyDown(a2, 'ArrowRight'); const a3 = root.querySelectorAll('md-radio')[2]; expect(a3.checked).toBeTrue(); @@ -136,11 +144,9 @@ describe('md-radio', () => { const a2 = root.querySelectorAll('md-radio')[1]; expect(a2.checked).toBeTrue(); - const eventRight = - new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true}); - a2.dispatchEvent(eventRight); + await simulateKeyDown(a2, 'ArrowRight'); const a3 = root.querySelectorAll('md-radio')[2]; - a3.dispatchEvent(eventRight); + await simulateKeyDown(a3, 'ArrowRight'); expect(a3.checked).toBeFalse(); const a1 = root.querySelectorAll('md-radio')[0]; @@ -343,4 +349,111 @@ describe('md-radio', () => { expect(focusRing.visible).toBeFalse(); }); }); + + 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` + + + `); + await env.waitForStability(); + // [[label, radio]] + return Array.from(root.querySelectorAll('label')) + .map(el => ([el, el.firstElementChild as MdRadio] as const)); + } + + it('toggles when label is clicked', async () => { + const [[label1, radio1], [label2, radio2], ] = await setupLabelTest(); + + label1.click(); + await env.waitForStability(); + expect(radio1.checked).toBeTrue(); + expect(radio2.checked).toBeFalse(); + + label2.click(); + await env.waitForStability(); + expect(radio1.checked).toBeFalse(); + expect(radio2.checked).toBeTrue(); + }); + }); });