From de9f9222eefc2d22a931383563aa605f143b9009 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Fri, 2 May 2025 15:42:23 -0400 Subject: [PATCH 1/2] feat(cdk-experimental/ui-patterns): radio button and group --- src/cdk-experimental/ui-patterns/BUILD.bazel | 1 + .../ui-patterns/public-api.ts | 2 + .../ui-patterns/radio/BUILD.bazel | 36 ++ .../ui-patterns/radio/radio-group.ts | 212 ++++++++++++ .../ui-patterns/radio/radio.spec.ts | 319 ++++++++++++++++++ .../ui-patterns/radio/radio.ts | 75 ++++ 6 files changed, 645 insertions(+) create mode 100644 src/cdk-experimental/ui-patterns/radio/BUILD.bazel create mode 100644 src/cdk-experimental/ui-patterns/radio/radio-group.ts create mode 100644 src/cdk-experimental/ui-patterns/radio/radio.spec.ts create mode 100644 src/cdk-experimental/ui-patterns/radio/radio.ts diff --git a/src/cdk-experimental/ui-patterns/BUILD.bazel b/src/cdk-experimental/ui-patterns/BUILD.bazel index fee524d85d42..4b8299b571ee 100644 --- a/src/cdk-experimental/ui-patterns/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/BUILD.bazel @@ -12,6 +12,7 @@ ts_project( "//:node_modules/@angular/core", "//src/cdk-experimental/ui-patterns/behaviors/signal-like", "//src/cdk-experimental/ui-patterns/listbox", + "//src/cdk-experimental/ui-patterns/radio", "//src/cdk-experimental/ui-patterns/tabs", ], ) diff --git a/src/cdk-experimental/ui-patterns/public-api.ts b/src/cdk-experimental/ui-patterns/public-api.ts index 9b11949ed77c..06383ea9b5bf 100644 --- a/src/cdk-experimental/ui-patterns/public-api.ts +++ b/src/cdk-experimental/ui-patterns/public-api.ts @@ -8,5 +8,7 @@ export * from './listbox/listbox'; export * from './listbox/option'; +export * from './radio/radio-group'; +export * from './radio/radio'; export * from './behaviors/signal-like/signal-like'; export * from './tabs/tabs'; diff --git a/src/cdk-experimental/ui-patterns/radio/BUILD.bazel b/src/cdk-experimental/ui-patterns/radio/BUILD.bazel new file mode 100644 index 000000000000..df2237eb7103 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/radio/BUILD.bazel @@ -0,0 +1,36 @@ +load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "radio", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/behaviors/event-manager", + "//src/cdk-experimental/ui-patterns/behaviors/list-focus", + "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", + "//src/cdk-experimental/ui-patterns/behaviors/list-selection", + "//src/cdk-experimental/ui-patterns/behaviors/signal-like", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":radio", + "//:node_modules/@angular/core", + "//src/cdk/keycodes", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/ui-patterns/radio/radio-group.ts b/src/cdk-experimental/ui-patterns/radio/radio-group.ts new file mode 100644 index 000000000000..5d1332165fe9 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/radio/radio-group.ts @@ -0,0 +1,212 @@ +/** + * @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 {computed, signal} 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'; +import {ListNavigation, ListNavigationInputs} from '../behaviors/list-navigation/list-navigation'; +import {ListSelection, ListSelectionInputs} from '../behaviors/list-selection/list-selection'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; +import {RadioButtonPattern} from './radio'; + +/** The selection operations that the radio group can perform. */ +interface SelectOptions { + selectOne?: boolean; +} + +/** Represents the required inputs for a radio group. */ +export type RadioGroupInputs = ListNavigationInputs> & + // Radio groups are always single-select. + Omit, V>, 'multi' | 'selectionMode'> & + ListFocusInputs> & { + /** Whether the radio group is disabled. */ + disabled: SignalLike; + /** Whether the radio group is readonly. */ + readonly: SignalLike; + }; + +/** Controls the state of a radio group. */ +export class RadioGroupPattern { + /** Controls navigation for the radio group. */ + navigation: ListNavigation>; + + /** Controls selection for the radio group. */ + selection: ListSelection, V>; + + /** Controls focus for the radio group. */ + focusManager: ListFocus>; + + /** Whether the radio group is vertically or horizontally oriented. */ + orientation: SignalLike<'vertical' | 'horizontal'>; + + /** Whether the radio group is disabled. */ + disabled = computed(() => this.inputs.disabled() || this.focusManager.isListDisabled()); + + /** Whether the radio group is readonly. */ + readonly: SignalLike; + + /** The tabindex of the radio group (if using activedescendant). */ + tabindex = computed(() => this.focusManager.getListTabindex()); + + /** The id of the current active radio button (if using activedescendant). */ + activedescendant = computed(() => this.focusManager.getActiveDescendant()); + + /** The key used to navigate to the previous radio button. */ + prevKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return 'ArrowUp'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + }); + + /** The key used to navigate to the next radio button. */ + nextKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return 'ArrowDown'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + }); + + /** The keydown event manager for the radio group. */ + keydown = computed(() => { + const manager = new KeyboardEventManager(); + + // Readonly mode allows navigation but not selection changes. + if (this.readonly()) { + return manager + .on(this.prevKey, () => this.prev()) + .on(this.nextKey, () => this.next()) + .on('Home', () => this.first()) + .on('End', () => this.last()); + } + + // Default behavior: navigate and select on arrow keys, home, end. + // Space/Enter also select the focused item. + return manager + .on(this.prevKey, () => this.prev({selectOne: true})) + .on(this.nextKey, () => this.next({selectOne: true})) + .on('Home', () => this.first({selectOne: true})) + .on('End', () => this.last({selectOne: true})) + .on(' ', () => this.selection.selectOne()) + .on('Enter', () => this.selection.selectOne()); + }); + + /** The pointerdown event manager for the radio group. */ + pointerdown = computed(() => { + const manager = new PointerEventManager(); + + if (this.readonly()) { + // Navigate focus only in readonly mode. + return manager.on(e => this.goto(e)); + } + + // Default behavior: navigate and select on click. + return manager.on(e => this.goto(e, {selectOne: true})); + }); + + constructor(readonly inputs: RadioGroupInputs) { + this.readonly = inputs.readonly; + this.orientation = inputs.orientation; + + this.focusManager = new ListFocus(inputs); + this.navigation = new ListNavigation({...inputs, focusManager: this.focusManager}); + this.selection = new ListSelection({ + ...inputs, + // Radio groups are always single-select and selection follows focus. + multi: signal(false), + selectionMode: signal('follow'), + focusManager: this.focusManager, + }); + } + + /** Handles keydown events for the radio group. */ + onKeydown(event: KeyboardEvent) { + if (!this.disabled()) { + this.keydown().handle(event); + } + } + + /** Handles pointerdown events for the radio group. */ + onPointerdown(event: PointerEvent) { + if (!this.disabled()) { + this.pointerdown().handle(event); + } + } + + /** Navigates to the first enabled radio button in the group. */ + first(opts?: SelectOptions) { + this._navigate(opts, () => this.navigation.first()); + } + + /** Navigates to the last enabled radio button in the group. */ + last(opts?: SelectOptions) { + this._navigate(opts, () => this.navigation.last()); + } + + /** Navigates to the next enabled radio button in the group. */ + next(opts?: SelectOptions) { + this._navigate(opts, () => this.navigation.next()); + } + + /** Navigates to the previous enabled radio button in the group. */ + prev(opts?: SelectOptions) { + this._navigate(opts, () => this.navigation.prev()); + } + + /** Navigates to the radio button associated with the given pointer event. */ + goto(event: PointerEvent, opts?: SelectOptions) { + const item = this._getItem(event); + this._navigate(opts, () => this.navigation.goto(item)); + } + + /** + * Sets the radio group to its default initial state. + * + * Sets the active index to the selected radio button if one exists and is focusable. + * Otherwise, sets the active index to the first focusable radio button. + */ + setDefaultState() { + let firstItem: RadioButtonPattern | null = null; + + for (const item of this.inputs.items()) { + if (this.focusManager.isFocusable(item)) { + if (!firstItem) { + firstItem = item; + } + if (item.selected()) { + this.inputs.activeIndex.set(item.index()); + return; + } + } + } + + if (firstItem) { + this.inputs.activeIndex.set(firstItem.index()); + } + } + + /** Safely performs a navigation operation and updates selection if needed. */ + private _navigate(opts: SelectOptions = {}, operation: () => boolean) { + const moved = operation(); + if (moved && opts.selectOne) { + this.selection.selectOne(); + } + } + + /** Finds the RadioButtonPattern associated with a pointer event target. */ + private _getItem(e: PointerEvent): RadioButtonPattern | undefined { + if (!(e.target instanceof HTMLElement)) { + return undefined; + } + + // Assumes the target or its ancestor has role="radio" + const element = e.target.closest('[role="radio"]'); + return this.inputs.items().find(i => i.element() === element); + } +} diff --git a/src/cdk-experimental/ui-patterns/radio/radio.spec.ts b/src/cdk-experimental/ui-patterns/radio/radio.spec.ts new file mode 100644 index 000000000000..1fb717c94dc2 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/radio/radio.spec.ts @@ -0,0 +1,319 @@ +/** + * @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 {signal, WritableSignal} from '@angular/core'; +import {RadioGroupInputs, RadioGroupPattern} from './radio-group'; +import {RadioButtonPattern} from './radio'; +import {createKeyboardEvent} from '@angular/cdk/testing/private'; +import {ModifierKeys} from '@angular/cdk/testing'; + +type TestInputs = RadioGroupInputs; +type TestRadio = RadioButtonPattern & { + disabled: WritableSignal; +}; +type TestRadioGroup = RadioGroupPattern; + +const up = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 38, 'ArrowUp', mods); +const down = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 40, 'ArrowDown', mods); +const left = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 37, 'ArrowLeft', mods); +const right = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 39, 'ArrowRight', mods); +const home = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 36, 'Home', mods); +const end = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 35, 'End', mods); +const space = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 32, ' ', mods); +const enter = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 13, 'Enter', mods); + +describe('RadioGroup Pattern', () => { + function getRadioGroup(inputs: Partial & Pick) { + return new RadioGroupPattern({ + 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), + focusMode: inputs.focusMode ?? signal('roving'), + textDirection: inputs.textDirection ?? signal('ltr'), + orientation: inputs.orientation ?? signal('vertical'), + }); + } + + function getRadios(radioGroup: TestRadioGroup, values: string[]): TestRadio[] { + return values.map((value, index) => { + const element = document.createElement('div'); + element.role = 'radio'; + return new RadioButtonPattern({ + value: signal(value), + id: signal(`radio-${index}`), + disabled: signal(false), + group: signal(radioGroup), + element: signal(element), + }); + }) as TestRadio[]; + } + + function getPatterns(values: string[], inputs: Partial = {}) { + const radioButtons = signal([]); + const radioGroup = getRadioGroup({...inputs, items: radioButtons}); + radioButtons.set(getRadios(radioGroup, values)); + return {radioGroup, radioButtons: radioButtons()}; + } + + function getDefaultPatterns(inputs: Partial = {}) { + return getPatterns(['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry'], inputs); + } + + describe('Keyboard Navigation', () => { + it('should navigate next on ArrowDown', () => { + const {radioGroup} = getDefaultPatterns(); + expect(radioGroup.inputs.activeIndex()).toBe(0); + radioGroup.onKeydown(down()); + expect(radioGroup.inputs.activeIndex()).toBe(1); + }); + + it('should navigate prev on ArrowUp', () => { + const {radioGroup} = getDefaultPatterns({activeIndex: signal(1)}); + expect(radioGroup.inputs.activeIndex()).toBe(1); + radioGroup.onKeydown(up()); + expect(radioGroup.inputs.activeIndex()).toBe(0); + }); + + it('should navigate next on ArrowRight (horizontal)', () => { + const {radioGroup} = getDefaultPatterns({orientation: signal('horizontal')}); + expect(radioGroup.inputs.activeIndex()).toBe(0); + radioGroup.onKeydown(right()); + expect(radioGroup.inputs.activeIndex()).toBe(1); + }); + + it('should navigate prev on ArrowLeft (horizontal)', () => { + const {radioGroup} = getDefaultPatterns({ + activeIndex: signal(1), + orientation: signal('horizontal'), + }); + expect(radioGroup.inputs.activeIndex()).toBe(1); + radioGroup.onKeydown(left()); + expect(radioGroup.inputs.activeIndex()).toBe(0); + }); + + it('should navigate next on ArrowLeft (horizontal & rtl)', () => { + const {radioGroup} = getDefaultPatterns({ + textDirection: signal('rtl'), + orientation: signal('horizontal'), + }); + expect(radioGroup.inputs.activeIndex()).toBe(0); + radioGroup.onKeydown(left()); + expect(radioGroup.inputs.activeIndex()).toBe(1); + }); + + it('should navigate prev on ArrowRight (horizontal & rtl)', () => { + const {radioGroup} = getDefaultPatterns({ + activeIndex: signal(1), + textDirection: signal('rtl'), + orientation: signal('horizontal'), + }); + expect(radioGroup.inputs.activeIndex()).toBe(1); + radioGroup.onKeydown(right()); + expect(radioGroup.inputs.activeIndex()).toBe(0); + }); + + it('should navigate to the first radio on Home', () => { + const {radioGroup} = getDefaultPatterns({ + activeIndex: signal(4), + }); + expect(radioGroup.inputs.activeIndex()).toBe(4); + radioGroup.onKeydown(home()); + expect(radioGroup.inputs.activeIndex()).toBe(0); + }); + + it('should navigate to the last radio on End', () => { + const {radioGroup} = getDefaultPatterns(); + expect(radioGroup.inputs.activeIndex()).toBe(0); + radioGroup.onKeydown(end()); + 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); + radioGroup.onKeydown(down()); + expect(radioGroup.inputs.activeIndex()).toBe(2); + radioGroup.onKeydown(up()); + expect(radioGroup.inputs.activeIndex()).toBe(0); + }); + + it('should not skip disabled radios when skipDisabled is false', () => { + const {radioGroup, radioButtons} = getDefaultPatterns({skipDisabled: signal(false)}); + radioButtons[1].disabled.set(true); + radioGroup.onKeydown(down()); + expect(radioGroup.inputs.activeIndex()).toBe(1); + radioGroup.onKeydown(up()); + expect(radioGroup.inputs.activeIndex()).toBe(0); + }); + + it('should be able to navigate in readonly mode', () => { + const {radioGroup} = getDefaultPatterns({readonly: signal(true)}); + radioGroup.onKeydown(down()); + expect(radioGroup.inputs.activeIndex()).toBe(1); + radioGroup.onKeydown(up()); + expect(radioGroup.inputs.activeIndex()).toBe(0); + radioGroup.onKeydown(end()); + expect(radioGroup.inputs.activeIndex()).toBe(4); + radioGroup.onKeydown(home()); + expect(radioGroup.inputs.activeIndex()).toBe(0); + }); + }); + + describe('Keyboard Selection', () => { + let radioGroup: TestRadioGroup; + + beforeEach(() => { + radioGroup = getDefaultPatterns({value: signal([])}).radioGroup; + }); + + it('should select a radio on Space', () => { + radioGroup.onKeydown(space()); + expect(radioGroup.inputs.value()).toEqual(['Apple']); + }); + + it('should select a radio on Enter', () => { + radioGroup.onKeydown(enter()); + expect(radioGroup.inputs.value()).toEqual(['Apple']); + }); + + it('should select the focused radio on navigation (implicit selection)', () => { + radioGroup.onKeydown(down()); + expect(radioGroup.inputs.value()).toEqual(['Banana']); + radioGroup.onKeydown(up()); + expect(radioGroup.inputs.value()).toEqual(['Apple']); + radioGroup.onKeydown(end()); + expect(radioGroup.inputs.value()).toEqual(['Elderberry']); + radioGroup.onKeydown(home()); + expect(radioGroup.inputs.value()).toEqual(['Apple']); + }); + + it('should not be able to change selection when in readonly mode', () => { + const readonly = radioGroup.inputs.readonly as WritableSignal; + readonly.set(true); + radioGroup.onKeydown(space()); + expect(radioGroup.inputs.value()).toEqual([]); + + radioGroup.onKeydown(down()); // Navigation still works + expect(radioGroup.inputs.activeIndex()).toBe(1); + expect(radioGroup.inputs.value()).toEqual([]); // Selection doesn't change + + radioGroup.onKeydown(enter()); + expect(radioGroup.inputs.value()).toEqual([]); + }); + + it('should not select a disabled radio via keyboard', () => { + const {radioGroup, radioButtons} = getPatterns(['A', 'B', 'C'], { + skipDisabled: signal(false), + }); + radioButtons[1].disabled.set(true); + + radioGroup.onKeydown(down()); // Focus B (disabled) + expect(radioGroup.inputs.activeIndex()).toBe(1); + expect(radioGroup.inputs.value()).toEqual([]); // Should not select B + + radioGroup.onKeydown(space()); // Try selecting B with space + expect(radioGroup.inputs.value()).toEqual([]); + + radioGroup.onKeydown(enter()); // Try selecting B with enter + expect(radioGroup.inputs.value()).toEqual([]); + + radioGroup.onKeydown(down()); // Focus C + expect(radioGroup.inputs.activeIndex()).toBe(2); + expect(radioGroup.inputs.value()).toEqual(['C']); // Selects C on navigation + }); + }); + + describe('Pointer Events', () => { + function click(radios: TestRadio[], index: number) { + return { + target: radios[index].element(), + } as unknown as PointerEvent; + } + + it('should select a radio on click', () => { + const {radioGroup, radioButtons} = getDefaultPatterns(); + radioGroup.onPointerdown(click(radioButtons, 1)); + expect(radioGroup.inputs.value()).toEqual(['Banana']); + expect(radioGroup.inputs.activeIndex()).toBe(1); + }); + + it('should not select a disabled radio on click', () => { + const {radioGroup, radioButtons} = getDefaultPatterns(); + radioButtons[1].disabled.set(true); + radioGroup.onPointerdown(click(radioButtons, 1)); + expect(radioGroup.inputs.value()).toEqual([]); + expect(radioGroup.inputs.activeIndex()).toBe(0); // Active index shouldn't change + }); + + it('should only update active index when readonly', () => { + const {radioGroup, radioButtons} = getDefaultPatterns({readonly: signal(true)}); + radioGroup.onPointerdown(click(radioButtons, 1)); + expect(radioGroup.inputs.value()).toEqual([]); + expect(radioGroup.inputs.activeIndex()).toBe(1); // Active index should update + }); + }); + + describe('#setDefaultState', () => { + it('should set the active index to the first radio', () => { + const {radioGroup} = getDefaultPatterns({activeIndex: signal(-1)}); + radioGroup.setDefaultState(); + expect(radioGroup.inputs.activeIndex()).toBe(0); + }); + + it('should set the active index to the first focusable radio', () => { + const {radioGroup, radioButtons} = getDefaultPatterns({ + skipDisabled: signal(true), + activeIndex: signal(-1), + }); + radioButtons[0].disabled.set(true); + radioGroup.setDefaultState(); + expect(radioGroup.inputs.activeIndex()).toBe(1); + }); + + it('should set the active index to the selected radio', () => { + const {radioGroup} = getDefaultPatterns({ + value: signal(['Cherry']), + activeIndex: signal(-1), + }); + radioGroup.setDefaultState(); + expect(radioGroup.inputs.activeIndex()).toBe(2); + }); + + it('should set the active index to the first focusable radio if selected is disabled', () => { + const {radioGroup, radioButtons} = getDefaultPatterns({ + value: signal(['Cherry']), + skipDisabled: signal(true), + activeIndex: signal(-1), + }); + radioButtons[2].disabled.set(true); // Disable Cherry + radioGroup.setDefaultState(); + expect(radioGroup.inputs.activeIndex()).toBe(0); // Defaults to first focusable + }); + }); +}); diff --git a/src/cdk-experimental/ui-patterns/radio/radio.ts b/src/cdk-experimental/ui-patterns/radio/radio.ts new file mode 100644 index 000000000000..bf4e4ec61817 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/radio/radio.ts @@ -0,0 +1,75 @@ +/** + * @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 {computed} from '@angular/core'; +import {ListSelection, ListSelectionItem} from '../behaviors/list-selection/list-selection'; +import {ListNavigation, ListNavigationItem} from '../behaviors/list-navigation/list-navigation'; +import {ListFocus, ListFocusItem} from '../behaviors/list-focus/list-focus'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; + +/** + * Represents the properties exposed by a radio group that need to be accessed by a radio button. + * This exists to avoid circular dependency errors between the radio group and radio button. + */ +interface RadioGroupLike { + focusManager: ListFocus>; + selection: ListSelection, V>; + navigation: ListNavigation>; +} + +/** Represents the required inputs for a radio button in a radio group. */ +export interface RadioButtonInputs + extends ListNavigationItem, + ListSelectionItem, + ListFocusItem { + /** A reference to the parent radio group. */ + group: SignalLike | undefined>; +} + +/** Represents a radio button within a radio group. */ +export class RadioButtonPattern { + /** A unique identifier for the radio button. */ + id: SignalLike; + + /** The value associated with the radio button. */ + value: SignalLike; + + /** The position of the radio button within the group. */ + index = computed( + () => + this.group() + ?.navigation.inputs.items() + .findIndex(i => i.id() === this.id()) ?? -1, + ); + + /** Whether the radio button is currently the active one (focused). */ + active = computed(() => this.group()?.focusManager.activeItem() === this); + + /** Whether the radio button is selected. */ + selected = computed(() => this.group()?.selection.inputs.value().includes(this.value())); + + /** Whether the radio button is disabled. */ + disabled: SignalLike; + + /** A reference to the parent radio group. */ + group: SignalLike | undefined>; + + /** The tabindex of the radio button. */ + tabindex = computed(() => this.group()?.focusManager.getItemTabindex(this)); + + /** The HTML element associated with the radio button. */ + element: SignalLike; + + constructor(readonly inputs: RadioButtonInputs) { + this.id = inputs.id; + this.value = inputs.value; + this.group = inputs.group; + this.element = inputs.element; + this.disabled = inputs.disabled; + } +} From a809b95c442c1db5b12cbf7013130a0f037a8ac6 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Mon, 5 May 2025 09:56:05 -0400 Subject: [PATCH 2/2] fixup! feat(cdk-experimental/ui-patterns): radio button and group --- src/cdk-experimental/ui-patterns/radio/BUILD.bazel | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cdk-experimental/ui-patterns/radio/BUILD.bazel b/src/cdk-experimental/ui-patterns/radio/BUILD.bazel index df2237eb7103..20efbcf2aded 100644 --- a/src/cdk-experimental/ui-patterns/radio/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/radio/BUILD.bazel @@ -4,10 +4,10 @@ package(default_visibility = ["//visibility:public"]) ts_project( name = "radio", - srcs = glob( - ["**/*.ts"], - exclude = ["**/*.spec.ts"], - ), + srcs = [ + "radio.ts", + "radio-group.ts", + ], deps = [ "//:node_modules/@angular/core", "//src/cdk-experimental/ui-patterns/behaviors/event-manager",