Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 50c3e0e

Browse files
authoredMay 14, 2025··
feat(cdk-experimental/radio): create radio group and button directives (#31050)
* feat(cdk-experimental/radio): create radio group and button directives * fixup! feat(cdk-experimental/radio): create radio group and button directives * fixup! feat(cdk-experimental/radio): create radio group and button directives * fixup! feat(cdk-experimental/radio): create radio group and button directives * fixup! feat(cdk-experimental/radio): create radio group and button directives * fixup! feat(cdk-experimental/radio): create radio group and button directives * fixup! feat(cdk-experimental/radio): create radio group and button directives * fixup! feat(cdk-experimental/radio): create radio group and button directives * fixup! feat(cdk-experimental/radio): create radio group and button directives * fixup! feat(cdk-experimental/radio): create radio group and button directives
1 parent 31077c2 commit 50c3e0e

File tree

12 files changed

+916
-25
lines changed

12 files changed

+916
-25
lines changed
 

‎.ng-dev/commit-message.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const commitMessage: CommitMessageConfig = {
1313
'cdk-experimental/combobox',
1414
'cdk-experimental/listbox',
1515
'cdk-experimental/popover-edit',
16+
'cdk-experimental/radio',
1617
'cdk-experimental/scrolling',
1718
'cdk-experimental/selection',
1819
'cdk-experimental/table-scroll-container',

‎package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
"@types/shelljs": "^0.8.11",
105105
"@types/yargs": "^17.0.8",
106106
"autoprefixer": "^10.4.2",
107+
"axe-core": "^4.10.3",
107108
"chalk": "^4.1.0",
108109
"dgeni": "^0.4.14",
109110
"dgeni-packages": "^0.29.5",

‎pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/cdk-experimental/config.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ CDK_EXPERIMENTAL_ENTRYPOINTS = [
55
"deferred-content",
66
"listbox",
77
"popover-edit",
8+
"radio",
89
"scrolling",
910
"selection",
1011
"tabs",
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ng_project(
6+
name = "radio",
7+
srcs = glob(
8+
["**/*.ts"],
9+
exclude = ["**/*.spec.ts"],
10+
),
11+
deps = [
12+
"//:node_modules/@angular/core",
13+
"//src/cdk-experimental/ui-patterns",
14+
"//src/cdk/a11y",
15+
"//src/cdk/bidi",
16+
],
17+
)
18+
19+
ts_project(
20+
name = "unit_test_sources",
21+
testonly = True,
22+
srcs = glob(
23+
["**/*.spec.ts"],
24+
exclude = ["**/*.e2e.spec.ts"],
25+
),
26+
deps = [
27+
":radio",
28+
"//:node_modules/@angular/core",
29+
"//:node_modules/@angular/platform-browser",
30+
"//:node_modules/axe-core",
31+
"//src/cdk/testing/private",
32+
],
33+
)
34+
35+
ng_web_test_suite(
36+
name = "unit_tests",
37+
deps = [":unit_test_sources"],
38+
)

‎src/cdk-experimental/radio/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
export * from './public-api';
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
export {CdkRadioGroup, CdkRadioButton} from './radio';

‎src/cdk-experimental/radio/radio.spec.ts

Lines changed: 637 additions & 0 deletions
Large diffs are not rendered by default.

‎src/cdk-experimental/radio/radio.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {
10+
afterRenderEffect,
11+
booleanAttribute,
12+
computed,
13+
contentChildren,
14+
Directive,
15+
ElementRef,
16+
inject,
17+
input,
18+
linkedSignal,
19+
model,
20+
signal,
21+
WritableSignal,
22+
} from '@angular/core';
23+
import {RadioButtonPattern, RadioGroupPattern} from '../ui-patterns';
24+
import {Directionality} from '@angular/cdk/bidi';
25+
import {_IdGenerator} from '@angular/cdk/a11y';
26+
27+
// TODO: Move mapSignal to it's own file so it can be reused across components.
28+
29+
/**
30+
* Creates a new writable signal (signal V) whose value is connected to the given original
31+
* writable signal (signal T) such that updating signal V updates signal T and vice-versa.
32+
*
33+
* This function establishes a two-way synchronization between the source signal and the new mapped
34+
* signal. When the source signal changes, the mapped signal updates by applying the `transform`
35+
* function. When the mapped signal is explicitly set or updated, the change is propagated back to
36+
* the source signal by applying the `reverse` function.
37+
*/
38+
export function mapSignal<T, V>(
39+
originalSignal: WritableSignal<T>,
40+
operations: {
41+
transform: (value: T) => V;
42+
reverse: (value: V) => T;
43+
},
44+
) {
45+
const mappedSignal = linkedSignal(() => operations.transform(originalSignal()));
46+
const updateMappedSignal = mappedSignal.update;
47+
const setMappedSignal = mappedSignal.set;
48+
49+
mappedSignal.set = (newValue: V) => {
50+
setMappedSignal(newValue);
51+
originalSignal.set(operations.reverse(newValue));
52+
};
53+
54+
mappedSignal.update = (updateFn: (value: V) => V) => {
55+
updateMappedSignal(oldValue => updateFn(oldValue));
56+
originalSignal.update(oldValue => operations.reverse(updateFn(operations.transform(oldValue))));
57+
};
58+
59+
return mappedSignal;
60+
}
61+
62+
/**
63+
* A radio button group container.
64+
*
65+
* Radio groups are used to group multiple radio buttons or radio group labels so they function as
66+
* a single form control. The CdkRadioGroup is meant to be used in conjunction with CdkRadioButton
67+
* as follows:
68+
*
69+
* ```html
70+
* <div cdkRadioGroup>
71+
* <label cdkRadioButton value="1">Option 1</label>
72+
* <label cdkRadioButton value="2">Option 2</label>
73+
* <label cdkRadioButton value="3">Option 3</label>
74+
* </div>
75+
* ```
76+
*/
77+
@Directive({
78+
selector: '[cdkRadioGroup]',
79+
exportAs: 'cdkRadioGroup',
80+
host: {
81+
'role': 'radiogroup',
82+
'class': 'cdk-radio-group',
83+
'[attr.tabindex]': 'pattern.tabindex()',
84+
'[attr.aria-readonly]': 'pattern.readonly()',
85+
'[attr.aria-disabled]': 'pattern.disabled()',
86+
'[attr.aria-orientation]': 'pattern.orientation()',
87+
'[attr.aria-activedescendant]': 'pattern.activedescendant()',
88+
'(keydown)': 'pattern.onKeydown($event)',
89+
'(pointerdown)': 'pattern.onPointerdown($event)',
90+
'(focusin)': 'onFocus()',
91+
},
92+
})
93+
export class CdkRadioGroup<V> {
94+
/** The CdkRadioButtons nested inside of the CdkRadioGroup. */
95+
private readonly _cdkRadioButtons = contentChildren(CdkRadioButton, {descendants: true});
96+
97+
/** A signal wrapper for directionality. */
98+
protected textDirection = inject(Directionality).valueSignal;
99+
100+
/** The RadioButton UIPatterns of the child CdkRadioButtons. */
101+
protected items = computed(() => this._cdkRadioButtons().map(radio => radio.pattern));
102+
103+
/** Whether the radio group is vertically or horizontally oriented. */
104+
orientation = input<'vertical' | 'horizontal'>('horizontal');
105+
106+
/** Whether disabled items in the group should be skipped when navigating. */
107+
skipDisabled = input(true, {transform: booleanAttribute});
108+
109+
/** The focus strategy used by the radio group. */
110+
focusMode = input<'roving' | 'activedescendant'>('roving');
111+
112+
/** Whether the radio group is disabled. */
113+
disabled = input(false, {transform: booleanAttribute});
114+
115+
/** Whether the radio group is readonly. */
116+
readonly = input(false, {transform: booleanAttribute});
117+
118+
/** The value of the currently selected radio button. */
119+
value = model<V | null>(null);
120+
121+
/** The internal selection state for the radio group. */
122+
private readonly _value = mapSignal<V | null, V[]>(this.value, {
123+
transform: value => (value !== null ? [value] : []),
124+
reverse: values => (values.length === 0 ? null : values[0]),
125+
});
126+
127+
/** The RadioGroup UIPattern. */
128+
pattern: RadioGroupPattern<V> = new RadioGroupPattern<V>({
129+
...this,
130+
items: this.items,
131+
value: this._value,
132+
activeIndex: signal(0),
133+
textDirection: this.textDirection,
134+
});
135+
136+
/** Whether the radio group has received focus yet. */
137+
private _hasFocused = signal(false);
138+
139+
constructor() {
140+
afterRenderEffect(() => {
141+
if (!this._hasFocused()) {
142+
this.pattern.setDefaultState();
143+
}
144+
});
145+
}
146+
147+
onFocus() {
148+
this._hasFocused.set(true);
149+
}
150+
}
151+
152+
/** A selectable radio button in a CdkRadioGroup. */
153+
@Directive({
154+
selector: '[cdkRadioButton]',
155+
exportAs: 'cdkRadioButton',
156+
host: {
157+
'role': 'radio',
158+
'class': 'cdk-radio-button',
159+
'[class.cdk-active]': 'pattern.active()',
160+
'[attr.tabindex]': 'pattern.tabindex()',
161+
'[attr.aria-checked]': 'pattern.selected()',
162+
'[attr.aria-disabled]': 'pattern.disabled()',
163+
'[id]': 'pattern.id()',
164+
},
165+
})
166+
export class CdkRadioButton<V> {
167+
/** A reference to the radio button element. */
168+
private readonly _elementRef = inject(ElementRef);
169+
170+
/** The parent CdkRadioGroup. */
171+
private readonly _cdkRadioGroup = inject(CdkRadioGroup);
172+
173+
/** A unique identifier for the radio button. */
174+
private readonly _generatedId = inject(_IdGenerator).getId('cdk-radio-button-');
175+
176+
/** A unique identifier for the radio button. */
177+
protected id = computed(() => this._generatedId);
178+
179+
/** The value associated with the radio button. */
180+
protected value = input.required<V>();
181+
182+
/** The parent RadioGroup UIPattern. */
183+
protected group = computed(() => this._cdkRadioGroup.pattern);
184+
185+
/** A reference to the radio button element to be focused on navigation. */
186+
protected element = computed(() => this._elementRef.nativeElement);
187+
188+
/** Whether the radio button is disabled. */
189+
disabled = input(false, {transform: booleanAttribute});
190+
191+
/** The RadioButton UIPattern. */
192+
pattern = new RadioButtonPattern<V>({
193+
...this,
194+
id: this.id,
195+
value: this.value,
196+
group: this.group,
197+
element: this.element,
198+
});
199+
}

‎src/cdk-experimental/ui-patterns/radio/radio-group.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {computed, signal} from '@angular/core';
9+
import {computed} from '@angular/core';
1010
import {KeyboardEventManager} from '../behaviors/event-manager/keyboard-event-manager';
1111
import {PointerEventManager} from '../behaviors/event-manager/pointer-event-manager';
1212
import {ListFocus, ListFocusInputs} from '../behaviors/list-focus/list-focus';
@@ -21,7 +21,7 @@ interface SelectOptions {
2121
}
2222

2323
/** Represents the required inputs for a radio group. */
24-
export type RadioGroupInputs<V> = ListNavigationInputs<RadioButtonPattern<V>> &
24+
export type RadioGroupInputs<V> = Omit<ListNavigationInputs<RadioButtonPattern<V>>, 'wrap'> &
2525
// Radio groups are always single-select.
2626
Omit<ListSelectionInputs<RadioButtonPattern<V>, V>, 'multi' | 'selectionMode'> &
2727
ListFocusInputs<RadioButtonPattern<V>> & {
@@ -115,12 +115,15 @@ export class RadioGroupPattern<V> {
115115
this.orientation = inputs.orientation;
116116

117117
this.focusManager = new ListFocus(inputs);
118-
this.navigation = new ListNavigation({...inputs, focusManager: this.focusManager});
118+
this.navigation = new ListNavigation({
119+
...inputs,
120+
wrap: () => false,
121+
focusManager: this.focusManager,
122+
});
119123
this.selection = new ListSelection({
120124
...inputs,
121-
// Radio groups are always single-select and selection follows focus.
122-
multi: signal(false),
123-
selectionMode: signal('follow'),
125+
multi: () => false,
126+
selectionMode: () => 'follow',
124127
focusManager: this.focusManager,
125128
});
126129
}

‎src/cdk-experimental/ui-patterns/radio/radio.spec.ts

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ describe('RadioGroup Pattern', () => {
3333
items: inputs.items,
3434
value: inputs.value ?? signal([]),
3535
activeIndex: inputs.activeIndex ?? signal(0),
36-
wrap: inputs.wrap ?? signal(true),
3736
readonly: inputs.readonly ?? signal(false),
3837
disabled: inputs.disabled ?? signal(false),
3938
skipDisabled: inputs.skipDisabled ?? signal(true),
@@ -137,23 +136,6 @@ describe('RadioGroup Pattern', () => {
137136
expect(radioGroup.inputs.activeIndex()).toBe(4);
138137
});
139138

140-
it('should wrap navigation when wrap is true', () => {
141-
const {radioGroup} = getDefaultPatterns({wrap: signal(true)});
142-
radioGroup.onKeydown(up());
143-
expect(radioGroup.inputs.activeIndex()).toBe(4);
144-
radioGroup.onKeydown(down());
145-
expect(radioGroup.inputs.activeIndex()).toBe(0);
146-
});
147-
148-
it('should not wrap navigation when wrap is false', () => {
149-
const {radioGroup} = getDefaultPatterns({wrap: signal(false)});
150-
radioGroup.onKeydown(up());
151-
expect(radioGroup.inputs.activeIndex()).toBe(0);
152-
radioGroup.onKeydown(end());
153-
radioGroup.onKeydown(down());
154-
expect(radioGroup.inputs.activeIndex()).toBe(4);
155-
});
156-
157139
it('should skip disabled radios when skipDisabled is true', () => {
158140
const {radioGroup, radioButtons} = getDefaultPatterns({skipDisabled: signal(true)});
159141
radioButtons[1].disabled.set(true);

‎src/cdk-experimental/ui-patterns/radio/radio.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ export class RadioButtonPattern<V> {
5151
active = computed(() => this.group()?.focusManager.activeItem() === this);
5252

5353
/** Whether the radio button is selected. */
54-
selected = computed(() => this.group()?.selection.inputs.value().includes(this.value()));
54+
selected: SignalLike<boolean> = computed(
55+
() => !!this.group()?.selection.inputs.value().includes(this.value()),
56+
);
5557

5658
/** Whether the radio button is disabled. */
5759
disabled: SignalLike<boolean>;

0 commit comments

Comments
 (0)
Please sign in to comment.