From 610e25a1b37359daf478d23bcfdeddc0540490dc Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Thu, 28 Aug 2025 16:54:32 +0000 Subject: [PATCH] refactor(cdk-experimental/ui-patterns): add toolbar widget group to decouple toolbar and radio group --- .../radio-group/radio-group.ts | 112 ++-- src/cdk-experimental/toolbar/BUILD.bazel | 21 +- src/cdk-experimental/toolbar/public-api.ts | 2 +- src/cdk-experimental/toolbar/toolbar.spec.ts | 477 ++++++++++++++ src/cdk-experimental/toolbar/toolbar.ts | 133 ++-- .../list-navigation/list-navigation.spec.ts | 13 + .../list-navigation/list-navigation.ts | 20 +- .../ui-patterns/public-api.ts | 4 + .../ui-patterns/radio-group/BUILD.bazel | 3 + .../ui-patterns/radio-group/radio-button.ts | 49 +- .../ui-patterns/radio-group/radio-group.ts | 98 +-- .../ui-patterns/radio-group/radio.spec.ts | 49 +- .../radio-group/toolbar-radio-group.spec.ts | 212 ++++++ .../radio-group/toolbar-radio-group.ts | 91 +++ .../ui-patterns/toolbar/BUILD.bazel | 4 +- .../toolbar/toolbar-widget-group.ts | 105 +++ .../ui-patterns/toolbar/toolbar-widget.ts | 55 ++ .../ui-patterns/toolbar/toolbar.spec.ts | 616 ++++++++++-------- .../ui-patterns/toolbar/toolbar.ts | 254 ++++---- 19 files changed, 1665 insertions(+), 653 deletions(-) create mode 100644 src/cdk-experimental/toolbar/toolbar.spec.ts create mode 100644 src/cdk-experimental/ui-patterns/radio-group/toolbar-radio-group.spec.ts create mode 100644 src/cdk-experimental/ui-patterns/radio-group/toolbar-radio-group.ts create mode 100644 src/cdk-experimental/ui-patterns/toolbar/toolbar-widget-group.ts create mode 100644 src/cdk-experimental/ui-patterns/toolbar/toolbar-widget.ts diff --git a/src/cdk-experimental/radio-group/radio-group.ts b/src/cdk-experimental/radio-group/radio-group.ts index a5fc07124ed0..71c6052153cf 100644 --- a/src/cdk-experimental/radio-group/radio-group.ts +++ b/src/cdk-experimental/radio-group/radio-group.ts @@ -19,12 +19,17 @@ import { model, signal, WritableSignal, - OnDestroy, } from '@angular/core'; -import {RadioButtonPattern, RadioGroupPattern} from '../ui-patterns'; +import { + RadioButtonPattern, + RadioGroupInputs, + RadioGroupPattern, + ToolbarRadioGroupInputs, + ToolbarRadioGroupPattern, +} from '../ui-patterns'; import {Directionality} from '@angular/cdk/bidi'; import {_IdGenerator} from '@angular/cdk/a11y'; -import {CdkToolbar} from '../toolbar'; +import {CdkToolbarWidgetGroup} from '@angular/cdk-experimental/toolbar'; // TODO: Move mapSignal to it's own file so it can be reused across components. @@ -91,43 +96,49 @@ export function mapSignal( '(pointerdown)': 'pattern.onPointerdown($event)', '(focusin)': 'onFocus()', }, + hostDirectives: [ + { + directive: CdkToolbarWidgetGroup, + inputs: ['disabled'], + }, + ], }) export class CdkRadioGroup { /** A reference to the radio group element. */ private readonly _elementRef = inject(ElementRef); + /** A reference to the CdkToolbarWidgetGroup, if the radio group is in a toolbar. */ + private readonly _cdkToolbarWidgetGroup = inject(CdkToolbarWidgetGroup); + + /** Whether the radio group is inside of a CdkToolbar. */ + private readonly _hasToolbar = computed(() => !!this._cdkToolbarWidgetGroup.toolbar()); + /** The CdkRadioButtons nested inside of the CdkRadioGroup. */ private readonly _cdkRadioButtons = contentChildren(CdkRadioButton, {descendants: true}); /** A signal wrapper for directionality. */ protected textDirection = inject(Directionality).valueSignal; - /** A signal wrapper for toolbar. */ - toolbar = inject(CdkToolbar, {optional: true}); - - /** Toolbar pattern if applicable */ - private readonly _toolbarPattern = computed(() => this.toolbar?.pattern); - /** 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'>('vertical'); + readonly orientation = input<'vertical' | 'horizontal'>('vertical'); /** Whether disabled items in the group should be skipped when navigating. */ - skipDisabled = input(true, {transform: booleanAttribute}); + readonly skipDisabled = input(true, {transform: booleanAttribute}); /** The focus strategy used by the radio group. */ - focusMode = input<'roving' | 'activedescendant'>('roving'); + readonly focusMode = input<'roving' | 'activedescendant'>('roving'); /** Whether the radio group is disabled. */ - disabled = input(false, {transform: booleanAttribute}); + readonly disabled = input(false, {transform: booleanAttribute}); /** Whether the radio group is readonly. */ - readonly = input(false, {transform: booleanAttribute}); + readonly readonly = input(false, {transform: booleanAttribute}); /** The value of the currently selected radio button. */ - value = model(null); + readonly value = model(null); /** The internal selection state for the radio group. */ private readonly _value = mapSignal(this.value, { @@ -136,22 +147,37 @@ export class CdkRadioGroup { }); /** The RadioGroup UIPattern. */ - pattern: RadioGroupPattern = new RadioGroupPattern({ - ...this, - items: this.items, - value: this._value, - activeItem: signal(undefined), - textDirection: this.textDirection, - toolbar: this._toolbarPattern, - element: () => this._elementRef.nativeElement, - focusMode: this._toolbarPattern()?.inputs.focusMode ?? this.focusMode, - skipDisabled: this._toolbarPattern()?.inputs.skipDisabled ?? this.skipDisabled, - }); + readonly pattern: RadioGroupPattern; /** Whether the radio group has received focus yet. */ private _hasFocused = signal(false); constructor() { + const inputs: RadioGroupInputs | ToolbarRadioGroupInputs = { + ...this, + items: this.items, + value: this._value, + activeItem: signal(undefined), + textDirection: this.textDirection, + element: () => this._elementRef.nativeElement, + getItem: e => { + if (!(e.target instanceof HTMLElement)) { + return undefined; + } + const element = e.target.closest('[role="radio"]'); + return this.items().find(i => i.element() === element); + }, + toolbar: this._cdkToolbarWidgetGroup.toolbar, + }; + + this.pattern = this._hasToolbar() + ? new ToolbarRadioGroupPattern(inputs as ToolbarRadioGroupInputs) + : new RadioGroupPattern(inputs as RadioGroupInputs); + + if (this._hasToolbar()) { + this._cdkToolbarWidgetGroup.controls.set(this.pattern as ToolbarRadioGroupPattern); + } + afterRenderEffect(() => { if (typeof ngDevMode === 'undefined' || ngDevMode) { const violations = this.pattern.validate(); @@ -162,35 +188,15 @@ export class CdkRadioGroup { }); afterRenderEffect(() => { - if (!this._hasFocused() && !this.toolbar) { + if (!this._hasFocused() && !this._hasToolbar()) { this.pattern.setDefaultState(); } }); - - // TODO: Refactor to be handled within list behavior - afterRenderEffect(() => { - if (this.toolbar) { - const radioButtons = this._cdkRadioButtons(); - // If the group is disabled and the toolbar is set to skip disabled items, - // the radio buttons should not be part of the toolbar's navigation. - if (this.disabled() && this.toolbar.skipDisabled()) { - radioButtons.forEach(radio => this.toolbar!.unregister(radio)); - } else { - radioButtons.forEach(radio => this.toolbar!.register(radio)); - } - } - }); } onFocus() { this._hasFocused.set(true); } - - toolbarButtonUnregister(radio: CdkRadioButton) { - if (this.toolbar) { - this.toolbar.unregister(radio); - } - } } /** A selectable radio button in a CdkRadioGroup. */ @@ -207,7 +213,7 @@ export class CdkRadioGroup { '[id]': 'pattern.id()', }, }) -export class CdkRadioButton implements OnDestroy { +export class CdkRadioButton { /** A reference to the radio button element. */ private readonly _elementRef = inject(ElementRef); @@ -218,13 +224,13 @@ export class CdkRadioButton implements OnDestroy { private readonly _generatedId = inject(_IdGenerator).getId('cdk-radio-button-'); /** A unique identifier for the radio button. */ - protected id = computed(() => this._generatedId); + readonly id = computed(() => this._generatedId); /** The value associated with the radio button. */ readonly value = input.required(); /** The parent RadioGroup UIPattern. */ - protected group = computed(() => this._cdkRadioGroup.pattern); + readonly group = computed(() => this._cdkRadioGroup.pattern); /** A reference to the radio button element to be focused on navigation. */ element = computed(() => this._elementRef.nativeElement); @@ -240,10 +246,4 @@ export class CdkRadioButton implements OnDestroy { group: this.group, element: this.element, }); - - ngOnDestroy() { - if (this._cdkRadioGroup.toolbar) { - this._cdkRadioGroup.toolbarButtonUnregister(this); - } - } } diff --git a/src/cdk-experimental/toolbar/BUILD.bazel b/src/cdk-experimental/toolbar/BUILD.bazel index 667f7b86dd99..86ede70e5285 100644 --- a/src/cdk-experimental/toolbar/BUILD.bazel +++ b/src/cdk-experimental/toolbar/BUILD.bazel @@ -1,4 +1,4 @@ -load("//tools:defaults.bzl", "ng_project") +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") package(default_visibility = ["//visibility:public"]) @@ -15,3 +15,22 @@ ng_project( "//src/cdk/bidi", ], ) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = [ + "toolbar.spec.ts", + ], + deps = [ + ":toolbar", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/toolbar/public-api.ts b/src/cdk-experimental/toolbar/public-api.ts index ea524ae5a225..61681240782d 100644 --- a/src/cdk-experimental/toolbar/public-api.ts +++ b/src/cdk-experimental/toolbar/public-api.ts @@ -6,4 +6,4 @@ * found in the LICENSE file at https://angular.dev/license */ -export {CdkToolbar, CdkToolbarWidget} from './toolbar'; +export {CdkToolbar, CdkToolbarWidget, CdkToolbarWidgetGroup} from './toolbar'; diff --git a/src/cdk-experimental/toolbar/toolbar.spec.ts b/src/cdk-experimental/toolbar/toolbar.spec.ts new file mode 100644 index 000000000000..70a390fdf87a --- /dev/null +++ b/src/cdk-experimental/toolbar/toolbar.spec.ts @@ -0,0 +1,477 @@ +import {Component, inject, signal} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {Direction} from '@angular/cdk/bidi'; +import {provideFakeDirectionality} from '@angular/cdk/testing/private'; +import {CdkToolbar, CdkToolbarWidget, CdkToolbarWidgetGroup} from './toolbar'; + +interface ModifierKeys { + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + metaKey?: boolean; +} + +describe('CdkToolbar', () => { + let fixture: ComponentFixture; + let testComponent: TestToolbarComponent; + let toolbarElement: HTMLElement; + let widgetElements: HTMLElement[]; + let testWidgetGroupInstance: TestToolbarWidgetGroup; + + const keydown = (key: string, modifierKeys: ModifierKeys = {}) => { + const event = new KeyboardEvent('keydown', {key, bubbles: true, ...modifierKeys}); + toolbarElement.dispatchEvent(event); + fixture.detectChanges(); + defineTestVariables(); + }; + + const pointerDown = (target: HTMLElement, eventInit: PointerEventInit = {}) => { + target.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true, ...eventInit})); + fixture.detectChanges(); + defineTestVariables(); + }; + + const up = (modifierKeys?: ModifierKeys) => keydown('ArrowUp', modifierKeys); + const down = (modifierKeys?: ModifierKeys) => keydown('ArrowDown', modifierKeys); + const left = (modifierKeys?: ModifierKeys) => keydown('ArrowLeft', modifierKeys); + const right = (modifierKeys?: ModifierKeys) => keydown('ArrowRight', modifierKeys); + const home = (modifierKeys?: ModifierKeys) => keydown('Home', modifierKeys); + const end = (modifierKeys?: ModifierKeys) => keydown('End', modifierKeys); + const enter = (modifierKeys?: ModifierKeys) => keydown('Enter', modifierKeys); + const space = (modifierKeys?: ModifierKeys) => keydown(' ', modifierKeys); + const click = (target: HTMLElement) => pointerDown(target); + + function setupTestToolbar(textDirection: Direction = 'ltr') { + TestBed.configureTestingModule({ + imports: [CdkToolbar, CdkToolbarWidget, CdkToolbarWidgetGroup, TestToolbarComponent], + providers: [provideFakeDirectionality(textDirection)], + }); + + fixture = TestBed.createComponent(TestToolbarComponent); + testComponent = fixture.componentInstance; + + fixture.detectChanges(); + defineTestVariables(); + } + + function defineTestVariables() { + const toolbarDebugElement = fixture.debugElement.query(By.directive(CdkToolbar)); + const widgetDebugElements = fixture.debugElement.queryAll(By.css('[data-value="widget"]')); + const testWidgetGroupElement = fixture.debugElement.query(By.directive(TestToolbarWidgetGroup)); + + toolbarElement = toolbarDebugElement.nativeElement as HTMLElement; + widgetElements = widgetDebugElements.map(debugEl => debugEl.nativeElement); + testWidgetGroupInstance = testWidgetGroupElement.componentInstance as TestToolbarWidgetGroup; + } + + function updateToolbar( + config: { + disabled?: boolean; + widgetGroupDisabled?: boolean; + orientation?: 'horizontal' | 'vertical'; + wrap?: boolean; + skipDisabled?: boolean; + } = {}, + ) { + if (config.disabled !== undefined) testComponent.disabled.set(config.disabled); + if (config.widgetGroupDisabled !== undefined) + testComponent.widgetGroupDisabled.set(config.widgetGroupDisabled); + if (config.orientation !== undefined) testComponent.orientation.set(config.orientation); + if (config.wrap !== undefined) testComponent.wrap.set(config.wrap); + if (config.skipDisabled !== undefined) testComponent.skipDisabled.set(config.skipDisabled); + + fixture.detectChanges(); + defineTestVariables(); + } + + describe('ARIA attributes and roles', () => { + describe('default configuration', () => { + beforeEach(() => { + setupTestToolbar(); + }); + + it('should correctly set the role attribute to "toolbar" for CdkToolbar', () => { + expect(toolbarElement.getAttribute('role')).toBe('toolbar'); + }); + + it('should set aria-orientation to "horizontal" by default', () => { + expect(toolbarElement.getAttribute('aria-orientation')).toBe('horizontal'); + }); + + it('should set aria-disabled to "false" by default for the toolbar', () => { + expect(toolbarElement.getAttribute('aria-disabled')).toBe('false'); + }); + + it('should set aria-disabled to "false" by default for widgets', () => { + expect(widgetElements[0].getAttribute('aria-disabled')).toBe('false'); + expect(widgetElements[1].getAttribute('aria-disabled')).toBe('true'); + expect(widgetElements[2].getAttribute('aria-disabled')).toBe('false'); + }); + + it('should set initial focus (tabindex="0") on the first non-disabled widget', () => { + expect(widgetElements[0].getAttribute('tabindex')).toBe('0'); + expect(widgetElements[1].getAttribute('tabindex')).toBe('-1'); + expect(widgetElements[2].getAttribute('tabindex')).toBe('-1'); + }); + + it('should not have aria-activedescendant by default', () => { + expect(toolbarElement.hasAttribute('aria-activedescendant')).toBe(false); + }); + }); + + describe('custom configuration', () => { + beforeEach(() => { + setupTestToolbar(); + }); + + it('should set aria-orientation to "vertical"', () => { + updateToolbar({orientation: 'vertical'}); + expect(toolbarElement.getAttribute('aria-orientation')).toBe('vertical'); + }); + + it('should set aria-disabled to "true" for the toolbar', () => { + updateToolbar({disabled: true}); + expect(toolbarElement.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should set aria-disabled to "true" for all widgets when toolbar is disabled', () => { + updateToolbar({disabled: true}); + expect(widgetElements[0].getAttribute('aria-disabled')).toBe('true'); + expect(widgetElements[1].getAttribute('aria-disabled')).toBe('true'); + expect(widgetElements[2].getAttribute('aria-disabled')).toBe('true'); + }); + }); + }); + + describe('keyboard navigation', () => { + describe('LTR', () => { + beforeEach(() => { + setupTestToolbar('ltr'); + updateToolbar({widgetGroupDisabled: true}); + }); + + describe('vertical orientation', () => { + beforeEach(() => { + updateToolbar({orientation: 'vertical'}); + }); + + it('should move focus to the next widget on ArrowDown', () => { + down(); + expect(document.activeElement).toBe(widgetElements[2]); + }); + + it('should move focus to the previous widget on ArrowUp', () => { + down(); + expect(document.activeElement).toBe(widgetElements[2]); + + up(); + expect(document.activeElement).toBe(widgetElements[0]); + }); + }); + + describe('horizontal orientation', () => { + beforeEach(() => { + updateToolbar({orientation: 'horizontal'}); + }); + + it('should move focus to the next widget on ArrowRight', () => { + right(); + expect(document.activeElement).toBe(widgetElements[2]); + }); + + it('should move focus to the previous widget on ArrowLeft', () => { + right(); + expect(document.activeElement).toBe(widgetElements[2]); + + left(); + expect(document.activeElement).toBe(widgetElements[0]); + }); + }); + + it('should move focus to the last enabled widget on End', () => { + end(); + expect(document.activeElement).toBe(widgetElements[2]); + }); + + it('should move focus to the first enabled widget on Home', () => { + end(); + expect(document.activeElement).toBe(widgetElements[2]); + + home(); + expect(document.activeElement).toBe(widgetElements[0]); + }); + + it('should skip disabled widgets with arrow keys if skipDisabled=true', () => { + updateToolbar({skipDisabled: true}); + right(); + expect(document.activeElement).toBe(widgetElements[2]); + }); + + it('should not skip disabled widgets with arrow keys if skipDisabled=false', () => { + updateToolbar({skipDisabled: false}); + right(); + expect(document.activeElement).toBe(widgetElements[1]); + }); + + it('should wrap focus from last to first when wrap is true', () => { + updateToolbar({wrap: true}); + end(); + expect(document.activeElement).toBe(widgetElements[2]); + + right(); + expect(document.activeElement).toBe(widgetElements[0]); + }); + + it('should not wrap focus from last to first when wrap is false', () => { + updateToolbar({wrap: false}); + end(); + expect(document.activeElement).toBe(widgetElements[2]); + + right(); + expect(document.activeElement).toBe(widgetElements[2]); + }); + }); + + describe('RTL', () => { + beforeEach(() => { + setupTestToolbar('rtl'); + updateToolbar({widgetGroupDisabled: true, orientation: 'horizontal'}); + }); + + describe('horizontal orientation', () => { + it('should move focus to the next widget on ArrowLeft', () => { + left(); + expect(document.activeElement).toBe(widgetElements[2]); + }); + + it('should move focus to the previous widget on ArrowRight', () => { + left(); + expect(document.activeElement).toBe(widgetElements[2]); + + right(); + expect(document.activeElement).toBe(widgetElements[0]); + }); + }); + }); + }); + + describe('pointer navigation', () => { + beforeEach(() => setupTestToolbar()); + + it('should move focus to the clicked widget', () => { + click(widgetElements[2]); + expect(document.activeElement).toBe(widgetElements[2]); + }); + + it('should move focus to the clicked disabled widget if skipDisabled=false', () => { + updateToolbar({skipDisabled: false}); + click(widgetElements[1]); + expect(document.activeElement).toBe(widgetElements[1]); + }); + + it('should not move focus to the clicked disabled widget if skipDisabled=true', () => { + updateToolbar({skipDisabled: true}); + const initiallyFocused = document.activeElement; + + click(widgetElements[1]); + + expect(document.activeElement).toBe(initiallyFocused); + }); + }); + + describe('widget group', () => { + describe('LTR', () => { + beforeEach(() => { + setupTestToolbar('ltr'); + const widgetGroupElement = testWidgetGroupInstance.cdkToolbarWidgetGroup.element(); + click(widgetGroupElement); + testWidgetGroupInstance.lastAction.set(undefined); + }); + + describe('vertical orientation', () => { + beforeEach(() => { + updateToolbar({orientation: 'vertical'}); + }); + + it('should call "next" on ArrowDown', () => { + down(); + expect(testWidgetGroupInstance.lastAction()).toBe('next'); + }); + + it('should call "prev" on ArrowUp', () => { + up(); + expect(testWidgetGroupInstance.lastAction()).toBe('prev'); + }); + + it('should call "next" with wrap on ArrowRight', () => { + right(); + expect(testWidgetGroupInstance.lastAction()).toBe('nextWithWrap'); + }); + + it('should call "prev" with wrap on ArrowLeft', () => { + left(); + expect(testWidgetGroupInstance.lastAction()).toBe('prevWithWrap'); + }); + }); + + describe('horizontal orientation', () => { + beforeEach(() => { + updateToolbar({orientation: 'horizontal'}); + }); + + it('should call "next" on ArrowRight', () => { + right(); + expect(testWidgetGroupInstance.lastAction()).toBe('next'); + }); + + it('should call "prev" on ArrowLeft', () => { + left(); + expect(testWidgetGroupInstance.lastAction()).toBe('prev'); + }); + + it('should call "next" with wrap on ArrowDown', () => { + down(); + expect(testWidgetGroupInstance.lastAction()).toBe('nextWithWrap'); + }); + + it('should call "prev" with wrap on ArrowUp', () => { + up(); + expect(testWidgetGroupInstance.lastAction()).toBe('prevWithWrap'); + }); + }); + + it('should call "unfocus" on Home key', () => { + home(); + expect(testWidgetGroupInstance.lastAction()).toBe('unfocus'); + expect(document.activeElement).toBe(widgetElements[0]); + }); + + it('should call "unfocus" on End key', () => { + end(); + expect(testWidgetGroupInstance.lastAction()).toBe('unfocus'); + expect(document.activeElement).toBe(widgetElements[2]); + }); + + it('should call "trigger" on Enter key', () => { + enter(); + expect(testWidgetGroupInstance.lastAction()).toBe('trigger'); + }); + + it('should call "trigger" on Space key', () => { + space(); + expect(testWidgetGroupInstance.lastAction()).toBe('trigger'); + }); + + it('should call "first" when navigating into a group from the previous widget', () => { + click(widgetElements[0]); + right(); + expect(testWidgetGroupInstance.lastAction()).toBe('first'); + }); + + it('should call "last" when navigating into a group from the next widget', () => { + click(widgetElements[2]); + left(); + expect(testWidgetGroupInstance.lastAction()).toBe('last'); + }); + + it('should call "goto" on click', () => { + click(testWidgetGroupInstance.cdkToolbarWidgetGroup.element()); + expect(testWidgetGroupInstance.lastAction()).toBe('goto'); + }); + }); + + describe('RTL', () => { + beforeEach(() => { + setupTestToolbar('rtl'); + const widgetGroupElement = testWidgetGroupInstance.cdkToolbarWidgetGroup.element(); + click(widgetGroupElement); + testWidgetGroupInstance.lastAction.set(undefined); + updateToolbar({orientation: 'horizontal'}); + }); + + describe('horizontal orientation', () => { + it('should call "next" on ArrowLeft', () => { + left(); + expect(testWidgetGroupInstance.lastAction()).toBe('next'); + }); + + it('should call "prev" on ArrowRight', () => { + right(); + expect(testWidgetGroupInstance.lastAction()).toBe('prev'); + }); + }); + }); + }); +}); + +@Component({ + template: 'a black box', + selector: 'testWidgetGroup', + hostDirectives: [ + { + directive: CdkToolbarWidgetGroup, + inputs: ['disabled'], + }, + ], +}) +class TestToolbarWidgetGroup { + readonly cdkToolbarWidgetGroup = inject(CdkToolbarWidgetGroup); + readonly lastAction = signal(undefined); + + constructor() { + this.cdkToolbarWidgetGroup.controls.set({ + isOnFirstItem: () => false, + isOnLastItem: () => false, + next: wrap => { + this.lastAction.set(wrap ? 'nextWithWrap' : 'next'); + }, + prev: wrap => { + this.lastAction.set(wrap ? 'prevWithWrap' : 'prev'); + }, + first: () => { + this.lastAction.set('first'); + }, + last: () => { + this.lastAction.set('last'); + }, + unfocus: () => { + this.lastAction.set('unfocus'); + }, + trigger: () => { + this.lastAction.set('trigger'); + }, + goto: () => { + this.lastAction.set('goto'); + }, + setDefaultState: () => { + this.lastAction.set('setDefaultState'); + }, + }); + } +} + +@Component({ + template: ` +
+ + + + +
+ `, + imports: [CdkToolbar, CdkToolbarWidget, CdkToolbarWidgetGroup, TestToolbarWidgetGroup], +}) +class TestToolbarComponent { + orientation = signal<'vertical' | 'horizontal'>('horizontal'); + disabled = signal(false); + widgetGroupDisabled = signal(false); + wrap = signal(true); + skipDisabled = signal(true); +} diff --git a/src/cdk-experimental/toolbar/toolbar.ts b/src/cdk-experimental/toolbar/toolbar.ts index 884bfc487682..41912fc7428d 100644 --- a/src/cdk-experimental/toolbar/toolbar.ts +++ b/src/cdk-experimental/toolbar/toolbar.ts @@ -19,20 +19,15 @@ import { OnInit, OnDestroy, } from '@angular/core'; -import {ToolbarPattern, RadioButtonPattern, ToolbarWidgetPattern} from '../ui-patterns'; +import { + ToolbarPattern, + ToolbarWidgetPattern, + ToolbarWidgetGroupPattern, + ToolbarWidgetGroupControls, +} from '../ui-patterns'; import {Directionality} from '@angular/cdk/bidi'; import {_IdGenerator} from '@angular/cdk/a11y'; -/** Interface for a radio button that can be used with a toolbar. Based on radio-button in ui-patterns */ -interface CdkRadioButtonInterface { - /** The HTML element associated with the radio button. */ - element: Signal; - /** Whether the radio button is disabled. */ - disabled: Signal; - - pattern: RadioButtonPattern; -} - interface HasElement { element: Signal; } @@ -73,7 +68,6 @@ function sortDirectives(a: HasElement, b: HasElement) { '[attr.tabindex]': 'pattern.tabindex()', '[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()', @@ -84,51 +78,41 @@ export class CdkToolbar { private readonly _elementRef = inject(ElementRef); /** The CdkTabList nested inside of the container. */ - private readonly _cdkWidgets = signal(new Set | CdkToolbarWidget>()); + private readonly _cdkWidgets = signal(new Set | CdkToolbarWidgetGroup>()); /** A signal wrapper for directionality. */ - textDirection = inject(Directionality).valueSignal; + readonly textDirection = inject(Directionality).valueSignal; /** Sorted UIPatterns of the child widgets */ - items = computed(() => + readonly items = computed(() => [...this._cdkWidgets()].sort(sortDirectives).map(widget => widget.pattern), ); /** Whether the toolbar is vertically or horizontally oriented. */ - orientation = input<'vertical' | 'horizontal'>('horizontal'); + readonly orientation = input<'vertical' | 'horizontal'>('horizontal'); /** Whether disabled items in the group should be skipped when navigating. */ - skipDisabled = input(false, {transform: booleanAttribute}); + readonly skipDisabled = input(false, {transform: booleanAttribute}); /** Whether the toolbar is disabled. */ - disabled = input(false, {transform: booleanAttribute}); + readonly disabled = input(false, {transform: booleanAttribute}); /** Whether focus should wrap when navigating. */ readonly wrap = input(true, {transform: booleanAttribute}); /** The toolbar UIPattern. */ - pattern: ToolbarPattern = new ToolbarPattern({ + readonly pattern: ToolbarPattern = new ToolbarPattern({ ...this, activeItem: signal(undefined), textDirection: this.textDirection, - focusMode: signal('roving'), element: () => this._elementRef.nativeElement, + getItem: e => this._getItem(e), }); /** Whether the toolbar has received focus yet. */ private _hasFocused = signal(false); - onFocus() { - this._hasFocused.set(true); - } - constructor() { - afterRenderEffect(() => { - if (!this._hasFocused()) { - this.pattern.setDefaultState(); - } - }); - afterRenderEffect(() => { if (typeof ngDevMode === 'undefined' || ngDevMode) { const violations = this.pattern.validate(); @@ -137,9 +121,19 @@ export class CdkToolbar { } } }); + + afterRenderEffect(() => { + if (!this._hasFocused()) { + this.pattern.setDefaultState(); + } + }); } - register(widget: CdkRadioButtonInterface | CdkToolbarWidget) { + onFocus() { + this._hasFocused.set(true); + } + + register(widget: CdkToolbarWidget | CdkToolbarWidgetGroup) { const widgets = this._cdkWidgets(); if (!widgets.has(widget)) { widgets.add(widget); @@ -147,12 +141,21 @@ export class CdkToolbar { } } - unregister(widget: CdkRadioButtonInterface | CdkToolbarWidget) { + unregister(widget: CdkToolbarWidget | CdkToolbarWidgetGroup) { const widgets = this._cdkWidgets(); if (widgets.delete(widget)) { this._cdkWidgets.set(new Set(widgets)); } } + + /** Finds the toolbar item associated with a given element. */ + private _getItem(element: Element) { + const widgetTarget = element.closest('.cdk-toolbar-widget'); + const groupTarget = element.closest('.cdk-toolbar-widget-group'); + return this.items().find( + widget => widget.element() === widgetTarget || widget.element() === groupTarget, + ); + } } /** @@ -165,7 +168,6 @@ export class CdkToolbar { selector: '[cdkToolbarWidget]', exportAs: 'cdkToolbarWidget', host: { - 'role': 'button', 'class': 'cdk-toolbar-widget', '[class.cdk-active]': 'pattern.active()', '[attr.tabindex]': 'pattern.tabindex()', @@ -175,7 +177,7 @@ export class CdkToolbar { '[id]': 'pattern.id()', }, }) -export class CdkToolbarWidget implements OnInit, OnDestroy { +export class CdkToolbarWidget implements OnInit, OnDestroy { /** A reference to the widget element. */ private readonly _elementRef = inject(ElementRef); @@ -186,27 +188,28 @@ export class CdkToolbarWidget implements OnInit, OnDestroy { private readonly _generatedId = inject(_IdGenerator).getId('cdk-toolbar-widget-'); /** A unique identifier for the widget. */ - protected id = computed(() => this._generatedId); + readonly id = computed(() => this._generatedId); /** The parent Toolbar UIPattern. */ - protected parentToolbar = computed(() => this._cdkToolbar.pattern); + readonly toolbar = computed(() => this._cdkToolbar.pattern); /** A reference to the widget element to be focused on navigation. */ - element = computed(() => this._elementRef.nativeElement); + readonly element = computed(() => this._elementRef.nativeElement); /** Whether the widget is disabled. */ - disabled = input(false, {transform: booleanAttribute}); + readonly disabled = input(false, {transform: booleanAttribute}); + /** Whether the widget is 'hard' disabled, which is different from `aria-disabled`. A hard disabled widget cannot receive focus. */ readonly hardDisabled = computed( () => this.pattern.disabled() && this._cdkToolbar.skipDisabled(), ); - pattern = new ToolbarWidgetPattern({ + /** The ToolbarWidget UIPattern. */ + readonly pattern = new ToolbarWidgetPattern({ ...this, id: this.id, element: this.element, disabled: computed(() => this._cdkToolbar.disabled() || this.disabled()), - parentToolbar: this.parentToolbar, }); ngOnInit() { @@ -217,3 +220,53 @@ export class CdkToolbarWidget implements OnInit, OnDestroy { this._cdkToolbar.unregister(this); } } + +/** + * A directive that groups toolbar widgets, used for more complex widgets like radio groups that + * have their own internal navigation. + */ +@Directive({ + host: { + '[class.cdk-toolbar-widget-group]': '!!toolbar()', + }, +}) +export class CdkToolbarWidgetGroup implements OnInit, OnDestroy { + /** A reference to the widget element. */ + private readonly _elementRef = inject(ElementRef); + + /** The parent CdkToolbar. */ + private readonly _cdkToolbar = inject(CdkToolbar, {optional: true}); + + /** A unique identifier for the widget. */ + private readonly _generatedId = inject(_IdGenerator).getId('cdk-toolbar-widget-group-'); + + /** A unique identifier for the widget. */ + readonly id = computed(() => this._generatedId); + + /** The parent Toolbar UIPattern. */ + readonly toolbar = computed(() => this._cdkToolbar?.pattern); + + /** A reference to the widget element to be focused on navigation. */ + readonly element = computed(() => this._elementRef.nativeElement); + + /** Whether the widget group is disabled. */ + readonly disabled = input(false, {transform: booleanAttribute}); + + /** The controls that can be performed on the widget group. */ + readonly controls = signal(undefined); + + /** The ToolbarWidgetGroup UIPattern. */ + readonly pattern = new ToolbarWidgetGroupPattern({ + ...this, + id: this.id, + element: this.element, + }); + + ngOnInit() { + this._cdkToolbar?.register(this); + } + + ngOnDestroy() { + this._cdkToolbar?.unregister(this); + } +} diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.spec.ts index dd02a5b3e920..04bc68f605af 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.spec.ts @@ -46,6 +46,12 @@ describe('List Navigation', () => { expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[1]); }); + it('should peek next item', () => { + const nav = getNavigation(); + expect(nav.peekNext()).toBe(nav.inputs.items()[1]); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[0]); + }); + it('should wrap', () => { const nav = getNavigation({wrap: signal(true)}); nav.next(); // 0 -> 1 @@ -124,6 +130,13 @@ describe('List Navigation', () => { expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[1]); }); + it('should peek previous item', () => { + const nav = getNavigation(); + nav.goto(nav.inputs.items()[2]); + expect(nav.peekPrev()).toBe(nav.inputs.items()[1]); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[2]); + }); + it('should wrap', () => { const nav = getNavigation({wrap: signal(true)}); nav.prev(); // 0 -> 4 diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.ts b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.ts index 57d576789cdb..198880d8a585 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.ts @@ -38,11 +38,21 @@ export class ListNavigation { return this._advance(1); } + /** Peeks the next item in the list. */ + peekNext(): T | undefined { + return this._peek(1); + } + /** Navigates to the previous item in the list. */ prev(): boolean { return this._advance(-1); } + /** Peeks the previous item in the list. */ + peekPrev(): T | undefined { + return this._peek(-1); + } + /** Navigates to the first item in the list. */ first(): boolean { const item = this.inputs.items().find(i => this.inputs.focusManager.isFocusable(i)); @@ -62,6 +72,12 @@ export class ListNavigation { /** Advances to the next or previous focusable item in the list based on the given delta. */ private _advance(delta: 1 | -1): boolean { + const item = this._peek(delta); + return item ? this.goto(item) : false; + } + + /** Peeks the next or previous focusable item in the list based on the given delta. */ + private _peek(delta: 1 | -1): T | undefined { const items = this.inputs.items(); const itemCount = items.length; const startIndex = this.inputs.focusManager.activeIndex(); @@ -73,10 +89,10 @@ export class ListNavigation { // when the index goes out of bounds. for (let i = step(startIndex); i !== startIndex && i < itemCount && i >= 0; i = step(i)) { if (this.inputs.focusManager.isFocusable(items[i])) { - return this.goto(items[i]); + return items[i]; } } - return false; + return; } } diff --git a/src/cdk-experimental/ui-patterns/public-api.ts b/src/cdk-experimental/ui-patterns/public-api.ts index dd1c225735bb..7260025d2930 100644 --- a/src/cdk-experimental/ui-patterns/public-api.ts +++ b/src/cdk-experimental/ui-patterns/public-api.ts @@ -10,7 +10,11 @@ export * from './listbox/listbox'; export * from './listbox/option'; export * from './radio-group/radio-group'; export * from './radio-group/radio-button'; +export * from './radio-group/toolbar-radio-group'; export * from './behaviors/signal-like/signal-like'; export * from './tabs/tabs'; +export * from './toolbar/toolbar'; +export * from './toolbar/toolbar-widget'; +export * from './toolbar/toolbar-widget-group'; export * from './accordion/accordion'; export * from './toolbar/toolbar'; diff --git a/src/cdk-experimental/ui-patterns/radio-group/BUILD.bazel b/src/cdk-experimental/ui-patterns/radio-group/BUILD.bazel index 21687e35c20a..b43b3d1bae35 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/radio-group/BUILD.bazel @@ -7,12 +7,14 @@ ts_project( srcs = [ "radio-button.ts", "radio-group.ts", + "toolbar-radio-group.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/toolbar", ], ) @@ -23,6 +25,7 @@ ts_project( deps = [ ":radio-group", "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/toolbar", "//src/cdk/keycodes", "//src/cdk/testing/private", ], 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 ac3ff5728712..a2f49050f836 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts @@ -8,72 +8,47 @@ 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 | ToolbarWidgetLike, V>; - /** Whether the list is readonly */ - readonly: SignalLike; - /** Whether the radio group is disabled. */ - disabled: SignalLike; -} +import {ListItem} from '../behaviors/list/list'; +import type {RadioGroupPattern} from './radio-group'; /** Represents the required inputs for a radio button in a radio group. */ export interface RadioButtonInputs extends Omit, 'searchTerm' | 'index'> { /** A reference to the parent radio group. */ - group: SignalLike | undefined>; + group: SignalLike | undefined>; } /** Represents a radio button within a radio group. */ export class RadioButtonPattern { /** A unique identifier for the radio button. */ - id: SignalLike; + readonly id: SignalLike; /** The value associated with the radio button. */ - value: SignalLike; + readonly value: SignalLike; /** The position of the radio button within the group. */ - index: SignalLike = computed( + readonly 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); + readonly active = computed(() => this.group()?.listBehavior.inputs.activeItem() === this); /** Whether the radio button is selected. */ - selected: SignalLike = computed( + readonly selected: SignalLike = computed( () => !!this.group()?.listBehavior.inputs.value().includes(this.value()), ); /** Whether the radio button is disabled. */ - disabled: SignalLike; + readonly disabled: SignalLike; /** A reference to the parent radio group. */ - group: SignalLike | undefined>; + readonly group: SignalLike | undefined>; /** The tabindex of the radio button. */ - tabindex = computed(() => this.group()?.listBehavior.getItemTabindex(this)); + readonly tabindex = computed(() => this.group()?.listBehavior.getItemTabindex(this)); /** The HTML element associated with the radio button. */ - element: SignalLike; + readonly element: SignalLike; /** The search term for typeahead. */ readonly searchTerm = () => ''; // Radio groups do not support typeahead. 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 60bde6eed5e7..93026a3decd9 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {computed} from '@angular/core'; +import {computed, signal} from '@angular/core'; import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager'; import {List, ListInputs} from '../behaviors/list/list'; import {SignalLike} from '../behaviors/signal-like/signal-like'; @@ -22,58 +22,42 @@ 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; + /** A function that returns the radio button associated with a given element. */ + getItem: (e: PointerEvent) => RadioButtonPattern | undefined; }; -/** - * 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 | ToolbarWidgetLike, V>; + readonly listBehavior: List, V>; /** Whether the radio group is vertically or horizontally oriented. */ - orientation: SignalLike<'vertical' | 'horizontal'>; + readonly orientation: SignalLike<'vertical' | 'horizontal'>; + + /** Whether focus should wrap when navigating. */ + readonly wrap = signal(false); + + /** The selection strategy used by the radio group. */ + readonly selectionMode = signal<'follow' | 'explicit'>('follow'); /** Whether the radio group is disabled. */ - disabled = computed(() => this.inputs.disabled() || this.listBehavior.disabled()); + readonly disabled = computed(() => this.inputs.disabled() || this.listBehavior.disabled()); /** The currently selected radio button. */ - selectedItem = computed(() => this.listBehavior.selectionBehavior.selectedItems()[0]); + readonly selectedItem = computed(() => this.listBehavior.selectionBehavior.selectedItems()[0]); /** Whether the radio group is readonly. */ - readonly = computed(() => this.selectedItem()?.disabled() || this.inputs.readonly()); + readonly readonly = computed(() => this.selectedItem()?.disabled() || this.inputs.readonly()); /** The tabindex of the radio group. */ - tabindex = computed(() => (this.inputs.toolbar() ? -1 : this.listBehavior.tabindex())); + readonly tabindex = computed(() => this.listBehavior.tabindex()); /** The id of the current active radio button (if using activedescendant). */ - activedescendant = computed(() => this.listBehavior.activedescendant()); + readonly activedescendant = computed(() => this.listBehavior.activedescendant()); /** The key used to navigate to the previous radio button. */ - prevKey = computed(() => { + private readonly _prevKey = computed(() => { if (this.inputs.orientation() === 'vertical') { return 'ArrowUp'; } @@ -81,7 +65,7 @@ export class RadioGroupPattern { }); /** The key used to navigate to the next radio button. */ - nextKey = computed(() => { + private readonly _nextKey = computed(() => { if (this.inputs.orientation() === 'vertical') { return 'ArrowDown'; } @@ -89,19 +73,14 @@ export class RadioGroupPattern { }); /** The keydown event manager for the radio group. */ - keydown = computed(() => { + readonly 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 - .on(this.prevKey, () => this.listBehavior.prev()) - .on(this.nextKey, () => this.listBehavior.next()) + .on(this._prevKey, () => this.listBehavior.prev()) + .on(this._nextKey, () => this.listBehavior.next()) .on('Home', () => this.listBehavior.first()) .on('End', () => this.listBehavior.last()); } @@ -109,8 +88,8 @@ export class RadioGroupPattern { // Default behavior: navigate and select on arrow keys, home, end. // Space/Enter also select the focused item. return manager - .on(this.prevKey, () => this.listBehavior.prev({selectOne: true})) - .on(this.nextKey, () => this.listBehavior.next({selectOne: true})) + .on(this._prevKey, () => this.listBehavior.prev({selectOne: true})) + .on(this._nextKey, () => this.listBehavior.next({selectOne: true})) .on('Home', () => this.listBehavior.first({selectOne: true})) .on('End', () => this.listBehavior.last({selectOne: true})) .on(' ', () => this.listBehavior.selectOne()) @@ -118,33 +97,25 @@ export class RadioGroupPattern { }); /** The pointerdown event manager for the radio group. */ - pointerdown = computed(() => { + readonly 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)!)); + return manager.on(e => this.listBehavior.goto(this.inputs.getItem(e)!)); } // Default behavior: navigate and select on click. - return manager.on(e => this.listBehavior.goto(this._getItem(e)!, {selectOne: true})); + return manager.on(e => this.listBehavior.goto(this.inputs.getItem(e)!, {selectOne: true})); }); constructor(readonly inputs: RadioGroupInputs) { - this.orientation = - inputs.toolbar() !== undefined ? inputs.toolbar()!.orientation : inputs.orientation; - + this.orientation = inputs.orientation; this.listBehavior = new List({ ...inputs, - activeItem: inputs.toolbar()?.listBehavior.inputs.activeItem ?? inputs.activeItem, - wrap: () => !!inputs.toolbar(), + wrap: this.wrap, + selectionMode: this.selectionMode, multi: () => false, - selectionMode: () => (inputs.toolbar() ? 'explicit' : 'follow'), typeaheadDelay: () => 0, // Radio groups do not support typeahead. }); } @@ -201,15 +172,4 @@ export class RadioGroupPattern { return violations; } - - /** 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-group/radio.spec.ts b/src/cdk-experimental/ui-patterns/radio-group/radio.spec.ts index 1230d642e4c6..adff7b0ac5d2 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, ToolbarLike} from './radio-group'; +import {RadioGroupInputs, RadioGroupPattern} from './radio-group'; import {RadioButtonPattern} from './radio-button'; import {createKeyboardEvent} from '@angular/cdk/testing/private'; import {ModifierKeys} from '@angular/cdk/testing'; @@ -40,7 +40,7 @@ describe('RadioGroup Pattern', () => { focusMode: inputs.focusMode ?? signal('roving'), textDirection: inputs.textDirection ?? signal('ltr'), orientation: inputs.orientation ?? signal('vertical'), - toolbar: inputs.toolbar ?? signal(undefined), + getItem: e => inputs.items().find(i => i.element() === e.target), }); } @@ -305,49 +305,4 @@ 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/radio-group/toolbar-radio-group.spec.ts b/src/cdk-experimental/ui-patterns/radio-group/toolbar-radio-group.spec.ts new file mode 100644 index 000000000000..6b009f8aae7c --- /dev/null +++ b/src/cdk-experimental/ui-patterns/radio-group/toolbar-radio-group.spec.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 {signal, WritableSignal} from '@angular/core'; +import {ToolbarRadioGroupInputs, ToolbarRadioGroupPattern} from './toolbar-radio-group'; +import {RadioButtonPattern} from './radio-button'; +import {ToolbarPattern} from './../toolbar/toolbar'; +import {createKeyboardEvent} from '@angular/cdk/testing/private'; +import {ModifierKeys} from '@angular/cdk/testing'; + +type TestInputs = ToolbarRadioGroupInputs; +type TestRadio = RadioButtonPattern & { + disabled: WritableSignal; +}; +type TestRadioGroup = ToolbarRadioGroupPattern; + +const down = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 40, 'ArrowDown', mods); +const space = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 32, ' ', mods); +const enter = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 13, 'Enter', mods); + +describe('ToolbarRadioGroup Pattern', () => { + function getToolbarRadioGroup(inputs: Partial & Pick) { + return new ToolbarRadioGroupPattern({ + items: inputs.items, + value: inputs.value ?? signal([]), + activeItem: signal(undefined), + element: signal(document.createElement('div')), + 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'), + toolbar: inputs.toolbar ?? signal(undefined), + getItem: e => inputs.items().find(i => i.element() === e.target), + }); + } + + 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 = getToolbarRadioGroup({...inputs, items: radioButtons}); + radioButtons.set(getRadios(radioGroup, values)); + radioGroup.inputs.activeItem.set(radioButtons()[0]); + return {radioGroup, radioButtons: radioButtons()}; + } + + function getDefaultPatterns(inputs: Partial = {}) { + return getPatterns(['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry'], inputs); + } + + let radioGroup: TestRadioGroup; + let radioButtons: TestRadio[]; + let toolbar: ToolbarPattern; + + beforeEach(() => { + toolbar = new ToolbarPattern({ + items: signal([]), + activeItem: signal(undefined), + element: signal(document.createElement('div')), + orientation: signal('horizontal'), + textDirection: signal('ltr'), + disabled: signal(false), + skipDisabled: signal(true), + wrap: signal(false), + getItem: (e: Element) => undefined, + }); + const patterns = getDefaultPatterns({ + toolbar: signal(toolbar), + }); + radioButtons = patterns.radioButtons; + radioGroup = patterns.radioGroup; + }); + + it('should ignore keyboard navigation when within a toolbar', () => { + radioGroup.inputs.activeItem.set(radioButtons[0]); + 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', () => { + radioGroup.inputs.activeItem.set(radioButtons[0]); + 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([]); + }); + + describe('Toolbar Widget Group controls', () => { + beforeEach(() => { + radioGroup.inputs.activeItem.set(radioButtons[0]); + }); + + it('should correctly report when on the first item', () => { + radioGroup.inputs.activeItem.set(radioButtons[0]); + expect(radioGroup.isOnFirstItem()).toBe(true); + radioGroup.inputs.activeItem.set(radioButtons[1]); + expect(radioGroup.isOnFirstItem()).toBe(false); + }); + + it('should correctly report when on the last item', () => { + radioGroup.inputs.activeItem.set(radioButtons[4]); + expect(radioGroup.isOnLastItem()).toBe(true); + radioGroup.inputs.activeItem.set(radioButtons[3]); + expect(radioGroup.isOnLastItem()).toBe(false); + }); + + it('should handle "next" control', () => { + radioGroup.next(false); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[1]); + }); + + it('should handle "prev" control', () => { + radioGroup.inputs.activeItem.set(radioButtons[1]); + radioGroup.prev(false); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[0]); + }); + + it('should handle "first" control', () => { + radioGroup.first(); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[0]); + }); + + it('should handle "last" control', () => { + radioGroup.last(); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[4]); + }); + + it('should handle "unfocus" control by clearing active item', () => { + radioGroup.unfocus(); + expect(radioGroup.inputs.activeItem()).toBe(undefined); + }); + + it('should handle "trigger" control to select an item', () => { + expect(radioGroup.inputs.value()).toEqual([]); + radioGroup.trigger(); + expect(radioGroup.inputs.value()).toEqual(['Apple']); + }); + + it('should not "trigger" selection when readonly', () => { + (radioGroup.inputs.readonly as WritableSignal).set(true); + expect(radioGroup.inputs.value()).toEqual([]); + radioGroup.trigger(); + expect(radioGroup.inputs.value()).toEqual([]); + }); + + it('should handle "goto" control', () => { + const event = {target: radioButtons[2].element()} as unknown as PointerEvent; + radioGroup.goto(event); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[2]); + expect(radioGroup.inputs.value()).toEqual(['Cherry']); + }); + + it('should handle "goto" control in readonly mode (no selection)', () => { + (radioGroup.inputs.readonly as WritableSignal).set(true); + const event = {target: radioButtons[2].element()} as unknown as PointerEvent; + radioGroup.goto(event); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[2]); + expect(radioGroup.inputs.value()).toEqual([]); + }); + + it('should handle "setDefaultState" control', () => { + radioGroup.inputs.activeItem.set(undefined); + radioGroup.setDefaultState(); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[0]); + }); + + it('should wrap on "next" with wrap', () => { + radioGroup.inputs.activeItem.set(radioButtons[4]); + radioGroup.next(true); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[0]); + }); + + it('should wrap on "prev" with wrap', () => { + radioGroup.prev(true); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[4]); + }); + }); +}); diff --git a/src/cdk-experimental/ui-patterns/radio-group/toolbar-radio-group.ts b/src/cdk-experimental/ui-patterns/radio-group/toolbar-radio-group.ts new file mode 100644 index 000000000000..80b7d0de16c8 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/radio-group/toolbar-radio-group.ts @@ -0,0 +1,91 @@ +/** + * @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 {SignalLike} from '../behaviors/signal-like/signal-like'; +import {RadioGroupInputs, RadioGroupPattern} from './radio-group'; +import type {ToolbarPattern} from '../toolbar/toolbar'; +import type {ToolbarWidgetGroupControls} from '../toolbar/toolbar-widget-group'; + +/** Represents the required inputs for a toolbar controlled radio group. */ +export type ToolbarRadioGroupInputs = RadioGroupInputs & { + /** The toolbar controlling the radio group. */ + toolbar: SignalLike | undefined>; +}; + +/** Controls the state of a radio group in a toolbar. */ +export class ToolbarRadioGroupPattern + extends RadioGroupPattern + implements ToolbarWidgetGroupControls +{ + constructor(override readonly inputs: ToolbarRadioGroupInputs) { + if (!!inputs.toolbar()) { + inputs.orientation = inputs.toolbar()!.orientation; + inputs.skipDisabled = inputs.toolbar()!.skipDisabled; + } + + super(inputs); + } + + /** Noop. The toolbar handles keydown events. */ + override onKeydown(_: KeyboardEvent): void {} + + /** Noop. The toolbar handles pointerdown events. */ + override onPointerdown(_: PointerEvent): void {} + + /** Whether the radio group is currently on the first item. */ + isOnFirstItem() { + return this.listBehavior.navigationBehavior.peekPrev() === undefined; + } + + /** Whether the radio group is currently on the last item. */ + isOnLastItem() { + return this.listBehavior.navigationBehavior.peekNext() === undefined; + } + + /** Navigates to the next radio button in the group. */ + next(wrap: boolean) { + this.wrap.set(wrap); + this.listBehavior.next(); + this.wrap.set(false); + } + + /** Navigates to the previous radio button in the group. */ + prev(wrap: boolean) { + this.wrap.set(wrap); + this.listBehavior.prev(); + this.wrap.set(false); + } + + /** Navigates to the first radio button in the group. */ + first() { + this.listBehavior.first(); + } + + /** Navigates to the last radio button in the group. */ + last() { + this.listBehavior.last(); + } + + /** Removes focus from the radio group. */ + unfocus() { + this.inputs.activeItem.set(undefined); + } + + /** Triggers the action of the currently active radio button in the group. */ + trigger() { + if (this.readonly()) return; + this.listBehavior.selectOne(); + } + + /** Navigates to the radio button targeted by a pointer event. */ + goto(e: PointerEvent) { + this.listBehavior.goto(this.inputs.getItem(e)!, { + selectOne: !this.readonly(), + }); + } +} diff --git a/src/cdk-experimental/ui-patterns/toolbar/BUILD.bazel b/src/cdk-experimental/ui-patterns/toolbar/BUILD.bazel index 75115071c3ec..9e7b72a7a1f7 100644 --- a/src/cdk-experimental/ui-patterns/toolbar/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/toolbar/BUILD.bazel @@ -6,13 +6,14 @@ ts_project( name = "toolbar", srcs = [ "toolbar.ts", + "toolbar-widget.ts", + "toolbar-widget-group.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", ], ) @@ -24,6 +25,7 @@ ts_project( ":toolbar", "//:node_modules/@angular/core", "//src/cdk-experimental/radio-group", + "//src/cdk-experimental/ui-patterns/behaviors/signal-like", "//src/cdk-experimental/ui-patterns/radio-group", "//src/cdk/keycodes", "//src/cdk/testing/private", diff --git a/src/cdk-experimental/ui-patterns/toolbar/toolbar-widget-group.ts b/src/cdk-experimental/ui-patterns/toolbar/toolbar-widget-group.ts new file mode 100644 index 000000000000..60e52c378ff7 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/toolbar/toolbar-widget-group.ts @@ -0,0 +1,105 @@ +/** + * @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 {SignalLike} from '../behaviors/signal-like/signal-like'; +import {ListItem} from '../behaviors/list/list'; +import type {ToolbarPattern} from './toolbar'; + +/** An interface that allows sub patterns to expose the necessary controls for the toolbar. */ +export interface ToolbarWidgetGroupControls { + /** Whether the widget group is currently on the first item. */ + isOnFirstItem(): boolean; + + /** Whether the widget group is currently on the last item. */ + isOnLastItem(): boolean; + + /** Navigates to the next widget in the group. */ + next(wrap: boolean): void; + + /** Navigates to the previous widget in the group. */ + prev(wrap: boolean): void; + + /** Navigates to the first widget in the group. */ + first(): void; + + /** Navigates to the last widget in the group. */ + last(): void; + + /** Removes focus from the widget group. */ + unfocus(): void; + + /** Triggers the action of the currently active widget in the group. */ + trigger(): void; + + /** Navigates to the widget targeted by a pointer event. */ + goto(event: PointerEvent): void; + + /** Sets the widget group to its default initial state. */ + setDefaultState(): void; +} + +/** Represents the required inputs for a toolbar widget group. */ +export interface ToolbarWidgetGroupInputs + extends Omit, 'searchTerm' | 'value' | 'index'> { + /** A reference to the parent toolbar. */ + toolbar: SignalLike | undefined>; + + /** The controls for the sub patterns associated with the toolbar. */ + controls: SignalLike; +} + +/** A group of widgets within a toolbar that provides nested navigation. */ +export class ToolbarWidgetGroupPattern implements ListItem { + /** A unique identifier for the widget. */ + readonly id: SignalLike; + + /** The html element that should receive focus. */ + readonly element: SignalLike; + + /** Whether the widget is disabled. */ + readonly disabled: SignalLike; + + /** A reference to the parent toolbar. */ + readonly toolbar: SignalLike | undefined>; + + /** 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 V; // Unused because toolbar does not support selection. + + /** The position of the widget within the toolbar. */ + readonly index = computed(() => this.toolbar()?.inputs.items().indexOf(this) ?? -1); + + /** The actions that can be performed on the widget group. */ + readonly controls: SignalLike = computed( + () => this.inputs.controls() ?? this._defaultControls, + ); + + /** Default toolbar widget group controls when no controls provided. */ + private readonly _defaultControls: ToolbarWidgetGroupControls = { + isOnFirstItem: () => true, + isOnLastItem: () => true, + next: () => {}, + prev: () => {}, + first: () => {}, + last: () => {}, + unfocus: () => {}, + trigger: () => {}, + goto: () => {}, + setDefaultState: () => {}, + }; + + constructor(readonly inputs: ToolbarWidgetGroupInputs) { + this.id = inputs.id; + this.element = inputs.element; + this.disabled = inputs.disabled; + this.toolbar = inputs.toolbar; + } +} diff --git a/src/cdk-experimental/ui-patterns/toolbar/toolbar-widget.ts b/src/cdk-experimental/ui-patterns/toolbar/toolbar-widget.ts new file mode 100644 index 000000000000..69d582fad39b --- /dev/null +++ b/src/cdk-experimental/ui-patterns/toolbar/toolbar-widget.ts @@ -0,0 +1,55 @@ +/** + * @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 {SignalLike} from '../behaviors/signal-like/signal-like'; +import {ListItem} from '../behaviors/list/list'; +import type {ToolbarPattern} from './toolbar'; + +/** 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. */ + toolbar: SignalLike>; +} + +export class ToolbarWidgetPattern implements ListItem { + /** A unique identifier for the widget. */ + readonly id: SignalLike; + + /** The html element that should receive focus. */ + readonly element: SignalLike; + + /** Whether the widget is disabled. */ + readonly disabled: SignalLike; + + /** A reference to the parent toolbar. */ + readonly toolbar: SignalLike>; + + /** The tabindex of the widgdet. */ + readonly tabindex = computed(() => this.toolbar().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 V; // Unused because toolbar does not support selection. + + /** The position of the widget within the toolbar. */ + readonly index = computed(() => this.toolbar().inputs.items().indexOf(this) ?? -1); + + /** Whether the widget is currently the active one (focused). */ + readonly active = computed(() => this.toolbar().inputs.activeItem() === this); + + constructor(readonly inputs: ToolbarWidgetInputs) { + this.id = inputs.id; + this.element = inputs.element; + this.disabled = inputs.disabled; + this.toolbar = inputs.toolbar; + } +} diff --git a/src/cdk-experimental/ui-patterns/toolbar/toolbar.spec.ts b/src/cdk-experimental/ui-patterns/toolbar/toolbar.spec.ts index b4e8903efdba..2f1cfa673f27 100644 --- a/src/cdk-experimental/ui-patterns/toolbar/toolbar.spec.ts +++ b/src/cdk-experimental/ui-patterns/toolbar/toolbar.spec.ts @@ -7,25 +7,36 @@ */ import {signal, WritableSignal} from '@angular/core'; -import {ToolbarInputs, ToolbarPattern, ToolbarWidgetPattern} from './toolbar'; -import {RadioButtonPattern} from '../radio-group/radio-button'; -import {RadioGroupInputs, RadioGroupPattern} from '../radio-group/radio-group'; +import {ToolbarInputs, ToolbarPattern} from './toolbar'; +import {ToolbarWidgetInputs, ToolbarWidgetPattern} from './toolbar-widget'; +import { + ToolbarWidgetGroupControls, + ToolbarWidgetGroupInputs, + ToolbarWidgetGroupPattern, +} from './toolbar-widget-group'; import {createKeyboardEvent} from '@angular/cdk/testing/private'; import {ModifierKeys} from '@angular/cdk/testing'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; -type TestToolbarInputs = ToolbarInputs; -type TestRadioGroupInputs = RadioGroupInputs; -type TestRadio = RadioButtonPattern & { - disabled: WritableSignal; - element: WritableSignal; +// Converts the SignalLike type to WritableSignal type for controlling test inputs. +type WritableSignalOverrides = { + [K in keyof O as O[K] extends SignalLike ? K : never]: O[K] extends SignalLike + ? WritableSignal + : never; }; -type TestRadioGroup = RadioGroupPattern; -type TestToolbar = ToolbarPattern; -type TestWidget = ToolbarWidgetPattern & { - disabled: WritableSignal; - element: WritableSignal; -}; -type TestItem = TestRadio | TestWidget; + +type TestToolbarInputs = Omit< + ToolbarInputs & WritableSignalOverrides>, + 'items' | 'element' | 'getItem' +>; +type TestToolbarWidgetInputs = Omit< + ToolbarWidgetInputs & WritableSignalOverrides>, + 'element' | 'id' | 'toolbar' +>; +type TestToolbarWidgetGroupInputs = Omit< + ToolbarWidgetGroupInputs & WritableSignalOverrides>, + 'element' | 'id' | 'toolbar' +>; const up = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 38, 'ArrowUp', mods); const down = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 40, 'ArrowDown', mods); @@ -35,364 +46,421 @@ const home = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 36, 'Home', 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); +const click = (target: Element) => + ({target, stopPropagation: () => {}, preventDefault: () => {}}) as unknown as PointerEvent; describe('Toolbar Pattern', () => { - function getRadioGroup( - inputs: Partial & Pick, + function createToolbar( + widgets: (TestToolbarWidgetInputs | TestToolbarWidgetGroupInputs)[], + toolbarInputs: TestToolbarInputs, ) { - return new RadioGroupPattern({ - items: inputs.items, - value: inputs.value ?? signal([]), - activeItem: signal(undefined), - 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'), - toolbar: inputs.toolbar ?? signal(undefined), + const items = signal<(ToolbarWidgetPattern | ToolbarWidgetGroupPattern)[]>([]); + const toolbar = new ToolbarPattern({ + ...toolbarInputs, + items, element: signal(document.createElement('div')), + getItem: target => items().find(widget => widget.element() === target), }); - } - function getRadios(radioGroup: TestRadioGroup, values: string[]): TestRadio[] { - return values.map((value, index) => { + const widgetPatterns = widgets.map((widgetInputs, index) => { + const id = `widget-${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[]; + element.id = id; + + if ('controls' in widgetInputs) { + // It's a group + element.classList.add('toolbar-widget-group'); + return new ToolbarWidgetGroupPattern({ + ...widgetInputs, + id: signal(id), + element: signal(element), + toolbar: signal(toolbar), + }); + } else { + // It's a widget + element.classList.add('toolbar-widget'); + return new ToolbarWidgetPattern({ + ...widgetInputs, + id: signal(id), + element: signal(element), + toolbar: signal(toolbar), + }); + } + }); + items.set(widgetPatterns); + return {toolbar, items: widgetPatterns}; } - function getWidgets(toolbar: TestToolbar, values: string[]): TestWidget[] { - return values.map((value, index) => { - const element = document.createElement('button'); - element.role = 'button'; + describe('Keyboard Navigation', () => { + let toolbar: ToolbarPattern; + let toolbarInputs: TestToolbarInputs; + let widgetInputs: (TestToolbarWidgetInputs | TestToolbarWidgetGroupInputs)[]; + let items: (ToolbarWidgetPattern | ToolbarWidgetGroupPattern)[]; - return new ToolbarWidgetPattern({ - id: signal(`button-${index}`), + beforeEach(() => { + toolbarInputs = { + activeItem: signal(undefined), + orientation: signal('horizontal'), + textDirection: signal('ltr'), disabled: signal(false), - parentToolbar: signal(toolbar as any), - element: signal(element), - }); - }) as TestWidget[]; - } - - function getToolbar(inputs: Partial & Pick) { - return new ToolbarPattern({ - items: inputs.items, - activeItem: inputs.activeItem ?? signal(undefined), - disabled: inputs.disabled ?? signal(false), - skipDisabled: inputs.skipDisabled ?? signal(true), - focusMode: inputs.focusMode ?? signal('roving'), - textDirection: inputs.textDirection ?? signal('ltr'), - orientation: inputs.orientation ?? signal('horizontal'), - wrap: inputs.wrap ?? signal(false), - element: signal(document.createElement('div')), + skipDisabled: signal(true), + wrap: signal(false), + }; + widgetInputs = [ + {disabled: signal(false)}, + {disabled: signal(false)}, + { + disabled: signal(false), + controls: signal(undefined), + }, + {disabled: signal(false)}, + ]; + const {toolbar: newToolbar, items: newItems} = createToolbar( + widgetInputs, + toolbarInputs, + ); + toolbar = newToolbar; + items = newItems; + toolbarInputs.activeItem.set(items[0]); }); - } - function getRadioPatterns(values: string[], inputs: Partial = {}) { - const radioButtons = signal([]); - const radioGroup = getRadioGroup({...inputs, items: radioButtons}); - radioButtons.set(getRadios(radioGroup, values)); - radioGroup.inputs.activeItem.set(radioButtons()[0]); - return {radioGroup, radioButtons}; - } - - function getToolbarPatterns( - widgetValues: string[], - inputs: Partial, - radioInputs: Partial = {}, - ) { - const {radioGroup, radioButtons} = getRadioPatterns(['Apple', 'Banana', 'Cherry'], radioInputs); - const widgets = signal([]); - const children = signal<(TestWidget | TestRadio)[]>([]); - - // Make the radio group and toolbar share an active item - inputs.activeItem = radioGroup.inputs.activeItem; - - const toolbar = getToolbar({ - ...inputs, - items: children, - }); - widgets.set(getWidgets(toolbar, widgetValues)); - children.set([...radioButtons(), ...widgets()]); - radioGroup.inputs.toolbar = signal(toolbar); - toolbar.inputs.activeItem.set(children()[0]); - - return {toolbar, widgets: children(), radioGroup}; - } - - function getDefaultPatterns( - inputs: Partial = {}, - radioInputs: Partial = {}, - ) { - return getToolbarPatterns(['Pear', 'Peach', 'Plum'], inputs, radioInputs); - } - - describe('Keyboard Navigation', () => { it('should navigate next on ArrowRight (horizontal)', () => { - const {toolbar, widgets} = getDefaultPatterns(); - expect(toolbar.inputs.activeItem()).toBe(widgets[0]); toolbar.onKeydown(right()); - expect(toolbar.inputs.activeItem()).toBe(widgets[1]); + expect(toolbarInputs.activeItem()).toBe(items[1]); }); it('should navigate prev on ArrowLeft (horizontal)', () => { - const {toolbar, widgets} = getDefaultPatterns(); - toolbar.inputs.activeItem.set(widgets[1]); + toolbarInputs.activeItem.set(items[1]); toolbar.onKeydown(left()); - expect(toolbar.inputs.activeItem()).toBe(widgets[0]); + expect(toolbarInputs.activeItem()).toBe(items[0]); }); it('should navigate next on ArrowDown (vertical)', () => { - const {toolbar, widgets} = getDefaultPatterns({orientation: signal('vertical')}); - expect(toolbar.inputs.activeItem()).toBe(widgets[0]); + toolbarInputs.orientation.set('vertical'); toolbar.onKeydown(down()); - expect(toolbar.inputs.activeItem()).toBe(widgets[1]); + expect(toolbarInputs.activeItem()).toBe(items[1]); }); it('should navigate prev on ArrowUp (vertical)', () => { - const {toolbar, widgets} = getDefaultPatterns({orientation: signal('vertical')}); - toolbar.inputs.activeItem.set(widgets[1]); - expect(toolbar.inputs.activeItem()).toBe(widgets[1]); + toolbarInputs.orientation.set('vertical'); + toolbarInputs.activeItem.set(items[1]); toolbar.onKeydown(up()); - expect(toolbar.inputs.activeItem()).toBe(widgets[0]); + expect(toolbarInputs.activeItem()).toBe(items[0]); }); it('should navigate next on ArrowLeft (rtl)', () => { - const {toolbar, widgets} = getDefaultPatterns({ - textDirection: signal('rtl'), - }); - expect(toolbar.inputs.activeItem()).toBe(widgets[0]); + toolbarInputs.textDirection.set('rtl'); toolbar.onKeydown(left()); - expect(toolbar.inputs.activeItem()).toBe(widgets[1]); + expect(toolbarInputs.activeItem()).toBe(items[1]); }); it('should navigate prev on ArrowRight (rtl)', () => { - const {toolbar, widgets} = getDefaultPatterns({ - textDirection: signal('rtl'), - }); - toolbar.inputs.activeItem.set(widgets[1]); - expect(toolbar.inputs.activeItem()).toBe(widgets[1]); + toolbarInputs.textDirection.set('rtl'); + toolbarInputs.activeItem.set(items[1]); toolbar.onKeydown(right()); - expect(toolbar.inputs.activeItem()).toBe(widgets[0]); + expect(toolbarInputs.activeItem()).toBe(items[0]); }); it('should navigate to the first item on Home', () => { - const {toolbar, widgets} = getDefaultPatterns(); - toolbar.inputs.activeItem.set(widgets[5]); - - expect(toolbar.inputs.activeItem()).toBe(widgets[5]); + toolbarInputs.activeItem.set(items[3]); toolbar.onKeydown(home()); - expect(toolbar.inputs.activeItem()).toBe(widgets[0]); + expect(toolbarInputs.activeItem()).toBe(items[0]); }); it('should navigate to the last item on End', () => { - const {toolbar, widgets} = getDefaultPatterns(); - expect(toolbar.inputs.activeItem()).toBe(widgets[0]); toolbar.onKeydown(end()); - expect(toolbar.inputs.activeItem()).toBe(widgets[5]); - }); - it('should navigate between a radio button and toolbar widget', () => { - const {toolbar, widgets} = getDefaultPatterns(); - toolbar.inputs.activeItem.set(widgets[2]); - toolbar.onKeydown(right()); - expect(toolbar.inputs.activeItem()).toBe(widgets[3]); - toolbar.onKeydown(left()); - expect(toolbar.inputs.activeItem()).toBe(widgets[2]); - }); - - it('should skip a disabled radio button when skipDisabled is true', () => { - const {toolbar, widgets} = getDefaultPatterns({skipDisabled: signal(true)}); - widgets[1].disabled.set(true); - toolbar.onKeydown(right()); - expect(toolbar.inputs.activeItem()).toBe(widgets[2]); + expect(toolbarInputs.activeItem()).toBe(items[3]); }); it('should skip a disabled toolbar widget when skipDisabled is true', () => { - const {toolbar, widgets} = getDefaultPatterns({skipDisabled: signal(true)}); - toolbar.inputs.activeItem.set(widgets[3]); - widgets[4].disabled.set(true); + widgetInputs[1].disabled.set(true); toolbar.onKeydown(right()); - expect(toolbar.inputs.activeItem()).toBe(widgets[5]); + expect(toolbarInputs.activeItem()).toBe(items[2]); }); it('should not skip disabled items when skipDisabled is false', () => { - const {toolbar, widgets} = getDefaultPatterns({skipDisabled: signal(false)}); - toolbar.inputs.activeItem.set(widgets[3]); - widgets[4].disabled.set(true); - toolbar.onKeydown(right()); - expect(toolbar.inputs.activeItem()).toBe(widgets[4]); - }); - - it('should be able to navigate when inner radio group in readonly mode', () => { - const {toolbar, widgets} = getDefaultPatterns({}, {readonly: signal(true)}); - expect(toolbar.inputs.activeItem()).toBe(widgets[0]); + toolbarInputs.skipDisabled.set(false); + widgetInputs[1].disabled.set(true); toolbar.onKeydown(right()); - expect(toolbar.inputs.activeItem()).toBe(widgets[1]); + expect(toolbarInputs.activeItem()).toBe(items[1]); }); it('should wrap back to the first item when wrap is true', () => { - const {toolbar, widgets} = getDefaultPatterns({wrap: signal(true)}); - toolbar.inputs.activeItem.set(widgets[5]); + toolbarInputs.wrap.set(true); + toolbarInputs.activeItem.set(items[3]); toolbar.onKeydown(right()); - expect(toolbar.inputs.activeItem()).toBe(widgets[0]); - toolbar.onKeydown(left()); - expect(toolbar.inputs.activeItem()).toBe(widgets[5]); + expect(toolbarInputs.activeItem()).toBe(items[0]); }); it('should not wrap when wrap is false', () => { - const {toolbar, widgets} = getDefaultPatterns({wrap: signal(false)}); - toolbar.inputs.activeItem.set(widgets[5]); + toolbarInputs.activeItem.set(items[3]); toolbar.onKeydown(right()); - expect(toolbar.inputs.activeItem()).toBe(widgets[5]); + expect(toolbarInputs.activeItem()).toBe(items[3]); }); - it('should wrap within the radio group when alternate right key is pressed', () => { - const {toolbar, widgets} = getDefaultPatterns({wrap: signal(false)}); - toolbar.inputs.activeItem.set(widgets[2]); - toolbar.onKeydown(down()); - expect(toolbar.inputs.activeItem()).toBe(widgets[0]); + it('should not navigate when the toolbar is disabled', () => { + toolbarInputs.disabled.set(true); + toolbar.onKeydown(right()); + expect(toolbarInputs.activeItem()).toBe(items[0]); }); - it('should wrap within the radio group when alternate left key is pressed', () => { - const {toolbar, widgets} = getDefaultPatterns({wrap: signal(false)}); - toolbar.inputs.activeItem.set(widgets[0]); - toolbar.onKeydown(up()); - expect(toolbar.inputs.activeItem()).toBe(widgets[2]); + }); + + describe('Pointer Events', () => { + let toolbar: ToolbarPattern; + let toolbarInputs: TestToolbarInputs; + let items: (ToolbarWidgetPattern | ToolbarWidgetGroupPattern)[]; + + beforeEach(() => { + toolbarInputs = { + activeItem: signal(undefined), + orientation: signal('horizontal'), + textDirection: signal('ltr'), + disabled: signal(false), + skipDisabled: signal(true), + wrap: signal(false), + }; + const widgetInputs = [ + {disabled: signal(false)}, + {disabled: signal(false)}, + { + disabled: signal(false), + controls: signal(undefined), + }, + {disabled: signal(false)}, + ]; + const {toolbar: newToolbar, items: newItems} = createToolbar( + widgetInputs, + toolbarInputs, + ); + toolbar = newToolbar; + items = newItems; + toolbarInputs.activeItem.set(items[0]); + }); + + it('should set the active item on pointerdown', () => { + toolbar.onPointerdown(click(items[1].element())); + expect(toolbarInputs.activeItem()).toBe(items[1]); + }); + + it('should not set the active item on pointerdown if the toolbar is disabled', () => { + toolbarInputs.disabled.set(true); + toolbar.onPointerdown(click(items[1].element())); + expect(toolbarInputs.activeItem()).toBe(items[0]); }); }); - describe('Keyboard Selection', () => { - let toolbar: TestToolbar; - let widgets: TestItem[]; - let radioGroup: TestRadioGroup; + describe('#setDefaultState', () => { + let toolbar: ToolbarPattern; + let toolbarInputs: TestToolbarInputs; + let widgetInputs: (TestToolbarWidgetInputs | TestToolbarWidgetGroupInputs)[]; + let items: (ToolbarWidgetPattern | ToolbarWidgetGroupPattern)[]; beforeEach(() => { - let patterns = getDefaultPatterns({}, {value: signal([])}); - toolbar = patterns.toolbar; - widgets = patterns.widgets; - radioGroup = patterns.radioGroup; + toolbarInputs = { + activeItem: signal(undefined), + orientation: signal('horizontal'), + textDirection: signal('ltr'), + disabled: signal(false), + skipDisabled: signal(true), + wrap: signal(false), + }; + widgetInputs = [ + {disabled: signal(false)}, + {disabled: signal(false)}, + { + disabled: signal(false), + controls: signal(undefined), + }, + ]; + const {toolbar: newToolbar, items: newItems} = createToolbar( + widgetInputs, + toolbarInputs, + ); + toolbar = newToolbar; + items = newItems; + }); + + it('should set the active item to the first focusable widget', () => { + toolbar.setDefaultState(); + expect(toolbarInputs.activeItem()).toBe(items[0]); }); - it('should select a radio on Space', () => { - toolbar.onKeydown(space()); - expect(radioGroup.inputs.value()).toEqual(['Apple']); + it('should skip disabled widgets and set the next focusable widget as active', () => { + widgetInputs[0].disabled.set(true); + toolbar.setDefaultState(); + expect(toolbarInputs.activeItem()).toBe(items[1]); }); - it('should select a radio on Enter', () => { - toolbar.onKeydown(enter()); - expect(radioGroup.inputs.value()).toEqual(['Apple']); + it('should call "setDefaultState" on a widget group if it is the first focusable item', () => { + const fakeControls = jasmine.createSpyObj('fakeControls', [ + 'setDefaultState', + ]); + (widgetInputs[2] as TestToolbarWidgetGroupInputs).controls.set(fakeControls); + + widgetInputs[0].disabled.set(true); + widgetInputs[1].disabled.set(true); + toolbar.setDefaultState(); + expect(toolbarInputs.activeItem()).toBe(items[2]); + expect(fakeControls.setDefaultState).toHaveBeenCalled(); }); + }); - it('should not be able to change selection when in readonly mode', () => { - const readonly = radioGroup.inputs.readonly as WritableSignal; - readonly.set(true); + describe('Widget Group', () => { + let toolbar: ToolbarPattern; + let toolbarInputs: TestToolbarInputs; + let items: (ToolbarWidgetPattern | ToolbarWidgetGroupPattern)[]; + let fakeControls: jasmine.SpyObj; - toolbar.onKeydown(space()); - expect(radioGroup.inputs.value()).toEqual([]); + beforeEach(() => { + fakeControls = jasmine.createSpyObj('fakeControls', [ + 'next', + 'prev', + 'first', + 'last', + 'unfocus', + 'trigger', + 'goto', + 'setDefaultState', + 'isOnFirstItem', + 'isOnLastItem', + ]); + toolbarInputs = { + activeItem: signal(undefined), + orientation: signal('horizontal'), + textDirection: signal('ltr'), + disabled: signal(false), + skipDisabled: signal(true), + wrap: signal(false), + }; + const widgetInputs = [ + {disabled: signal(false)}, + { + disabled: signal(false), + controls: signal(fakeControls), + }, + {disabled: signal(false)}, + ]; + const {toolbar: newToolbar, items: newItems} = createToolbar( + widgetInputs, + toolbarInputs, + ); + toolbar = newToolbar; + items = newItems; + + // Set the widget group as the active item for tests. + toolbarInputs.activeItem.set(items[1]); + }); + + it('should call "next" on the group handler when navigating next (horizontal)', () => { + fakeControls.isOnLastItem.and.returnValue(false); + toolbar.onKeydown(right()); + expect(fakeControls.next).toHaveBeenCalledWith(false); + }); - toolbar.onKeydown(enter()); - expect(radioGroup.inputs.value()).toEqual([]); + it('should call "next" on the group handler when navigating next (vertical)', () => { + fakeControls.isOnLastItem.and.returnValue(false); + toolbarInputs.orientation.set('vertical'); + toolbar.onKeydown(down()); + expect(fakeControls.next).toHaveBeenCalledWith(false); }); - it('should not select a disabled radio via keyboard', () => { - const skipDisabled = toolbar.inputs.skipDisabled as WritableSignal; - skipDisabled.set(false); - widgets[1].disabled.set(true); + it('should navigate to the next widget if the group allows it', () => { + fakeControls.isOnLastItem.and.returnValue(true); + toolbar.onKeydown(right()); + expect(toolbarInputs.activeItem()).toBe(items[2]); + expect(fakeControls.unfocus).toHaveBeenCalled(); + }); + it('should not navigate to the next widget if the group prevents it', () => { + fakeControls.isOnLastItem.and.returnValue(false); toolbar.onKeydown(right()); - expect(radioGroup.inputs.value()).toEqual([]); + expect(toolbarInputs.activeItem()).toBe(items[1]); + expect(fakeControls.next).toHaveBeenCalledWith(false); + }); - toolbar.onKeydown(space()); - expect(radioGroup.inputs.value()).toEqual([]); + it('should call "prev" on the group handler when navigating previous (horizontal)', () => { + fakeControls.isOnFirstItem.and.returnValue(false); + toolbar.onKeydown(left()); + expect(fakeControls.prev).toHaveBeenCalledWith(false); + }); - toolbar.onKeydown(enter()); + it('should call "prev" on the group handler when navigating previous (vertical)', () => { + fakeControls.isOnFirstItem.and.returnValue(false); + toolbarInputs.orientation.set('vertical'); + toolbar.onKeydown(up()); + expect(fakeControls.prev).toHaveBeenCalledWith(false); }); - }); - describe('Pointer Events', () => { - function click(widgets: TestItem[], index: number) { - return { - target: widgets[index].element(), - } as unknown as PointerEvent; - } + it('should navigate to the previous widget if the group allows it', () => { + fakeControls.isOnFirstItem.and.returnValue(true); + toolbar.onKeydown(left()); + expect(toolbarInputs.activeItem()).toBe(items[0]); + expect(fakeControls.unfocus).toHaveBeenCalled(); + }); - it('should select a radio on click', () => { - const {toolbar, widgets, radioGroup} = getDefaultPatterns(); - toolbar.onPointerdown(click(widgets, 0)); - expect(radioGroup.inputs.value()).toEqual(['Apple']); + it('should not navigate to the previous widget if the group prevents it', () => { + fakeControls.isOnFirstItem.and.returnValue(false); + toolbar.onKeydown(left()); + expect(toolbarInputs.activeItem()).toBe(items[1]); + expect(fakeControls.prev).toHaveBeenCalledWith(false); }); - it('should not select a disabled radio on click', () => { - const {toolbar, widgets, radioGroup} = getDefaultPatterns(); - widgets[0].disabled.set(true); - toolbar.onPointerdown(click(widgets, 0)); - expect(radioGroup.inputs.value()).toEqual([]); + it('should call "unfocus" on the group handler on Home', () => { + toolbar.onKeydown(home()); + expect(fakeControls.unfocus).toHaveBeenCalled(); + expect(toolbarInputs.activeItem()).toBe(items[0]); // Also moves focus }); - it('should only update active index if the inner radio group is readonly', () => { - const {toolbar, widgets, radioGroup} = getDefaultPatterns({}, {readonly: signal(true)}); - toolbar.onPointerdown(click(widgets, 0)); - expect(toolbar.inputs.activeItem()).toBe(widgets[0]); - expect(radioGroup.inputs.value()).toEqual([]); + it('should call "unfocus" on the group handler on End', () => { + toolbar.onKeydown(end()); + expect(fakeControls.unfocus).toHaveBeenCalled(); + expect(toolbarInputs.activeItem()).toBe(items[2]); // Also moves focus }); - }); - describe('#setDefaultState', () => { - it('should set the active index to the first widget', () => { - const {toolbar, widgets} = getDefaultPatterns(); - toolbar.setDefaultState(); - expect(toolbar.inputs.activeItem()).toBe(widgets[0]); + it('should call "trigger" on the group handler on Enter', () => { + toolbar.onKeydown(enter()); + expect(fakeControls.trigger).toHaveBeenCalled(); }); - it('should set the active index to the first focusable widget (radio button)', () => { - const {toolbar, widgets} = getDefaultPatterns(); - widgets[0].disabled.set(true); - widgets[1].disabled.set(true); + it('should call "trigger" on the group handler on Space', () => { + toolbar.onKeydown(space()); + expect(fakeControls.trigger).toHaveBeenCalled(); + }); - toolbar.setDefaultState(); - expect(toolbar.inputs.activeItem()).toBe(widgets[2]); - }); - it('should set the active index to the first focusable widget (toolbar widget', () => { - const {toolbar, widgets} = getDefaultPatterns(); - widgets[0].disabled.set(true); - widgets[1].disabled.set(true); - widgets[2].disabled.set(true); - widgets[3].disabled.set(true); - toolbar.setDefaultState(); - expect(toolbar.inputs.activeItem()).toBe(widgets[4]); + it('should call "next" with wrap on the group handler (horizontal)', () => { + toolbar.onKeydown(down()); + expect(fakeControls.next).toHaveBeenCalledWith(true); }); - it('should set the active index to the selected radio if applicable', () => { - const {toolbar, widgets} = getDefaultPatterns({}, {value: signal(['Banana'])}); - toolbar.setDefaultState(); - expect(toolbar.inputs.activeItem()).toBe(widgets[1]); + it('should call "next" with wrap on the group handler (vertical)', () => { + toolbarInputs.orientation.set('vertical'); + toolbar.onKeydown(right()); + expect(fakeControls.next).toHaveBeenCalledWith(true); }); - it('should set the active index to the first focusable widget if selected radio is disabled', () => { - const {toolbar, widgets} = getDefaultPatterns({}, {value: signal(['Banana'])}); - widgets[1].disabled.set(true); - toolbar.setDefaultState(); - expect(toolbar.inputs.activeItem()).toBe(widgets[0]); + it('should call "prev" with wrap on the group handler (horizontal)', () => { + toolbar.onKeydown(up()); + expect(fakeControls.prev).toHaveBeenCalledWith(true); }); - }); - describe('validate', () => { - it('should report a violation if the selected item is disabled and skipDisabled is true', () => { - const {toolbar, widgets, radioGroup} = getDefaultPatterns({skipDisabled: signal(true)}); - toolbar.inputs.activeItem.set(widgets[1]); - radioGroup.inputs.value.set(['Banana']); - widgets[1].disabled.set(true); - expect(toolbar.validate()).toEqual([ - "Accessibility Violation: A selected radio button inside the toolbar is disabled while 'skipDisabled' is true, making the selection unreachable via keyboard.", - ]); + it('should call "prev" with wrap on the group handler (vertical)', () => { + toolbarInputs.orientation.set('vertical'); + toolbar.onKeydown(left()); + expect(fakeControls.prev).toHaveBeenCalledWith(true); + }); + + it('should call "first" when navigating into a group from the previous item', () => { + toolbarInputs.activeItem.set(items[0]); + toolbar.onKeydown(right()); + expect(toolbarInputs.activeItem()).toBe(items[1]); + expect(fakeControls.first).toHaveBeenCalled(); + }); + + it('should call "last" when navigating into a group from the next item', () => { + toolbarInputs.activeItem.set(items[2]); + toolbar.onKeydown(left()); + expect(toolbarInputs.activeItem()).toBe(items[1]); + expect(fakeControls.last).toHaveBeenCalled(); }); }); }); diff --git a/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts b/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts index 7689f286d41e..ff704976cc6a 100644 --- a/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts +++ b/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts @@ -7,36 +7,43 @@ */ 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'; +import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager'; +import {List, ListInputs} from '../behaviors/list/list'; +import {ToolbarWidgetPattern} from './toolbar-widget'; +import {ToolbarWidgetGroupPattern} from './toolbar-widget-group'; /** Represents the required inputs for a toolbar. */ export type ToolbarInputs = Omit< - ListInputs, V>, - 'multi' | 'typeaheadDelay' | 'value' | 'selectionMode' ->; + ListInputs | ToolbarWidgetGroupPattern, V>, + 'multi' | 'typeaheadDelay' | 'value' | 'selectionMode' | 'focusMode' +> & { + /** A function that returns the toolbar item associated with a given element. */ + getItem: (e: Element) => ToolbarWidgetPattern | ToolbarWidgetGroupPattern | undefined; +}; /** Controls the state of a toolbar. */ export class ToolbarPattern { /** The list behavior for the toolbar. */ - listBehavior: List, V>; + readonly listBehavior: List | ToolbarWidgetGroupPattern, V>; /** Whether the tablist is vertically or horizontally oriented. */ readonly orientation: SignalLike<'vertical' | 'horizontal'>; + /** Whether disabled items in the group should be skipped when navigating. */ + readonly skipDisabled: SignalLike; + /** Whether the toolbar is disabled. */ - disabled = computed(() => this.listBehavior.disabled()); + readonly disabled = computed(() => this.listBehavior.disabled()); /** The tabindex of the toolbar (if using activedescendant). */ - tabindex = computed(() => this.listBehavior.tabindex()); + readonly tabindex = computed(() => this.listBehavior.tabindex()); /** The id of the current active widget (if using activedescendant). */ - activedescendant = computed(() => this.listBehavior.activedescendant()); + readonly activedescendant = computed(() => this.listBehavior.activedescendant()); /** The key used to navigate to the previous widget. */ - prevKey = computed(() => { + private readonly _prevKey = computed(() => { if (this.inputs.orientation() === 'vertical') { return 'ArrowUp'; } @@ -44,15 +51,15 @@ export class ToolbarPattern { }); /** The key used to navigate to the next widget. */ - nextKey = computed(() => { + private readonly _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(() => { + /** The alternate key used to navigate to the previous widget. */ + private readonly _altPrevKey = computed(() => { if (this.inputs.orientation() === 'vertical') { return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; } @@ -60,7 +67,7 @@ export class ToolbarPattern { }); /** The alternate key used to navigate to the next widget. */ - altNextKey = computed(() => { + private readonly _altNextKey = computed(() => { if (this.inputs.orientation() === 'vertical') { return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; } @@ -68,95 +75,144 @@ export class ToolbarPattern { }); /** The keydown event manager for the toolbar. */ - keydown = computed(() => { + private readonly _keydown = computed(() => { const manager = new KeyboardEventManager(); - const activeItem = this.inputs.activeItem(); - const isRadioButton = activeItem instanceof RadioButtonPattern; + return manager + .on(this._nextKey, () => this._next()) + .on(this._prevKey, () => this._prev()) + .on(this._altNextKey, () => this._groupNext()) + .on(this._altPrevKey, () => this._groupPrev()) + .on(' ', () => this._trigger()) + .on('Enter', () => this._trigger()) + .on('Home', () => this._first()) + .on('End', () => this._last()); + }); - 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()); + /** The pointerdown event manager for the toolbar. */ + private readonly _pointerdown = computed(() => new PointerEventManager().on(e => this._goto(e))); + + /** Navigates to the next widget in the toolbar. */ + private _next() { + const item = this.inputs.activeItem(); + if (item instanceof ToolbarWidgetGroupPattern) { + if (!item.disabled() && !item.controls().isOnLastItem()) { + item.controls().next(false); + return; + } + item.controls().unfocus(); } - return manager - .on(this.prevKey, () => this.listBehavior.prev()) - .on(this.nextKey, () => this.listBehavior.next()) - .on('Home', () => this.listBehavior.first()) - .on('End', () => this.listBehavior.last()); - }); + this.listBehavior.next(); + const newItem = this.inputs.activeItem(); + if (newItem instanceof ToolbarWidgetGroupPattern) { + newItem.controls().first(); + } + } - selectRadioButton() { - const activeItem = this.inputs.activeItem() as RadioButtonPattern; + /** Navigates to the previous widget in the toolbar. */ + private _prev() { + const item = this.inputs.activeItem(); + if (item instanceof ToolbarWidgetGroupPattern) { + if (!item.disabled() && !item.controls().isOnFirstItem()) { + item.controls().prev(false); + return; + } + item.controls().unfocus(); + } - // activeItem must be a radio button - const group = activeItem!.group(); - if (group && !group.readonly() && !group.disabled()) { - group.listBehavior.selectOne(); + this.listBehavior.prev(); + const newItem = this.inputs.activeItem(); + if (newItem instanceof ToolbarWidgetGroupPattern) { + newItem.controls().last(); } } - /** The pointerdown event manager for the toolbar. */ - pointerdown = computed(() => new PointerEventManager().on(e => this.goto(e))); + private _groupNext() { + const item = this.inputs.activeItem(); + if (item instanceof ToolbarWidgetPattern) return; + item?.controls().next(true); + } - /** Navigates to the widget associated with the given pointer event. */ - goto(event: PointerEvent) { - const item = this._getItem(event); - if (!item) return; + private _groupPrev() { + const item = this.inputs.activeItem(); + if (item instanceof ToolbarWidgetPattern) return; + item?.controls().prev(true); + } - if (item instanceof RadioButtonPattern) { - const group = item.group(); - if (group && !group.disabled()) { - group.listBehavior.goto(item, {selectOne: !group.readonly()}); - } - } else { - this.listBehavior.goto(item); + /** Triggers the action of the currently active widget. */ + private _trigger() { + const item = this.inputs.activeItem(); + if (item instanceof ToolbarWidgetGroupPattern) { + item.controls().trigger(); } } - /** Handles keydown events for the toolbar. */ - onKeydown(event: KeyboardEvent) { - if (!this.disabled()) { - this.keydown().handle(event); + /** Navigates to the first widget in the toolbar. */ + private _first() { + const item = this.inputs.activeItem(); + if (item instanceof ToolbarWidgetGroupPattern) { + item.controls().unfocus(); } - } - /** Handles pointerdown events for the toolbar. */ - onPointerdown(event: PointerEvent) { - if (!this.disabled()) { - this.pointerdown().handle(event); + this.listBehavior.first(); + const newItem = this.inputs.activeItem(); + if (newItem instanceof ToolbarWidgetGroupPattern) { + newItem.controls().first(); } } - /** Finds the Toolbar Widget associated with a pointer event target. */ - private _getItem(e: PointerEvent): RadioButtonPattern | ToolbarWidgetPattern | undefined { - if (!(e.target instanceof HTMLElement)) { - return undefined; + /** Navigates to the last widget in the toolbar. */ + private _last() { + const item = this.inputs.activeItem(); + if (item instanceof ToolbarWidgetGroupPattern) { + item.controls().unfocus(); + } + + this.listBehavior.last(); + const newItem = this.inputs.activeItem(); + if (newItem instanceof ToolbarWidgetGroupPattern) { + newItem.controls().last(); } + } + + /** Navigates to the widget targeted by a pointer event. */ + private _goto(e: PointerEvent) { + const item = this.inputs.getItem(e.target as Element); + if (!item) return; - // 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); + this.listBehavior.goto(item); + if (item instanceof ToolbarWidgetGroupPattern) { + item.controls().goto(e); + } } constructor(readonly inputs: ToolbarInputs) { this.orientation = inputs.orientation; + this.skipDisabled = inputs.skipDisabled; this.listBehavior = new List({ ...inputs, multi: () => false, + focusMode: () => 'roving', selectionMode: () => 'explicit', - value: signal([] as any), + value: signal([] as V[]), typeaheadDelay: () => 0, // Toolbar widgets do not support typeahead. }); } + /** Handles keydown events for the toolbar. */ + onKeydown(event: KeyboardEvent) { + if (this.disabled()) return; + this._keydown().handle(event); + } + + /** Handles pointerdown events for the toolbar. */ + onPointerdown(event: PointerEvent) { + if (this.disabled()) return; + this._pointerdown().handle(event); + } + /** * Sets the toolbar to its default initial state. * @@ -164,80 +220,28 @@ export class ToolbarPattern { * Otherwise, sets the active index to the first focusable widget. */ setDefaultState() { - let firstItem: RadioButtonPattern | ToolbarWidgetPattern | null = null; + let firstItem: ToolbarWidgetPattern | ToolbarWidgetGroupPattern | 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); } + if (firstItem instanceof ToolbarWidgetGroupPattern) { + firstItem.controls().setDefaultState(); + } } /** 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; - } -}