Skip to content

Commit

Permalink
feat(radio): add required constraint validation
Browse files Browse the repository at this point in the history
Fixes #4316

PiperOrigin-RevId: 586045132
  • Loading branch information
asyncLiz authored and copybara-github committed Nov 28, 2023
1 parent 33c1afe commit b5686ea
Show file tree
Hide file tree
Showing 4 changed files with 271 additions and 21 deletions.
98 changes: 98 additions & 0 deletions labs/behaviors/validators/radio-validator.ts
Original file line number Diff line number Diff line change
@@ -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
* `<input type="radio">` validation.
*/
export class RadioValidator extends Validator<RadioGroupState> {
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 <input>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;
}
}
121 changes: 121 additions & 0 deletions labs/behaviors/validators/radio-validator_test.ts
Original file line number Diff line number Diff line change
@@ -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('');
});
});
35 changes: 32 additions & 3 deletions radio/internal/radio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,15 +27,16 @@ 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';

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))),
);

/**
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
}
}
38 changes: 20 additions & 18 deletions radio/internal/single-selection-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SingleSelectionElement>(`[name="${name}"]`),
) as unknown as [SingleSelectionElement, ...SingleSelectionElement[]];
}

private focused = false;
private root: ParentNode | null = null;

Expand Down Expand Up @@ -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;
}
Expand All @@ -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) {
Expand All @@ -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<SingleSelectionElement>(`[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
Expand All @@ -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;
}
Expand Down

0 comments on commit b5686ea

Please sign in to comment.