From b5686ea4e0ad17717ef086bcdf7dfdeb565acc9b Mon Sep 17 00:00:00 2001 From: Elizabeth Mitchell Date: Tue, 28 Nov 2023 11:14:44 -0800 Subject: [PATCH] feat(radio): add required constraint validation Fixes #4316 PiperOrigin-RevId: 586045132 --- labs/behaviors/validators/radio-validator.ts | 98 ++++++++++++++ .../validators/radio-validator_test.ts | 121 ++++++++++++++++++ radio/internal/radio.ts | 35 ++++- radio/internal/single-selection-controller.ts | 38 +++--- 4 files changed, 271 insertions(+), 21 deletions(-) create mode 100644 labs/behaviors/validators/radio-validator.ts create mode 100644 labs/behaviors/validators/radio-validator_test.ts diff --git a/labs/behaviors/validators/radio-validator.ts b/labs/behaviors/validators/radio-validator.ts new file mode 100644 index 0000000000..6d8323020e --- /dev/null +++ b/labs/behaviors/validators/radio-validator.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Validator} from './validator.js'; + +/** + * Constraint validation properties for a radio. + */ +export interface RadioState { + /** + * Whether the radio is checked. + */ + readonly checked: boolean; + + /** + * Whether the radio is required. + */ + readonly required: boolean; +} + +/** + * Radio constraint validation properties for a single radio and its siblings. + */ +export type RadioGroupState = readonly [RadioState, ...RadioState[]]; + +/** + * A validator that provides constraint validation that emulates + * `` validation. + */ +export class RadioValidator extends Validator { + private radioElement?: HTMLInputElement; + + protected override computeValidity(states: RadioGroupState) { + if (!this.radioElement) { + // Lazily create the radio element + this.radioElement = document.createElement('input'); + this.radioElement.type = 'radio'; + // A name is required for validation to run + this.radioElement.name = 'group'; + } + + let isRequired = false; + let isChecked = false; + for (const {checked, required} of states) { + if (required) { + isRequired = true; + } + + if (checked) { + isChecked = true; + } + } + + // Firefox v119 doesn't compute grouped radio validation correctly while + // they are detached from the DOM, which is why we don't render multiple + // virtual s. Instead, we can check the required/checked states and + // grab the i18n'd validation message if the value is missing. + this.radioElement.checked = isChecked; + this.radioElement.required = isRequired; + return { + validity: { + valueMissing: isRequired && !isChecked, + }, + validationMessage: this.radioElement.validationMessage, + }; + } + + protected override equals( + prevGroup: RadioGroupState, + nextGroup: RadioGroupState, + ) { + if (prevGroup.length !== nextGroup.length) { + return false; + } + + for (let i = 0; i < prevGroup.length; i++) { + const prev = prevGroup[i]; + const next = nextGroup[i]; + if (prev.checked !== next.checked || prev.required !== next.required) { + return false; + } + } + + return true; + } + + protected override copy(states: RadioGroupState): RadioGroupState { + // Cast as unknown since typescript does not have enough information to + // infer that the array always has at least one element. + return states.map(({checked, required}) => ({ + checked, + required, + })) as unknown as RadioGroupState; + } +} diff --git a/labs/behaviors/validators/radio-validator_test.ts b/labs/behaviors/validators/radio-validator_test.ts new file mode 100644 index 0000000000..449e619344 --- /dev/null +++ b/labs/behaviors/validators/radio-validator_test.ts @@ -0,0 +1,121 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) + +import {RadioValidator} from './radio-validator.js'; + +describe('RadioValidator', () => { + it('is invalid when required and no radios are checked', () => { + const states = [ + { + required: true, + checked: false, + }, + { + required: true, + checked: false, + }, + { + required: true, + checked: false, + }, + ] as const; + + const validator = new RadioValidator(() => states); + const {validity, validationMessage} = validator.getValidity(); + expect(validity.valueMissing).withContext('valueMissing').toBeTrue(); + expect(validationMessage).withContext('validationMessage').not.toBe(''); + }); + + it('is invalid when any radio is required and no radios are checked', () => { + const states = [ + { + required: false, + checked: false, + }, + { + required: true, + checked: false, + }, + { + required: false, + checked: false, + }, + ] as const; + + const validator = new RadioValidator(() => states); + const {validity, validationMessage} = validator.getValidity(); + expect(validity.valueMissing).withContext('valueMissing').toBeTrue(); + expect(validationMessage).withContext('validationMessage').not.toBe(''); + }); + + it('is valid when required and any radio is checked', () => { + const states = [ + { + required: true, + checked: false, + }, + { + required: true, + checked: true, + }, + { + required: true, + checked: false, + }, + ] as const; + + const validator = new RadioValidator(() => states); + const {validity, validationMessage} = validator.getValidity(); + expect(validity.valueMissing).withContext('valueMissing').toBeFalse(); + expect(validationMessage).withContext('validationMessage').toBe(''); + }); + + it('is valid when required and multiple radios are checked', () => { + const states = [ + { + required: true, + checked: false, + }, + { + required: true, + checked: true, + }, + { + required: true, + checked: true, + }, + ] as const; + + const validator = new RadioValidator(() => states); + const {validity, validationMessage} = validator.getValidity(); + expect(validity.valueMissing).withContext('valueMissing').toBeFalse(); + expect(validationMessage).withContext('validationMessage').toBe(''); + }); + + it('is valid when not required', () => { + const states = [ + { + required: false, + checked: false, + }, + { + required: false, + checked: false, + }, + { + required: false, + checked: false, + }, + ] as const; + + const validator = new RadioValidator(() => states); + const {validity, validationMessage} = validator.getValidity(); + expect(validity.valueMissing).withContext('valueMissing').toBeFalse(); + expect(validationMessage).withContext('validationMessage').toBe(''); + }); +}); diff --git a/radio/internal/radio.ts b/radio/internal/radio.ts index bf453be5cb..98c1d70d59 100644 --- a/radio/internal/radio.ts +++ b/radio/internal/radio.ts @@ -8,10 +8,15 @@ import '../../focus/md-focus-ring.js'; import '../../ripple/ripple.js'; import {html, isServer, LitElement} from 'lit'; -import {property} from 'lit/decorators.js'; +import {property, query} from 'lit/decorators.js'; import {classMap} from 'lit/directives/class-map.js'; import {isActivationClick} from '../../internal/controller/events.js'; +import { + createValidator, + getValidityAnchor, + mixinConstraintValidation, +} from '../../labs/behaviors/constraint-validation.js'; import { internals, mixinElementInternals, @@ -22,6 +27,7 @@ import { getFormValue, mixinFormAssociated, } from '../../labs/behaviors/form-associated.js'; +import {RadioValidator} from '../../labs/behaviors/validators/radio-validator.js'; import {SingleSelectionController} from './single-selection-controller.js'; @@ -29,8 +35,8 @@ const CHECKED = Symbol('checked'); let maskId = 0; // Separate variable needed for closure. -const radioBaseClass = mixinFormAssociated( - mixinElementInternals(mixinFocusable(LitElement)), +const radioBaseClass = mixinConstraintValidation( + mixinFormAssociated(mixinElementInternals(mixinFocusable(LitElement))), ); /** @@ -66,11 +72,18 @@ export class Radio extends radioBaseClass { [CHECKED] = false; + /** + * Whether or not the radio is required. If any radio is required in a group, + * all radios are implicitly required. + */ + @property({type: Boolean}) required = false; + /** * The element value to use in form submission when checked. */ @property() value = 'on'; + @query('.container') private readonly container!: HTMLElement; private readonly selectionController = new SingleSelectionController(this); constructor() { @@ -175,4 +188,20 @@ export class Radio extends radioBaseClass { override formStateRestoreCallback(state: string) { this.checked = state === 'true'; } + + [createValidator]() { + return new RadioValidator(() => { + if (!this.selectionController) { + // Validation runs on superclass construction, so selection controller + // might not actually be ready until this class constructs. + return [this]; + } + + return this.selectionController.controls as [Radio, ...Radio[]]; + }); + } + + [getValidityAnchor]() { + return this.container; + } } diff --git a/radio/internal/single-selection-controller.ts b/radio/internal/single-selection-controller.ts index e87644583f..57630cb567 100644 --- a/radio/internal/single-selection-controller.ts +++ b/radio/internal/single-selection-controller.ts @@ -51,6 +51,23 @@ export interface SingleSelectionElement extends HTMLElement { * } */ export class SingleSelectionController implements ReactiveController { + /** + * All single selection elements in the host element's root with the same + * `name` attribute, including the host element. + */ + get controls(): [SingleSelectionElement, ...SingleSelectionElement[]] { + const name = this.host.getAttribute('name'); + if (!name || !this.root || !this.host.isConnected) { + return [this.host]; + } + + // Cast as unknown since there is not enough information for typescript to + // know that there is always at least one element (the host). + return Array.from( + this.root.querySelectorAll(`[name="${name}"]`), + ) as unknown as [SingleSelectionElement, ...SingleSelectionElement[]]; + } + private focused = false; private root: ParentNode | null = null; @@ -104,7 +121,7 @@ export class SingleSelectionController implements ReactiveController { }; private uncheckSiblings() { - for (const sibling of this.getNamedSiblings()) { + for (const sibling of this.controls) { if (sibling !== this.host) { sibling.checked = false; } @@ -117,7 +134,7 @@ export class SingleSelectionController implements ReactiveController { private updateTabIndices() { // There are three tabindex states for a group of elements: // 1. If any are checked, that element is focusable. - const siblings = this.getNamedSiblings(); + const siblings = this.controls; const checkedSibling = siblings.find((sibling) => sibling.checked); // 2. If an element is focused, the others are no longer focusable. if (checkedSibling || this.focused) { @@ -138,21 +155,6 @@ export class SingleSelectionController implements ReactiveController { } } - /** - * Retrieves all siblings in the host element's root with the same `name` - * attribute. - */ - private getNamedSiblings() { - const name = this.host.getAttribute('name'); - if (!name || !this.root) { - return []; - } - - return Array.from( - this.root.querySelectorAll(`[name="${name}"]`), - ); - } - /** * Handles arrow key events from the host. Using the arrow keys will * select and check the next or previous sibling with the host's @@ -169,7 +171,7 @@ export class SingleSelectionController implements ReactiveController { } // Don't try to select another sibling if there aren't any. - const siblings = this.getNamedSiblings(); + const siblings = this.controls; if (!siblings.length) { return; }