Skip to content

Commit 9f249d0

Browse files
authored
feat(cdk-experimental/ui-patterns): radio button and group (angular#31016)
* feat(cdk-experimental/ui-patterns): radio button and group * fixup! feat(cdk-experimental/ui-patterns): radio button and group
1 parent 78f15b1 commit 9f249d0

File tree

6 files changed

+645
-0
lines changed

6 files changed

+645
-0
lines changed

src/cdk-experimental/ui-patterns/BUILD.bazel

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ ts_project(
1212
"//:node_modules/@angular/core",
1313
"//src/cdk-experimental/ui-patterns/behaviors/signal-like",
1414
"//src/cdk-experimental/ui-patterns/listbox",
15+
"//src/cdk-experimental/ui-patterns/radio",
1516
"//src/cdk-experimental/ui-patterns/tabs",
1617
],
1718
)

src/cdk-experimental/ui-patterns/public-api.ts

+2
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,7 @@
88

99
export * from './listbox/listbox';
1010
export * from './listbox/option';
11+
export * from './radio/radio-group';
12+
export * from './radio/radio';
1113
export * from './behaviors/signal-like/signal-like';
1214
export * from './tabs/tabs';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ts_project(
6+
name = "radio",
7+
srcs = [
8+
"radio.ts",
9+
"radio-group.ts",
10+
],
11+
deps = [
12+
"//:node_modules/@angular/core",
13+
"//src/cdk-experimental/ui-patterns/behaviors/event-manager",
14+
"//src/cdk-experimental/ui-patterns/behaviors/list-focus",
15+
"//src/cdk-experimental/ui-patterns/behaviors/list-navigation",
16+
"//src/cdk-experimental/ui-patterns/behaviors/list-selection",
17+
"//src/cdk-experimental/ui-patterns/behaviors/signal-like",
18+
],
19+
)
20+
21+
ts_project(
22+
name = "unit_test_sources",
23+
testonly = True,
24+
srcs = glob(["**/*.spec.ts"]),
25+
deps = [
26+
":radio",
27+
"//:node_modules/@angular/core",
28+
"//src/cdk/keycodes",
29+
"//src/cdk/testing/private",
30+
],
31+
)
32+
33+
ng_web_test_suite(
34+
name = "unit_tests",
35+
deps = [":unit_test_sources"],
36+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
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 {computed, signal} from '@angular/core';
10+
import {KeyboardEventManager} from '../behaviors/event-manager/keyboard-event-manager';
11+
import {PointerEventManager} from '../behaviors/event-manager/pointer-event-manager';
12+
import {ListFocus, ListFocusInputs} from '../behaviors/list-focus/list-focus';
13+
import {ListNavigation, ListNavigationInputs} from '../behaviors/list-navigation/list-navigation';
14+
import {ListSelection, ListSelectionInputs} from '../behaviors/list-selection/list-selection';
15+
import {SignalLike} from '../behaviors/signal-like/signal-like';
16+
import {RadioButtonPattern} from './radio';
17+
18+
/** The selection operations that the radio group can perform. */
19+
interface SelectOptions {
20+
selectOne?: boolean;
21+
}
22+
23+
/** Represents the required inputs for a radio group. */
24+
export type RadioGroupInputs<V> = ListNavigationInputs<RadioButtonPattern<V>> &
25+
// Radio groups are always single-select.
26+
Omit<ListSelectionInputs<RadioButtonPattern<V>, V>, 'multi' | 'selectionMode'> &
27+
ListFocusInputs<RadioButtonPattern<V>> & {
28+
/** Whether the radio group is disabled. */
29+
disabled: SignalLike<boolean>;
30+
/** Whether the radio group is readonly. */
31+
readonly: SignalLike<boolean>;
32+
};
33+
34+
/** Controls the state of a radio group. */
35+
export class RadioGroupPattern<V> {
36+
/** Controls navigation for the radio group. */
37+
navigation: ListNavigation<RadioButtonPattern<V>>;
38+
39+
/** Controls selection for the radio group. */
40+
selection: ListSelection<RadioButtonPattern<V>, V>;
41+
42+
/** Controls focus for the radio group. */
43+
focusManager: ListFocus<RadioButtonPattern<V>>;
44+
45+
/** Whether the radio group is vertically or horizontally oriented. */
46+
orientation: SignalLike<'vertical' | 'horizontal'>;
47+
48+
/** Whether the radio group is disabled. */
49+
disabled = computed(() => this.inputs.disabled() || this.focusManager.isListDisabled());
50+
51+
/** Whether the radio group is readonly. */
52+
readonly: SignalLike<boolean>;
53+
54+
/** The tabindex of the radio group (if using activedescendant). */
55+
tabindex = computed(() => this.focusManager.getListTabindex());
56+
57+
/** The id of the current active radio button (if using activedescendant). */
58+
activedescendant = computed(() => this.focusManager.getActiveDescendant());
59+
60+
/** The key used to navigate to the previous radio button. */
61+
prevKey = computed(() => {
62+
if (this.inputs.orientation() === 'vertical') {
63+
return 'ArrowUp';
64+
}
65+
return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
66+
});
67+
68+
/** The key used to navigate to the next radio button. */
69+
nextKey = computed(() => {
70+
if (this.inputs.orientation() === 'vertical') {
71+
return 'ArrowDown';
72+
}
73+
return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
74+
});
75+
76+
/** The keydown event manager for the radio group. */
77+
keydown = computed(() => {
78+
const manager = new KeyboardEventManager();
79+
80+
// Readonly mode allows navigation but not selection changes.
81+
if (this.readonly()) {
82+
return manager
83+
.on(this.prevKey, () => this.prev())
84+
.on(this.nextKey, () => this.next())
85+
.on('Home', () => this.first())
86+
.on('End', () => this.last());
87+
}
88+
89+
// Default behavior: navigate and select on arrow keys, home, end.
90+
// Space/Enter also select the focused item.
91+
return manager
92+
.on(this.prevKey, () => this.prev({selectOne: true}))
93+
.on(this.nextKey, () => this.next({selectOne: true}))
94+
.on('Home', () => this.first({selectOne: true}))
95+
.on('End', () => this.last({selectOne: true}))
96+
.on(' ', () => this.selection.selectOne())
97+
.on('Enter', () => this.selection.selectOne());
98+
});
99+
100+
/** The pointerdown event manager for the radio group. */
101+
pointerdown = computed(() => {
102+
const manager = new PointerEventManager();
103+
104+
if (this.readonly()) {
105+
// Navigate focus only in readonly mode.
106+
return manager.on(e => this.goto(e));
107+
}
108+
109+
// Default behavior: navigate and select on click.
110+
return manager.on(e => this.goto(e, {selectOne: true}));
111+
});
112+
113+
constructor(readonly inputs: RadioGroupInputs<V>) {
114+
this.readonly = inputs.readonly;
115+
this.orientation = inputs.orientation;
116+
117+
this.focusManager = new ListFocus(inputs);
118+
this.navigation = new ListNavigation({...inputs, focusManager: this.focusManager});
119+
this.selection = new ListSelection({
120+
...inputs,
121+
// Radio groups are always single-select and selection follows focus.
122+
multi: signal(false),
123+
selectionMode: signal('follow'),
124+
focusManager: this.focusManager,
125+
});
126+
}
127+
128+
/** Handles keydown events for the radio group. */
129+
onKeydown(event: KeyboardEvent) {
130+
if (!this.disabled()) {
131+
this.keydown().handle(event);
132+
}
133+
}
134+
135+
/** Handles pointerdown events for the radio group. */
136+
onPointerdown(event: PointerEvent) {
137+
if (!this.disabled()) {
138+
this.pointerdown().handle(event);
139+
}
140+
}
141+
142+
/** Navigates to the first enabled radio button in the group. */
143+
first(opts?: SelectOptions) {
144+
this._navigate(opts, () => this.navigation.first());
145+
}
146+
147+
/** Navigates to the last enabled radio button in the group. */
148+
last(opts?: SelectOptions) {
149+
this._navigate(opts, () => this.navigation.last());
150+
}
151+
152+
/** Navigates to the next enabled radio button in the group. */
153+
next(opts?: SelectOptions) {
154+
this._navigate(opts, () => this.navigation.next());
155+
}
156+
157+
/** Navigates to the previous enabled radio button in the group. */
158+
prev(opts?: SelectOptions) {
159+
this._navigate(opts, () => this.navigation.prev());
160+
}
161+
162+
/** Navigates to the radio button associated with the given pointer event. */
163+
goto(event: PointerEvent, opts?: SelectOptions) {
164+
const item = this._getItem(event);
165+
this._navigate(opts, () => this.navigation.goto(item));
166+
}
167+
168+
/**
169+
* Sets the radio group to its default initial state.
170+
*
171+
* Sets the active index to the selected radio button if one exists and is focusable.
172+
* Otherwise, sets the active index to the first focusable radio button.
173+
*/
174+
setDefaultState() {
175+
let firstItem: RadioButtonPattern<V> | null = null;
176+
177+
for (const item of this.inputs.items()) {
178+
if (this.focusManager.isFocusable(item)) {
179+
if (!firstItem) {
180+
firstItem = item;
181+
}
182+
if (item.selected()) {
183+
this.inputs.activeIndex.set(item.index());
184+
return;
185+
}
186+
}
187+
}
188+
189+
if (firstItem) {
190+
this.inputs.activeIndex.set(firstItem.index());
191+
}
192+
}
193+
194+
/** Safely performs a navigation operation and updates selection if needed. */
195+
private _navigate(opts: SelectOptions = {}, operation: () => boolean) {
196+
const moved = operation();
197+
if (moved && opts.selectOne) {
198+
this.selection.selectOne();
199+
}
200+
}
201+
202+
/** Finds the RadioButtonPattern associated with a pointer event target. */
203+
private _getItem(e: PointerEvent): RadioButtonPattern<V> | undefined {
204+
if (!(e.target instanceof HTMLElement)) {
205+
return undefined;
206+
}
207+
208+
// Assumes the target or its ancestor has role="radio"
209+
const element = e.target.closest('[role="radio"]');
210+
return this.inputs.items().find(i => i.element() === element);
211+
}
212+
}

0 commit comments

Comments
 (0)