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 ef789fd

Browse files
committedMay 13, 2025·
fixup! feat(cdk-experimental/radio): create radio group and button directives
1 parent 6952744 commit ef789fd

File tree

3 files changed

+573
-5
lines changed

3 files changed

+573
-5
lines changed
 

‎src/cdk-experimental/radio/BUILD.bazel

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
load("//tools:defaults.bzl", "ng_project")
1+
load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project")
22

33
package(default_visibility = ["//visibility:public"])
44

@@ -15,3 +15,22 @@ ng_project(
1515
"//src/cdk/bidi",
1616
],
1717
)
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+
],
31+
)
32+
33+
ng_web_test_suite(
34+
name = "unit_tests",
35+
deps = [":unit_test_sources"],
36+
)
Lines changed: 550 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,550 @@
1+
import {Component, DebugElement, EventEmitter, signal, Type, WritableSignal} from '@angular/core';
2+
import {CdkRadioButton, CdkRadioGroup} from './radio';
3+
import {ComponentFixture, TestBed} from '@angular/core/testing';
4+
import {By} from '@angular/platform-browser';
5+
import {BidiModule, Direction, Directionality} from '@angular/cdk/bidi';
6+
7+
describe('CdkRadioGroup', () => {
8+
let fixture: ComponentFixture<RadioGroupExample>;
9+
let textDirection = new EventEmitter<Direction>();
10+
11+
let radioGroup: DebugElement;
12+
let radioButtons: DebugElement[];
13+
let radioGroupInstance: CdkRadioGroup<number>;
14+
let radioGroupElement: HTMLElement;
15+
let radioButtonElements: HTMLElement[];
16+
17+
const keydown = (key: string) => {
18+
radioGroupElement.dispatchEvent(new KeyboardEvent('keydown', {bubbles: true, key}));
19+
fixture.detectChanges();
20+
};
21+
22+
const click = (index: number) => {
23+
radioButtonElements[index].dispatchEvent(new PointerEvent('pointerdown', {bubbles: true}));
24+
fixture.detectChanges();
25+
};
26+
27+
const space = () => keydown(' ');
28+
const enter = () => keydown('Enter');
29+
const up = () => keydown('ArrowUp');
30+
const down = () => keydown('ArrowDown');
31+
const left = () => keydown('ArrowLeft');
32+
const right = () => keydown('ArrowRight');
33+
const home = () => keydown('Home');
34+
const end = () => keydown('End');
35+
36+
function setupTestEnvironment<T>(component: Type<T>) {
37+
TestBed.configureTestingModule({
38+
providers: [
39+
{
40+
provide: Directionality,
41+
useValue: {value: 'ltr', change: textDirection},
42+
},
43+
],
44+
imports: [BidiModule, component],
45+
}).compileComponents();
46+
47+
const fixture = TestBed.createComponent<T>(component);
48+
fixture.detectChanges();
49+
50+
radioGroup = fixture.debugElement.query(By.directive(CdkRadioGroup));
51+
radioButtons = radioGroup.queryAll(By.directive(CdkRadioButton));
52+
radioGroupInstance = radioGroup.injector.get<CdkRadioGroup<number>>(CdkRadioGroup);
53+
radioGroupElement = radioGroup.nativeElement;
54+
radioButtonElements = radioButtons.map(radioButton => radioButton.nativeElement);
55+
56+
return fixture;
57+
}
58+
59+
function setupRadioGroup(opts?: {
60+
orientation?: 'horizontal' | 'vertical';
61+
disabled?: boolean;
62+
readonly?: boolean;
63+
value?: number | null;
64+
skipDisabled?: boolean;
65+
focusMode?: 'roving' | 'activedescendant';
66+
disabledOptions?: number[];
67+
options?: TestOption[];
68+
textDirection?: Direction;
69+
}) {
70+
const testComponent = fixture.componentInstance;
71+
72+
if (opts?.orientation !== undefined) {
73+
testComponent.orientation.set(opts.orientation);
74+
}
75+
if (opts?.disabled !== undefined) {
76+
testComponent.disabled.set(opts.disabled);
77+
}
78+
if (opts?.readonly !== undefined) {
79+
testComponent.readonly.set(opts.readonly);
80+
}
81+
if (opts?.value !== undefined) {
82+
testComponent.value.set(opts.value);
83+
}
84+
if (opts?.skipDisabled !== undefined) {
85+
testComponent.skipDisabled.set(opts.skipDisabled);
86+
}
87+
if (opts?.focusMode !== undefined) {
88+
testComponent.focusMode.set(opts.focusMode);
89+
}
90+
if (opts?.options !== undefined) {
91+
testComponent.options.set(opts.options);
92+
}
93+
if (opts?.disabledOptions !== undefined) {
94+
opts.disabledOptions.forEach(index => {
95+
testComponent.options()[index].disabled.set(true);
96+
});
97+
}
98+
if (opts?.textDirection !== undefined) {
99+
textDirection.emit(opts.textDirection);
100+
}
101+
fixture.detectChanges();
102+
}
103+
104+
describe('ARIA attributes and roles', () => {
105+
describe('default configuration', () => {
106+
beforeEach(() => {
107+
setupTestEnvironment(DefaultRadioGroupExample);
108+
});
109+
110+
it('should correctly set the role attribute to "radiogroup"', () => {
111+
expect(radioGroupElement.getAttribute('role')).toBe('radiogroup');
112+
});
113+
114+
it('should correctly set the role attribute to "radio" for the radio buttons', () => {
115+
radioButtonElements.forEach(radioButtonElement => {
116+
expect(radioButtonElement.getAttribute('role')).toBe('radio');
117+
});
118+
});
119+
120+
it('should set aria-orientation to "horizontal"', () => {
121+
expect(radioGroupElement.getAttribute('aria-orientation')).toBe('horizontal');
122+
});
123+
124+
it('should set aria-disabled to false', () => {
125+
expect(radioGroupElement.getAttribute('aria-disabled')).toBe('false');
126+
});
127+
128+
it('should set aria-readonly to false', () => {
129+
expect(radioGroupElement.getAttribute('aria-readonly')).toBe('false');
130+
});
131+
});
132+
133+
describe('custom configuration', () => {
134+
beforeEach(() => {
135+
fixture = setupTestEnvironment(RadioGroupExample);
136+
});
137+
138+
it('should be able to set aria-orientation to "vertical"', () => {
139+
setupRadioGroup({orientation: 'vertical'});
140+
expect(radioGroupElement.getAttribute('aria-orientation')).toBe('vertical');
141+
});
142+
143+
it('should be able to set aria-disabled to true', () => {
144+
setupRadioGroup({disabled: true});
145+
expect(radioGroupElement.getAttribute('aria-disabled')).toBe('true');
146+
});
147+
148+
it('should be able to set aria-readonly to true', () => {
149+
setupRadioGroup({readonly: true});
150+
expect(radioGroupElement.getAttribute('aria-readonly')).toBe('true');
151+
});
152+
});
153+
154+
describe('roving focus mode', () => {
155+
beforeEach(() => {
156+
fixture = setupTestEnvironment(RadioGroupExample);
157+
});
158+
159+
it('should have tabindex="-1" when focusMode is "roving"', () => {
160+
setupRadioGroup({focusMode: 'roving'});
161+
expect(radioGroupElement.getAttribute('tabindex')).toBe('-1');
162+
});
163+
164+
it('should set tabindex="0" when disabled', () => {
165+
setupRadioGroup({disabled: true, focusMode: 'roving'});
166+
expect(radioGroupElement.getAttribute('tabindex')).toBe('0');
167+
});
168+
169+
it('should set initial focus on the selected option', () => {
170+
setupRadioGroup({focusMode: 'roving', value: 3});
171+
expect(radioButtonElements[3].getAttribute('tabindex')).toBe('0');
172+
});
173+
174+
it('should set initial focus on the first option if none are selected', () => {
175+
setupRadioGroup({focusMode: 'roving'});
176+
expect(radioButtonElements[0].getAttribute('tabindex')).toBe('0');
177+
});
178+
179+
it('should not have aria-activedescendant when focusMode is "roving"', () => {
180+
setupRadioGroup({focusMode: 'roving'});
181+
expect(radioGroupElement.getAttribute('aria-activedescendant')).toBeNull();
182+
});
183+
});
184+
185+
describe('activedescendant focus mode', () => {
186+
beforeEach(() => {
187+
fixture = setupTestEnvironment(RadioGroupExample);
188+
});
189+
190+
it('should have tabindex="0"', () => {
191+
setupRadioGroup({focusMode: 'activedescendant'});
192+
expect(radioGroupElement.getAttribute('tabindex')).toBe('0');
193+
});
194+
195+
it('should set initial focus on the selected option', () => {
196+
setupRadioGroup({focusMode: 'activedescendant', value: 3});
197+
expect(radioGroupElement.getAttribute('aria-activedescendant')).toBe(
198+
radioButtonElements[3].id,
199+
);
200+
});
201+
202+
it('should set initial focus on the first option if none are selected', () => {
203+
setupRadioGroup({focusMode: 'activedescendant'});
204+
expect(radioGroupElement.getAttribute('aria-activedescendant')).toBe(
205+
radioButtonElements[0].id,
206+
);
207+
});
208+
});
209+
});
210+
211+
describe('value and selection', () => {
212+
beforeEach(() => {
213+
fixture = setupTestEnvironment(RadioGroupExample);
214+
});
215+
216+
it('should select the radio button corresponding to the value input', () => {
217+
radioGroupInstance.value.set(1);
218+
fixture.detectChanges();
219+
expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true');
220+
});
221+
222+
describe('pointer interaction', () => {
223+
it('should update the group value when a radio button is selected via pointer click', () => {
224+
click(1);
225+
expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true');
226+
});
227+
228+
it('should only allow one radio button to be selected at a time', () => {
229+
click(1);
230+
click(2);
231+
expect(radioButtonElements[0].getAttribute('aria-checked')).toBe('false');
232+
expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('false');
233+
expect(radioButtonElements[2].getAttribute('aria-checked')).toBe('true');
234+
expect(radioButtonElements[3].getAttribute('aria-checked')).toBe('false');
235+
expect(radioButtonElements[4].getAttribute('aria-checked')).toBe('false');
236+
});
237+
238+
it('should not change the value if the radio group is readonly', () => {
239+
setupRadioGroup({readonly: true});
240+
click(3);
241+
expect(radioButtonElements[3].getAttribute('aria-checked')).toBe('false');
242+
});
243+
244+
it('should not change the value if the radio group is disabled', () => {
245+
setupRadioGroup({disabled: true});
246+
click(3);
247+
expect(radioButtonElements[3].getAttribute('aria-checked')).toBe('false');
248+
});
249+
250+
it('should not change the value if a disabled radio button is clicked', () => {
251+
setupRadioGroup({disabledOptions: [2]});
252+
click(2);
253+
expect(radioButtonElements[2].getAttribute('aria-checked')).toBe('false');
254+
});
255+
256+
it('should not change the value if a radio button is clicked in a readonly group', () => {
257+
setupRadioGroup({readonly: true});
258+
click(1);
259+
expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('false');
260+
});
261+
});
262+
263+
describe('keyboard interaction', () => {
264+
it('should update the group value on Space', () => {
265+
space();
266+
expect(radioButtonElements[0].getAttribute('aria-checked')).toBe('true');
267+
});
268+
269+
it('should update the group value on Enter', () => {
270+
enter();
271+
expect(radioButtonElements[0].getAttribute('aria-checked')).toBe('true');
272+
});
273+
274+
it('should not change the value if the radio group is readonly', () => {
275+
setupRadioGroup({orientation: 'horizontal', readonly: true});
276+
right();
277+
expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('false');
278+
});
279+
280+
it('should not change the value if the radio group is disabled', () => {
281+
setupRadioGroup({orientation: 'horizontal', disabled: true});
282+
right();
283+
expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('false');
284+
});
285+
286+
describe('horizontal orientation', () => {
287+
beforeEach(() => setupRadioGroup({orientation: 'horizontal'}));
288+
289+
it('should update the group value on ArrowRight', () => {
290+
right();
291+
expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true');
292+
});
293+
294+
it('should update the group value on ArrowLeft', () => {
295+
right();
296+
right();
297+
left();
298+
expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true');
299+
});
300+
301+
describe('text direction rtl', () => {
302+
beforeEach(() => setupRadioGroup({textDirection: 'rtl'}));
303+
304+
it('should update the group value on ArrowLeft', () => {
305+
left();
306+
expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true');
307+
});
308+
309+
it('should update the group value on ArrowRight', () => {
310+
left();
311+
left();
312+
right();
313+
expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true');
314+
});
315+
});
316+
});
317+
318+
describe('vertical orientation', () => {
319+
beforeEach(() => setupRadioGroup({orientation: 'vertical'}));
320+
321+
it('should update the group value on ArrowDown', () => {
322+
down();
323+
expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true');
324+
});
325+
326+
it('should update the group value on ArrowUp', () => {
327+
down();
328+
down();
329+
up();
330+
expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true');
331+
});
332+
});
333+
});
334+
});
335+
336+
function runNavigationTests(
337+
focusMode: 'activedescendant' | 'roving',
338+
isFocused: (index: number) => boolean,
339+
) {
340+
describe(`keyboard navigation (focusMode="${focusMode}")`, () => {
341+
beforeEach(() => {
342+
fixture = setupTestEnvironment(RadioGroupExample);
343+
setupRadioGroup({focusMode});
344+
});
345+
346+
it('should move focus to and select the last enabled radio button on End', () => {
347+
end();
348+
expect(isFocused(4)).toBe(true);
349+
});
350+
351+
it('should move focus to and select the first enabled radio button on Home', () => {
352+
end();
353+
home();
354+
expect(isFocused(0)).toBe(true);
355+
});
356+
357+
it('should not allow keyboard navigation or selection if the group is disabled', () => {
358+
setupRadioGroup({orientation: 'horizontal', disabled: true});
359+
right();
360+
expect(isFocused(0)).toBe(false);
361+
});
362+
363+
it('should allow keyboard navigation if the group is readonly', () => {
364+
setupRadioGroup({orientation: 'horizontal', readonly: true});
365+
right();
366+
expect(isFocused(1)).toBe(true);
367+
});
368+
369+
describe('vertical orientation', () => {
370+
beforeEach(() => setupRadioGroup({orientation: 'vertical'}));
371+
372+
it('should move focus to the next radio button on ArrowDown', () => {
373+
down();
374+
expect(isFocused(1)).toBe(true);
375+
});
376+
377+
it('should move focus to the previous radio button on ArrowUp', () => {
378+
down();
379+
down();
380+
up();
381+
expect(isFocused(1)).toBe(true);
382+
});
383+
384+
it('should skip disabled radio buttons (skipDisabled="true")', () => {
385+
setupRadioGroup({skipDisabled: true, disabledOptions: [1, 2]});
386+
down();
387+
expect(isFocused(3)).toBe(true);
388+
});
389+
390+
it('should not skip disabled radio buttons (skipDisabled="false")', () => {
391+
setupRadioGroup({skipDisabled: false, disabledOptions: [1, 2]});
392+
down();
393+
expect(isFocused(1)).toBe(true);
394+
});
395+
});
396+
397+
describe('horizontal orientation', () => {
398+
beforeEach(() => setupRadioGroup({orientation: 'horizontal'}));
399+
400+
it('should move focus to the next radio button on ArrowRight', () => {
401+
right();
402+
expect(isFocused(1)).toBe(true);
403+
});
404+
405+
it('should move focus to the previous radio button on ArrowLeft', () => {
406+
right();
407+
right();
408+
left();
409+
expect(isFocused(1)).toBe(true);
410+
});
411+
412+
it('should skip disabled radio buttons (skipDisabled="true")', () => {
413+
setupRadioGroup({skipDisabled: true, disabledOptions: [1, 2]});
414+
right();
415+
expect(isFocused(3)).toBe(true);
416+
});
417+
418+
it('should not skip disabled radio buttons (skipDisabled="false")', () => {
419+
setupRadioGroup({skipDisabled: false, disabledOptions: [1, 2]});
420+
right();
421+
expect(isFocused(1)).toBe(true);
422+
});
423+
424+
describe('text direction rtl', () => {
425+
beforeEach(() => setupRadioGroup({textDirection: 'rtl'}));
426+
427+
it('should move focus to the next radio button on ArrowLeft', () => {
428+
setupRadioGroup({orientation: 'horizontal'});
429+
left();
430+
expect(isFocused(1)).toBe(true);
431+
});
432+
433+
it('should move focus to the previous radio button on ArrowRight', () => {
434+
setupRadioGroup({orientation: 'horizontal'});
435+
left();
436+
left();
437+
right();
438+
expect(isFocused(1)).toBe(true);
439+
});
440+
441+
it('should skip disabled radio buttons when navigating', () => {
442+
setupRadioGroup({
443+
skipDisabled: true,
444+
disabledOptions: [1, 2],
445+
orientation: 'horizontal',
446+
});
447+
left();
448+
expect(isFocused(3)).toBe(true);
449+
});
450+
});
451+
});
452+
});
453+
454+
describe(`pointer navigation (focusMode="${focusMode}")`, () => {
455+
beforeEach(() => {
456+
fixture = setupTestEnvironment(RadioGroupExample);
457+
setupRadioGroup({focusMode});
458+
});
459+
460+
it('should move focus to the clicked radio button', () => {
461+
click(3);
462+
expect(isFocused(3)).toBe(true);
463+
});
464+
465+
it('should move focus to the clicked radio button if the group is disabled (skipDisabled="true")', () => {
466+
setupRadioGroup({skipDisabled: true, disabled: true});
467+
click(3);
468+
expect(isFocused(3)).toBe(false);
469+
});
470+
471+
it('should not move focus to the clicked radio button if the group is disabled (skipDisabled="false")', () => {
472+
setupRadioGroup({skipDisabled: true, disabled: true});
473+
click(3);
474+
expect(isFocused(0)).toBe(false);
475+
});
476+
477+
it('should move focus to the clicked radio button if the group is readonly', () => {
478+
setupRadioGroup({readonly: true});
479+
click(3);
480+
expect(isFocused(3)).toBe(true);
481+
});
482+
});
483+
}
484+
485+
runNavigationTests('roving', i => {
486+
return radioButtonElements[i].getAttribute('tabindex') === '0';
487+
});
488+
489+
runNavigationTests('activedescendant', i => {
490+
return radioGroupElement.getAttribute('aria-activedescendant') === radioButtonElements[i].id;
491+
});
492+
493+
it('should handle an empty set of radio buttons gracefully', () => {
494+
setupRadioGroup({options: []});
495+
radioButtons = fixture.debugElement.queryAll(By.directive(CdkRadioButton));
496+
expect(radioButtons.length).toBe(0);
497+
});
498+
});
499+
500+
interface TestOption {
501+
value: number;
502+
label: string;
503+
disabled: WritableSignal<boolean>;
504+
}
505+
506+
@Component({
507+
template: `
508+
<div
509+
[value]="value()"
510+
[disabled]="disabled()"
511+
[readonly]="readonly()"
512+
[focusMode]="focusMode()"
513+
[orientation]="orientation()"
514+
[skipDisabled]="skipDisabled()"
515+
cdkRadioGroup>
516+
@for (option of options(); track option.value) {
517+
<div cdkRadioButton [value]="option.value" [disabled]="option.disabled()">{{ option.label }}</div>
518+
}
519+
</div>
520+
`,
521+
imports: [CdkRadioGroup, CdkRadioButton],
522+
})
523+
class RadioGroupExample {
524+
options = signal<TestOption[]>([
525+
{value: 0, label: '0', disabled: signal(false)},
526+
{value: 1, label: '1', disabled: signal(false)},
527+
{value: 2, label: '2', disabled: signal(false)},
528+
{value: 3, label: '3', disabled: signal(false)},
529+
{value: 4, label: '4', disabled: signal(false)},
530+
]);
531+
532+
disabled = signal<boolean>(false);
533+
readonly = signal<boolean>(false);
534+
value = signal<number | null>(null);
535+
skipDisabled = signal<boolean>(true);
536+
focusMode = signal<'roving' | 'activedescendant'>('roving');
537+
orientation = signal<'horizontal' | 'vertical'>('horizontal');
538+
}
539+
540+
@Component({
541+
template: `
542+
<div cdkRadioGroup>
543+
<div cdkRadioButton [value]="0">0</div>
544+
<div cdkRadioButton [value]="1">1</div>
545+
<div cdkRadioButton [value]="2">2</div>
546+
</div>
547+
`,
548+
imports: [CdkRadioGroup, CdkRadioButton],
549+
})
550+
class DefaultRadioGroupExample {}

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export class CdkRadioGroup<V> implements AfterViewInit {
7272
protected items = computed(() => this._cdkRadioButtons().map(radio => radio.pattern));
7373

7474
/** Whether the radio group is vertically or horizontally oriented. */
75-
orientation = input<'vertical' | 'horizontal'>('vertical');
75+
orientation = input<'vertical' | 'horizontal'>('horizontal');
7676

7777
/** Whether focus should wrap when navigating. */
7878
wrap = input(false, {transform: booleanAttribute}); // Radio groups typically don't wrap
@@ -95,14 +95,12 @@ export class CdkRadioGroup<V> implements AfterViewInit {
9595
/** The internal selection state for the radio group. */
9696
private readonly _value = linkedSignal(() => (this.value() ? [this.value()!] : []));
9797

98-
/** The current index that has been navigated to. */
99-
activeIndex = model<number>(0);
100-
10198
/** The RadioGroup UIPattern. */
10299
pattern: RadioGroupPattern<V> = new RadioGroupPattern<V>({
103100
...this,
104101
items: this.items,
105102
value: this._value,
103+
activeIndex: signal(0),
106104
textDirection: this.textDirection,
107105
});
108106

@@ -140,6 +138,7 @@ export class CdkRadioGroup<V> implements AfterViewInit {
140138
'[attr.tabindex]': 'pattern.tabindex()',
141139
'[attr.aria-checked]': 'pattern.selected()',
142140
'[attr.aria-disabled]': 'pattern.disabled()',
141+
'[id]': 'pattern.id()',
143142
},
144143
})
145144
export class CdkRadioButton<V> {

0 commit comments

Comments
 (0)
Please sign in to comment.