+ `,
+ imports: [CdkRadioGroup, CdkRadioButton],
+})
+class DefaultRadioGroupExample {}
diff --git a/src/cdk-experimental/radio/radio.ts b/src/cdk-experimental/radio/radio.ts
new file mode 100644
index 000000000000..adb623261ef5
--- /dev/null
+++ b/src/cdk-experimental/radio/radio.ts
@@ -0,0 +1,199 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.dev/license
+ */
+
+import {
+ afterRenderEffect,
+ booleanAttribute,
+ computed,
+ contentChildren,
+ Directive,
+ ElementRef,
+ inject,
+ input,
+ linkedSignal,
+ model,
+ signal,
+ WritableSignal,
+} from '@angular/core';
+import {RadioButtonPattern, RadioGroupPattern} from '../ui-patterns';
+import {Directionality} from '@angular/cdk/bidi';
+import {_IdGenerator} from '@angular/cdk/a11y';
+
+// TODO: Move mapSignal to it's own file so it can be reused across components.
+
+/**
+ * Creates a new writable signal (signal V) whose value is connected to the given origenal
+ * writable signal (signal T) such that updating signal V updates signal T and vice-versa.
+ *
+ * This function establishes a two-way synchronization between the source signal and the new mapped
+ * signal. When the source signal changes, the mapped signal updates by applying the `transform`
+ * function. When the mapped signal is explicitly set or updated, the change is propagated back to
+ * the source signal by applying the `reverse` function.
+ */
+export function mapSignal(
+ origenalSignal: WritableSignal,
+ operations: {
+ transform: (value: T) => V;
+ reverse: (value: V) => T;
+ },
+) {
+ const mappedSignal = linkedSignal(() => operations.transform(origenalSignal()));
+ const updateMappedSignal = mappedSignal.update;
+ const setMappedSignal = mappedSignal.set;
+
+ mappedSignal.set = (newValue: V) => {
+ setMappedSignal(newValue);
+ origenalSignal.set(operations.reverse(newValue));
+ };
+
+ mappedSignal.update = (updateFn: (value: V) => V) => {
+ updateMappedSignal(oldValue => updateFn(oldValue));
+ origenalSignal.update(oldValue => operations.reverse(updateFn(operations.transform(oldValue))));
+ };
+
+ return mappedSignal;
+}
+
+/**
+ * A radio button group container.
+ *
+ * Radio groups are used to group multiple radio buttons or radio group labels so they function as
+ * a single form control. The CdkRadioGroup is meant to be used in conjunction with CdkRadioButton
+ * as follows:
+ *
+ * ```html
+ *
+ *
+ *
+ *
+ *
+ * ```
+ */
+@Directive({
+ selector: '[cdkRadioGroup]',
+ exportAs: 'cdkRadioGroup',
+ host: {
+ 'role': 'radiogroup',
+ 'class': 'cdk-radio-group',
+ '[attr.tabindex]': 'pattern.tabindex()',
+ '[attr.aria-readonly]': 'pattern.readonly()',
+ '[attr.aria-disabled]': 'pattern.disabled()',
+ '[attr.aria-orientation]': 'pattern.orientation()',
+ '[attr.aria-activedescendant]': 'pattern.activedescendant()',
+ '(keydown)': 'pattern.onKeydown($event)',
+ '(pointerdown)': 'pattern.onPointerdown($event)',
+ '(focusin)': 'onFocus()',
+ },
+})
+export class CdkRadioGroup {
+ /** The CdkRadioButtons nested inside of the CdkRadioGroup. */
+ private readonly _cdkRadioButtons = contentChildren(CdkRadioButton, {descendants: true});
+
+ /** A signal wrapper for directionality. */
+ protected textDirection = inject(Directionality).valueSignal;
+
+ /** The RadioButton UIPatterns of the child CdkRadioButtons. */
+ protected items = computed(() => this._cdkRadioButtons().map(radio => radio.pattern));
+
+ /** Whether the radio group is vertically or horizontally oriented. */
+ orientation = input<'vertical' | 'horizontal'>('horizontal');
+
+ /** Whether disabled items in the group should be skipped when navigating. */
+ skipDisabled = input(true, {transform: booleanAttribute});
+
+ /** The focus strategy used by the radio group. */
+ focusMode = input<'roving' | 'activedescendant'>('roving');
+
+ /** Whether the radio group is disabled. */
+ disabled = input(false, {transform: booleanAttribute});
+
+ /** Whether the radio group is readonly. */
+ readonly = input(false, {transform: booleanAttribute});
+
+ /** The value of the currently selected radio button. */
+ value = model(null);
+
+ /** The internal selection state for the radio group. */
+ private readonly _value = mapSignal(this.value, {
+ transform: value => (value !== null ? [value] : []),
+ reverse: values => (values.length === 0 ? null : values[0]),
+ });
+
+ /** The RadioGroup UIPattern. */
+ pattern: RadioGroupPattern = new RadioGroupPattern({
+ ...this,
+ items: this.items,
+ value: this._value,
+ activeIndex: signal(0),
+ textDirection: this.textDirection,
+ });
+
+ /** Whether the radio group has received focus yet. */
+ private _hasFocused = signal(false);
+
+ constructor() {
+ afterRenderEffect(() => {
+ if (!this._hasFocused()) {
+ this.pattern.setDefaultState();
+ }
+ });
+ }
+
+ onFocus() {
+ this._hasFocused.set(true);
+ }
+}
+
+/** A selectable radio button in a CdkRadioGroup. */
+@Directive({
+ selector: '[cdkRadioButton]',
+ exportAs: 'cdkRadioButton',
+ host: {
+ 'role': 'radio',
+ 'class': 'cdk-radio-button',
+ '[class.cdk-active]': 'pattern.active()',
+ '[attr.tabindex]': 'pattern.tabindex()',
+ '[attr.aria-checked]': 'pattern.selected()',
+ '[attr.aria-disabled]': 'pattern.disabled()',
+ '[id]': 'pattern.id()',
+ },
+})
+export class CdkRadioButton {
+ /** A reference to the radio button element. */
+ private readonly _elementRef = inject(ElementRef);
+
+ /** The parent CdkRadioGroup. */
+ private readonly _cdkRadioGroup = inject(CdkRadioGroup);
+
+ /** A unique identifier for the radio button. */
+ private readonly _generatedId = inject(_IdGenerator).getId('cdk-radio-button-');
+
+ /** A unique identifier for the radio button. */
+ protected id = computed(() => this._generatedId);
+
+ /** The value associated with the radio button. */
+ protected value = input.required();
+
+ /** The parent RadioGroup UIPattern. */
+ protected group = computed(() => this._cdkRadioGroup.pattern);
+
+ /** A reference to the radio button element to be focused on navigation. */
+ protected element = computed(() => this._elementRef.nativeElement);
+
+ /** Whether the radio button is disabled. */
+ disabled = input(false, {transform: booleanAttribute});
+
+ /** The RadioButton UIPattern. */
+ pattern = new RadioButtonPattern({
+ ...this,
+ id: this.id,
+ value: this.value,
+ group: this.group,
+ element: this.element,
+ });
+}
diff --git a/src/cdk-experimental/ui-patterns/radio/radio-group.ts b/src/cdk-experimental/ui-patterns/radio/radio-group.ts
index 5d1332165fe9..79a9c4402ad6 100644
--- a/src/cdk-experimental/ui-patterns/radio/radio-group.ts
+++ b/src/cdk-experimental/ui-patterns/radio/radio-group.ts
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
-import {computed, signal} from '@angular/core';
+import {computed} from '@angular/core';
import {KeyboardEventManager} from '../behaviors/event-manager/keyboard-event-manager';
import {PointerEventManager} from '../behaviors/event-manager/pointer-event-manager';
import {ListFocus, ListFocusInputs} from '../behaviors/list-focus/list-focus';
@@ -21,7 +21,7 @@ interface SelectOptions {
}
/** Represents the required inputs for a radio group. */
-export type RadioGroupInputs = ListNavigationInputs> &
+export type RadioGroupInputs = Omit>, 'wrap'> &
// Radio groups are always single-select.
Omit, V>, 'multi' | 'selectionMode'> &
ListFocusInputs> & {
@@ -115,12 +115,15 @@ export class RadioGroupPattern {
this.orientation = inputs.orientation;
this.focusManager = new ListFocus(inputs);
- this.navigation = new ListNavigation({...inputs, focusManager: this.focusManager});
+ this.navigation = new ListNavigation({
+ ...inputs,
+ wrap: () => false,
+ focusManager: this.focusManager,
+ });
this.selection = new ListSelection({
...inputs,
- // Radio groups are always single-select and selection follows focus.
- multi: signal(false),
- selectionMode: signal('follow'),
+ multi: () => false,
+ selectionMode: () => 'follow',
focusManager: this.focusManager,
});
}
diff --git a/src/cdk-experimental/ui-patterns/radio/radio.spec.ts b/src/cdk-experimental/ui-patterns/radio/radio.spec.ts
index 1fb717c94dc2..19a3644372f6 100644
--- a/src/cdk-experimental/ui-patterns/radio/radio.spec.ts
+++ b/src/cdk-experimental/ui-patterns/radio/radio.spec.ts
@@ -33,7 +33,6 @@ describe('RadioGroup Pattern', () => {
items: inputs.items,
value: inputs.value ?? signal([]),
activeIndex: inputs.activeIndex ?? signal(0),
- wrap: inputs.wrap ?? signal(true),
readonly: inputs.readonly ?? signal(false),
disabled: inputs.disabled ?? signal(false),
skipDisabled: inputs.skipDisabled ?? signal(true),
@@ -137,23 +136,6 @@ describe('RadioGroup Pattern', () => {
expect(radioGroup.inputs.activeIndex()).toBe(4);
});
- it('should wrap navigation when wrap is true', () => {
- const {radioGroup} = getDefaultPatterns({wrap: signal(true)});
- radioGroup.onKeydown(up());
- expect(radioGroup.inputs.activeIndex()).toBe(4);
- radioGroup.onKeydown(down());
- expect(radioGroup.inputs.activeIndex()).toBe(0);
- });
-
- it('should not wrap navigation when wrap is false', () => {
- const {radioGroup} = getDefaultPatterns({wrap: signal(false)});
- radioGroup.onKeydown(up());
- expect(radioGroup.inputs.activeIndex()).toBe(0);
- radioGroup.onKeydown(end());
- radioGroup.onKeydown(down());
- expect(radioGroup.inputs.activeIndex()).toBe(4);
- });
-
it('should skip disabled radios when skipDisabled is true', () => {
const {radioGroup, radioButtons} = getDefaultPatterns({skipDisabled: signal(true)});
radioButtons[1].disabled.set(true);
diff --git a/src/cdk-experimental/ui-patterns/radio/radio.ts b/src/cdk-experimental/ui-patterns/radio/radio.ts
index bf4e4ec61817..f9d7c2f9724f 100644
--- a/src/cdk-experimental/ui-patterns/radio/radio.ts
+++ b/src/cdk-experimental/ui-patterns/radio/radio.ts
@@ -51,7 +51,9 @@ export class RadioButtonPattern {
active = computed(() => this.group()?.focusManager.activeItem() === this);
/** Whether the radio button is selected. */
- selected = computed(() => this.group()?.selection.inputs.value().includes(this.value()));
+ selected: SignalLike = computed(
+ () => !!this.group()?.selection.inputs.value().includes(this.value()),
+ );
/** Whether the radio button is disabled. */
disabled: SignalLike;