diff --git a/.ng-dev/commit-message.mts b/.ng-dev/commit-message.mts index 16ec612231f8..314d1e3c36f0 100644 --- a/.ng-dev/commit-message.mts +++ b/.ng-dev/commit-message.mts @@ -19,6 +19,7 @@ export const commitMessage: CommitMessageConfig = { 'cdk-experimental/selection', 'cdk-experimental/table-scroll-container', 'cdk-experimental/tabs', + 'cdk-experimental/toolbar', 'cdk-experimental/tree', 'cdk-experimental/ui-patterns', 'cdk/a11y', diff --git a/src/cdk-experimental/radio-group/radio-group.ts b/src/cdk-experimental/radio-group/radio-group.ts index 8029142a6fda..9b87d15c61b3 100644 --- a/src/cdk-experimental/radio-group/radio-group.ts +++ b/src/cdk-experimental/radio-group/radio-group.ts @@ -131,6 +131,7 @@ export class CdkRadioGroup { value: this._value, activeItem: signal(undefined), textDirection: this.textDirection, + toolbar: signal(undefined), // placeholder until Toolbar CDK is added }); /** Whether the radio group has received focus yet. */ diff --git a/src/cdk-experimental/ui-patterns/BUILD.bazel b/src/cdk-experimental/ui-patterns/BUILD.bazel index 0d4d60260c23..f58daabc832b 100644 --- a/src/cdk-experimental/ui-patterns/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/BUILD.bazel @@ -15,6 +15,7 @@ ts_project( "//src/cdk-experimental/ui-patterns/listbox", "//src/cdk-experimental/ui-patterns/radio-group", "//src/cdk-experimental/ui-patterns/tabs", + "//src/cdk-experimental/ui-patterns/toolbar", "//src/cdk-experimental/ui-patterns/tree", ], ) diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts index 6da57eeb3b8b..57f9b17bde0e 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts @@ -48,10 +48,14 @@ export class ListFocus { prevActiveItem = signal(undefined); /** The index of the last item that was active. */ - prevActiveIndex = computed(() => this.prevActiveItem()?.index() ?? -1); + prevActiveIndex = computed(() => { + return this.prevActiveItem() ? this.inputs.items().indexOf(this.prevActiveItem()!) : -1; + }); /** The current active index in the list. */ - activeIndex = computed(() => this.inputs.activeItem()?.index() ?? -1); + activeIndex = computed(() => { + return this.inputs.activeItem() ? this.inputs.items().indexOf(this.inputs.activeItem()!) : -1; + }); constructor(readonly inputs: ListFocusInputs) {} diff --git a/src/cdk-experimental/ui-patterns/public-api.ts b/src/cdk-experimental/ui-patterns/public-api.ts index 79f121e9f1e1..dd1c225735bb 100644 --- a/src/cdk-experimental/ui-patterns/public-api.ts +++ b/src/cdk-experimental/ui-patterns/public-api.ts @@ -13,3 +13,4 @@ export * from './radio-group/radio-button'; export * from './behaviors/signal-like/signal-like'; export * from './tabs/tabs'; export * from './accordion/accordion'; +export * from './toolbar/toolbar'; diff --git a/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts b/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts index 8d3e1f1b5fce..ac3ff5728712 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts @@ -10,13 +10,30 @@ import {computed} from '@angular/core'; import {SignalLike} from '../behaviors/signal-like/signal-like'; import {List, ListItem} from '../behaviors/list/list'; +/** + * Represents the properties exposed by a toolbar widget that need to be accessed by a radio group. + * This exists to avoid circular dependency errors between the toolbar and radio button. + */ +type ToolbarWidgetLike = { + id: SignalLike; + index: SignalLike; + element: SignalLike; + disabled: SignalLike; + searchTerm: SignalLike; + value: SignalLike; +}; + /** * 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 { /** The list behavior for the radio group. */ - listBehavior: List, V>; + listBehavior: List | ToolbarWidgetLike, V>; + /** Whether the list is readonly */ + readonly: SignalLike; + /** Whether the radio group is disabled. */ + disabled: SignalLike; } /** Represents the required inputs for a radio button in a radio group. */ @@ -34,7 +51,9 @@ export class RadioButtonPattern { value: SignalLike; /** The position of the radio button within the group. */ - index = computed(() => this.group()?.listBehavior.inputs.items().indexOf(this) ?? -1); + index: SignalLike = computed( + () => this.group()?.listBehavior.inputs.items().indexOf(this) ?? -1, + ); /** Whether the radio button is currently the active one (focused). */ active = computed(() => this.group()?.listBehavior.inputs.activeItem() === this); diff --git a/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts b/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts index 6945034909b8..60bde6eed5e7 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts @@ -22,12 +22,37 @@ export type RadioGroupInputs = Omit< /** Whether the radio group is readonly. */ readonly: SignalLike; + /** Parent toolbar of radio group */ + toolbar: SignalLike | undefined>; }; +/** + * Represents the properties exposed by a toolbar widget that need to be accessed by a radio group. + * This exists to avoid circular dependency errors between the toolbar and radio button. + */ +type ToolbarWidgetLike = { + id: SignalLike; + index: SignalLike; + element: SignalLike; + disabled: SignalLike; + searchTerm: SignalLike; + value: SignalLike; +}; + +/** + * Represents the properties exposed by a toolbar that need to be accessed by a radio group. + * This exists to avoid circular dependency errors between the toolbar and radio button. + */ +export interface ToolbarLike { + listBehavior: List | ToolbarWidgetLike, V>; + orientation: SignalLike<'vertical' | 'horizontal'>; + disabled: SignalLike; +} + /** Controls the state of a radio group. */ export class RadioGroupPattern { /** The list behavior for the radio group. */ - readonly listBehavior: List, V>; + readonly listBehavior: List | ToolbarWidgetLike, V>; /** Whether the radio group is vertically or horizontally oriented. */ orientation: SignalLike<'vertical' | 'horizontal'>; @@ -41,8 +66,8 @@ export class RadioGroupPattern { /** Whether the radio group is readonly. */ readonly = computed(() => this.selectedItem()?.disabled() || this.inputs.readonly()); - /** The tabindex of the radio group (if using activedescendant). */ - tabindex = computed(() => this.listBehavior.tabindex()); + /** The tabindex of the radio group. */ + tabindex = computed(() => (this.inputs.toolbar() ? -1 : this.listBehavior.tabindex())); /** The id of the current active radio button (if using activedescendant). */ activedescendant = computed(() => this.listBehavior.activedescendant()); @@ -67,6 +92,11 @@ export class RadioGroupPattern { keydown = computed(() => { const manager = new KeyboardEventManager(); + // When within a toolbar relinquish keyboard control + if (this.inputs.toolbar()) { + return manager; + } + // Readonly mode allows navigation but not selection changes. if (this.readonly()) { return manager @@ -91,6 +121,11 @@ export class RadioGroupPattern { pointerdown = computed(() => { const manager = new PointerEventManager(); + // When within a toolbar relinquish pointer control + if (this.inputs.toolbar()) { + return manager; + } + if (this.readonly()) { // Navigate focus only in readonly mode. return manager.on(e => this.listBehavior.goto(this._getItem(e)!)); @@ -101,13 +136,15 @@ export class RadioGroupPattern { }); constructor(readonly inputs: RadioGroupInputs) { - this.orientation = inputs.orientation; + this.orientation = + inputs.toolbar() !== undefined ? inputs.toolbar()!.orientation : inputs.orientation; this.listBehavior = new List({ ...inputs, - wrap: () => false, + activeItem: inputs.toolbar()?.listBehavior.inputs.activeItem ?? inputs.activeItem, + wrap: () => !!inputs.toolbar(), multi: () => false, - selectionMode: () => 'follow', + selectionMode: () => (inputs.toolbar() ? 'explicit' : 'follow'), typeaheadDelay: () => 0, // Radio groups do not support typeahead. }); } diff --git a/src/cdk-experimental/ui-patterns/radio-group/radio.spec.ts b/src/cdk-experimental/ui-patterns/radio-group/radio.spec.ts index 2aed6abfaa1f..1197b1699d5b 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio.spec.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio.spec.ts @@ -7,7 +7,7 @@ */ import {signal, WritableSignal} from '@angular/core'; -import {RadioGroupInputs, RadioGroupPattern} from './radio-group'; +import {RadioGroupInputs, RadioGroupPattern, ToolbarLike} from './radio-group'; import {RadioButtonPattern} from './radio-button'; import {createKeyboardEvent} from '@angular/cdk/testing/private'; import {ModifierKeys} from '@angular/cdk/testing'; @@ -39,6 +39,7 @@ describe('RadioGroup Pattern', () => { focusMode: inputs.focusMode ?? signal('roving'), textDirection: inputs.textDirection ?? signal('ltr'), orientation: inputs.orientation ?? signal('vertical'), + toolbar: inputs.toolbar ?? signal(undefined), }); } @@ -303,4 +304,49 @@ describe('RadioGroup Pattern', () => { expect(violations.length).toBe(1); }); }); + + describe('toolbar', () => { + let radioGroup: TestRadioGroup; + let radioButtons: TestRadio[]; + let toolbar: ToolbarLike; + + beforeEach(() => { + const patterns = getDefaultPatterns(); + radioGroup = patterns.radioGroup; + radioButtons = patterns.radioButtons; + toolbar = { + listBehavior: radioGroup.listBehavior, + orientation: radioGroup.orientation, + disabled: radioGroup.disabled, + }; + radioGroup.inputs.toolbar = signal(toolbar); + }); + + it('should ignore keyboard navigation when within a toolbar', () => { + const initialActive = radioGroup.inputs.activeItem(); + radioGroup.onKeydown(down()); + expect(radioGroup.inputs.activeItem()).toBe(initialActive); + }); + + it('should ignore keyboard selection when within a toolbar', () => { + expect(radioGroup.inputs.value()).toEqual([]); + radioGroup.onKeydown(space()); + expect(radioGroup.inputs.value()).toEqual([]); + radioGroup.onKeydown(enter()); + expect(radioGroup.inputs.value()).toEqual([]); + }); + + it('should ignore pointer events when within a toolbar', () => { + const initialActive = radioGroup.inputs.activeItem(); + expect(radioGroup.inputs.value()).toEqual([]); + + const clickEvent = { + target: radioButtons[1].element(), + } as unknown as PointerEvent; + radioGroup.onPointerdown(clickEvent); + + expect(radioGroup.inputs.activeItem()).toBe(initialActive); + expect(radioGroup.inputs.value()).toEqual([]); + }); + }); }); diff --git a/src/cdk-experimental/ui-patterns/toolbar/BUILD.bazel b/src/cdk-experimental/ui-patterns/toolbar/BUILD.bazel new file mode 100644 index 000000000000..d788b1d3e00b --- /dev/null +++ b/src/cdk-experimental/ui-patterns/toolbar/BUILD.bazel @@ -0,0 +1,17 @@ +load("//tools:defaults.bzl", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "toolbar", + srcs = [ + "toolbar.ts", + ], + deps = [ + "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/behaviors/event-manager", + "//src/cdk-experimental/ui-patterns/behaviors/list", + "//src/cdk-experimental/ui-patterns/behaviors/signal-like", + "//src/cdk-experimental/ui-patterns/radio-group", + ], +) diff --git a/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts b/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts new file mode 100644 index 000000000000..7689f286d41e --- /dev/null +++ b/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts @@ -0,0 +1,243 @@ +/** + * @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, PointerEventManager} from '../behaviors/event-manager'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; +import {RadioButtonPattern} from '../radio-group/radio-button'; +import {List, ListInputs, ListItem} from '../behaviors/list/list'; + +/** Represents the required inputs for a toolbar. */ +export type ToolbarInputs = Omit< + ListInputs, V>, + 'multi' | 'typeaheadDelay' | 'value' | 'selectionMode' +>; + +/** Controls the state of a toolbar. */ +export class ToolbarPattern { + /** The list behavior for the toolbar. */ + listBehavior: List, V>; + + /** Whether the tablist is vertically or horizontally oriented. */ + readonly orientation: SignalLike<'vertical' | 'horizontal'>; + + /** Whether the toolbar is disabled. */ + disabled = computed(() => this.listBehavior.disabled()); + + /** The tabindex of the toolbar (if using activedescendant). */ + tabindex = computed(() => this.listBehavior.tabindex()); + + /** The id of the current active widget (if using activedescendant). */ + activedescendant = computed(() => this.listBehavior.activedescendant()); + + /** The key used to navigate to the previous widget. */ + prevKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return 'ArrowUp'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + }); + + /** The key used to navigate to the next widget. */ + nextKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return 'ArrowDown'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + }); + + /** The alternate key used to navigate to the previous widget */ + altPrevKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + } + return 'ArrowUp'; + }); + + /** The alternate key used to navigate to the next widget. */ + altNextKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + } + return 'ArrowDown'; + }); + + /** The keydown event manager for the toolbar. */ + keydown = computed(() => { + const manager = new KeyboardEventManager(); + + const activeItem = this.inputs.activeItem(); + const isRadioButton = activeItem instanceof RadioButtonPattern; + + if (isRadioButton) { + manager + .on(' ', () => this.selectRadioButton()) + .on('Enter', () => this.selectRadioButton()) + .on(this.altNextKey, () => activeItem?.group()?.listBehavior.next()) + .on(this.altPrevKey, () => activeItem?.group()?.listBehavior.prev()); + } else { + manager.on(this.altNextKey, () => this.listBehavior.next()); + manager.on(this.altPrevKey, () => this.listBehavior.prev()); + } + + return manager + .on(this.prevKey, () => this.listBehavior.prev()) + .on(this.nextKey, () => this.listBehavior.next()) + .on('Home', () => this.listBehavior.first()) + .on('End', () => this.listBehavior.last()); + }); + + selectRadioButton() { + const activeItem = this.inputs.activeItem() as RadioButtonPattern; + + // activeItem must be a radio button + const group = activeItem!.group(); + if (group && !group.readonly() && !group.disabled()) { + group.listBehavior.selectOne(); + } + } + + /** The pointerdown event manager for the toolbar. */ + pointerdown = computed(() => new PointerEventManager().on(e => this.goto(e))); + + /** Navigates to the widget associated with the given pointer event. */ + goto(event: PointerEvent) { + const item = this._getItem(event); + if (!item) return; + + if (item instanceof RadioButtonPattern) { + const group = item.group(); + if (group && !group.disabled()) { + group.listBehavior.goto(item, {selectOne: !group.readonly()}); + } + } else { + this.listBehavior.goto(item); + } + } + + /** Handles keydown events for the toolbar. */ + onKeydown(event: KeyboardEvent) { + if (!this.disabled()) { + this.keydown().handle(event); + } + } + + /** Handles pointerdown events for the toolbar. */ + onPointerdown(event: PointerEvent) { + if (!this.disabled()) { + this.pointerdown().handle(event); + } + } + + /** Finds the Toolbar Widget associated with a pointer event target. */ + private _getItem(e: PointerEvent): RadioButtonPattern | ToolbarWidgetPattern | undefined { + if (!(e.target instanceof HTMLElement)) { + return undefined; + } + + // Assumes the target or its ancestor has role="radio" or role="button" + const element = e.target.closest('[role="button"], [role="radio"]'); + return this.inputs.items().find(i => i.element() === element); + } + + constructor(readonly inputs: ToolbarInputs) { + this.orientation = inputs.orientation; + + this.listBehavior = new List({ + ...inputs, + multi: () => false, + selectionMode: () => 'explicit', + value: signal([] as any), + typeaheadDelay: () => 0, // Toolbar widgets do not support typeahead. + }); + } + + /** + * Sets the toolbar to its default initial state. + * + * Sets the active index to the selected widget if one exists and is focusable. + * Otherwise, sets the active index to the first focusable widget. + */ + setDefaultState() { + let firstItem: RadioButtonPattern | ToolbarWidgetPattern | null = null; + + for (const item of this.inputs.items()) { + if (this.listBehavior.isFocusable(item)) { + if (!firstItem) { + firstItem = item; + } + if (item instanceof RadioButtonPattern && item.selected()) { + this.inputs.activeItem.set(item); + return; + } + } + } + + if (firstItem) { + this.inputs.activeItem.set(firstItem); + } + } + + /** Validates the state of the toolbar and returns a list of accessibility violations. */ + validate(): string[] { + const violations: string[] = []; + + if (this.inputs.skipDisabled()) { + for (const item of this.inputs.items()) { + if (item instanceof RadioButtonPattern && item.selected() && item.disabled()) { + violations.push( + "Accessibility Violation: A selected radio button inside the toolbar is disabled while 'skipDisabled' is true, making the selection unreachable via keyboard.", + ); + } + } + } + return violations; + } +} + +/** Represents the required inputs for a toolbar widget in a toolbar. */ +export interface ToolbarWidgetInputs extends Omit, 'searchTerm' | 'value' | 'index'> { + /** A reference to the parent toolbar. */ + parentToolbar: SignalLike>; +} + +export class ToolbarWidgetPattern { + /** A unique identifier for the widget. */ + id: SignalLike; + + /** The html element that should receive focus. */ + readonly element: SignalLike; + + /** Whether the widget is disabled. */ + disabled: SignalLike; + + /** A reference to the parent toolbar. */ + parentToolbar: SignalLike | undefined>; + + /** The tabindex of the widgdet. */ + tabindex = computed(() => this.inputs.parentToolbar().listBehavior.getItemTabindex(this)); + + /** The text used by the typeahead search. */ + readonly searchTerm = () => ''; // Unused because toolbar does not support typeahead. + + /** The value associated with the widget. */ + readonly value = () => '' as any; // Unused because toolbar does not support selection. + + /** The position of the widget within the toolbar. */ + index = computed(() => this.parentToolbar()?.inputs.items().indexOf(this) ?? -1); + + /** Whether the widget is currently the active one (focused). */ + active = computed(() => this.parentToolbar()?.inputs.activeItem() === this); + + constructor(readonly inputs: ToolbarWidgetInputs) { + this.id = inputs.id; + this.element = inputs.element; + this.disabled = inputs.disabled; + this.parentToolbar = inputs.parentToolbar; + } +}