From 2bc2c36296f56b95517bd2bbc849f7fc0656d069 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): separate interaction from toolbar and radio group --- .../radio-group/radio-group.ts | 96 +++--- src/cdk-experimental/toolbar/public-api.ts | 2 +- src/cdk-experimental/toolbar/toolbar.ts | 146 ++++++--- .../ui-patterns/public-api.ts | 5 + .../ui-patterns/radio-group/BUILD.bazel | 2 + .../ui-patterns/radio-group/radio-button.ts | 49 +-- .../radio-group/radio-group-interaction.ts | 98 ++++++ .../ui-patterns/radio-group/radio-group.ts | 207 ++++++------ .../ui-patterns/toolbar/BUILD.bazel | 4 +- .../toolbar/toolbar-interaction.ts | 135 ++++++++ .../toolbar/toolbar-widget-group.ts | 88 ++++++ .../ui-patterns/toolbar/toolbar-widget.ts | 55 ++++ .../ui-patterns/toolbar/toolbar.ts | 296 ++++++++---------- 13 files changed, 784 insertions(+), 399 deletions(-) create mode 100644 src/cdk-experimental/ui-patterns/radio-group/radio-group-interaction.ts create mode 100644 src/cdk-experimental/ui-patterns/toolbar/toolbar-interaction.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..61403aef2a2d 100644 --- a/src/cdk-experimental/radio-group/radio-group.ts +++ b/src/cdk-experimental/radio-group/radio-group.ts @@ -19,12 +19,16 @@ import { model, signal, WritableSignal, - OnDestroy, } from '@angular/core'; -import {RadioButtonPattern, RadioGroupPattern} from '../ui-patterns'; +import { + RadioButtonPattern, + RadioGroupPattern, + RadioGroupInteraction, + RadioGroupInstructionHandler, +} 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. @@ -87,27 +91,28 @@ export function mapSignal( '[attr.aria-disabled]': 'pattern.disabled()', '[attr.aria-orientation]': 'pattern.orientation()', '[attr.aria-activedescendant]': 'pattern.activedescendant()', - '(keydown)': 'pattern.onKeydown($event)', - '(pointerdown)': 'pattern.onPointerdown($event)', + '(keydown)': 'onKeydown($event)', + '(pointerdown)': 'onPointerdown($event)', '(focusin)': 'onFocus()', }, + hostDirectives: [CdkToolbarWidgetGroup], }) 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)); @@ -135,17 +140,36 @@ export class CdkRadioGroup { reverse: values => (values.length === 0 ? null : values[0]), }); + /** + * The effective orientation of the radio group + * taking the parent toolbar's orientation into account. + */ + private _orientation = computed( + () => this._cdkToolbarWidgetGroup.toolbar()?.orientation() ?? this.orientation(), + ); + + /** The effective skipDisabled behavior, taking the parent toolbar's setting into account. */ + private _skipDisabled = computed( + () => this._cdkToolbarWidgetGroup.toolbar()?.skipDisabled() ?? this.skipDisabled(), + ); + /** The RadioGroup UIPattern. */ - pattern: RadioGroupPattern = new RadioGroupPattern({ + readonly pattern: RadioGroupPattern = new RadioGroupPattern({ ...this, items: this.items, value: this._value, activeItem: signal(undefined), + orientation: this._orientation, textDirection: this.textDirection, - toolbar: this._toolbarPattern, + skipDisabled: this._skipDisabled, element: () => this._elementRef.nativeElement, - focusMode: this._toolbarPattern()?.inputs.focusMode ?? this.focusMode, - skipDisabled: this._toolbarPattern()?.inputs.skipDisabled ?? this.skipDisabled, + }); + + /** The interaction manager for the radio group, which translates DOM events into instructions. */ + readonly interaction = new RadioGroupInteraction({ + orientation: this._orientation, + textDirection: this.textDirection, + handler: () => (i => this.pattern.execute(i)) as RadioGroupInstructionHandler, }); /** Whether the radio group has received focus yet. */ @@ -162,34 +186,34 @@ 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)); - } + if (this._hasToolbar()) { + this._cdkToolbarWidgetGroup.disabled.set(this.disabled()); } }); + + if (this._hasToolbar()) { + this._cdkToolbarWidgetGroup.handler.set(i => this.pattern.toolbarExecute(i)); + } } onFocus() { this._hasFocused.set(true); } - toolbarButtonUnregister(radio: CdkRadioButton) { - if (this.toolbar) { - this.toolbar.unregister(radio); - } + onKeydown(event: KeyboardEvent) { + if (this._hasToolbar()) return; + this.interaction.onKeydown(event); + } + + onPointerdown(event: PointerEvent) { + if (this._hasToolbar()) return; + this.interaction.onPointerdown(event); } } @@ -207,7 +231,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 +242,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 +264,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/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.ts b/src/cdk-experimental/toolbar/toolbar.ts index 884bfc487682..c0c59e79de82 100644 --- a/src/cdk-experimental/toolbar/toolbar.ts +++ b/src/cdk-experimental/toolbar/toolbar.ts @@ -19,20 +19,17 @@ import { OnInit, OnDestroy, } from '@angular/core'; -import {ToolbarPattern, RadioButtonPattern, ToolbarWidgetPattern} from '../ui-patterns'; +import { + ToolbarPattern, + ToolbarWidgetPattern, + ToolbarWidgetGroupPattern, + ToolbarWidgetGroupInteractionHandler, + ToolbarInteraction, + ToolbarInstructionHandler, +} 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; } @@ -74,8 +71,8 @@ function sortDirectives(a: HasElement, b: HasElement) { '[attr.aria-disabled]': 'pattern.disabled()', '[attr.aria-orientation]': 'pattern.orientation()', '[attr.aria-activedescendant]': 'pattern.activedescendant()', - '(keydown)': 'pattern.onKeydown($event)', - '(pointerdown)': 'pattern.onPointerdown($event)', + '(keydown)': 'interaction.onKeydown($event)', + '(pointerdown)': 'interaction.onPointerdown($event)', '(focusin)': 'onFocus()', }, }) @@ -84,51 +81,52 @@ 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), + }); + + /** + * The interaction manager for the toolbar + * which translates DOM events into toolbar instructions. + */ + readonly interaction: ToolbarInteraction = new ToolbarInteraction({ + orientation: this.orientation, + textDirection: this.textDirection, + handler: () => (i => this.pattern.execute(i)) as ToolbarInstructionHandler, }); /** 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 +135,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 +155,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, + ); + } } /** @@ -175,7 +192,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 +203,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 +235,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 = signal(false); + + /** The handler for instructions that are passed to the widget group. */ + readonly handler = signal>(() => {}); + + /** The ToolbarWidgetGroup UIPattern. */ + 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/public-api.ts b/src/cdk-experimental/ui-patterns/public-api.ts index dd1c225735bb..305b595bc176 100644 --- a/src/cdk-experimental/ui-patterns/public-api.ts +++ b/src/cdk-experimental/ui-patterns/public-api.ts @@ -9,8 +9,13 @@ export * from './listbox/listbox'; export * from './listbox/option'; export * from './radio-group/radio-group'; +export * from './radio-group/radio-group-interaction'; export * from './radio-group/radio-button'; 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 './toolbar/toolbar-interaction'; 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..802617d88e46 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", + "radio-group-interaction.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", ], ) 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-interaction.ts b/src/cdk-experimental/ui-patterns/radio-group/radio-group-interaction.ts new file mode 100644 index 000000000000..a17243effa57 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/radio-group/radio-group-interaction.ts @@ -0,0 +1,98 @@ +/** + * @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 {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; + +/** The type of operation to be performed on the radio group as a result of a user interaction. */ +export type RadioGroupOperation = 'next' | 'prev' | 'home' | 'end' | 'trigger' | 'goto'; + +/** An instruction to be sent to the radio group when a user interaction occurs. */ +export interface RadioGroupInstruction { + /** The operation to be performed. */ + op: RadioGroupOperation; + + /** Additional information about the interaction. */ + metadata: { + /** The underlying DOM event that triggered the instruction. */ + event?: KeyboardEvent | PointerEvent; + }; +} + +/** A function that handles a radio group instruction. */ +export type RadioGroupInstructionHandler = (instruction: RadioGroupInstruction) => void; + +/** Represents the required inputs for radio group interaction. */ +export interface RadioGroupInteractionInputs { + /** Whether the list is vertically or horizontally oriented. */ + orientation: SignalLike<'vertical' | 'horizontal'>; + + /** The direction that text is read based on the users locale. */ + textDirection: SignalLike<'rtl' | 'ltr'>; + + /** The handler for radio group instructions. */ + handler: SignalLike; +} + +/** Manages user interactions for a radio group, translating them into radio group instructions. */ +export class RadioGroupInteraction { + /** The key used to navigate to the previous radio button. */ + private readonly _prevKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return 'ArrowUp'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + }); + + /** The key used to navigate to the next radio button. */ + private readonly _nextKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return 'ArrowDown'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + }); + + /** The keydown event manager for the radio group. */ + private readonly _keydown = computed(() => + new KeyboardEventManager() + .on(this._nextKey, e => this._handler(e, 'next')) + .on(this._prevKey, e => this._handler(e, 'prev')) + .on(' ', e => this._handler(e, 'trigger')) + .on('Enter', e => this._handler(e, 'trigger')) + .on('Home', e => this._handler(e, 'home')) + .on('End', e => this._handler(e, 'end')), + ); + + /** The pointerdown event manager for the radio group. */ + private readonly _pointerdown = computed(() => + new PointerEventManager().on(e => this._handler(e, 'goto')), + ); + + constructor(readonly inputs: RadioGroupInteractionInputs) {} + + /** Handles keydown events for the radio group. */ + onKeydown(event: KeyboardEvent) { + this._keydown().handle(event); + } + + /** Handles pointerdown events for the radio group. */ + onPointerdown(event: PointerEvent) { + this._pointerdown().handle(event); + } + + /** Creates and dispatches a radio group instruction to the handler. */ + private _handler(event: KeyboardEvent | PointerEvent, op: RadioGroupOperation) { + this.inputs.handler()({ + op, + metadata: { + event: event, + }, + }); + } +} 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..3d346dee3ba3 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts @@ -6,11 +6,20 @@ * found in the LICENSE file at https://angular.dev/license */ -import {computed} from '@angular/core'; -import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager'; +import {computed, signal} from '@angular/core'; import {List, ListInputs} from '../behaviors/list/list'; import {SignalLike} from '../behaviors/signal-like/signal-like'; import {RadioButtonPattern} from './radio-button'; +import { + RadioGroupInstruction, + RadioGroupInstructionHandler, + RadioGroupOperation, +} from './radio-group-interaction'; +import { + ToolbarWidgetGroupOperation, + ToolbarWidgetGroupInteractionHandler, + ToolbarWidgetGroupInstruction, +} from '../toolbar/toolbar-widget-group'; /** Represents the required inputs for a radio group. */ export type RadioGroupInputs = Omit< @@ -22,145 +31,119 @@ export type RadioGroupInputs = Omit< /** Whether the radio group is readonly. */ readonly: SignalLike; - /** Parent toolbar of radio group */ - toolbar: SignalLike | undefined>; }; -/** - * Represents the properties exposed by a toolbar widget that need to be accessed by a radio group. - * This exists to avoid circular dependency errors between the toolbar and radio button. - */ -type ToolbarWidgetLike = { - id: SignalLike; - index: SignalLike; - element: SignalLike; - disabled: SignalLike; - searchTerm: SignalLike; - value: SignalLike; -}; - -/** - * Represents the properties exposed by a toolbar that need to be accessed by a radio group. - * This exists to avoid circular dependency errors between the toolbar and radio button. - */ -export interface ToolbarLike { - listBehavior: List | ToolbarWidgetLike, V>; - orientation: SignalLike<'vertical' | 'horizontal'>; - disabled: SignalLike; -} - /** Controls the state of a radio group. */ export class RadioGroupPattern { /** The list behavior for the radio group. */ - readonly listBehavior: List | 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); /** 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()); - - /** The key used to navigate to the previous radio button. */ - prevKey = computed(() => { - if (this.inputs.orientation() === 'vertical') { - return 'ArrowUp'; - } - return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; - }); - - /** The key used to navigate to the next radio button. */ - nextKey = computed(() => { - if (this.inputs.orientation() === 'vertical') { - return 'ArrowDown'; - } - return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; - }); - - /** The keydown event manager for the radio group. */ - keydown = computed(() => { - const manager = new KeyboardEventManager(); - - // 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('Home', () => this.listBehavior.first()) - .on('End', () => this.listBehavior.last()); - } - - // 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('Home', () => this.listBehavior.first({selectOne: true})) - .on('End', () => this.listBehavior.last({selectOne: true})) - .on(' ', () => this.listBehavior.selectOne()) - .on('Enter', () => this.listBehavior.selectOne()); - }); - - /** The pointerdown event manager for the radio group. */ - 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)!)); - } - - // Default behavior: navigate and select on click. - return manager.on(e => this.listBehavior.goto(this._getItem(e)!, {selectOne: true})); - }); + readonly activedescendant = computed(() => this.listBehavior.activedescendant()); + + /** A map of radio group operations to their corresponding instruction handlers. */ + private readonly _actionMap: Record = { + next: () => this.listBehavior.next({selectOne: !this.readonly()}), + prev: () => this.listBehavior.prev({selectOne: !this.readonly()}), + home: () => this.listBehavior.first({selectOne: !this.readonly()}), + end: () => this.listBehavior.last({selectOne: !this.readonly()}), + trigger: () => !this.readonly() && this.listBehavior.selectOne(), + goto: i => + this.listBehavior.goto(this._getItem(i.metadata.event as PointerEvent)!, { + selectOne: !this.readonly(), + }), + }; + + /** A map of toolbar widget group operations to their corresponding instruction handlers. */ + private readonly _toolbarActionMap: Record< + ToolbarWidgetGroupOperation, + ToolbarWidgetGroupInteractionHandler + > = { + enterFromStart: () => this.listBehavior.first(), + enterFromEnd: () => this.listBehavior.last(), + next: () => { + const item = this.inputs.activeItem(); + this.listBehavior.next(); + + const leaveGroup = item === this.inputs.activeItem(); + if (leaveGroup) { + this.inputs.activeItem.set(undefined); + } + return { + leaveGroup, + }; + }, + prev: () => { + const item = this.inputs.activeItem(); + this.listBehavior.prev(); + + const leaveGroup = item === this.inputs.activeItem(); + if (leaveGroup) { + this.inputs.activeItem.set(undefined); + } + return { + leaveGroup, + }; + }, + groupNextWrap: () => { + this.wrap.set(true); + this.listBehavior.next(); + this.wrap.set(false); + }, + groupPrevWrap: () => { + this.wrap.set(true); + this.listBehavior.prev(); + this.wrap.set(false); + }, + home: () => this.inputs.activeItem.set(undefined), + end: () => this.inputs.activeItem.set(undefined), + trigger: i => this.execute({op: 'trigger', metadata: i.metadata}), + goto: i => this.execute({op: 'goto', metadata: i.metadata}), + asEntryPoint: () => this.setDefaultState(), + }; 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, multi: () => false, - selectionMode: () => (inputs.toolbar() ? 'explicit' : 'follow'), + selectionMode: () => 'follow', typeaheadDelay: () => 0, // Radio groups do not support typeahead. }); } - /** Handles keydown events for the radio group. */ - onKeydown(event: KeyboardEvent) { - if (!this.disabled()) { - this.keydown().handle(event); - } + /** Executes an instruction on the radio group. */ + execute(instruction: RadioGroupInstruction) { + if (this.disabled()) return; + + return this._actionMap[instruction.op](instruction); } - /** Handles pointerdown events for the radio group. */ - onPointerdown(event: PointerEvent) { - if (!this.disabled()) { - this.pointerdown().handle(event); - } + /** Executes an instruction on the radio group as a toolbar widget group. */ + toolbarExecute(instruction: ToolbarWidgetGroupInstruction) { + if (this.disabled()) return; + + return this._toolbarActionMap[instruction.op](instruction); } /** diff --git a/src/cdk-experimental/ui-patterns/toolbar/BUILD.bazel b/src/cdk-experimental/ui-patterns/toolbar/BUILD.bazel index bcae2e3c2086..bd2e1cdebf15 100644 --- a/src/cdk-experimental/ui-patterns/toolbar/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/toolbar/BUILD.bazel @@ -6,13 +6,15 @@ ts_project( name = "toolbar", srcs = [ "toolbar.ts", + "toolbar-interaction.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", ], ) diff --git a/src/cdk-experimental/ui-patterns/toolbar/toolbar-interaction.ts b/src/cdk-experimental/ui-patterns/toolbar/toolbar-interaction.ts new file mode 100644 index 000000000000..0422ca3911d0 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/toolbar/toolbar-interaction.ts @@ -0,0 +1,135 @@ +/** + * @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 {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; + +/** The type of operation to be performed on the toolbar as a result of a user interaction. */ +export type ToolbarOperation = + | 'next' + | 'prev' + | 'groupNextWrap' + | 'groupPrevWrap' + | 'home' + | 'end' + | 'trigger' + | 'goto'; + +/** An instruction to be sent to the toolbar when a user interaction occurs. */ +export interface ToolbarInstruction { + /** The operation to be performed. */ + op: ToolbarOperation; + + /** Additional information about the interaction. */ + metadata: { + /** The underlying DOM event that triggered the instruction. */ + event?: KeyboardEvent | PointerEvent; + }; +} + +/** A function that handles a toolbar instruction, with the ability to control event behavior. */ +export type ToolbarInstructionHandler = (instruction: ToolbarInstruction) => { + stopPropagation?: boolean; + preventDefault?: boolean; +} | void; + +/** Represents the required inputs for toolbar interaction. */ +export interface ToolbarInteractionInputs { + /** Whether the toolbar is vertically or horizontally oriented. */ + orientation: SignalLike<'vertical' | 'horizontal'>; + + /** The direction that text is read based on the users locale. */ + textDirection: SignalLike<'rtl' | 'ltr'>; + + /** The handler for toolbar instructions. */ + handler: SignalLike; +} + +/** Manages user interactions for a toolbar, translating them into toolbar instructions. */ +export class ToolbarInteraction { + /** The key used to navigate to the previous widget. */ + private readonly _prevKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return 'ArrowUp'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + }); + + /** The key used to navigate to the next widget. */ + 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. */ + private readonly _altPrevKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + } + return 'ArrowUp'; + }); + + /** The alternate key used to navigate to the next widget. */ + private readonly _altNextKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + } + return 'ArrowDown'; + }); + + /** The keydown event manager for the toolbar. */ + private readonly _keydown = computed(() => { + const manager = new KeyboardEventManager(); + manager.options = { + stopPropagation: false, + preventDefault: false, + }; + + return manager + .on(this._nextKey, e => this._handler(e, 'next')) + .on(this._prevKey, e => this._handler(e, 'prev')) + .on(this._altNextKey, e => this._handler(e, 'groupNextWrap')) + .on(this._altPrevKey, e => this._handler(e, 'groupPrevWrap')) + .on(' ', e => this._handler(e, 'trigger')) + .on('Enter', e => this._handler(e, 'trigger')) + .on('Home', e => this._handler(e, 'home')) + .on('End', e => this._handler(e, 'end')); + }); + + /** The pointerdown event manager for the toolbar. */ + private readonly _pointerdown = computed(() => + new PointerEventManager().on(e => this._handler(e, 'goto')), + ); + + constructor(readonly inputs: ToolbarInteractionInputs) {} + + /** Handles keydown events for the toolbar. */ + onKeydown(event: KeyboardEvent) { + this._keydown().handle(event); + } + + /** Handles pointerdown events for the toolbar. */ + onPointerdown(event: PointerEvent) { + this._pointerdown().handle(event); + } + + /** Creates and dispatches a toolbar instruction to the handler. */ + private _handler(event: KeyboardEvent | PointerEvent, op: ToolbarOperation) { + const {stopPropagation = true, preventDefault = true} = + this.inputs.handler()({ + op, + metadata: { + event: event, + }, + }) ?? {}; + if (stopPropagation) event.stopPropagation(); + if (preventDefault) event.preventDefault(); + } +} 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..9100d0ae1d3a --- /dev/null +++ b/src/cdk-experimental/ui-patterns/toolbar/toolbar-widget-group.ts @@ -0,0 +1,88 @@ +/** + * @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 {ToolbarOperation} from './toolbar-interaction'; +import type {ToolbarPattern} from './toolbar'; + +/** The type of operation to be performed on a toolbar widget group. */ +export type ToolbarWidgetGroupOperation = + | 'enterFromStart' + | 'enterFromEnd' + | 'asEntryPoint' + | ToolbarOperation; + +/** An instruction to be sent to the toolbar widget group when an interaction occurs. */ +export type ToolbarWidgetGroupInstruction = { + /** The operation to be performed. */ + op: ToolbarWidgetGroupOperation; + + /** Additional information about the interaction. */ + metadata: { + /** The underlying DOM event that triggered the instruction. */ + event?: KeyboardEvent | PointerEvent; + }; +}; + +/** + * A function that handles a toolbar widget group instruction. + * Can indicate whether focus should leave the group. + */ +export type ToolbarWidgetGroupInteractionHandler = ( + instruction: ToolbarWidgetGroupInstruction, +) => { + leaveGroup?: boolean; +} | 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 handler for toolbar widget group instructions. */ + handler: 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); + + constructor(readonly inputs: ToolbarWidgetGroupInputs) { + this.id = inputs.id; + this.element = inputs.element; + this.disabled = inputs.disabled; + this.toolbar = inputs.toolbar; + } + + /** Executes an instruction on the widget group. */ + execute(instruction: ToolbarWidgetGroupInstruction) { + return this.inputs.handler()(instruction); + } +} 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.ts b/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts index 7689f286d41e..7c47d32ed351 100644 --- a/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts +++ b/src/cdk-experimental/ui-patterns/toolbar/toolbar.ts @@ -7,156 +7,161 @@ */ 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 {List, ListInputs} from '../behaviors/list/list'; +import { + ToolbarInstruction, + ToolbarInstructionHandler, + ToolbarOperation, +} from './toolbar-interaction'; +import {ToolbarWidgetPattern} from './toolbar-widget'; +import {ToolbarWidgetGroupPattern} from './toolbar-widget-group'; /** Represents the required inputs for a toolbar. */ export type ToolbarInputs = Omit< - ListInputs, V>, + ListInputs | ToolbarWidgetGroupPattern, V>, 'multi' | 'typeaheadDelay' | 'value' | 'selectionMode' ->; +> & { + /** 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()); - - /** The key used to navigate to the previous widget. */ - prevKey = computed(() => { - if (this.inputs.orientation() === 'vertical') { - return 'ArrowUp'; - } - return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; - }); - - /** The key used to navigate to the next widget. */ - nextKey = computed(() => { - if (this.inputs.orientation() === 'vertical') { - return 'ArrowDown'; - } - return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; - }); - - /** The alternate key used to navigate to the previous widget */ - altPrevKey = computed(() => { - if (this.inputs.orientation() === 'vertical') { - return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; - } - return 'ArrowUp'; - }); - - /** The alternate key used to navigate to the next widget. */ - altNextKey = computed(() => { - if (this.inputs.orientation() === 'vertical') { - return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; - } - return 'ArrowDown'; - }); - - /** The keydown event manager for the toolbar. */ - keydown = computed(() => { - const manager = new KeyboardEventManager(); - - const activeItem = this.inputs.activeItem(); - const isRadioButton = activeItem instanceof RadioButtonPattern; - - if (isRadioButton) { - manager - .on(' ', () => this.selectRadioButton()) - .on('Enter', () => this.selectRadioButton()) - .on(this.altNextKey, () => activeItem?.group()?.listBehavior.next()) - .on(this.altPrevKey, () => activeItem?.group()?.listBehavior.prev()); - } else { - manager.on(this.altNextKey, () => this.listBehavior.next()); - manager.on(this.altPrevKey, () => this.listBehavior.prev()); - } - - return manager - .on(this.prevKey, () => this.listBehavior.prev()) - .on(this.nextKey, () => this.listBehavior.next()) - .on('Home', () => this.listBehavior.first()) - .on('End', () => this.listBehavior.last()); - }); - - selectRadioButton() { - const activeItem = this.inputs.activeItem() as RadioButtonPattern; - - // activeItem must be a radio button - const group = activeItem!.group(); - if (group && !group.readonly() && !group.disabled()) { - group.listBehavior.selectOne(); - } - } - - /** The pointerdown event manager for the toolbar. */ - pointerdown = computed(() => new PointerEventManager().on(e => this.goto(e))); - - /** Navigates to the widget associated with the given pointer event. */ - goto(event: PointerEvent) { - const item = this._getItem(event); - if (!item) return; + readonly activedescendant = computed(() => this.listBehavior.activedescendant()); + + /** A map of toolbar operations to their corresponding instruction handlers. */ + private readonly _actionMap: Record = { + next: i => { + const item = this.inputs.activeItem(); + if (item instanceof ToolbarWidgetGroupPattern) { + const {leaveGroup} = item.execute(i) ?? {}; + if (!leaveGroup) return; + } - if (item instanceof RadioButtonPattern) { - const group = item.group(); - if (group && !group.disabled()) { - group.listBehavior.goto(item, {selectOne: !group.readonly()}); + this.listBehavior.next(); + const newItem = this.inputs.activeItem(); + if (newItem instanceof ToolbarWidgetGroupPattern) { + newItem.execute({ + op: 'enterFromStart', + metadata: i.metadata, + }); + } + }, + prev: i => { + const item = this.inputs.activeItem(); + if (item instanceof ToolbarWidgetGroupPattern) { + const {leaveGroup} = item.execute(i) ?? {}; + if (!leaveGroup) return; } - } else { - this.listBehavior.goto(item); - } - } - /** Handles keydown events for the toolbar. */ - onKeydown(event: KeyboardEvent) { - if (!this.disabled()) { - this.keydown().handle(event); - } - } + this.listBehavior.prev(); + const newItem = this.inputs.activeItem(); + if (newItem instanceof ToolbarWidgetGroupPattern) { + newItem.execute({ + op: 'enterFromEnd', + metadata: i.metadata, + }); + } + }, + groupNextWrap: i => { + const item = this.inputs.activeItem(); + if (item instanceof ToolbarWidgetPattern) return; + item?.execute(i); + }, + groupPrevWrap: i => { + const item = this.inputs.activeItem(); + if (item instanceof ToolbarWidgetPattern) return; + item?.execute(i); + }, + home: i => { + const item = this.inputs.activeItem(); + if (item instanceof ToolbarWidgetGroupPattern) { + item.execute(i); + } - /** 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.execute({ + op: 'enterFromStart', + metadata: i.metadata, + }); + } + }, + end: i => { + const item = this.inputs.activeItem(); + if (item instanceof ToolbarWidgetGroupPattern) { + item.execute(i); + } - /** Finds the Toolbar Widget associated with a pointer event target. */ - private _getItem(e: PointerEvent): RadioButtonPattern | ToolbarWidgetPattern | undefined { - if (!(e.target instanceof HTMLElement)) { - return undefined; - } + this.listBehavior.last(); + const newItem = this.inputs.activeItem(); + if (newItem instanceof ToolbarWidgetGroupPattern) { + newItem.execute({ + op: 'enterFromEnd', + metadata: i.metadata, + }); + } + }, + trigger: i => { + const item = this.inputs.activeItem(); + if (item instanceof ToolbarWidgetGroupPattern) { + item.execute(i); + return; + } + return { + stopPropagation: false, + preventDefault: false, + }; + }, + goto: i => { + const item = this.inputs.getItem(i.metadata.event!.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.execute(i); + } + }, + }; constructor(readonly inputs: ToolbarInputs) { this.orientation = inputs.orientation; + this.skipDisabled = inputs.skipDisabled; this.listBehavior = new List({ ...inputs, multi: () => false, selectionMode: () => 'explicit', - value: signal([] as any), + value: signal([] as V[]), typeaheadDelay: () => 0, // Toolbar widgets do not support typeahead. }); } + /** Executes an instruction on the toolbar. */ + execute(instruction: ToolbarInstruction) { + if (this.disabled()) return; + + return this._actionMap[instruction.op](instruction); + } + /** * Sets the toolbar to its default initial state. * @@ -164,80 +169,31 @@ 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.execute({ + op: 'asEntryPoint', + metadata: {}, + }); + } } /** 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; - } -}