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 9f249d0

Browse files
authoredMay 5, 2025··
feat(cdk-experimental/ui-patterns): radio button and group (#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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 2 additions & 0 deletions
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';
Lines changed: 36 additions & 0 deletions
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+
)
Lines changed: 212 additions & 0 deletions
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+
}
Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
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 {signal, WritableSignal} from '@angular/core';
10+
import {RadioGroupInputs, RadioGroupPattern} from './radio-group';
11+
import {RadioButtonPattern} from './radio';
12+
import {createKeyboardEvent} from '@angular/cdk/testing/private';
13+
import {ModifierKeys} from '@angular/cdk/testing';
14+
15+
type TestInputs = RadioGroupInputs<string>;
16+
type TestRadio = RadioButtonPattern<string> & {
17+
disabled: WritableSignal<boolean>;
18+
};
19+
type TestRadioGroup = RadioGroupPattern<string>;
20+
21+
const up = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 38, 'ArrowUp', mods);
22+
const down = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 40, 'ArrowDown', mods);
23+
const left = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 37, 'ArrowLeft', mods);
24+
const right = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 39, 'ArrowRight', mods);
25+
const home = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 36, 'Home', mods);
26+
const end = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 35, 'End', mods);
27+
const space = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 32, ' ', mods);
28+
const enter = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 13, 'Enter', mods);
29+
30+
describe('RadioGroup Pattern', () => {
31+
function getRadioGroup(inputs: Partial<TestInputs> & Pick<TestInputs, 'items'>) {
32+
return new RadioGroupPattern({
33+
items: inputs.items,
34+
value: inputs.value ?? signal([]),
35+
activeIndex: inputs.activeIndex ?? signal(0),
36+
wrap: inputs.wrap ?? signal(true),
37+
readonly: inputs.readonly ?? signal(false),
38+
disabled: inputs.disabled ?? signal(false),
39+
skipDisabled: inputs.skipDisabled ?? signal(true),
40+
focusMode: inputs.focusMode ?? signal('roving'),
41+
textDirection: inputs.textDirection ?? signal('ltr'),
42+
orientation: inputs.orientation ?? signal('vertical'),
43+
});
44+
}
45+
46+
function getRadios(radioGroup: TestRadioGroup, values: string[]): TestRadio[] {
47+
return values.map((value, index) => {
48+
const element = document.createElement('div');
49+
element.role = 'radio';
50+
return new RadioButtonPattern({
51+
value: signal(value),
52+
id: signal(`radio-${index}`),
53+
disabled: signal(false),
54+
group: signal(radioGroup),
55+
element: signal(element),
56+
});
57+
}) as TestRadio[];
58+
}
59+
60+
function getPatterns(values: string[], inputs: Partial<TestInputs> = {}) {
61+
const radioButtons = signal<TestRadio[]>([]);
62+
const radioGroup = getRadioGroup({...inputs, items: radioButtons});
63+
radioButtons.set(getRadios(radioGroup, values));
64+
return {radioGroup, radioButtons: radioButtons()};
65+
}
66+
67+
function getDefaultPatterns(inputs: Partial<TestInputs> = {}) {
68+
return getPatterns(['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry'], inputs);
69+
}
70+
71+
describe('Keyboard Navigation', () => {
72+
it('should navigate next on ArrowDown', () => {
73+
const {radioGroup} = getDefaultPatterns();
74+
expect(radioGroup.inputs.activeIndex()).toBe(0);
75+
radioGroup.onKeydown(down());
76+
expect(radioGroup.inputs.activeIndex()).toBe(1);
77+
});
78+
79+
it('should navigate prev on ArrowUp', () => {
80+
const {radioGroup} = getDefaultPatterns({activeIndex: signal(1)});
81+
expect(radioGroup.inputs.activeIndex()).toBe(1);
82+
radioGroup.onKeydown(up());
83+
expect(radioGroup.inputs.activeIndex()).toBe(0);
84+
});
85+
86+
it('should navigate next on ArrowRight (horizontal)', () => {
87+
const {radioGroup} = getDefaultPatterns({orientation: signal('horizontal')});
88+
expect(radioGroup.inputs.activeIndex()).toBe(0);
89+
radioGroup.onKeydown(right());
90+
expect(radioGroup.inputs.activeIndex()).toBe(1);
91+
});
92+
93+
it('should navigate prev on ArrowLeft (horizontal)', () => {
94+
const {radioGroup} = getDefaultPatterns({
95+
activeIndex: signal(1),
96+
orientation: signal('horizontal'),
97+
});
98+
expect(radioGroup.inputs.activeIndex()).toBe(1);
99+
radioGroup.onKeydown(left());
100+
expect(radioGroup.inputs.activeIndex()).toBe(0);
101+
});
102+
103+
it('should navigate next on ArrowLeft (horizontal & rtl)', () => {
104+
const {radioGroup} = getDefaultPatterns({
105+
textDirection: signal('rtl'),
106+
orientation: signal('horizontal'),
107+
});
108+
expect(radioGroup.inputs.activeIndex()).toBe(0);
109+
radioGroup.onKeydown(left());
110+
expect(radioGroup.inputs.activeIndex()).toBe(1);
111+
});
112+
113+
it('should navigate prev on ArrowRight (horizontal & rtl)', () => {
114+
const {radioGroup} = getDefaultPatterns({
115+
activeIndex: signal(1),
116+
textDirection: signal('rtl'),
117+
orientation: signal('horizontal'),
118+
});
119+
expect(radioGroup.inputs.activeIndex()).toBe(1);
120+
radioGroup.onKeydown(right());
121+
expect(radioGroup.inputs.activeIndex()).toBe(0);
122+
});
123+
124+
it('should navigate to the first radio on Home', () => {
125+
const {radioGroup} = getDefaultPatterns({
126+
activeIndex: signal(4),
127+
});
128+
expect(radioGroup.inputs.activeIndex()).toBe(4);
129+
radioGroup.onKeydown(home());
130+
expect(radioGroup.inputs.activeIndex()).toBe(0);
131+
});
132+
133+
it('should navigate to the last radio on End', () => {
134+
const {radioGroup} = getDefaultPatterns();
135+
expect(radioGroup.inputs.activeIndex()).toBe(0);
136+
radioGroup.onKeydown(end());
137+
expect(radioGroup.inputs.activeIndex()).toBe(4);
138+
});
139+
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+
157+
it('should skip disabled radios when skipDisabled is true', () => {
158+
const {radioGroup, radioButtons} = getDefaultPatterns({skipDisabled: signal(true)});
159+
radioButtons[1].disabled.set(true);
160+
radioGroup.onKeydown(down());
161+
expect(radioGroup.inputs.activeIndex()).toBe(2);
162+
radioGroup.onKeydown(up());
163+
expect(radioGroup.inputs.activeIndex()).toBe(0);
164+
});
165+
166+
it('should not skip disabled radios when skipDisabled is false', () => {
167+
const {radioGroup, radioButtons} = getDefaultPatterns({skipDisabled: signal(false)});
168+
radioButtons[1].disabled.set(true);
169+
radioGroup.onKeydown(down());
170+
expect(radioGroup.inputs.activeIndex()).toBe(1);
171+
radioGroup.onKeydown(up());
172+
expect(radioGroup.inputs.activeIndex()).toBe(0);
173+
});
174+
175+
it('should be able to navigate in readonly mode', () => {
176+
const {radioGroup} = getDefaultPatterns({readonly: signal(true)});
177+
radioGroup.onKeydown(down());
178+
expect(radioGroup.inputs.activeIndex()).toBe(1);
179+
radioGroup.onKeydown(up());
180+
expect(radioGroup.inputs.activeIndex()).toBe(0);
181+
radioGroup.onKeydown(end());
182+
expect(radioGroup.inputs.activeIndex()).toBe(4);
183+
radioGroup.onKeydown(home());
184+
expect(radioGroup.inputs.activeIndex()).toBe(0);
185+
});
186+
});
187+
188+
describe('Keyboard Selection', () => {
189+
let radioGroup: TestRadioGroup;
190+
191+
beforeEach(() => {
192+
radioGroup = getDefaultPatterns({value: signal([])}).radioGroup;
193+
});
194+
195+
it('should select a radio on Space', () => {
196+
radioGroup.onKeydown(space());
197+
expect(radioGroup.inputs.value()).toEqual(['Apple']);
198+
});
199+
200+
it('should select a radio on Enter', () => {
201+
radioGroup.onKeydown(enter());
202+
expect(radioGroup.inputs.value()).toEqual(['Apple']);
203+
});
204+
205+
it('should select the focused radio on navigation (implicit selection)', () => {
206+
radioGroup.onKeydown(down());
207+
expect(radioGroup.inputs.value()).toEqual(['Banana']);
208+
radioGroup.onKeydown(up());
209+
expect(radioGroup.inputs.value()).toEqual(['Apple']);
210+
radioGroup.onKeydown(end());
211+
expect(radioGroup.inputs.value()).toEqual(['Elderberry']);
212+
radioGroup.onKeydown(home());
213+
expect(radioGroup.inputs.value()).toEqual(['Apple']);
214+
});
215+
216+
it('should not be able to change selection when in readonly mode', () => {
217+
const readonly = radioGroup.inputs.readonly as WritableSignal<boolean>;
218+
readonly.set(true);
219+
radioGroup.onKeydown(space());
220+
expect(radioGroup.inputs.value()).toEqual([]);
221+
222+
radioGroup.onKeydown(down()); // Navigation still works
223+
expect(radioGroup.inputs.activeIndex()).toBe(1);
224+
expect(radioGroup.inputs.value()).toEqual([]); // Selection doesn't change
225+
226+
radioGroup.onKeydown(enter());
227+
expect(radioGroup.inputs.value()).toEqual([]);
228+
});
229+
230+
it('should not select a disabled radio via keyboard', () => {
231+
const {radioGroup, radioButtons} = getPatterns(['A', 'B', 'C'], {
232+
skipDisabled: signal(false),
233+
});
234+
radioButtons[1].disabled.set(true);
235+
236+
radioGroup.onKeydown(down()); // Focus B (disabled)
237+
expect(radioGroup.inputs.activeIndex()).toBe(1);
238+
expect(radioGroup.inputs.value()).toEqual([]); // Should not select B
239+
240+
radioGroup.onKeydown(space()); // Try selecting B with space
241+
expect(radioGroup.inputs.value()).toEqual([]);
242+
243+
radioGroup.onKeydown(enter()); // Try selecting B with enter
244+
expect(radioGroup.inputs.value()).toEqual([]);
245+
246+
radioGroup.onKeydown(down()); // Focus C
247+
expect(radioGroup.inputs.activeIndex()).toBe(2);
248+
expect(radioGroup.inputs.value()).toEqual(['C']); // Selects C on navigation
249+
});
250+
});
251+
252+
describe('Pointer Events', () => {
253+
function click(radios: TestRadio[], index: number) {
254+
return {
255+
target: radios[index].element(),
256+
} as unknown as PointerEvent;
257+
}
258+
259+
it('should select a radio on click', () => {
260+
const {radioGroup, radioButtons} = getDefaultPatterns();
261+
radioGroup.onPointerdown(click(radioButtons, 1));
262+
expect(radioGroup.inputs.value()).toEqual(['Banana']);
263+
expect(radioGroup.inputs.activeIndex()).toBe(1);
264+
});
265+
266+
it('should not select a disabled radio on click', () => {
267+
const {radioGroup, radioButtons} = getDefaultPatterns();
268+
radioButtons[1].disabled.set(true);
269+
radioGroup.onPointerdown(click(radioButtons, 1));
270+
expect(radioGroup.inputs.value()).toEqual([]);
271+
expect(radioGroup.inputs.activeIndex()).toBe(0); // Active index shouldn't change
272+
});
273+
274+
it('should only update active index when readonly', () => {
275+
const {radioGroup, radioButtons} = getDefaultPatterns({readonly: signal(true)});
276+
radioGroup.onPointerdown(click(radioButtons, 1));
277+
expect(radioGroup.inputs.value()).toEqual([]);
278+
expect(radioGroup.inputs.activeIndex()).toBe(1); // Active index should update
279+
});
280+
});
281+
282+
describe('#setDefaultState', () => {
283+
it('should set the active index to the first radio', () => {
284+
const {radioGroup} = getDefaultPatterns({activeIndex: signal(-1)});
285+
radioGroup.setDefaultState();
286+
expect(radioGroup.inputs.activeIndex()).toBe(0);
287+
});
288+
289+
it('should set the active index to the first focusable radio', () => {
290+
const {radioGroup, radioButtons} = getDefaultPatterns({
291+
skipDisabled: signal(true),
292+
activeIndex: signal(-1),
293+
});
294+
radioButtons[0].disabled.set(true);
295+
radioGroup.setDefaultState();
296+
expect(radioGroup.inputs.activeIndex()).toBe(1);
297+
});
298+
299+
it('should set the active index to the selected radio', () => {
300+
const {radioGroup} = getDefaultPatterns({
301+
value: signal(['Cherry']),
302+
activeIndex: signal(-1),
303+
});
304+
radioGroup.setDefaultState();
305+
expect(radioGroup.inputs.activeIndex()).toBe(2);
306+
});
307+
308+
it('should set the active index to the first focusable radio if selected is disabled', () => {
309+
const {radioGroup, radioButtons} = getDefaultPatterns({
310+
value: signal(['Cherry']),
311+
skipDisabled: signal(true),
312+
activeIndex: signal(-1),
313+
});
314+
radioButtons[2].disabled.set(true); // Disable Cherry
315+
radioGroup.setDefaultState();
316+
expect(radioGroup.inputs.activeIndex()).toBe(0); // Defaults to first focusable
317+
});
318+
});
319+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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} from '@angular/core';
10+
import {ListSelection, ListSelectionItem} from '../behaviors/list-selection/list-selection';
11+
import {ListNavigation, ListNavigationItem} from '../behaviors/list-navigation/list-navigation';
12+
import {ListFocus, ListFocusItem} from '../behaviors/list-focus/list-focus';
13+
import {SignalLike} from '../behaviors/signal-like/signal-like';
14+
15+
/**
16+
* Represents the properties exposed by a radio group that need to be accessed by a radio button.
17+
* This exists to avoid circular dependency errors between the radio group and radio button.
18+
*/
19+
interface RadioGroupLike<V> {
20+
focusManager: ListFocus<RadioButtonPattern<V>>;
21+
selection: ListSelection<RadioButtonPattern<V>, V>;
22+
navigation: ListNavigation<RadioButtonPattern<V>>;
23+
}
24+
25+
/** Represents the required inputs for a radio button in a radio group. */
26+
export interface RadioButtonInputs<V>
27+
extends ListNavigationItem,
28+
ListSelectionItem<V>,
29+
ListFocusItem {
30+
/** A reference to the parent radio group. */
31+
group: SignalLike<RadioGroupLike<V> | undefined>;
32+
}
33+
34+
/** Represents a radio button within a radio group. */
35+
export class RadioButtonPattern<V> {
36+
/** A unique identifier for the radio button. */
37+
id: SignalLike<string>;
38+
39+
/** The value associated with the radio button. */
40+
value: SignalLike<V>;
41+
42+
/** The position of the radio button within the group. */
43+
index = computed(
44+
() =>
45+
this.group()
46+
?.navigation.inputs.items()
47+
.findIndex(i => i.id() === this.id()) ?? -1,
48+
);
49+
50+
/** Whether the radio button is currently the active one (focused). */
51+
active = computed(() => this.group()?.focusManager.activeItem() === this);
52+
53+
/** Whether the radio button is selected. */
54+
selected = computed(() => this.group()?.selection.inputs.value().includes(this.value()));
55+
56+
/** Whether the radio button is disabled. */
57+
disabled: SignalLike<boolean>;
58+
59+
/** A reference to the parent radio group. */
60+
group: SignalLike<RadioGroupLike<V> | undefined>;
61+
62+
/** The tabindex of the radio button. */
63+
tabindex = computed(() => this.group()?.focusManager.getItemTabindex(this));
64+
65+
/** The HTML element associated with the radio button. */
66+
element: SignalLike<HTMLElement>;
67+
68+
constructor(readonly inputs: RadioButtonInputs<V>) {
69+
this.id = inputs.id;
70+
this.value = inputs.value;
71+
this.group = inputs.group;
72+
this.element = inputs.element;
73+
this.disabled = inputs.disabled;
74+
}
75+
}

0 commit comments

Comments
 (0)
Please sign in to comment.