Skip to content

Commit d37988b

Browse files
asynclizcopybara-github
authored andcommitted
feat(labs): add radio utility class component
PiperOrigin-RevId: 897824405
1 parent 0c8986c commit d37988b

7 files changed

Lines changed: 544 additions & 5 deletions

File tree

labs/gb/components/checkbox/md-checkbox.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,14 @@ export class Checkbox extends baseClass {
114114

115115
@query('input', true)
116116
private readonly input!: HTMLInputElement | null;
117-
private dirty = false;
117+
/**
118+
* Mimics the behavior of <input> dirty checkedness, where the `checked`
119+
* attribute only updates the checked state if the checkbox has not been
120+
* interacted with.
121+
*
122+
* @see https://html.spec.whatwg.org/multipage/input.html#concept-input-checked-dirty-flag
123+
*/
124+
private dirtyCheckedness = false;
118125

119126
protected override render() {
120127
// Needed for closure conformance
@@ -137,15 +144,15 @@ export class Checkbox extends baseClass {
137144
}
138145

139146
private handleInput(event: Event) {
140-
this.dirty = true;
147+
this.dirtyCheckedness = true;
141148
const target = event.target as HTMLInputElement;
142149
this.checked = target.checked;
143150
this.indeterminate = target.indeterminate;
144151
// <input> 'input' event bubbles and is composed, don't re-dispatch it.
145152
}
146153

147154
private handleChange(event: Event) {
148-
this.dirty = true;
155+
this.dirtyCheckedness = true;
149156
// <input> 'change' event is not composed, re-dispatch it.
150157
redispatchEvent(this, event);
151158
}
@@ -155,7 +162,7 @@ export class Checkbox extends baseClass {
155162
oldValue: string | null,
156163
newValue: string | null,
157164
) {
158-
if (name === 'checked' && this.dirty) {
165+
if (name === 'checked' && this.dirtyCheckedness) {
159166
// The 'checked' attribute does not update checkboxes that have been
160167
// interacted with.
161168
return;
@@ -173,7 +180,7 @@ export class Checkbox extends baseClass {
173180
}
174181

175182
override formResetCallback() {
176-
this.dirty = false;
183+
this.dirtyCheckedness = false;
177184
this.checked = this.defaultChecked;
178185
}
179186

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
//
2+
// Copyright 2026 Google LLC
3+
// SPDX-License-Identifier: Apache-2.0
4+
//
5+
6+
@mixin root {
7+
--icon-color: var(--md-sys-color-on-surface-variant);
8+
--icon-size: 20px;
9+
--state-layer-color: var(--md-sys-color-on-surface);
10+
--state-layer-shape: 50%;
11+
--state-layer-size: 40px;
12+
}
13+
14+
@mixin hovered {
15+
--icon-color: var(--md-sys-color-on-surface);
16+
}
17+
18+
@mixin focused {
19+
--icon-color: var(--md-sys-color-on-surface);
20+
}
21+
22+
@mixin pressed {
23+
--state-layer-color: var(--md-sys-color-primary);
24+
}
25+
26+
@mixin selected {
27+
--icon-color: var(--md-sys-color-primary);
28+
--state-layer-color: var(--md-sys-color-primary);
29+
}
30+
31+
@mixin selected-pressed {
32+
--state-layer-color: var(--md-sys-color-on-surface);
33+
}
34+
35+
@mixin disabled {
36+
--icon-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%);
37+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import './material-collection.js';
8+
import './index.js';
9+
10+
import {
11+
KnobTypesToKnobs,
12+
MaterialCollection,
13+
materialInitsToStoryInits,
14+
setUpDemo,
15+
} from './material-collection.js';
16+
import {boolInput, Knob} from './index.js';
17+
18+
import {stories, StoryKnobs} from './stories.js';
19+
20+
const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
21+
'Radio',
22+
[
23+
new Knob('disabled', {
24+
ui: boolInput(),
25+
}),
26+
],
27+
);
28+
29+
collection.addStories(...materialInitsToStoryInits(stories));
30+
31+
setUpDemo(collection, {fonts: 'roboto', icons: 'material-symbols'});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import '@material/web/labs/gb/components/radio/md-radio.js';
8+
9+
import {MaterialStoryInit} from './material-collection.js';
10+
import {adoptStyles} from '@material/web/labs/gb/styles/adopt-styles.js';
11+
import {css, html} from 'lit';
12+
13+
import {styles as m3Styles} from '@material/web/labs/gb/styles/m3.css' with {type: 'css'}; // github-only
14+
// import {styles as m3Styles} from '@material/web/labs/gb/styles/m3.cssresult.js'; // google3-only
15+
16+
/** Knob types for radio stories. */
17+
export interface StoryKnobs {
18+
disabled: boolean;
19+
}
20+
21+
adoptStyles(document, [
22+
m3Styles,
23+
css`
24+
:root {
25+
--md-icon-font: 'Material Symbols Outlined';
26+
}
27+
`,
28+
]);
29+
30+
const playground: MaterialStoryInit<StoryKnobs> = {
31+
name: 'Playground',
32+
render(knobs) {
33+
return html`
34+
<md-radio name="group" ?disabled=${knobs.disabled}></md-radio>
35+
<md-radio name="group" ?disabled=${knobs.disabled}></md-radio>
36+
<md-radio name="group" ?disabled=${knobs.disabled}></md-radio>
37+
`;
38+
},
39+
};
40+
41+
/** Radio stories. */
42+
export const stories = [playground];
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {
8+
afterDispatch,
9+
setupDispatchHooks,
10+
} from '@material/web/internal/events/dispatch-hooks.js';
11+
import {
12+
createValidator,
13+
getValidityAnchor,
14+
mixinConstraintValidation,
15+
} from '@material/web/labs/behaviors/constraint-validation.js';
16+
import {
17+
internals,
18+
mixinElementInternals,
19+
} from '@material/web/labs/behaviors/element-internals.js';
20+
import {mixinFocusable} from '@material/web/labs/behaviors/focusable.js';
21+
import {
22+
getFormState,
23+
getFormValue,
24+
mixinFormAssociated,
25+
} from '@material/web/labs/behaviors/form-associated.js';
26+
import {RadioValidator} from '@material/web/labs/behaviors/validators/radio-validator.js';
27+
import {SingleSelectionController} from '@material/web/radio/internal/single-selection-controller.js';
28+
import {css, CSSResultOrNative, html, isServer, LitElement} from 'lit';
29+
import {customElement, property, query, state} from 'lit/decorators.js';
30+
31+
import focusRingStyles from '@material/web/labs/gb/components/focus/focus-ring.css' with {type: 'css'}; // github-only
32+
// import focusRingStyles from '@material/web/labs/gb/components/focus/focus-ring.cssresult.js'; // google3-only
33+
import rippleStyles from '@material/web/labs/gb/components/ripple/ripple.css' with {type: 'css'}; // github-only
34+
// import rippleStyles from '@material/web/labs/gb/components/ripple/ripple.cssresult.js'; // google3-only
35+
import radioStyles from './radio.css' with {type: 'css'}; // github-only
36+
// import {styles as radioStyles} from './radio.cssresult.js'; // google3-only
37+
38+
import {radio} from './radio.js';
39+
40+
declare global {
41+
interface HTMLElementTagNameMap {
42+
/** A Material Design radio component. */
43+
'md-radio': Radio;
44+
}
45+
}
46+
47+
// Separate variable needed for closure.
48+
const radioBaseClass = mixinConstraintValidation(
49+
mixinFormAssociated(mixinElementInternals(mixinFocusable(LitElement))),
50+
);
51+
52+
/**
53+
* A Material Design radio component.
54+
*/
55+
@customElement('md-radio')
56+
export class Radio extends radioBaseClass {
57+
static override styles: CSSResultOrNative[] = [
58+
focusRingStyles,
59+
rippleStyles,
60+
radioStyles,
61+
css`
62+
:host {
63+
display: inline-flex;
64+
outline: none;
65+
}
66+
.radio {
67+
flex: 1;
68+
}
69+
`,
70+
];
71+
72+
/**
73+
* Whether or not the radio is selected.
74+
*/
75+
@property({type: Boolean})
76+
get checked() {
77+
return this[internals].ariaChecked === 'true';
78+
}
79+
set checked(checked: boolean) {
80+
const wasChecked = this.checked;
81+
if (wasChecked === checked) {
82+
return;
83+
}
84+
85+
this[internals].ariaChecked = String(checked);
86+
this.requestUpdate('checked', wasChecked);
87+
this.selectionController.handleCheckedChange();
88+
}
89+
90+
/**
91+
* The default checked state of the radio.
92+
*/
93+
get defaultChecked(): boolean {
94+
return this.hasAttribute('checked');
95+
}
96+
set defaultChecked(value: boolean) {
97+
this.toggleAttribute('checked', value || false);
98+
}
99+
100+
/**
101+
* Whether or not the radio is required. If any radio is required in a group,
102+
* all radios are implicitly required.
103+
*/
104+
@property({type: Boolean}) required = false;
105+
106+
/**
107+
* The element value to use in form submission when checked.
108+
*/
109+
@property() value = 'on';
110+
111+
@query('.radio', true) private readonly radio!: HTMLElement;
112+
private readonly selectionController = new SingleSelectionController(this);
113+
/**
114+
* Mimics the behavior of <input> dirty checkedness, where the `checked`
115+
* attribute only updates the checked state if the radio has not been
116+
* interacted with.
117+
*
118+
* @see https://html.spec.whatwg.org/multipage/input.html#concept-input-checked-dirty-flag
119+
*/
120+
private dirtyCheckedness = false;
121+
@state() private isFocused = false;
122+
123+
constructor() {
124+
super();
125+
if (isServer) return;
126+
this[internals].role = 'radio';
127+
this.addController(this.selectionController);
128+
setupDispatchHooks(this, 'click', 'keydown');
129+
this.addEventListener('click', (event) => {
130+
// Return if disabled, or already checked since clicking on a checked
131+
// radio does not dispatch events.
132+
if (this.disabled || this.checked) return;
133+
afterDispatch(event, () => {
134+
if (event.defaultPrevented) return;
135+
// Per spec, clicking on a radio input always selects it.
136+
this.checked = true;
137+
this.dirtyCheckedness = true;
138+
this.dispatchEvent(new Event('change', {bubbles: true}));
139+
this.dispatchEvent(
140+
new InputEvent('input', {bubbles: true, composed: true}),
141+
);
142+
});
143+
});
144+
145+
this.addEventListener('keydown', (event) => {
146+
afterDispatch(event, () => {
147+
if (event.key !== ' ' || event.defaultPrevented) {
148+
return;
149+
}
150+
151+
this.click();
152+
});
153+
});
154+
155+
this.addEventListener('focus', () => {
156+
this.isFocused = true;
157+
});
158+
159+
this.addEventListener('blur', () => {
160+
this.isFocused = false;
161+
});
162+
}
163+
164+
protected override render() {
165+
return html`<div
166+
part="radio"
167+
class="ripple-host focus-ring-host ${radio({
168+
checked: this.checked,
169+
disabled: this.disabled,
170+
focus: this.isFocused,
171+
})}"></div>`;
172+
}
173+
174+
override attributeChangedCallback(
175+
name: string,
176+
oldValue: string | null,
177+
newValue: string | null,
178+
) {
179+
if (name === 'checked' && this.dirtyCheckedness) {
180+
// The 'checked' attribute does not update radios that have been
181+
// interacted with.
182+
return;
183+
}
184+
185+
super.attributeChangedCallback(name, oldValue, newValue);
186+
}
187+
188+
override [getFormValue]() {
189+
return this.checked ? this.value : null;
190+
}
191+
192+
override [getFormState]() {
193+
return String(this.checked);
194+
}
195+
196+
override formResetCallback() {
197+
this.dirtyCheckedness = false;
198+
this.checked = this.defaultChecked;
199+
}
200+
201+
override formStateRestoreCallback(state: string) {
202+
this.checked = state === 'true';
203+
}
204+
205+
override [createValidator]() {
206+
return new RadioValidator(() => {
207+
if (!this.selectionController) {
208+
// Validation runs on superclass construction, so selection controller
209+
// might not actually be ready until this class constructs.
210+
return [this];
211+
}
212+
213+
return this.selectionController.controls as [Radio, ...Radio[]];
214+
});
215+
}
216+
217+
override [getValidityAnchor]() {
218+
return this.radio;
219+
}
220+
}

0 commit comments

Comments
 (0)