Skip to content

Commit b5686ea

Browse files
asynclizcopybara-github
authored andcommitted
feat(radio): add required constraint validation
Fixes #4316 PiperOrigin-RevId: 586045132
1 parent 33c1afe commit b5686ea

File tree

4 files changed

+271
-21
lines changed

4 files changed

+271
-21
lines changed
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* @license
3+
* Copyright 2023 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {Validator} from './validator.js';
8+
9+
/**
10+
* Constraint validation properties for a radio.
11+
*/
12+
export interface RadioState {
13+
/**
14+
* Whether the radio is checked.
15+
*/
16+
readonly checked: boolean;
17+
18+
/**
19+
* Whether the radio is required.
20+
*/
21+
readonly required: boolean;
22+
}
23+
24+
/**
25+
* Radio constraint validation properties for a single radio and its siblings.
26+
*/
27+
export type RadioGroupState = readonly [RadioState, ...RadioState[]];
28+
29+
/**
30+
* A validator that provides constraint validation that emulates
31+
* `<input type="radio">` validation.
32+
*/
33+
export class RadioValidator extends Validator<RadioGroupState> {
34+
private radioElement?: HTMLInputElement;
35+
36+
protected override computeValidity(states: RadioGroupState) {
37+
if (!this.radioElement) {
38+
// Lazily create the radio element
39+
this.radioElement = document.createElement('input');
40+
this.radioElement.type = 'radio';
41+
// A name is required for validation to run
42+
this.radioElement.name = 'group';
43+
}
44+
45+
let isRequired = false;
46+
let isChecked = false;
47+
for (const {checked, required} of states) {
48+
if (required) {
49+
isRequired = true;
50+
}
51+
52+
if (checked) {
53+
isChecked = true;
54+
}
55+
}
56+
57+
// Firefox v119 doesn't compute grouped radio validation correctly while
58+
// they are detached from the DOM, which is why we don't render multiple
59+
// virtual <input>s. Instead, we can check the required/checked states and
60+
// grab the i18n'd validation message if the value is missing.
61+
this.radioElement.checked = isChecked;
62+
this.radioElement.required = isRequired;
63+
return {
64+
validity: {
65+
valueMissing: isRequired && !isChecked,
66+
},
67+
validationMessage: this.radioElement.validationMessage,
68+
};
69+
}
70+
71+
protected override equals(
72+
prevGroup: RadioGroupState,
73+
nextGroup: RadioGroupState,
74+
) {
75+
if (prevGroup.length !== nextGroup.length) {
76+
return false;
77+
}
78+
79+
for (let i = 0; i < prevGroup.length; i++) {
80+
const prev = prevGroup[i];
81+
const next = nextGroup[i];
82+
if (prev.checked !== next.checked || prev.required !== next.required) {
83+
return false;
84+
}
85+
}
86+
87+
return true;
88+
}
89+
90+
protected override copy(states: RadioGroupState): RadioGroupState {
91+
// Cast as unknown since typescript does not have enough information to
92+
// infer that the array always has at least one element.
93+
return states.map(({checked, required}) => ({
94+
checked,
95+
required,
96+
})) as unknown as RadioGroupState;
97+
}
98+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* @license
3+
* Copyright 2023 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
// import 'jasmine'; (google3-only)
8+
9+
import {RadioValidator} from './radio-validator.js';
10+
11+
describe('RadioValidator', () => {
12+
it('is invalid when required and no radios are checked', () => {
13+
const states = [
14+
{
15+
required: true,
16+
checked: false,
17+
},
18+
{
19+
required: true,
20+
checked: false,
21+
},
22+
{
23+
required: true,
24+
checked: false,
25+
},
26+
] as const;
27+
28+
const validator = new RadioValidator(() => states);
29+
const {validity, validationMessage} = validator.getValidity();
30+
expect(validity.valueMissing).withContext('valueMissing').toBeTrue();
31+
expect(validationMessage).withContext('validationMessage').not.toBe('');
32+
});
33+
34+
it('is invalid when any radio is required and no radios are checked', () => {
35+
const states = [
36+
{
37+
required: false,
38+
checked: false,
39+
},
40+
{
41+
required: true,
42+
checked: false,
43+
},
44+
{
45+
required: false,
46+
checked: false,
47+
},
48+
] as const;
49+
50+
const validator = new RadioValidator(() => states);
51+
const {validity, validationMessage} = validator.getValidity();
52+
expect(validity.valueMissing).withContext('valueMissing').toBeTrue();
53+
expect(validationMessage).withContext('validationMessage').not.toBe('');
54+
});
55+
56+
it('is valid when required and any radio is checked', () => {
57+
const states = [
58+
{
59+
required: true,
60+
checked: false,
61+
},
62+
{
63+
required: true,
64+
checked: true,
65+
},
66+
{
67+
required: true,
68+
checked: false,
69+
},
70+
] as const;
71+
72+
const validator = new RadioValidator(() => states);
73+
const {validity, validationMessage} = validator.getValidity();
74+
expect(validity.valueMissing).withContext('valueMissing').toBeFalse();
75+
expect(validationMessage).withContext('validationMessage').toBe('');
76+
});
77+
78+
it('is valid when required and multiple radios are checked', () => {
79+
const states = [
80+
{
81+
required: true,
82+
checked: false,
83+
},
84+
{
85+
required: true,
86+
checked: true,
87+
},
88+
{
89+
required: true,
90+
checked: true,
91+
},
92+
] as const;
93+
94+
const validator = new RadioValidator(() => states);
95+
const {validity, validationMessage} = validator.getValidity();
96+
expect(validity.valueMissing).withContext('valueMissing').toBeFalse();
97+
expect(validationMessage).withContext('validationMessage').toBe('');
98+
});
99+
100+
it('is valid when not required', () => {
101+
const states = [
102+
{
103+
required: false,
104+
checked: false,
105+
},
106+
{
107+
required: false,
108+
checked: false,
109+
},
110+
{
111+
required: false,
112+
checked: false,
113+
},
114+
] as const;
115+
116+
const validator = new RadioValidator(() => states);
117+
const {validity, validationMessage} = validator.getValidity();
118+
expect(validity.valueMissing).withContext('valueMissing').toBeFalse();
119+
expect(validationMessage).withContext('validationMessage').toBe('');
120+
});
121+
});

radio/internal/radio.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,15 @@ import '../../focus/md-focus-ring.js';
88
import '../../ripple/ripple.js';
99

1010
import {html, isServer, LitElement} from 'lit';
11-
import {property} from 'lit/decorators.js';
11+
import {property, query} from 'lit/decorators.js';
1212
import {classMap} from 'lit/directives/class-map.js';
1313

1414
import {isActivationClick} from '../../internal/controller/events.js';
15+
import {
16+
createValidator,
17+
getValidityAnchor,
18+
mixinConstraintValidation,
19+
} from '../../labs/behaviors/constraint-validation.js';
1520
import {
1621
internals,
1722
mixinElementInternals,
@@ -22,15 +27,16 @@ import {
2227
getFormValue,
2328
mixinFormAssociated,
2429
} from '../../labs/behaviors/form-associated.js';
30+
import {RadioValidator} from '../../labs/behaviors/validators/radio-validator.js';
2531

2632
import {SingleSelectionController} from './single-selection-controller.js';
2733

2834
const CHECKED = Symbol('checked');
2935
let maskId = 0;
3036

3137
// Separate variable needed for closure.
32-
const radioBaseClass = mixinFormAssociated(
33-
mixinElementInternals(mixinFocusable(LitElement)),
38+
const radioBaseClass = mixinConstraintValidation(
39+
mixinFormAssociated(mixinElementInternals(mixinFocusable(LitElement))),
3440
);
3541

3642
/**
@@ -66,11 +72,18 @@ export class Radio extends radioBaseClass {
6672

6773
[CHECKED] = false;
6874

75+
/**
76+
* Whether or not the radio is required. If any radio is required in a group,
77+
* all radios are implicitly required.
78+
*/
79+
@property({type: Boolean}) required = false;
80+
6981
/**
7082
* The element value to use in form submission when checked.
7183
*/
7284
@property() value = 'on';
7385

86+
@query('.container') private readonly container!: HTMLElement;
7487
private readonly selectionController = new SingleSelectionController(this);
7588

7689
constructor() {
@@ -175,4 +188,20 @@ export class Radio extends radioBaseClass {
175188
override formStateRestoreCallback(state: string) {
176189
this.checked = state === 'true';
177190
}
191+
192+
[createValidator]() {
193+
return new RadioValidator(() => {
194+
if (!this.selectionController) {
195+
// Validation runs on superclass construction, so selection controller
196+
// might not actually be ready until this class constructs.
197+
return [this];
198+
}
199+
200+
return this.selectionController.controls as [Radio, ...Radio[]];
201+
});
202+
}
203+
204+
[getValidityAnchor]() {
205+
return this.container;
206+
}
178207
}

radio/internal/single-selection-controller.ts

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,23 @@ export interface SingleSelectionElement extends HTMLElement {
5151
* }
5252
*/
5353
export class SingleSelectionController implements ReactiveController {
54+
/**
55+
* All single selection elements in the host element's root with the same
56+
* `name` attribute, including the host element.
57+
*/
58+
get controls(): [SingleSelectionElement, ...SingleSelectionElement[]] {
59+
const name = this.host.getAttribute('name');
60+
if (!name || !this.root || !this.host.isConnected) {
61+
return [this.host];
62+
}
63+
64+
// Cast as unknown since there is not enough information for typescript to
65+
// know that there is always at least one element (the host).
66+
return Array.from(
67+
this.root.querySelectorAll<SingleSelectionElement>(`[name="${name}"]`),
68+
) as unknown as [SingleSelectionElement, ...SingleSelectionElement[]];
69+
}
70+
5471
private focused = false;
5572
private root: ParentNode | null = null;
5673

@@ -104,7 +121,7 @@ export class SingleSelectionController implements ReactiveController {
104121
};
105122

106123
private uncheckSiblings() {
107-
for (const sibling of this.getNamedSiblings()) {
124+
for (const sibling of this.controls) {
108125
if (sibling !== this.host) {
109126
sibling.checked = false;
110127
}
@@ -117,7 +134,7 @@ export class SingleSelectionController implements ReactiveController {
117134
private updateTabIndices() {
118135
// There are three tabindex states for a group of elements:
119136
// 1. If any are checked, that element is focusable.
120-
const siblings = this.getNamedSiblings();
137+
const siblings = this.controls;
121138
const checkedSibling = siblings.find((sibling) => sibling.checked);
122139
// 2. If an element is focused, the others are no longer focusable.
123140
if (checkedSibling || this.focused) {
@@ -138,21 +155,6 @@ export class SingleSelectionController implements ReactiveController {
138155
}
139156
}
140157

141-
/**
142-
* Retrieves all siblings in the host element's root with the same `name`
143-
* attribute.
144-
*/
145-
private getNamedSiblings() {
146-
const name = this.host.getAttribute('name');
147-
if (!name || !this.root) {
148-
return [];
149-
}
150-
151-
return Array.from(
152-
this.root.querySelectorAll<SingleSelectionElement>(`[name="${name}"]`),
153-
);
154-
}
155-
156158
/**
157159
* Handles arrow key events from the host. Using the arrow keys will
158160
* select and check the next or previous sibling with the host's
@@ -169,7 +171,7 @@ export class SingleSelectionController implements ReactiveController {
169171
}
170172

171173
// Don't try to select another sibling if there aren't any.
172-
const siblings = this.getNamedSiblings();
174+
const siblings = this.controls;
173175
if (!siblings.length) {
174176
return;
175177
}

0 commit comments

Comments
 (0)