diff --git a/labs/behaviors/validators/checkbox-validator.ts b/labs/behaviors/validators/checkbox-validator.ts
index c0f9a2d3712..a2b4b39ef7d 100644
--- a/labs/behaviors/validators/checkbox-validator.ts
+++ b/labs/behaviors/validators/checkbox-validator.ts
@@ -13,12 +13,12 @@ export interface CheckboxState {
/**
* Whether the checkbox is checked.
*/
- checked: boolean;
+ readonly checked: boolean;
/**
* Whether the checkbox is required.
*/
- required: boolean;
+ readonly required: boolean;
}
/**
diff --git a/labs/behaviors/validators/radio-validator.ts b/labs/behaviors/validators/radio-validator.ts
new file mode 100644
index 00000000000..0aba630f9a8
--- /dev/null
+++ b/labs/behaviors/validators/radio-validator.ts
@@ -0,0 +1,90 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {html, render} from 'lit';
+
+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 root?: HTMLElement;
+
+ protected override computeValidity(states: RadioGroupState) {
+ if (!this.root) {
+ // Lazily create the container element
+ this.root = document.createElement('div');
+ }
+
+ render(
+ states.map(
+ (state) =>
+ html``,
+ ),
+ this.root,
+ );
+
+ const control = this.root.firstElementChild as HTMLInputElement;
+ return {
+ validity: control.validity,
+ validationMessage: control.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 00000000000..ddec3503694
--- /dev/null
+++ b/labs/behaviors/validators/radio-validator_test.ts
@@ -0,0 +1,77 @@
+/**
+ * @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 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 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 bf453be5cb9..c1fe0895b9a 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,17 @@ export class Radio extends radioBaseClass {
[CHECKED] = false;
+ /**
+ * Whether or not the radio is disabled.
+ */
+ @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 +187,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 e87644583ff..e89df2d07ca 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) {
+ 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;
}