From 6b857e08241f006937ba6489e3871c9636420a9b Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Mon, 3 Feb 2025 17:42:01 -0500 Subject: [PATCH 01/11] feat(cdk-experimental/ui-patterns): listbox ui pattern --- .ng-dev/commit-message.mts | 2 + src/cdk-experimental/config.bzl | 2 + src/cdk-experimental/listbox/BUILD.bazel | 15 + src/cdk-experimental/listbox/index.ts | 9 + src/cdk-experimental/listbox/listbox.ts | 156 +++++++++ src/cdk-experimental/listbox/public-api.ts | 9 + src/cdk-experimental/ui-patterns/BUILD.bazel | 15 + .../behaviors/event-manager/BUILD.bazel | 12 + .../behaviors/event-manager/event-manager.ts | 194 +++++++++++ .../event-manager/keyboard-event-manager.ts | 99 ++++++ .../event-manager/mouse-event-manager.ts | 137 ++++++++ .../behaviors/list-focus/BUILD.bazel | 29 ++ .../behaviors/list-focus/controller.ts | 24 ++ .../behaviors/list-focus/list-focus.spec.ts | 159 +++++++++ .../behaviors/list-focus/list-focus.ts | 83 +++++ .../behaviors/list-navigation/BUILD.bazel | 23 ++ .../behaviors/list-navigation/controller.ts | 72 ++++ .../list-navigation/list-navigation.spec.ts | 308 ++++++++++++++++++ .../list-navigation/list-navigation.ts | 93 ++++++ .../behaviors/list-selection/BUILD.bazel | 29 ++ .../behaviors/list-selection/controller.ts | 113 +++++++ .../list-selection/list-selection.spec.ts | 216 ++++++++++++ .../list-selection/list-selection.ts | 109 +++++++ .../behaviors/list-typeahead/BUILD.bazel | 29 ++ .../behaviors/list-typeahead/controller.ts | 69 ++++ .../list-typeahead/list-typeahead.spec.ts | 152 +++++++++ .../list-typeahead/list-typeahead.ts | 54 +++ src/cdk-experimental/ui-patterns/index.ts | 9 + .../ui-patterns/listbox/BUILD.bazel | 19 ++ .../ui-patterns/listbox/controller.ts | 217 ++++++++++++ .../ui-patterns/listbox/listbox.ts | 107 ++++++ .../ui-patterns/listbox/option.ts | 69 ++++ .../ui-patterns/public-api.ts | 10 + .../cdk-experimental/listbox/BUILD.bazel | 27 ++ .../cdk-listbox/cdk-listbox-example.css | 60 ++++ .../cdk-listbox/cdk-listbox-example.html | 54 +++ .../cdk-listbox/cdk-listbox-example.ts | 70 ++++ .../cdk-experimental/listbox/index.ts | 1 + src/dev-app/BUILD.bazel | 1 + .../cdk-experimental-listbox/BUILD.bazel | 10 + .../cdk-listbox-demo.html | 4 + .../cdk-listbox-demo.ts | 17 + src/dev-app/dev-app/dev-app-layout.ts | 1 + src/dev-app/routes.ts | 5 + tslint.json | 2 +- 45 files changed, 2894 insertions(+), 1 deletion(-) create mode 100644 src/cdk-experimental/listbox/BUILD.bazel create mode 100644 src/cdk-experimental/listbox/index.ts create mode 100644 src/cdk-experimental/listbox/listbox.ts create mode 100644 src/cdk-experimental/listbox/public-api.ts create mode 100644 src/cdk-experimental/ui-patterns/BUILD.bazel create mode 100644 src/cdk-experimental/ui-patterns/behaviors/event-manager/BUILD.bazel create mode 100644 src/cdk-experimental/ui-patterns/behaviors/event-manager/event-manager.ts create mode 100644 src/cdk-experimental/ui-patterns/behaviors/event-manager/keyboard-event-manager.ts create mode 100644 src/cdk-experimental/ui-patterns/behaviors/event-manager/mouse-event-manager.ts create mode 100644 src/cdk-experimental/ui-patterns/behaviors/list-focus/BUILD.bazel create mode 100644 src/cdk-experimental/ui-patterns/behaviors/list-focus/controller.ts create mode 100644 src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts create mode 100644 src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts create mode 100644 src/cdk-experimental/ui-patterns/behaviors/list-navigation/BUILD.bazel create mode 100644 src/cdk-experimental/ui-patterns/behaviors/list-navigation/controller.ts create mode 100644 src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.spec.ts create mode 100644 src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.ts create mode 100644 src/cdk-experimental/ui-patterns/behaviors/list-selection/BUILD.bazel create mode 100644 src/cdk-experimental/ui-patterns/behaviors/list-selection/controller.ts create mode 100644 src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts create mode 100644 src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts create mode 100644 src/cdk-experimental/ui-patterns/behaviors/list-typeahead/BUILD.bazel create mode 100644 src/cdk-experimental/ui-patterns/behaviors/list-typeahead/controller.ts create mode 100644 src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts create mode 100644 src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts create mode 100644 src/cdk-experimental/ui-patterns/index.ts create mode 100644 src/cdk-experimental/ui-patterns/listbox/BUILD.bazel create mode 100644 src/cdk-experimental/ui-patterns/listbox/controller.ts create mode 100644 src/cdk-experimental/ui-patterns/listbox/listbox.ts create mode 100644 src/cdk-experimental/ui-patterns/listbox/option.ts create mode 100644 src/cdk-experimental/ui-patterns/public-api.ts create mode 100644 src/components-examples/cdk-experimental/listbox/BUILD.bazel create mode 100644 src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.css create mode 100644 src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html create mode 100644 src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.ts create mode 100644 src/components-examples/cdk-experimental/listbox/index.ts create mode 100644 src/dev-app/cdk-experimental-listbox/BUILD.bazel create mode 100644 src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.html create mode 100644 src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.ts diff --git a/.ng-dev/commit-message.mts b/.ng-dev/commit-message.mts index 92dbdecf796e..da05c828512e 100644 --- a/.ng-dev/commit-message.mts +++ b/.ng-dev/commit-message.mts @@ -11,10 +11,12 @@ export const commitMessage: CommitMessageConfig = { 'multiple', // For when a commit applies to multiple components. 'cdk-experimental/column-resize', 'cdk-experimental/combobox', + 'cdk-experimental/listbox', 'cdk-experimental/popover-edit', 'cdk-experimental/scrolling', 'cdk-experimental/selection', 'cdk-experimental/table-scroll-container', + 'cdk-experimental/ui-patterns', 'cdk/a11y', 'cdk/accordion', 'cdk/bidi', diff --git a/src/cdk-experimental/config.bzl b/src/cdk-experimental/config.bzl index 7198c3d37550..17509954fef2 100644 --- a/src/cdk-experimental/config.bzl +++ b/src/cdk-experimental/config.bzl @@ -2,10 +2,12 @@ CDK_EXPERIMENTAL_ENTRYPOINTS = [ "column-resize", "combobox", + "listbox", "popover-edit", "scrolling", "selection", "table-scroll-container", + "ui-patterns", ] # List of all entry-point targets of the Angular cdk-experimental package. diff --git a/src/cdk-experimental/listbox/BUILD.bazel b/src/cdk-experimental/listbox/BUILD.bazel new file mode 100644 index 000000000000..66c19f59857b --- /dev/null +++ b/src/cdk-experimental/listbox/BUILD.bazel @@ -0,0 +1,15 @@ +load("//tools:defaults.bzl", "ng_module") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "listbox", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//src/cdk-experimental/ui-patterns", + "//src/cdk/bidi", + ], +) diff --git a/src/cdk-experimental/listbox/index.ts b/src/cdk-experimental/listbox/index.ts new file mode 100644 index 000000000000..52b3c7a5156f --- /dev/null +++ b/src/cdk-experimental/listbox/index.ts @@ -0,0 +1,9 @@ +/** + * @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 + */ + +export * from './public-api'; diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts new file mode 100644 index 000000000000..d343c31d052d --- /dev/null +++ b/src/cdk-experimental/listbox/listbox.ts @@ -0,0 +1,156 @@ +/** + * @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 { + booleanAttribute, + computed, + contentChildren, + Directive, + ElementRef, + inject, + input, + model, + OnDestroy, + signal, +} from '@angular/core'; +import {ListboxPattern, OptionPattern} from '@angular/cdk-experimental/ui-patterns'; +import {Directionality} from '@angular/cdk/bidi'; +import {toSignal} from '@angular/core/rxjs-interop'; + +/** + * A listbox container. + * + * Listboxes are used to display a list of items for a user to select from. The CdkListbox is meant + * to be used in conjunction with CdkOption as follows: + * + * ```html + * + * ``` + */ +@Directive({ + selector: '[cdkListbox]', + exportAs: 'cdkListbox', + host: { + 'role': 'listbox', + 'class': 'cdk-listbox', + '[attr.tabindex]': 'pattern.tabindex()', + '[attr.aria-disabled]': 'pattern.disabled()', + '[attr.aria-orientation]': 'pattern.orientation()', + '[attr.aria-multiselectable]': 'pattern.multiselectable()', + '[attr.aria-activedescendant]': 'pattern.activedescendant()', + '(focusin)': 'pattern.onFocus()', + '(keydown)': 'pattern.onKeydown($event)', + '(mousedown)': 'pattern.onMousedown($event)', + }, +}) +export class CdkListbox { + /** The directionality (LTR / RTL) context for the application (or a subtree of it). */ + private _dir = inject(Directionality); + + /** The CdkOptions nested inside of the CdkListbox. */ + private _cdkOptions = contentChildren(CdkOption, {descendants: true}); + + /** A signal wrapper for directionality. */ + protected directionality = toSignal(this._dir.change, { + initialValue: 'ltr', + }); + + /** The Option UIPatterns of the child CdkOptions. */ + protected items = computed(() => this._cdkOptions().map(option => option.pattern)); + + /** Whether the list is vertically or horizontally oriented. */ + orientation = input<'vertical' | 'horizontal'>('vertical'); + + /** Whether multiple items in the list can be selected at once. */ + multiselectable = input(false, {transform: booleanAttribute}); + + /** Whether focus should wrap when navigating. */ + wrap = input(true, {transform: booleanAttribute}); + + /** Whether disabled items in the list should be skipped when navigating. */ + skipDisabled = input(true, {transform: booleanAttribute}); + + /** The focus strategy used by the list. */ + focusMode = input<'roving' | 'activedescendant'>('roving'); + + /** The selection strategy used by the list. */ + selectionMode = input<'follow' | 'explicit'>('follow'); + + /** The amount of time before the typeahead search is reset. */ + typeaheadDelay = input(0.5); // Picked arbitrarily. + + /** Whether the listbox is disabled. */ + disabled = input(false, {transform: booleanAttribute}); + + /** The ids of the current selected items. */ + selectedIds = model([]); + + /** The current index that has been navigated to. */ + activeIndex = model(0); + + /** The Listbox UIPattern. */ + pattern: ListboxPattern = new ListboxPattern({ + ...this, + items: this.items, + directionality: this.directionality, + }); +} + +// TODO(wagnermaciel): Figure out how we actually want to do this. +let count = 0; + +/** A selectable option in a CdkListbox. */ +@Directive({ + selector: '[cdkOption]', + exportAs: 'cdkOption', + host: { + 'role': 'option', + 'class': 'cdk-option', + '[attr.tabindex]': 'pattern.tabindex()', + '[attr.aria-selected]': 'pattern.selected()', + '[attr.aria-disabled]': 'pattern.disabled()', + }, +}) +export class CdkOption { + /** A reference to the option element. */ + private _elementRef = inject(ElementRef); + + /** The parent CdkListbox. */ + private _cdkListbox = inject(CdkListbox); + + /** A unique identifier for the option. */ + protected id = computed(() => `${count++}`); + + /** The text used by the typeahead search. */ + protected searchTerm = computed(() => this.label() ?? this.element().textContent); + + /** The parent Listbox UIPattern. */ + protected listbox = computed(() => this._cdkListbox.pattern); + + /** A reference to the option element to be focused on navigation. */ + protected element = computed(() => this._elementRef.nativeElement); + + /** Whether an item is disabled. */ + disabled = input(false, {transform: booleanAttribute}); + + /** The text used by the typeahead search. */ + label = input(); + + /** The Option UIPattern. */ + pattern = new OptionPattern({ + ...this, + id: this.id, + listbox: this.listbox, + element: this.element, + searchTerm: this.searchTerm, + }); +} diff --git a/src/cdk-experimental/listbox/public-api.ts b/src/cdk-experimental/listbox/public-api.ts new file mode 100644 index 000000000000..d78fbe19d194 --- /dev/null +++ b/src/cdk-experimental/listbox/public-api.ts @@ -0,0 +1,9 @@ +/** + * @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 + */ + +export {CdkListbox, CdkOption} from './listbox'; diff --git a/src/cdk-experimental/ui-patterns/BUILD.bazel b/src/cdk-experimental/ui-patterns/BUILD.bazel new file mode 100644 index 000000000000..f420959e2ef8 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/BUILD.bazel @@ -0,0 +1,15 @@ +load("//tools:defaults.bzl", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +ts_library( + name = "ui-patterns", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//src/cdk-experimental/ui-patterns/listbox", + "@npm//@angular/core", + ], +) diff --git a/src/cdk-experimental/ui-patterns/behaviors/event-manager/BUILD.bazel b/src/cdk-experimental/ui-patterns/behaviors/event-manager/BUILD.bazel new file mode 100644 index 000000000000..df8008162f4b --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/event-manager/BUILD.bazel @@ -0,0 +1,12 @@ +load("//tools:defaults.bzl", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +ts_library( + name = "event-manager", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = ["@npm//@angular/core"], +) diff --git a/src/cdk-experimental/ui-patterns/behaviors/event-manager/event-manager.ts b/src/cdk-experimental/ui-patterns/behaviors/event-manager/event-manager.ts new file mode 100644 index 000000000000..932dd49e3ba1 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/event-manager/event-manager.ts @@ -0,0 +1,194 @@ +/** + * @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 + */ + +/** + * An event that supports modifier keys. + * + * Matches the native KeyboardEvent, MouseEvent, and TouchEvent. + */ +export interface EventWithModifiers extends Event { + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; + metaKey: boolean; +} + +/** + * Options that are applicable to all event handlers. + * + * This library has not yet had a need for stopPropagationImmediate. + */ +export interface EventHandlerOptions { + stopPropagation: boolean; + preventDefault: boolean; +} + +/** + * A config that specifies how to handle a particular event. + */ +export interface EventHandlerConfig extends EventHandlerOptions { + handler: (event: T) => Promise; +} + +/** + * Bit flag representation of the possible modifier keys that can be present on an event. + */ +export enum ModifierKey { + None = 0, + Ctrl = 0b1, + Shift = 0b10, + Alt = 0b100, + Meta = 0b1000, +} + +/** + * Abstract base class for all event managers. + * + * Event managers are designed to normalize how event handlers are authored and create a safety net + * for common event handling gotchas like remembering to call preventDefault or stopPropagation. + */ +export abstract class EventManager { + private _submanagers: EventManager[] = []; + + protected configs: EventHandlerConfig[] = []; + protected beforeFns: ((event: T) => void)[] = []; + protected afterFns: ((event: T) => void)[] = []; + + protected defaultHandlerOptions: EventHandlerOptions = { + preventDefault: false, + stopPropagation: false, + }; + + constructor(defaultHandlerOptions?: Partial) { + this.defaultHandlerOptions = { + ...this.defaultHandlerOptions, + ...defaultHandlerOptions, + }; + } + + /** + * Composes together multiple event managers into a single event manager that delegates to the + * individual managers. + */ + static compose(...managers: EventManager[]) { + const composedManager = new GenericEventManager(); + composedManager._submanagers = managers; + return composedManager; + } + + /** + * Runs any handlers that have been configured to handle this event. If multiple handlers are + * configured for this event, they are run in the order they were configured. Returns + * `true` if the event has been handled, otherwise returns `undefined`. + * + * Note: the use of `undefined` instead of `false` in the unhandled case is necessary to avoid + * accidentally preventing the default behavior on an unhandled event. + */ + async handle(event: T): Promise { + if (!this.isHandled(event)) { + return undefined; + } + for (const fn of this.beforeFns) { + fn(event); + } + for (const submanager of this._submanagers) { + await submanager.handle(event); + } + for (const config of this.getHandlersForKey(event)) { + await config.handler(event); + if (config.stopPropagation) { + event.stopPropagation(); + } + if (config.preventDefault) { + event.preventDefault(); + } + } + for (const fn of this.afterFns) { + fn(event); + } + return true; + } + + /** + * Configures the event manager to run a function immediately before it as about to handle + * any event. + */ + beforeHandling(fn: (event: T) => void): this { + this.beforeFns.push(fn); + return this; + } + + /** + * Configures the event manager to run a function immediately after it handles any event. + */ + afterHandling(fn: (event: T) => void): this { + this.afterFns.push(fn); + return this; + } + + /** + * Configures the event manager to handle specific events. (See subclasses for more). + */ + abstract on(...args: [...unknown[]]): this; + + /** + * Gets all of the handler configs that are applicable to the given event. + */ + protected abstract getHandlersForKey(event: T): EventHandlerConfig[]; + + /** + * Checks whether this event manager is confugred to handle the given event. + */ + protected isHandled(event: T): boolean { + return ( + this.getHandlersForKey(event).length > 0 || this._submanagers.some(sm => sm.isHandled(event)) + ); + } +} + +/** + * A generic event manager that can work with any type of event. + */ +export class GenericEventManager extends EventManager { + /** + * Configures this event manager to handle all events with the given handler. + */ + on(handler: (event: T) => Promise): this { + this.configs.push({ + ...this.defaultHandlerOptions, + handler, + }); + return this; + } + + getHandlersForKey(_event: T): EventHandlerConfig[] { + return this.configs; + } +} + +/** + * Gets bit flag representation of the modifier keys present on the given event. + */ +export function getModifiers(event: EventWithModifiers): number { + return ( + (+event.ctrlKey && ModifierKey.Ctrl) | + (+event.shiftKey && ModifierKey.Shift) | + (+event.altKey && ModifierKey.Alt) | + (+event.metaKey && ModifierKey.Meta) + ); +} + +/** + * Checks if the given event has modifiers that are an exact match for any of the given modifier + * flag combinations. + */ +export function hasModifiers(event: EventWithModifiers, modifiers: number | number[]): boolean { + const eventModifiers = getModifiers(event); + const modifiersList = Array.isArray(modifiers) ? modifiers : [modifiers]; + return modifiersList.some(modifiers => eventModifiers === modifiers); +} diff --git a/src/cdk-experimental/ui-patterns/behaviors/event-manager/keyboard-event-manager.ts b/src/cdk-experimental/ui-patterns/behaviors/event-manager/keyboard-event-manager.ts new file mode 100644 index 000000000000..258147e35892 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/event-manager/keyboard-event-manager.ts @@ -0,0 +1,99 @@ +/** + * @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} from '@angular/core'; +import { + EventHandlerConfig, + EventHandlerOptions, + EventManager, + hasModifiers, + ModifierKey, +} from './event-manager'; + +/** + * A config that specifies how to handle a particular keyboard event. + */ +export interface KeyboardEventHandlerConfig extends EventHandlerConfig { + key: string | Signal | RegExp; + modifiers: number | number[]; +} + +/** + * An event manager that is specialized for handling keyboard events. By default this manager stops + * propagation and prevents default on all events it handles. + */ +export class KeyboardEventManager extends EventManager { + override configs: KeyboardEventHandlerConfig[] = []; + + protected override defaultHandlerOptions: EventHandlerOptions = { + preventDefault: true, + stopPropagation: true, + }; + + /** + * Configures this event manager to handle events with a specific modifer and key combination. + * + * @param modifiers The modifier combinations that this handler should run for. + * @param key The key that this handler should run for (or a predicate function that takes the + * event's key and returns whether to run this handler). + * @param handler The handler function + * @param options Options for whether to stop propagation or prevent default. + */ + on( + modifiers: number | number[], + key: string | Signal | RegExp, + handler: ((event: KeyboardEvent) => void) | ((event: KeyboardEvent) => boolean), + options?: Partial, + ): this; + + /** + * Configures this event manager to handle events with a specific key and no modifiers. + * + * @param key The key that this handler should run for (or a predicate function that takes the + * event's key and returns whether to run this handler). + * @param handler The handler function + * @param options Options for whether to stop propagation or prevent default. + */ + on( + key: string | Signal | RegExp, + handler: ((event: KeyboardEvent) => void) | ((event: KeyboardEvent) => boolean), + options?: Partial, + ): this; + + on(...args: any[]) { + const key = args.length === 3 ? args[1] : args[0]; + const handler = args.length === 3 ? args[2] : args[1]; + const modifiers = args.length === 3 ? args[0] : ModifierKey.None; + + // TODO: Add strict type checks again when finalizing this API. + + this.configs.push({ + key, + handler, + modifiers, + ...this.defaultHandlerOptions, + }); + + return this; + } + + getHandlersForKey(event: KeyboardEvent) { + return this.configs.filter(config => this._isKeyMatch(config, event)); + } + + // TODO: Make modifiers accept a signal as well. + + private _isKeyMatch(config: KeyboardEventHandlerConfig, event: KeyboardEvent) { + if (config.key instanceof RegExp) { + return config.key.test(event.key); + } + + const key = typeof config.key === 'string' ? config.key : config.key(); + return key.toLowerCase() === event.key.toLowerCase() && hasModifiers(event, config.modifiers); + } +} diff --git a/src/cdk-experimental/ui-patterns/behaviors/event-manager/mouse-event-manager.ts b/src/cdk-experimental/ui-patterns/behaviors/event-manager/mouse-event-manager.ts new file mode 100644 index 000000000000..9c08e78ba2d3 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/event-manager/mouse-event-manager.ts @@ -0,0 +1,137 @@ +/** + * @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 { + EventHandlerConfig, + EventHandlerOptions, + EventManager, + hasModifiers, + ModifierKey, +} from './event-manager'; + +/** + * The different mouse buttons that may appear on a mouse event. + */ +export enum MouseButton { + Main = 0, + Auxiliary = 1, + Secondary = 2, +} + +/** + * A config that specifies how to handle a particular mouse event. + */ +export interface MouseEventHandlerConfig extends EventHandlerConfig { + button: number; + modifiers: number | number[]; +} + +/** + * An event manager that is specialized for handling mouse events. By default this manager stops + * propagation and prevents default on all events it handles. + */ +export class MouseEventManager extends EventManager { + override configs: MouseEventHandlerConfig[] = []; + + protected override defaultHandlerOptions: EventHandlerOptions = { + preventDefault: true, + stopPropagation: true, + }; + + /** + * Configures this event manager to handle events with a specific modifer and mouse button + * combination. + * + * @param button The mouse button that this handler should run for. + * @param modifiers The modifier combinations that this handler should run for. + * @param handler The handler function + * @param options Options for whether to stop propagation or prevent default. + */ + on( + button: MouseButton, + modifiers: number | number[], + handler: ((event: MouseEvent) => void) | ((event: MouseEvent) => boolean), + options?: EventHandlerOptions, + ): this; + + /** + * Configures this event manager to handle events with a specific mouse button and no modifiers. + * + * @param modifiers The modifier combinations that this handler should run for. + * @param handler The handler function + * @param options Options for whether to stop propagation or prevent default. + */ + on( + modifiers: number | number[], + handler: ((event: MouseEvent) => void) | ((event: MouseEvent) => boolean), + options?: EventHandlerOptions, + ): this; + + /** + * Configures this event manager to handle events with the main mouse button and no modifiers. + * + * @param handler The handler function + * @param options Options for whether to stop propagation or prevent default. + */ + on( + handler: ((event: MouseEvent) => void) | ((event: MouseEvent) => boolean), + options?: EventHandlerOptions, + ): this; + + on(...args: any[]) { + const {button, handler, modifiers, options} = this.normalizeHandlerOptions(...args); + + // TODO: Add strict type checks again when finalizing this API. + + this.configs.push({ + button, + handler, + modifiers, + ...this.defaultHandlerOptions, + ...options, + }); + return this; + } + + normalizeHandlerOptions(...args: any[]) { + if (typeof args[0] === 'number' && typeof args[1] === 'number') { + return { + button: args[0], + modifiers: args[1], + handler: args[2], + options: args[3] || {}, + }; + } + + if (typeof args[0] === 'number' && typeof args[1] === 'function') { + return { + button: MouseButton.Main, + modifiers: args[0], + handler: args[1], + options: args[2] || {}, + }; + } + + return { + button: MouseButton.Main, + modifiers: ModifierKey.None, + handler: args[0], + options: args[1] || {}, + }; + } + + getHandlersForKey(event: MouseEvent) { + const configs: MouseEventHandlerConfig[] = []; + for (const config of this.configs) { + if (config.button === (event.button ?? 0) && hasModifiers(event, config.modifiers)) { + configs.push(config); + } + } + return configs; + } +} diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/BUILD.bazel b/src/cdk-experimental/ui-patterns/behaviors/list-focus/BUILD.bazel new file mode 100644 index 000000000000..e0820a382473 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/BUILD.bazel @@ -0,0 +1,29 @@ +load("//tools:defaults.bzl", "ng_test_library", "ng_web_test_suite", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +ts_library( + name = "list-focus", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", + "@npm//@angular/core", + ], +) + +ng_test_library( + name = "unit_test_sources", + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":list-focus", + "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/controller.ts b/src/cdk-experimental/ui-patterns/behaviors/list-focus/controller.ts new file mode 100644 index 000000000000..86f1a707e77f --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/controller.ts @@ -0,0 +1,24 @@ +/** + * @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 {ListFocusItem, ListFocus} from './list-focus'; + +/** Controls focus for a list of items. */ +export class ListFocusController { + constructor(readonly state: ListFocus) {} + + /** Focuses the current active item. */ + focus() { + if (this.state.inputs.focusMode() === 'activedescendant') { + return; + } + + const item = this.state.navigation.inputs.items()[this.state.navigation.inputs.activeIndex()]; + item.element().focus(); + } +} diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts new file mode 100644 index 000000000000..3533127a3fa5 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts @@ -0,0 +1,159 @@ +/** + * @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, signal} from '@angular/core'; +import {ListNavigation, ListNavigationInputs} from '../list-navigation/list-navigation'; +import {ListFocus, ListFocusInputs, ListFocusItem} from './list-focus'; + +describe('List Focus', () => { + interface TestItem extends ListFocusItem { + tabindex: Signal<-1 | 0>; + } + + function getItems(length: number): Signal { + return signal( + Array.from({length}).map((_, i) => ({ + index: signal(i), + id: signal(`${i}`), + tabindex: signal(-1), + disabled: signal(false), + element: signal({focus: () => {}} as HTMLElement), + })), + ); + } + + function getNavigation( + items: Signal, + args: Partial> = {}, + ): ListNavigation { + return new ListNavigation({ + items, + wrap: signal(false), + activeIndex: signal(0), + skipDisabled: signal(false), + directionality: signal('ltr'), + orientation: signal('vertical'), + ...args, + }); + } + + function getFocus( + navigation: ListNavigation, + args: Partial> = {}, + ): ListFocus { + return new ListFocus({ + navigation, + focusMode: signal('roving'), + ...args, + }); + } + + describe('roving', () => { + it('should set the list tabindex to -1', () => { + const items = getItems(5); + const nav = getNavigation(items); + const focus = getFocus(nav); + const tabindex = focus.getListTabindex(); + expect(tabindex()).toBe(-1); + }); + + it('should set the activedescendant to null', () => { + const items = getItems(5); + const nav = getNavigation(items); + const focus = getFocus(nav); + const activeId = focus.getActiveDescendant(); + expect(activeId()).toBeNull(); + }); + + it('should set the first items tabindex to 0', () => { + const items = getItems(5); + const nav = getNavigation(items); + const focus = getFocus(nav); + + items().forEach(i => { + i.tabindex = focus.getItemTabindex(i); + }); + + expect(items()[0].tabindex()).toBe(0); + expect(items()[1].tabindex()).toBe(-1); + expect(items()[2].tabindex()).toBe(-1); + expect(items()[3].tabindex()).toBe(-1); + expect(items()[4].tabindex()).toBe(-1); + }); + + it('should update the tabindex of the active item when navigating', async () => { + const items = getItems(5); + const nav = getNavigation(items); + const focus = getFocus(nav); + + items().forEach(i => { + i.tabindex = focus.getItemTabindex(i); + }); + + await nav.next(); + + expect(items()[0].tabindex()).toBe(-1); + expect(items()[1].tabindex()).toBe(0); + expect(items()[2].tabindex()).toBe(-1); + expect(items()[3].tabindex()).toBe(-1); + expect(items()[4].tabindex()).toBe(-1); + }); + }); + + describe('activedescendant', () => { + it('should set the list tabindex to 0', () => { + const items = getItems(5); + const nav = getNavigation(items); + const focus = getFocus(nav, { + focusMode: signal('activedescendant'), + }); + const tabindex = focus.getListTabindex(); + expect(tabindex()).toBe(0); + }); + + it('should set the activedescendant to the active items id', () => { + const items = getItems(5); + const nav = getNavigation(items); + const focus = getFocus(nav, { + focusMode: signal('activedescendant'), + }); + const activeId = focus.getActiveDescendant(); + expect(activeId()).toBe(items()[0].id()); + }); + + it('should set the tabindex of all items to -1', () => { + const items = getItems(5); + const nav = getNavigation(items); + const focus = getFocus(nav, { + focusMode: signal('activedescendant'), + }); + + items().forEach(i => { + i.tabindex = focus.getItemTabindex(i); + }); + + expect(items()[0].tabindex()).toBe(-1); + expect(items()[1].tabindex()).toBe(-1); + expect(items()[2].tabindex()).toBe(-1); + expect(items()[3].tabindex()).toBe(-1); + expect(items()[4].tabindex()).toBe(-1); + }); + + it('should update the activedescendant of the list when navigating', async () => { + const items = getItems(5); + const nav = getNavigation(items); + const focus = getFocus(nav, { + focusMode: signal('activedescendant'), + }); + const activeId = focus.getActiveDescendant(); + + await nav.next(); + expect(activeId()).toBe(items()[1].id()); + }); + }); +}); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts new file mode 100644 index 000000000000..644bb5a57d81 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {computed, Signal} from '@angular/core'; +import {ListNavigation, ListNavigationItem} from '../list-navigation/list-navigation'; +import type {ListFocusController} from './controller'; + +/** The required properties for focus items. */ +export interface ListFocusItem extends ListNavigationItem { + /** A unique identifier for the item. */ + id: Signal; + + /** The html element that should receive focus. */ + element: Signal; +} + +/** The required inputs for list focus. */ +export interface ListFocusInputs { + /** The focus strategy used by the list. */ + focusMode: Signal<'roving' | 'activedescendant'>; +} + +/** Controls focus for a list of items. */ +export class ListFocus { + /** The navigation controller of the parent list. */ + navigation: ListNavigation; + + get controller(): Promise> { + if (this._controller === null) { + return this.loadController(); + } + return Promise.resolve(this._controller); + } + private _controller: ListFocusController | null = null; + + constructor(readonly inputs: ListFocusInputs & {navigation: ListNavigation}) { + this.navigation = inputs.navigation; + } + + /** Loads the controller for list focus. */ + async loadController(): Promise> { + return import('./controller').then(m => { + this._controller = new m.ListFocusController(this); + return this._controller; + }); + } + + /** Returns the id of the current active item. */ + getActiveDescendant(): Signal { + return computed(() => { + if (this.inputs.focusMode() === 'roving') { + return null; + } + return this.navigation.inputs.items()[this.navigation.inputs.activeIndex()].id(); + }); + } + + /** Returns a signal that keeps track of the tabindex for the list. */ + getListTabindex(): Signal<-1 | 0> { + return computed(() => (this.inputs.focusMode() === 'activedescendant' ? 0 : -1)); + } + + /** Returns a signal that keeps track of the tabindex for the given item. */ + getItemTabindex(item: T): Signal<-1 | 0> { + return computed(() => { + if (this.inputs.focusMode() === 'activedescendant') { + return -1; + } + const index = this.navigation.inputs.items().indexOf(item); + return this.navigation.inputs.activeIndex() === index ? 0 : -1; + }); + } + + /** Focuses the current active item. */ + async focus() { + (await this.controller).focus(); + } +} diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-navigation/BUILD.bazel b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/BUILD.bazel new file mode 100644 index 000000000000..b81395f2c2ce --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/BUILD.bazel @@ -0,0 +1,23 @@ +load("//tools:defaults.bzl", "ng_test_library", "ng_web_test_suite", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +ts_library( + name = "list-navigation", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = ["@npm//@angular/core"], +) + +ng_test_library( + name = "unit_test_sources", + srcs = glob(["**/*.spec.ts"]), + deps = [":list-navigation"], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-navigation/controller.ts b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/controller.ts new file mode 100644 index 000000000000..9e2670c18672 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/controller.ts @@ -0,0 +1,72 @@ +/** + * @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 {ListNavigationItem, ListNavigation} from './list-navigation'; + +/** Controls navigation for a list of items. */ +export class ListNavigationController { + constructor(readonly state: ListNavigation) {} + + /** Navigates to the given item. */ + goto(item: T) { + if (this.isFocusable(item)) { + this.state.prevActiveIndex.set(this.state.inputs.activeIndex()); + const index = this.state.inputs.items().indexOf(item); + this.state.inputs.activeIndex.set(index); + } + } + + /** Navigates to the next item in the list. */ + next() { + const items = this.state.inputs.items(); + const after = items.slice(this.state.inputs.activeIndex() + 1); + const before = items.slice(0, this.state.inputs.activeIndex()); + const array = this.state.inputs.wrap() ? after.concat(before) : after; + const item = array.find(i => this.isFocusable(i)); + + if (item) { + this.goto(item); + } + } + + /** Navigates to the previous item in the list. */ + prev() { + const items = this.state.inputs.items(); + const after = items.slice(this.state.inputs.activeIndex() + 1).reverse(); + const before = items.slice(0, this.state.inputs.activeIndex()).reverse(); + const array = this.state.inputs.wrap() ? before.concat(after) : before; + const item = array.find(i => this.isFocusable(i)); + + if (item) { + this.goto(item); + } + } + + /** Navigates to the first item in the list. */ + first() { + const item = this.state.inputs.items().find(i => this.isFocusable(i)); + + if (item) { + this.goto(item); + } + } + + /** Navigates to the last item in the list. */ + last() { + const item = [...this.state.inputs.items()].reverse().find(i => this.isFocusable(i)); + + if (item) { + this.goto(item); + } + } + + /** Returns true if the given item can be navigated to. */ + isFocusable(item: T): boolean { + return !item.disabled() || !this.state.inputs.skipDisabled(); + } +} 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 new file mode 100644 index 000000000000..c4f8dd14f61c --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.spec.ts @@ -0,0 +1,308 @@ +/** + * @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, signal, WritableSignal} from '@angular/core'; +import {ListNavigationItem, ListNavigation, ListNavigationInputs} from './list-navigation'; + +describe('List Navigation', () => { + interface TestItem extends ListNavigationItem { + disabled: WritableSignal; + } + + function getItems(length: number): Signal { + return signal( + Array.from({length}).map((_, i) => ({ + index: signal(i), + disabled: signal(false), + })), + ); + } + + function getNavigation( + items: Signal, + args: Partial> = {}, + ): ListNavigation { + return new ListNavigation({ + items, + wrap: signal(false), + activeIndex: signal(0), + skipDisabled: signal(false), + directionality: signal('ltr'), + orientation: signal('vertical'), + ...args, + }); + } + + describe('#goto', () => { + it('should navigate to an item', async () => { + const items = getItems(5); + const nav = getNavigation(items); + + expect(nav.inputs.activeIndex()).toBe(0); + await nav.goto(items()[3]); + expect(nav.inputs.activeIndex()).toBe(3); + }); + }); + + describe('#next', () => { + it('should navigate next', async () => { + const nav = getNavigation(getItems(3)); + await nav.next(); // 0 -> 1 + expect(nav.inputs.activeIndex()).toBe(1); + }); + + it('should wrap', async () => { + const nav = getNavigation(getItems(3), { + wrap: signal(true), + }); + + await nav.next(); // 0 -> 1 + await nav.next(); // 1 -> 2 + await nav.next(); // 2 -> 0 + + expect(nav.inputs.activeIndex()).toBe(0); + }); + + it('should not wrap', async () => { + const nav = getNavigation(getItems(3), { + wrap: signal(false), + }); + + await nav.next(); // 0 -> 1 + await nav.next(); // 1 -> 2 + await nav.next(); // 2 -> 2 + + expect(nav.inputs.activeIndex()).toBe(2); + }); + + it('should skip disabled items', async () => { + const nav = getNavigation(getItems(3), { + skipDisabled: signal(true), + }); + nav.inputs.items()[1].disabled.set(true); + + await nav.next(); // 0 -> 2 + expect(nav.inputs.activeIndex()).toBe(2); + }); + + it('should not skip disabled items', async () => { + const nav = getNavigation(getItems(3), { + skipDisabled: signal(false), + }); + nav.inputs.items()[1].disabled.set(true); + + await nav.next(); // 0 -> 1 + expect(nav.inputs.activeIndex()).toBe(1); + }); + + it('should wrap and skip disabled items', async () => { + const nav = getNavigation(getItems(3), { + wrap: signal(true), + skipDisabled: signal(true), + }); + nav.inputs.items()[2].disabled.set(true); + + await nav.next(); // 0 -> 1 + await nav.next(); // 1 -> 0 + + expect(nav.inputs.activeIndex()).toBe(0); + }); + + it('should do nothing if other items are disabled', async () => { + const nav = getNavigation(getItems(3), { + skipDisabled: signal(true), + }); + nav.inputs.items()[1].disabled.set(true); + nav.inputs.items()[2].disabled.set(true); + + await nav.next(); // 0 -> 0 + expect(nav.inputs.activeIndex()).toBe(0); + }); + + it('should do nothing if there are no other items to navigate to', async () => { + const nav = getNavigation(getItems(1)); + await nav.next(); // 0 -> 0 + expect(nav.inputs.activeIndex()).toBe(0); + }); + }); + + describe('#prev', () => { + it('should navigate prev', async () => { + const nav = getNavigation(getItems(3), { + activeIndex: signal(2), + }); + await nav.prev(); // 2 -> 1 + expect(nav.inputs.activeIndex()).toBe(1); + }); + + it('should wrap', async () => { + const nav = getNavigation(getItems(3), { + wrap: signal(true), + }); + await nav.prev(); // 0 -> 2 + expect(nav.inputs.activeIndex()).toBe(2); + }); + + it('should not wrap', async () => { + const nav = getNavigation(getItems(3), { + wrap: signal(false), + }); + await nav.prev(); // 0 -> 0 + expect(nav.inputs.activeIndex()).toBe(0); + }); + + it('should skip disabled items', async () => { + const nav = getNavigation(getItems(3), { + activeIndex: signal(2), + skipDisabled: signal(true), + }); + nav.inputs.items()[1].disabled.set(true); + + await nav.prev(); // 2 -> 0 + expect(nav.inputs.activeIndex()).toBe(0); + }); + + it('should not skip disabled items', async () => { + const nav = getNavigation(getItems(3), { + activeIndex: signal(2), + skipDisabled: signal(false), + }); + nav.inputs.items()[1].disabled.set(true); + + await nav.prev(); // 2 -> 1 + expect(nav.inputs.activeIndex()).toBe(1); + }); + + it('should wrap and skip disabled items', async () => { + const nav = getNavigation(getItems(3), { + wrap: signal(true), + activeIndex: signal(2), + skipDisabled: signal(true), + }); + nav.inputs.items()[0].disabled.set(true); + + await nav.prev(); // 2 -> 1 + await nav.prev(); // 1 -> 2 + + expect(nav.inputs.activeIndex()).toBe(2); + }); + + it('should do nothing if other items are disabled', async () => { + const nav = getNavigation(getItems(3), { + activeIndex: signal(2), + skipDisabled: signal(true), + }); + nav.inputs.items()[0].disabled.set(true); + nav.inputs.items()[1].disabled.set(true); + + await nav.prev(); // 2 -> 2 + expect(nav.inputs.activeIndex()).toBe(2); + }); + + it('should do nothing if there are no other items to navigate to', async () => { + const nav = getNavigation(getItems(1)); + await nav.prev(); // 0 -> 0 + expect(nav.inputs.activeIndex()).toBe(0); + }); + }); + + describe('#first', () => { + it('should navigate to the first item', async () => { + const nav = getNavigation(getItems(3), { + activeIndex: signal(2), + }); + + await nav.first(); + expect(nav.inputs.activeIndex()).toBe(0); + }); + + it('should skip disabled items', async () => { + const nav = getNavigation(getItems(3), { + activeIndex: signal(2), + skipDisabled: signal(true), + }); + nav.inputs.items()[0].disabled.set(true); + + await nav.first(); + expect(nav.inputs.activeIndex()).toBe(1); + }); + + it('should not skip disabled items', async () => { + const nav = getNavigation(getItems(3), { + activeIndex: signal(2), + skipDisabled: signal(false), + }); + nav.inputs.items()[0].disabled.set(true); + + await nav.first(); + expect(nav.inputs.activeIndex()).toBe(0); + }); + }); + + describe('#last', () => { + it('should navigate to the last item', async () => { + const nav = getNavigation(getItems(3)); + await nav.last(); + expect(nav.inputs.activeIndex()).toBe(2); + }); + + it('should skip disabled items', async () => { + const nav = getNavigation(getItems(3), { + skipDisabled: signal(true), + }); + nav.inputs.items()[2].disabled.set(true); + + await nav.last(); + expect(nav.inputs.activeIndex()).toBe(1); + }); + + it('should not skip disabled items', async () => { + const nav = getNavigation(getItems(3), { + skipDisabled: signal(false), + }); + nav.inputs.items()[2].disabled.set(true); + + await nav.last(); + expect(nav.inputs.activeIndex()).toBe(2); + }); + }); + + describe('#isFocusable', () => { + it('should return true for enabled items', async () => { + const nav = getNavigation(getItems(3), { + skipDisabled: signal(true), + }); + + expect(await nav.isFocusable(nav.inputs.items()[0])).toBeTrue(); + expect(await nav.isFocusable(nav.inputs.items()[1])).toBeTrue(); + expect(await nav.isFocusable(nav.inputs.items()[2])).toBeTrue(); + }); + + it('should return false for disabled items', async () => { + const nav = getNavigation(getItems(3), { + skipDisabled: signal(true), + }); + nav.inputs.items()[1].disabled.set(true); + + expect(await nav.isFocusable(nav.inputs.items()[0])).toBeTrue(); + expect(await nav.isFocusable(nav.inputs.items()[1])).toBeFalse(); + expect(await nav.isFocusable(nav.inputs.items()[2])).toBeTrue(); + }); + + it('should return true for disabled items if skip disabled is false', async () => { + const nav = getNavigation(getItems(3), { + skipDisabled: signal(false), + }); + nav.inputs.items()[1].disabled.set(true); + + expect(await nav.isFocusable(nav.inputs.items()[0])).toBeTrue(); + expect(await nav.isFocusable(nav.inputs.items()[1])).toBeTrue(); + expect(await nav.isFocusable(nav.inputs.items()[2])).toBeTrue(); + }); + }); +}); 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 new file mode 100644 index 000000000000..392dd400922b --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.ts @@ -0,0 +1,93 @@ +/** + * @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, Signal, WritableSignal} from '@angular/core'; +import type {ListNavigationController} from './controller'; + +/** The required properties for navigation items. */ +export interface ListNavigationItem { + /** Whether an item is disabled. */ + disabled: Signal; +} + +/** The required inputs for list navigation. */ +export interface ListNavigationInputs { + /** Whether focus should wrap when navigating. */ + wrap: Signal; + + /** The items in the list. */ + items: Signal; + + /** Whether disabled items in the list should be skipped when navigating. */ + skipDisabled: Signal; + + /** The current index that has been navigated to. */ + activeIndex: WritableSignal; + + /** Whether the list is vertically or horizontally oriented. */ + orientation: Signal<'vertical' | 'horizontal'>; + + /** The direction that text is read based on the users locale. */ + directionality: Signal<'rtl' | 'ltr'>; +} + +/** Controls navigation for a list of items. */ +export class ListNavigation { + /** The last index that was active. */ + prevActiveIndex = signal(0); + + get controller(): Promise> { + if (this._controller === null) { + return this.loadController(); + } + return Promise.resolve(this._controller); + } + private _controller: ListNavigationController | null = null; + + constructor(readonly inputs: ListNavigationInputs) { + this.prevActiveIndex.set(inputs.activeIndex()); + } + + /** Loads the controller for list navigation. */ + async loadController(): Promise> { + return import('./controller').then(m => { + this._controller = new m.ListNavigationController(this); + return this._controller; + }); + } + + /** Navigates to the given item. */ + async goto(item: T) { + return (await this.controller).goto(item); + } + + /** Navigates to the next item in the list. */ + async next() { + return (await this.controller).next(); + } + + /** Navigates to the previous item in the list. */ + async prev() { + return (await this.controller).prev(); + } + + /** Navigates to the first item in the list. */ + async first() { + return (await this.controller).first(); + } + + /** Navigates to the last item in the list. */ + async last() { + return (await this.controller).last(); + } + + /** Returns true if the given item can be navigated to. */ + async isFocusable(item: T) { + return (await this.controller).isFocusable(item); + } +} diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/BUILD.bazel b/src/cdk-experimental/ui-patterns/behaviors/list-selection/BUILD.bazel new file mode 100644 index 000000000000..60b7f627c039 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/BUILD.bazel @@ -0,0 +1,29 @@ +load("//tools:defaults.bzl", "ng_test_library", "ng_web_test_suite", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +ts_library( + name = "list-selection", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", + "@npm//@angular/core", + ], +) + +ng_test_library( + name = "unit_test_sources", + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":list-selection", + "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/controller.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/controller.ts new file mode 100644 index 000000000000..f2ba0a5086fa --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/controller.ts @@ -0,0 +1,113 @@ +/** + * @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 {ListSelectionItem, ListSelection} from './list-selection'; + +/** Controls selection for a list of items. */ +export class ListSelectionController { + constructor(readonly state: ListSelection) { + if (this.state.inputs.selectedIds()) { + this._anchor(); + } + } + + /** Selects the item at the current active index. */ + select(item?: T) { + item = item ?? this.state.inputs.items()[this.state.inputs.navigation.inputs.activeIndex()]; + + if (item.disabled() || this.state.inputs.selectedIds().includes(item.id())) { + return; + } + + if (!this.state.inputs.multiselectable()) { + this.deselectAll(); + } + + // TODO: Need to discuss when to drop this. + this._anchor(); + this.state.inputs.selectedIds.update(ids => ids.concat(item.id())); + } + + /** Deselects the item at the current active index. */ + deselect(item?: T) { + item = item ?? this.state.inputs.items()[this.state.inputs.navigation.inputs.activeIndex()]; + + if (!item.disabled()) { + this.state.inputs.selectedIds.update(ids => ids.filter(id => id !== item.id())); + } + } + + /** Toggles the item at the current active index. */ + toggle() { + const item = this.state.inputs.items()[this.state.inputs.navigation.inputs.activeIndex()]; + this.state.inputs.selectedIds().includes(item.id()) ? this.deselect() : this.select(); + } + + /** Toggles only the item at the current active index. */ + toggleOne() { + const item = this.state.inputs.items()[this.state.inputs.navigation.inputs.activeIndex()]; + this.state.inputs.selectedIds().includes(item.id()) ? this.deselect() : this.selectOne(); + } + + /** Selects all items in the list. */ + selectAll() { + if (!this.state.inputs.multiselectable()) { + return; // Should we log a warning? + } + + for (const item of this.state.inputs.items()) { + this.select(item); + } + + this._anchor(); + } + + /** Deselects all items in the list. */ + deselectAll() { + for (const item of this.state.inputs.items()) { + this.deselect(item); + } + } + + /** Selects the items in the list starting at the last selected item. */ + selectFromAnchor() { + const anchorIndex = this.state.inputs.items().findIndex(i => this.state.anchorId() === i.id()); + this._selectFromIndex(anchorIndex); + } + + /** Selects the items in the list starting at the last active item. */ + selectFromActive() { + this._selectFromIndex(this.state.inputs.navigation.prevActiveIndex()); + } + + /** Selects the items in the list starting at the given index. */ + private _selectFromIndex(index: number) { + if (index === -1) { + return; + } + + const upper = Math.max(this.state.inputs.navigation.inputs.activeIndex(), index); + const lower = Math.min(this.state.inputs.navigation.inputs.activeIndex(), index); + + for (let i = lower; i <= upper; i++) { + this.select(this.state.inputs.items()[i]); + } + } + + /** Sets the selection to only the current active item. */ + selectOne() { + this.deselectAll(); + this.select(); + } + + /** Sets the anchor to the current active index. */ + private _anchor() { + const item = this.state.inputs.items()[this.state.inputs.navigation.inputs.activeIndex()]; + this.state.anchorId.set(item.id()); + } +} diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts new file mode 100644 index 000000000000..a6b56df52cd2 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts @@ -0,0 +1,216 @@ +/** + * @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, signal, WritableSignal} from '@angular/core'; +import {ListSelectionItem, ListSelection, ListSelectionInputs} from './list-selection'; +import {ListNavigation, ListNavigationInputs} from '../list-navigation/list-navigation'; + +describe('List Selection', () => { + interface TestItem extends ListSelectionItem { + disabled: WritableSignal; + } + + function getItems(length: number): Signal { + return signal( + Array.from({length}).map((_, i) => ({ + index: signal(i), + id: signal(`${i}`), + disabled: signal(false), + isAnchor: signal(false), + })), + ); + } + + function getNavigation( + items: Signal, + args: Partial> = {}, + ): ListNavigation { + return new ListNavigation({ + items, + wrap: signal(false), + activeIndex: signal(0), + skipDisabled: signal(false), + directionality: signal('ltr'), + orientation: signal('vertical'), + ...args, + }); + } + + function getSelection( + items: Signal, + navigation: ListNavigation, + args: Partial> = {}, + ): ListSelection { + return new ListSelection({ + items, + navigation, + selectedIds: signal([]), + multiselectable: signal(true), + selectionMode: signal('explicit'), + ...args, + }); + } + + describe('#select', () => { + it('should select an item', async () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + + await selection.select(); // [0] + expect(selection.inputs.selectedIds()).toEqual(['0']); + }); + + it('should select multiple options', async () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + + await selection.select(); // [0] + await nav.next(); + await selection.select(); // [0, 1] + + expect(selection.inputs.selectedIds()).toEqual(['0', '1']); + }); + + it('should not select multiple options', async () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav, { + multiselectable: signal(false), + }); + + await selection.select(); // [0] + await nav.next(); + await selection.select(); // [1] + + expect(selection.inputs.selectedIds()).toEqual(['1']); + }); + + it('should not select disabled items', async () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + items()[0].disabled.set(true); + + await selection.select(); // [] + expect(selection.inputs.selectedIds()).toEqual([]); + }); + + it('should do nothing to already selected items', async () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + + await selection.select(); // [0] + await selection.select(); // [0] + + expect(selection.inputs.selectedIds()).toEqual(['0']); + }); + }); + + describe('#deselect', () => { + it('should deselect an item', async () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + await selection.deselect(); // [] + expect(selection.inputs.selectedIds().length).toBe(0); + }); + + it('should not deselect disabled items', async () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + + await selection.select(); // [0] + items()[0].disabled.set(true); + await selection.deselect(); // [0] + + expect(selection.inputs.selectedIds()).toEqual(['0']); + }); + }); + + describe('#toggle', () => { + it('should select an unselected item', async () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + + await selection.toggle(); // [0] + expect(selection.inputs.selectedIds()).toEqual(['0']); + }); + + it('should deselect a selected item', async () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + await selection.select(); // [0] + await selection.toggle(); // [] + expect(selection.inputs.selectedIds().length).toBe(0); + }); + }); + + describe('#selectAll', () => { + it('should select all items', async () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + await selection.selectAll(); + expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2', '3', '4']); + }); + + it('should do nothing if a list is not multiselectable', async () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + await selection.selectAll(); + expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2', '3', '4']); + }); + }); + + describe('#deselectAll', () => { + it('should deselect all items', async () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + await selection.deselectAll(); // [] + expect(selection.inputs.selectedIds().length).toBe(0); + }); + }); + + describe('#selectFromAnchor', () => { + it('should select all items from an anchor at a lower index', async () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + + await selection.select(); // [0] + await nav.next(); + await nav.next(); + await selection.selectFromAnchor(); // [0, 1, 2] + + expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2']); + }); + + it('should select all items from an anchor at a higher index', async () => { + const items = getItems(5); + const nav = getNavigation(items, { + activeIndex: signal(3), + }); + const selection = getSelection(items, nav); + + await selection.select(); // [3] + await nav.prev(); + await nav.prev(); + await selection.selectFromAnchor(); // [3, 1, 2] + + expect(selection.inputs.selectedIds()).toEqual(['3', '1', '2']); + }); + }); +}); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts new file mode 100644 index 000000000000..8ecdbc289e60 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts @@ -0,0 +1,109 @@ +/** + * @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, Signal, WritableSignal} from '@angular/core'; +import {ListSelectionController} from './controller'; +import {ListNavigation, ListNavigationItem} from '../list-navigation/list-navigation'; + +/** The required properties for selection items. */ +export interface ListSelectionItem extends ListNavigationItem { + /** A unique identifier for the item. */ + id: Signal; + + /** Whether an item is disabled. */ + disabled: Signal; +} + +/** The required inputs for list selection. */ +export interface ListSelectionInputs { + /** The items in the list. */ + items: Signal; + + /** Whether multiple items in the list can be selected at once. */ + multiselectable: Signal; + + /** The ids of the current selected items. */ + selectedIds: WritableSignal; + + /** The selection strategy used by the list. */ + selectionMode: Signal<'follow' | 'explicit'>; +} + +/** Controls selection for a list of items. */ +export class ListSelection { + /** The id of the previous selected item. */ + anchorId = signal(null); + + /** The navigation controller of the parent list. */ + navigation: ListNavigation; + + get controller(): Promise> { + if (this._controller === null) { + return this.loadController(); + } + return Promise.resolve(this._controller); + } + private _controller: ListSelectionController | null = null; + + constructor(readonly inputs: ListSelectionInputs & {navigation: ListNavigation}) { + this.navigation = inputs.navigation; + } + + /** Loads the controller for list selection. */ + async loadController(): Promise> { + return import('./controller').then(m => { + this._controller = new m.ListSelectionController(this); + return this._controller; + }); + } + + /** Selects the item at the current active index. */ + async select(item?: T) { + return (await this.controller).select(item); + } + + /** Deselects the item at the current active index. */ + async deselect(item?: T) { + return (await this.controller).deselect(item); + } + + /** Toggles the item at the current active index. */ + async toggle() { + return (await this.controller).toggle(); + } + + /** Toggles only the item at the current active index. */ + async toggleOne() { + return (await this.controller).toggleOne(); + } + + /** Selects all items in the list. */ + async selectAll() { + return (await this.controller).selectAll(); + } + + /** Deselects all items in the list. */ + async deselectAll() { + return (await this.controller).deselectAll(); + } + + /** Selects the items in the list starting at the last selected item. */ + async selectFromAnchor() { + return (await this.controller).selectFromAnchor(); + } + + /** Selects the items in the list starting at the last active item. */ + async selectFromActive() { + return (await this.controller).selectFromActive(); + } + + /** Sets the selection to only the current active item. */ + async selectOne() { + return (await this.controller).selectOne(); + } +} diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/BUILD.bazel b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/BUILD.bazel new file mode 100644 index 000000000000..4120c12c0ad0 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/BUILD.bazel @@ -0,0 +1,29 @@ +load("//tools:defaults.bzl", "ng_test_library", "ng_web_test_suite", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +ts_library( + name = "list-typeahead", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", + "@npm//@angular/core", + ], +) + +ng_test_library( + name = "unit_test_sources", + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":list-typeahead", + "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/controller.ts b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/controller.ts new file mode 100644 index 000000000000..17177370f52a --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/controller.ts @@ -0,0 +1,69 @@ +/** + * @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} from '@angular/core'; +import {ListTypeaheadItem, ListTypeahead} from './list-typeahead'; + +/** Controls typeahead for a list of items. */ +export class ListTypeaheadController { + /** A reference to the timeout for resetting the typeahead search. */ + timeout?: any; + + /** Keeps track of the characters that typeahead search is being called with. */ + query = signal(''); + + /** The index where that the typeahead search was initiated from. */ + anchorIndex = signal(null); + + constructor(readonly state: ListTypeahead) {} + + /** Performs a typeahead search, appending the given character to the search string. */ + async search(char: string) { + if (char.length !== 1) { + return; + } + + if (this.anchorIndex() === null) { + this.anchorIndex.set(this.state.navigation.inputs.activeIndex()); + } + + clearTimeout(this.timeout); + this.query.update(q => q + char.toLowerCase()); + const item = await this._getItem(); + + if (item) { + await this.state.navigation.goto(item); + } + + this.timeout = setTimeout(() => { + this.query.set(''); + this.anchorIndex.set(null); + }, this.state.inputs.typeaheadDelay() * 1000); + } + + /** + * Returns the first item whose search term matches the + * current query starting from the the current anchor index. + */ + private async _getItem() { + let items = this.state.navigation.inputs.items(); + const after = items.slice(this.anchorIndex()! + 1); + const before = items.slice(0, this.anchorIndex()!); + items = this.state.navigation.inputs.wrap() ? after.concat(before) : after; // TODO: Always wrap? + items.push(this.state.navigation.inputs.items()[this.anchorIndex()!]); + + const focusableItems = []; + for (const item of items) { + if (await this.state.navigation.isFocusable(item)) { + focusableItems.push(item); + } + } + + return focusableItems.find(i => i.searchTerm().toLowerCase().startsWith(this.query())); + } +} diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts new file mode 100644 index 000000000000..1b882f561aaa --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts @@ -0,0 +1,152 @@ +/** + * @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, signal, WritableSignal} from '@angular/core'; +import {ListTypeaheadItem, ListTypeahead} from './list-typeahead'; +import {fakeAsync, tick} from '@angular/core/testing'; +import {ListNavigation} from '../list-navigation/list-navigation'; + +describe('List Typeahead', () => { + interface TestItem extends ListTypeaheadItem { + disabled: WritableSignal; + } + + function getItems(length: number): Signal { + return signal( + Array.from({length}).map((_, i) => ({ + index: signal(i), + disabled: signal(false), + searchTerm: signal(`Item ${i}`), + })), + ); + } + + describe('#search', () => { + it('should navigate to an item', async () => { + const items = getItems(5); + const activeIndex = signal(0); + const navigation = new ListNavigation({ + items, + activeIndex, + wrap: signal(false), + skipDisabled: signal(false), + directionality: signal('ltr'), + orientation: signal('vertical'), + }); + const typeahead = new ListTypeahead({ + navigation, + typeaheadDelay: signal(0.5), + }); + + await typeahead.search('i'); + expect(activeIndex()).toBe(1); + + await typeahead.search('t'); + await typeahead.search('e'); + await typeahead.search('m'); + await typeahead.search(' '); + await typeahead.search('3'); + expect(activeIndex()).toBe(3); + }); + + it('should reset after a delay', fakeAsync(async () => { + const items = getItems(5); + const activeIndex = signal(0); + const navigation = new ListNavigation({ + items, + activeIndex, + wrap: signal(false), + skipDisabled: signal(false), + directionality: signal('ltr'), + orientation: signal('vertical'), + }); + const typeahead = new ListTypeahead({ + navigation, + typeaheadDelay: signal(0.5), + }); + + await typeahead.search('i'); + expect(activeIndex()).toBe(1); + + tick(500); + + await typeahead.search('i'); + expect(activeIndex()).toBe(2); + })); + + it('should skip disabled items', async () => { + const items = getItems(5); + const activeIndex = signal(0); + const navigation = new ListNavigation({ + items, + activeIndex, + wrap: signal(false), + skipDisabled: signal(true), + directionality: signal('ltr'), + orientation: signal('vertical'), + }); + const typeahead = new ListTypeahead({ + navigation, + typeaheadDelay: signal(0.5), + }); + items()[1].disabled.set(true); + + await typeahead.search('i'); + expect(activeIndex()).toBe(2); + }); + + it('should not skip disabled items', async () => { + const items = getItems(5); + const activeIndex = signal(0); + const navigation = new ListNavigation({ + items, + activeIndex, + wrap: signal(false), + skipDisabled: signal(false), + directionality: signal('ltr'), + orientation: signal('vertical'), + }); + const typeahead = new ListTypeahead({ + navigation, + typeaheadDelay: signal(0.5), + }); + items()[1].disabled.set(true); + + await typeahead.search('i'); + expect(activeIndex()).toBe(1); + }); + + it('should ignore keys like shift', async () => { + const items = getItems(5); + const activeIndex = signal(0); + const navigation = new ListNavigation({ + items, + activeIndex, + wrap: signal(false), + skipDisabled: signal(false), + directionality: signal('ltr'), + orientation: signal('vertical'), + }); + const typeahead = new ListTypeahead({ + navigation, + typeaheadDelay: signal(0.5), + }); + + await typeahead.search('i'); + await typeahead.search('t'); + await typeahead.search('e'); + + await typeahead.search('Shift'); + + await typeahead.search('m'); + await typeahead.search(' '); + await typeahead.search('2'); + expect(activeIndex()).toBe(2); + }); + }); +}); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts new file mode 100644 index 000000000000..e43a0c2f4e58 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts @@ -0,0 +1,54 @@ +/** + * @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} from '@angular/core'; +import type {ListTypeaheadController} from './controller'; +import {ListNavigationItem, ListNavigation} from '../list-navigation/list-navigation'; + +/** The required properties for typeahead items. */ +export interface ListTypeaheadItem extends ListNavigationItem { + /** The text used by the typeahead search. */ + searchTerm: Signal; +} + +/** The required inputs for list typeahead. */ +export interface ListTypeaheadInputs { + /** The amount of time before the typeahead search is reset. */ + typeaheadDelay: Signal; +} + +/** Controls typeahead for a list of items. */ +export class ListTypeahead { + /** The navigation controller of the parent list. */ + navigation: ListNavigation; + + get controller(): Promise> { + if (this._controller === null) { + return this.loadController(); + } + return Promise.resolve(this._controller); + } + private _controller: ListTypeaheadController | null = null; + + constructor(readonly inputs: ListTypeaheadInputs & {navigation: ListNavigation}) { + this.navigation = inputs.navigation; + } + + /** Loads the controller for list typeahead. */ + async loadController(): Promise> { + return import('./controller').then(m => { + this._controller = new m.ListTypeaheadController(this); + return this._controller; + }); + } + + /** Performs a typeahead search, appending the given character to the search string. */ + async search(char: string) { + return (await this.controller).search(char); + } +} diff --git a/src/cdk-experimental/ui-patterns/index.ts b/src/cdk-experimental/ui-patterns/index.ts new file mode 100644 index 000000000000..52b3c7a5156f --- /dev/null +++ b/src/cdk-experimental/ui-patterns/index.ts @@ -0,0 +1,9 @@ +/** + * @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 + */ + +export * from './public-api'; diff --git a/src/cdk-experimental/ui-patterns/listbox/BUILD.bazel b/src/cdk-experimental/ui-patterns/listbox/BUILD.bazel new file mode 100644 index 000000000000..00137bf59503 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/listbox/BUILD.bazel @@ -0,0 +1,19 @@ +load("//tools:defaults.bzl", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +ts_library( + name = "listbox", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//src/cdk-experimental/ui-patterns/behaviors/event-manager", + "//src/cdk-experimental/ui-patterns/behaviors/list-focus", + "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", + "//src/cdk-experimental/ui-patterns/behaviors/list-selection", + "//src/cdk-experimental/ui-patterns/behaviors/list-typeahead", + "@npm//@angular/core", + ], +) diff --git a/src/cdk-experimental/ui-patterns/listbox/controller.ts b/src/cdk-experimental/ui-patterns/listbox/controller.ts new file mode 100644 index 000000000000..a1723c3ca5d1 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/listbox/controller.ts @@ -0,0 +1,217 @@ +/** + * @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} from '../behaviors/event-manager/keyboard-event-manager'; +import {ModifierKey as Modifier} from '../behaviors/event-manager/event-manager'; +import {MouseEventManager} from '../behaviors/event-manager/mouse-event-manager'; +import {ListboxPattern} from './listbox'; + +/** The selection operations that the listbox can perform. */ +interface SelectOptions { + select?: boolean; + toggle?: boolean; + toggleOne?: boolean; + selectOne?: boolean; + selectAll?: boolean; + selectFromAnchor?: boolean; + selectFromActive?: boolean; +} + +/** Controls selection for a list of items. */ +export class ListboxController { + followFocus = computed(() => this.state.inputs.selectionMode() === 'follow'); + + /** The key used to navigate to the previous item in the list. */ + prevKey = computed(() => { + if (this.state.inputs.orientation() === 'vertical') { + return 'ArrowUp'; + } + return this.state.inputs.directionality() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + }); + + /** The key used to navigate to the next item in the list. */ + nextKey = computed(() => { + if (this.state.inputs.orientation() === 'vertical') { + return 'ArrowDown'; + } + return this.state.inputs.directionality() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + }); + + /** The regexp used to decide if a key should trigger typeahead. */ + typeaheadRegexp = /^.$/; // TODO: Ignore spaces? + + /** The keydown event manager for the listbox. */ + keydown = computed(() => { + const manager = new KeyboardEventManager(); + + if (!this.followFocus()) { + manager + .on(this.prevKey, () => this.prev()) + .on(this.nextKey, () => this.next()) + .on('Home', () => this.first()) + .on('End', () => this.last()) + .on(this.typeaheadRegexp, e => this.typeahead(e.key)); + } + + if (this.followFocus()) { + manager + .on(this.prevKey, () => this.prev({selectOne: true})) + .on(this.nextKey, () => this.next({selectOne: true})) + .on('Home', () => this.first({selectOne: true})) + .on('End', () => this.last({selectOne: true})) + .on(this.typeaheadRegexp, e => this.typeahead(e.key, {selectOne: true})); + } + + if (this.state.inputs.multiselectable()) { + manager + .on(Modifier.Shift, ' ', () => this._updateSelection({selectFromAnchor: true})) + .on(Modifier.Shift, this.prevKey, () => this.prev({toggle: true})) + .on(Modifier.Shift, this.nextKey, () => this.next({toggle: true})) + .on(Modifier.Ctrl | Modifier.Shift, 'Home', () => this.first({selectFromActive: true})) + .on(Modifier.Ctrl | Modifier.Shift, 'End', () => this.last({selectFromActive: true})) + .on(Modifier.Ctrl, 'A', () => this._updateSelection({selectAll: true})); + } + + if (!this.followFocus() && this.state.inputs.multiselectable()) { + manager.on(' ', () => this._updateSelection({toggle: true})); + } + + if (!this.followFocus() && !this.state.inputs.multiselectable()) { + manager.on(' ', () => this._updateSelection({toggleOne: true})); + } + + if (this.state.inputs.multiselectable() && this.followFocus()) { + manager + .on(Modifier.Ctrl, this.prevKey, () => this.prev()) + .on(Modifier.Ctrl, this.nextKey, () => this.next()) + .on(Modifier.Ctrl, 'Home', () => this.first()) // TODO: Not in spec but prob should be. + .on(Modifier.Ctrl, 'End', () => this.last()); // TODO: Not in spec but prob should be. + } + + return manager; + }); + + /** The mousedown event manager for the listbox. */ + mousedown = computed(() => { + const manager = new MouseEventManager(); + + if (!this.followFocus()) { + manager.on((e: MouseEvent) => this.goto(e)); + } + + if (this.followFocus()) { + manager.on((e: MouseEvent) => this.goto(e, {selectOne: true})); + } + + if (this.state.inputs.multiselectable() && this.followFocus()) { + manager.on(Modifier.Ctrl, (e: MouseEvent) => this.goto(e)); + } + + if (this.state.inputs.multiselectable()) { + manager.on(Modifier.Shift, (e: MouseEvent) => this.goto(e, {selectFromActive: true})); + } + + return manager; + }); + + constructor(readonly state: ListboxPattern) {} + + /** Handles keydown events for the listbox. */ + onKeydown(event: KeyboardEvent) { + if (!this.state.disabled()) { + this.keydown().handle(event); + } + } + + onMousedown(event: MouseEvent) { + if (!this.state.disabled()) { + this.mousedown().handle(event); + } + } + + /** Navigates to the first option in the listbox. */ + async first(opts?: SelectOptions) { + await this.state.navigation.first(); + await this.state.focus.focus(); + await this._updateSelection(opts); + } + + /** Navigates to the last option in the listbox. */ + async last(opts?: SelectOptions) { + await this.state.navigation.last(); + await this.state.focus.focus(); + await this._updateSelection(opts); + } + + /** Navigates to the next option in the listbox. */ + async next(opts?: SelectOptions) { + await this.state.navigation.next(); + await this.state.focus.focus(); + await this._updateSelection(opts); + } + + /** Navigates to the previous option in the listbox. */ + async prev(opts?: SelectOptions) { + await this.state.navigation.prev(); + await this.state.focus.focus(); + await this._updateSelection(opts); + } + + /** Navigates to the given item in the listbox. */ + async goto(event: MouseEvent, opts?: SelectOptions) { + const item = this._getItem(event); + + if (item) { + await this.state.navigation.goto(item); + await this.state.focus.focus(); + await this._updateSelection(opts); + } + } + + /** Handles typeahead navigation for the listbox. */ + async typeahead(char: string, opts?: SelectOptions) { + await this.state.typeahead.search(char); + await this.state.focus.focus(); + await this._updateSelection(opts); + } + + /** Handles updating selection for the listbox. */ + private async _updateSelection(opts?: SelectOptions) { + if (opts?.select) { + await this.state.selection.select(); + } + if (opts?.toggle) { + await this.state.selection.toggle(); + } + if (opts?.toggleOne) { + await this.state.selection.toggleOne(); + } + if (opts?.selectOne) { + await this.state.selection.selectOne(); + } + if (opts?.selectAll) { + await this.state.selection.selectAll(); + } + if (opts?.selectFromAnchor) { + await this.state.selection.selectFromAnchor(); + } + if (opts?.selectFromActive) { + await this.state.selection.selectFromActive(); + } + } + + private _getItem(e: MouseEvent) { + if (!(e.target instanceof HTMLElement)) { + return; + } + + const element = e.target.closest('[cdkoption]'); // TODO: Use a different identifier. + return this.state.inputs.items().find(i => i.element() === element); + } +} diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts new file mode 100644 index 000000000000..d7d52d241a6f --- /dev/null +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -0,0 +1,107 @@ +/** + * @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 {OptionPattern} from './option'; +import {ListSelection, ListSelectionInputs} from '../behaviors/list-selection/list-selection'; +import {ListTypeahead, ListTypeaheadInputs} from '../behaviors/list-typeahead/list-typeahead'; +import {ListNavigation, ListNavigationInputs} from '../behaviors/list-navigation/list-navigation'; +import {ListFocus, ListFocusInputs} from '../behaviors/list-focus/list-focus'; +import {computed, Signal} from '@angular/core'; +import {ListboxController} from './controller'; + +/** The required inputs for the listbox. */ +export type ListboxInputs = ListNavigationInputs & + ListSelectionInputs & + ListTypeaheadInputs & + ListFocusInputs & { + disabled: Signal; + }; + +/** Controls the state of a listbox. */ +export class ListboxPattern { + /** Controls navigation for the listbox. */ + navigation: ListNavigation; + + /** Controls selection for the listbox. */ + selection: ListSelection; + + /** Controls typeahead for the listbox. */ + typeahead: ListTypeahead; + + /** Controls focus for the listbox. */ + focus: ListFocus; + + /** Whether the list is vertically or horizontally oriented. */ + orientation: Signal<'vertical' | 'horizontal'>; + + /** Whether the listbox is disabled. */ + disabled: Signal; + + /** The tabindex of the listbox. */ + tabindex: Signal<-1 | 0>; + + /** The id of the current active item. */ + activedescendant: Signal; + + /** Whether multiple items in the list can be selected at once. */ + multiselectable: Signal; + + /** The number of items in the listbox. */ + setsize = computed(() => this.navigation.inputs.items().length); + + get controller(): Promise { + if (this._controller === null) { + return this.loadController(); + } + return Promise.resolve(this._controller); + } + private _controller: ListboxController | null = null; + + constructor(readonly inputs: ListboxInputs) { + this.disabled = inputs.disabled; + this.orientation = inputs.orientation; + this.multiselectable = inputs.multiselectable; + + this.navigation = new ListNavigation(inputs); + this.selection = new ListSelection({...inputs, navigation: this.navigation}); + this.typeahead = new ListTypeahead({...inputs, navigation: this.navigation}); + this.focus = new ListFocus({...inputs, navigation: this.navigation}); + + this.tabindex = this.focus.getListTabindex(); + this.activedescendant = this.focus.getActiveDescendant(); + } + + /** Loads the controller for the listbox. */ + async loadController(): Promise { + return import('./controller').then(module => { + this._controller = new module.ListboxController(this); + return this._controller; + }); + } + + /** The keydown handler for the listbox. */ + async onKeydown(event: KeyboardEvent) { + (await this.controller).onKeydown(event); + } + + /** The mousedown handler for the listbox. */ + async onMousedown(event: MouseEvent) { + (await this.controller).onMousedown(event); + } + + /** The focus handler for the listbox. */ + async onFocus() { + if (!this._controller) { + await this.loadController(); + await this.navigation.loadController(); + await this.selection.loadController(); + await this.typeahead.loadController(); + await this.focus.loadController(); + } + } +} diff --git a/src/cdk-experimental/ui-patterns/listbox/option.ts b/src/cdk-experimental/ui-patterns/listbox/option.ts new file mode 100644 index 000000000000..6c88f9758866 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/listbox/option.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {computed, Signal} from '@angular/core'; +import {ListSelection, ListSelectionItem} from '../behaviors/list-selection/list-selection'; +import {ListTypeaheadItem} from '../behaviors/list-typeahead/list-typeahead'; +import {ListNavigation, ListNavigationItem} from '../behaviors/list-navigation/list-navigation'; +import {ListFocus, ListFocusItem} from '../behaviors/list-focus/list-focus'; + +interface ListboxPattern { + focus: ListFocus; + selection: ListSelection; + navigation: ListNavigation; +} + +/** The required inputs to options. */ +export interface OptionInputs + extends ListNavigationItem, + ListSelectionItem, + ListTypeaheadItem, + ListFocusItem { + listbox: Signal; +} + +/** An option in a listbox. */ +export class OptionPattern { + /** A unique identifier for the option. */ + id: Signal; + + /** The position of the option in the list. */ + index = computed( + () => + this.listbox() + .navigation.inputs.items() + .findIndex(i => i.id() === this.id()) ?? -1, + ); + + /** Whether the option is selected. */ + selected = computed(() => this.listbox().selection.inputs.selectedIds().includes(this.id())); + + /** Whether the option is disabled. */ + disabled: Signal; + + /** The text used by the typeahead search. */ + searchTerm: Signal; + + /** A reference to the parent listbox. */ + listbox: Signal; + + /** The tabindex of the option. */ + tabindex: Signal<-1 | 0>; + + /** The html element that should receive focus. */ + element: Signal; + + constructor(args: OptionInputs) { + this.id = args.id; + this.listbox = args.listbox; + this.element = args.element; + this.disabled = args.disabled; + this.searchTerm = args.searchTerm; + this.tabindex = this.listbox().focus.getItemTabindex(this); + } +} diff --git a/src/cdk-experimental/ui-patterns/public-api.ts b/src/cdk-experimental/ui-patterns/public-api.ts new file mode 100644 index 000000000000..a30b67e1e378 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/public-api.ts @@ -0,0 +1,10 @@ +/** + * @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 + */ + +export * from './listbox/listbox'; +export * from './listbox/option'; diff --git a/src/components-examples/cdk-experimental/listbox/BUILD.bazel b/src/components-examples/cdk-experimental/listbox/BUILD.bazel new file mode 100644 index 000000000000..5540c6570171 --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/BUILD.bazel @@ -0,0 +1,27 @@ +load("//tools:defaults.bzl", "ng_module") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "listbox", + srcs = glob(["**/*.ts"]), + assets = glob([ + "**/*.html", + "**/*.css", + ]), + deps = [ + "//src/cdk-experimental/listbox", + "//src/material/checkbox", + "//src/material/form-field", + "//src/material/select", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.html", + "**/*.css", + "**/*.ts", + ]), +) diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.css b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.css new file mode 100644 index 000000000000..64aa89f9dae4 --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.css @@ -0,0 +1,60 @@ +.example-listbox-controls { + display: flex; + align-items: center; + gap: 16px; + padding-bottom: 16px; +} + +ul { + gap: 8px; + margin: 0; + padding: 8px; + max-height: 50vh; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); + display: flex; + list-style: none; + flex-direction: column; + overflow: scroll; +} + +ul[aria-orientation='horizontal'] { + flex-direction: row; +} + +ul[aria-orientation='horizontal'] li::before { + display: none; +} + +ul[aria-orientation='horizontal'] li[aria-selected='true']::before { + display: block; +} + +label { + padding: 16px; + flex-shrink: 0; +} + +li { + gap: 16px; + padding: 16px; + display: flex; + cursor: pointer; + align-items: center; + border-radius: var(--mat-sys-corner-extra-small); +} + +li:hover, +li[tabindex='0'] { + outline: 1px solid var(--mat-sys-outline); + background: var(--mat-sys-surface-container); +} + +li:focus-within { + outline: 2px solid var(--mat-sys-primary); + background: var(--mat-sys-surface-container); +} + +li[aria-selected='true'] { + background-color: var(--mat-sys-secondary-container); +} diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html new file mode 100644 index 000000000000..b726966d2814 --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html @@ -0,0 +1,54 @@ +
+ Wrap + Multi + Disabled + Skip Disabled + + + Orientation + + Vertical + Horizontal + + + + + Selection strategy + + Explicit + Follow Focus + + + + + Focus strategy + + Roving Tabindex + Active Descendant + + +
+ + +
    + + + @for (fruit of fruits; track fruit) { + @let checked = option.pattern.selected() ? 'checked' : 'unchecked'; + +
  • + + {{ fruit }} +
  • + } +
+ diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.ts b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.ts new file mode 100644 index 000000000000..7653a628abbc --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.ts @@ -0,0 +1,70 @@ +import {Component} from '@angular/core'; +import {CdkListbox, CdkOption} from '@angular/cdk-experimental/listbox'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatSelectModule} from '@angular/material/select'; +import {FormControl, ReactiveFormsModule} from '@angular/forms'; +import {MatPseudoCheckbox} from '@angular/material/core'; + +/** @title Listbox using UI Patterns. */ +@Component({ + selector: 'cdk-listbox-example', + exportAs: 'cdkListboxExample', + templateUrl: 'cdk-listbox-example.html', + styleUrl: 'cdk-listbox-example.css', + imports: [ + CdkListbox, + CdkOption, + MatCheckboxModule, + MatFormFieldModule, + MatSelectModule, + ReactiveFormsModule, + MatPseudoCheckbox, + ], +}) +export class CdkListboxExample { + orientation: 'vertical' | 'horizontal' = 'vertical'; + focusMode: 'roving' | 'activedescendant' = 'roving'; + selectionMode: 'explicit' | 'follow' = 'explicit'; + + wrap = new FormControl(true, {nonNullable: true}); + multi = new FormControl(false, {nonNullable: true}); + disabled = new FormControl(false, {nonNullable: true}); + skipDisabled = new FormControl(true, {nonNullable: true}); + + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; +} diff --git a/src/components-examples/cdk-experimental/listbox/index.ts b/src/components-examples/cdk-experimental/listbox/index.ts new file mode 100644 index 000000000000..7fa9cb73b9cc --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/index.ts @@ -0,0 +1 @@ +export {CdkListboxExample} from './cdk-listbox/cdk-listbox-example'; diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index 05ec303b4eab..87e88e18b767 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -24,6 +24,7 @@ ng_module( "//src/dev-app/card", "//src/dev-app/cdk-dialog", "//src/dev-app/cdk-experimental-combobox", + "//src/dev-app/cdk-experimental-listbox", "//src/dev-app/cdk-listbox", "//src/dev-app/cdk-menu", "//src/dev-app/checkbox", diff --git a/src/dev-app/cdk-experimental-listbox/BUILD.bazel b/src/dev-app/cdk-experimental-listbox/BUILD.bazel new file mode 100644 index 000000000000..0cdb1654aa7d --- /dev/null +++ b/src/dev-app/cdk-experimental-listbox/BUILD.bazel @@ -0,0 +1,10 @@ +load("//tools:defaults.bzl", "ng_module") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "cdk-experimental-listbox", + srcs = glob(["**/*.ts"]), + assets = ["cdk-listbox-demo.html"], + deps = ["//src/components-examples/cdk-experimental/listbox"], +) diff --git a/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.html b/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.html new file mode 100644 index 000000000000..20db798ae682 --- /dev/null +++ b/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.html @@ -0,0 +1,4 @@ +
+

Listbox using UI Patterns

+ +
\ No newline at end of file diff --git a/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.ts b/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.ts new file mode 100644 index 000000000000..79c86bf15aff --- /dev/null +++ b/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.ts @@ -0,0 +1,17 @@ +/** + * @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 {ChangeDetectionStrategy, Component} from '@angular/core'; +import {CdkListboxExample} from '@angular/components-examples/cdk-experimental/listbox'; + +@Component({ + templateUrl: 'cdk-listbox-demo.html', + imports: [CdkListboxExample], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkExperimentalListboxDemo {} diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index 058f9c0cbe2b..86d27e04692a 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -59,6 +59,7 @@ export class DevAppLayout { {name: 'Examples', route: '/examples'}, {name: 'CDK Dialog', route: '/cdk-dialog'}, {name: 'CDK Experimental Combobox', route: '/cdk-experimental-combobox'}, + {name: 'CDK Experimental Listbox', route: '/cdk-experimental-listbox'}, {name: 'CDK Listbox', route: '/cdk-listbox'}, {name: 'CDK Menu', route: '/cdk-menu'}, {name: 'Autocomplete', route: '/autocomplete'}, diff --git a/src/dev-app/routes.ts b/src/dev-app/routes.ts index 427b490a99b1..eb82f10c1a2e 100644 --- a/src/dev-app/routes.ts +++ b/src/dev-app/routes.ts @@ -45,6 +45,11 @@ export const DEV_APP_ROUTES: Routes = [ loadComponent: () => import('./cdk-experimental-combobox/cdk-combobox-demo').then(m => m.CdkComboboxDemo), }, + { + path: 'cdk-experimental-listbox', + loadComponent: () => + import('./cdk-experimental-listbox/cdk-listbox-demo').then(m => m.CdkExperimentalListboxDemo), + }, { path: 'cdk-dialog', loadComponent: () => import('./cdk-dialog/dialog-demo').then(m => m.DialogDemo), diff --git a/tslint.json b/tslint.json index b2754eadbc32..4315abeda607 100644 --- a/tslint.json +++ b/tslint.json @@ -164,7 +164,7 @@ true, [ // Files that we don't publish to npm so the relative imports don't matter. - "**/+(dev-app|components-examples|schematics|tools)/**", + "**/+(dev-app|components-examples|ui-patterns|schematics|tools)/**", "**/google-maps/testing/**", "**/cdk/testing/+(tests|private)/**", "**/*.spec.ts" From eef3526be5dd7571e9037bf9467d987f75c60bf4 Mon Sep 17 00:00:00 2001 From: Jeremy Elbourn Date: Tue, 18 Feb 2025 16:16:02 -0800 Subject: [PATCH 02/11] refactor(cdk-experimental/ui-patterns): remove controllers & lazy-loading --- src/cdk-experimental/listbox/listbox.ts | 3 +- .../behaviors/event-manager/event-manager.ts | 10 +- .../behaviors/list-focus/controller.ts | 24 -- .../behaviors/list-focus/list-focus.ts | 26 +- .../behaviors/list-navigation/controller.ts | 72 ------ .../list-navigation/list-navigation.ts | 69 +++--- .../behaviors/list-selection/controller.ts | 113 --------- .../list-selection/list-selection.ts | 103 +++++--- .../list-typeahead/list-typeahead.ts | 67 ++++-- .../ui-patterns/listbox/controller.ts | 217 ----------------- .../ui-patterns/listbox/listbox.ts | 222 +++++++++++++++--- 11 files changed, 366 insertions(+), 560 deletions(-) delete mode 100644 src/cdk-experimental/ui-patterns/behaviors/list-focus/controller.ts delete mode 100644 src/cdk-experimental/ui-patterns/behaviors/list-navigation/controller.ts delete mode 100644 src/cdk-experimental/ui-patterns/behaviors/list-selection/controller.ts delete mode 100644 src/cdk-experimental/ui-patterns/listbox/controller.ts diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts index d343c31d052d..ffbae2404942 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -47,7 +47,6 @@ import {toSignal} from '@angular/core/rxjs-interop'; '[attr.aria-orientation]': 'pattern.orientation()', '[attr.aria-multiselectable]': 'pattern.multiselectable()', '[attr.aria-activedescendant]': 'pattern.activedescendant()', - '(focusin)': 'pattern.onFocus()', '(keydown)': 'pattern.onKeydown($event)', '(mousedown)': 'pattern.onMousedown($event)', }, @@ -61,7 +60,7 @@ export class CdkListbox { /** A signal wrapper for directionality. */ protected directionality = toSignal(this._dir.change, { - initialValue: 'ltr', + initialValue: this._dir.value, }); /** The Option UIPatterns of the child CdkOptions. */ diff --git a/src/cdk-experimental/ui-patterns/behaviors/event-manager/event-manager.ts b/src/cdk-experimental/ui-patterns/behaviors/event-manager/event-manager.ts index 932dd49e3ba1..4b37004c0b5c 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/event-manager/event-manager.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/event-manager/event-manager.ts @@ -32,7 +32,7 @@ export interface EventHandlerOptions { * A config that specifies how to handle a particular event. */ export interface EventHandlerConfig extends EventHandlerOptions { - handler: (event: T) => Promise; + handler: (event: T) => boolean | void; } /** @@ -89,7 +89,7 @@ export abstract class EventManager { * Note: the use of `undefined` instead of `false` in the unhandled case is necessary to avoid * accidentally preventing the default behavior on an unhandled event. */ - async handle(event: T): Promise { + handle(event: T): true | undefined { if (!this.isHandled(event)) { return undefined; } @@ -97,10 +97,10 @@ export abstract class EventManager { fn(event); } for (const submanager of this._submanagers) { - await submanager.handle(event); + submanager.handle(event); } for (const config of this.getHandlersForKey(event)) { - await config.handler(event); + config.handler(event); if (config.stopPropagation) { event.stopPropagation(); } @@ -158,7 +158,7 @@ export class GenericEventManager extends EventManager { /** * Configures this event manager to handle all events with the given handler. */ - on(handler: (event: T) => Promise): this { + on(handler: (event: T) => boolean | void): this { this.configs.push({ ...this.defaultHandlerOptions, handler, diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/controller.ts b/src/cdk-experimental/ui-patterns/behaviors/list-focus/controller.ts deleted file mode 100644 index 86f1a707e77f..000000000000 --- a/src/cdk-experimental/ui-patterns/behaviors/list-focus/controller.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @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 {ListFocusItem, ListFocus} from './list-focus'; - -/** Controls focus for a list of items. */ -export class ListFocusController { - constructor(readonly state: ListFocus) {} - - /** Focuses the current active item. */ - focus() { - if (this.state.inputs.focusMode() === 'activedescendant') { - return; - } - - const item = this.state.navigation.inputs.items()[this.state.navigation.inputs.activeIndex()]; - item.element().focus(); - } -} diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts index 644bb5a57d81..b7ac964de3c3 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts @@ -8,7 +8,6 @@ import {computed, Signal} from '@angular/core'; import {ListNavigation, ListNavigationItem} from '../list-navigation/list-navigation'; -import type {ListFocusController} from './controller'; /** The required properties for focus items. */ export interface ListFocusItem extends ListNavigationItem { @@ -30,26 +29,10 @@ export class ListFocus { /** The navigation controller of the parent list. */ navigation: ListNavigation; - get controller(): Promise> { - if (this._controller === null) { - return this.loadController(); - } - return Promise.resolve(this._controller); - } - private _controller: ListFocusController | null = null; - constructor(readonly inputs: ListFocusInputs & {navigation: ListNavigation}) { this.navigation = inputs.navigation; } - /** Loads the controller for list focus. */ - async loadController(): Promise> { - return import('./controller').then(m => { - this._controller = new m.ListFocusController(this); - return this._controller; - }); - } - /** Returns the id of the current active item. */ getActiveDescendant(): Signal { return computed(() => { @@ -77,7 +60,12 @@ export class ListFocus { } /** Focuses the current active item. */ - async focus() { - (await this.controller).focus(); + focus() { + if (this.inputs.focusMode() === 'activedescendant') { + return; + } + + const item = this.navigation.inputs.items()[this.navigation.inputs.activeIndex()]; + item.element().focus(); } } diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-navigation/controller.ts b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/controller.ts deleted file mode 100644 index 9e2670c18672..000000000000 --- a/src/cdk-experimental/ui-patterns/behaviors/list-navigation/controller.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * @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 {ListNavigationItem, ListNavigation} from './list-navigation'; - -/** Controls navigation for a list of items. */ -export class ListNavigationController { - constructor(readonly state: ListNavigation) {} - - /** Navigates to the given item. */ - goto(item: T) { - if (this.isFocusable(item)) { - this.state.prevActiveIndex.set(this.state.inputs.activeIndex()); - const index = this.state.inputs.items().indexOf(item); - this.state.inputs.activeIndex.set(index); - } - } - - /** Navigates to the next item in the list. */ - next() { - const items = this.state.inputs.items(); - const after = items.slice(this.state.inputs.activeIndex() + 1); - const before = items.slice(0, this.state.inputs.activeIndex()); - const array = this.state.inputs.wrap() ? after.concat(before) : after; - const item = array.find(i => this.isFocusable(i)); - - if (item) { - this.goto(item); - } - } - - /** Navigates to the previous item in the list. */ - prev() { - const items = this.state.inputs.items(); - const after = items.slice(this.state.inputs.activeIndex() + 1).reverse(); - const before = items.slice(0, this.state.inputs.activeIndex()).reverse(); - const array = this.state.inputs.wrap() ? before.concat(after) : before; - const item = array.find(i => this.isFocusable(i)); - - if (item) { - this.goto(item); - } - } - - /** Navigates to the first item in the list. */ - first() { - const item = this.state.inputs.items().find(i => this.isFocusable(i)); - - if (item) { - this.goto(item); - } - } - - /** Navigates to the last item in the list. */ - last() { - const item = [...this.state.inputs.items()].reverse().find(i => this.isFocusable(i)); - - if (item) { - this.goto(item); - } - } - - /** Returns true if the given item can be navigated to. */ - isFocusable(item: T): boolean { - return !item.disabled() || !this.state.inputs.skipDisabled(); - } -} 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 392dd400922b..16c255c0d416 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 @@ -7,7 +7,6 @@ */ import {signal, Signal, WritableSignal} from '@angular/core'; -import type {ListNavigationController} from './controller'; /** The required properties for navigation items. */ export interface ListNavigationItem { @@ -41,53 +40,65 @@ export class ListNavigation { /** The last index that was active. */ prevActiveIndex = signal(0); - get controller(): Promise> { - if (this._controller === null) { - return this.loadController(); - } - return Promise.resolve(this._controller); - } - private _controller: ListNavigationController | null = null; - constructor(readonly inputs: ListNavigationInputs) { this.prevActiveIndex.set(inputs.activeIndex()); } - /** Loads the controller for list navigation. */ - async loadController(): Promise> { - return import('./controller').then(m => { - this._controller = new m.ListNavigationController(this); - return this._controller; - }); - } - /** Navigates to the given item. */ - async goto(item: T) { - return (await this.controller).goto(item); + goto(item: T) { + if (this.isFocusable(item)) { + this.prevActiveIndex.set(this.inputs.activeIndex()); + const index = this.inputs.items().indexOf(item); + this.inputs.activeIndex.set(index); + } } /** Navigates to the next item in the list. */ - async next() { - return (await this.controller).next(); + next() { + const items = this.inputs.items(); + const after = items.slice(this.inputs.activeIndex() + 1); + const before = items.slice(0, this.inputs.activeIndex()); + const array = this.inputs.wrap() ? after.concat(before) : after; + const item = array.find(i => this.isFocusable(i)); + + if (item) { + this.goto(item); + } } /** Navigates to the previous item in the list. */ - async prev() { - return (await this.controller).prev(); + prev() { + const items = this.inputs.items(); + const after = items.slice(this.inputs.activeIndex() + 1).reverse(); + const before = items.slice(0, this.inputs.activeIndex()).reverse(); + const array = this.inputs.wrap() ? before.concat(after) : before; + const item = array.find(i => this.isFocusable(i)); + + if (item) { + this.goto(item); + } } /** Navigates to the first item in the list. */ - async first() { - return (await this.controller).first(); + first() { + const item = this.inputs.items().find(i => this.isFocusable(i)); + + if (item) { + this.goto(item); + } } /** Navigates to the last item in the list. */ - async last() { - return (await this.controller).last(); + last() { + const item = [...this.inputs.items()].reverse().find(i => this.isFocusable(i)); + + if (item) { + this.goto(item); + } } /** Returns true if the given item can be navigated to. */ - async isFocusable(item: T) { - return (await this.controller).isFocusable(item); + isFocusable(item: T): boolean { + return !item.disabled() || !this.inputs.skipDisabled(); } } diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/controller.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/controller.ts deleted file mode 100644 index f2ba0a5086fa..000000000000 --- a/src/cdk-experimental/ui-patterns/behaviors/list-selection/controller.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * @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 {ListSelectionItem, ListSelection} from './list-selection'; - -/** Controls selection for a list of items. */ -export class ListSelectionController { - constructor(readonly state: ListSelection) { - if (this.state.inputs.selectedIds()) { - this._anchor(); - } - } - - /** Selects the item at the current active index. */ - select(item?: T) { - item = item ?? this.state.inputs.items()[this.state.inputs.navigation.inputs.activeIndex()]; - - if (item.disabled() || this.state.inputs.selectedIds().includes(item.id())) { - return; - } - - if (!this.state.inputs.multiselectable()) { - this.deselectAll(); - } - - // TODO: Need to discuss when to drop this. - this._anchor(); - this.state.inputs.selectedIds.update(ids => ids.concat(item.id())); - } - - /** Deselects the item at the current active index. */ - deselect(item?: T) { - item = item ?? this.state.inputs.items()[this.state.inputs.navigation.inputs.activeIndex()]; - - if (!item.disabled()) { - this.state.inputs.selectedIds.update(ids => ids.filter(id => id !== item.id())); - } - } - - /** Toggles the item at the current active index. */ - toggle() { - const item = this.state.inputs.items()[this.state.inputs.navigation.inputs.activeIndex()]; - this.state.inputs.selectedIds().includes(item.id()) ? this.deselect() : this.select(); - } - - /** Toggles only the item at the current active index. */ - toggleOne() { - const item = this.state.inputs.items()[this.state.inputs.navigation.inputs.activeIndex()]; - this.state.inputs.selectedIds().includes(item.id()) ? this.deselect() : this.selectOne(); - } - - /** Selects all items in the list. */ - selectAll() { - if (!this.state.inputs.multiselectable()) { - return; // Should we log a warning? - } - - for (const item of this.state.inputs.items()) { - this.select(item); - } - - this._anchor(); - } - - /** Deselects all items in the list. */ - deselectAll() { - for (const item of this.state.inputs.items()) { - this.deselect(item); - } - } - - /** Selects the items in the list starting at the last selected item. */ - selectFromAnchor() { - const anchorIndex = this.state.inputs.items().findIndex(i => this.state.anchorId() === i.id()); - this._selectFromIndex(anchorIndex); - } - - /** Selects the items in the list starting at the last active item. */ - selectFromActive() { - this._selectFromIndex(this.state.inputs.navigation.prevActiveIndex()); - } - - /** Selects the items in the list starting at the given index. */ - private _selectFromIndex(index: number) { - if (index === -1) { - return; - } - - const upper = Math.max(this.state.inputs.navigation.inputs.activeIndex(), index); - const lower = Math.min(this.state.inputs.navigation.inputs.activeIndex(), index); - - for (let i = lower; i <= upper; i++) { - this.select(this.state.inputs.items()[i]); - } - } - - /** Sets the selection to only the current active item. */ - selectOne() { - this.deselectAll(); - this.select(); - } - - /** Sets the anchor to the current active index. */ - private _anchor() { - const item = this.state.inputs.items()[this.state.inputs.navigation.inputs.activeIndex()]; - this.state.anchorId.set(item.id()); - } -} diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts index 8ecdbc289e60..94c84e33234c 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts @@ -7,7 +7,6 @@ */ import {signal, Signal, WritableSignal} from '@angular/core'; -import {ListSelectionController} from './controller'; import {ListNavigation, ListNavigationItem} from '../list-navigation/list-navigation'; /** The required properties for selection items. */ @@ -42,68 +41,102 @@ export class ListSelection { /** The navigation controller of the parent list. */ navigation: ListNavigation; - get controller(): Promise> { - if (this._controller === null) { - return this.loadController(); - } - return Promise.resolve(this._controller); - } - private _controller: ListSelectionController | null = null; - constructor(readonly inputs: ListSelectionInputs & {navigation: ListNavigation}) { this.navigation = inputs.navigation; } - /** Loads the controller for list selection. */ - async loadController(): Promise> { - return import('./controller').then(m => { - this._controller = new m.ListSelectionController(this); - return this._controller; - }); - } - /** Selects the item at the current active index. */ - async select(item?: T) { - return (await this.controller).select(item); + select(item?: T) { + item = item ?? this.inputs.items()[this.inputs.navigation.inputs.activeIndex()]; + + if (item.disabled() || this.inputs.selectedIds().includes(item.id())) { + return; + } + + if (!this.inputs.multiselectable()) { + this.deselectAll(); + } + + // TODO: Need to discuss when to drop this. + this._anchor(); + this.inputs.selectedIds.update(ids => ids.concat(item.id())); } /** Deselects the item at the current active index. */ - async deselect(item?: T) { - return (await this.controller).deselect(item); + deselect(item?: T) { + item = item ?? this.inputs.items()[this.inputs.navigation.inputs.activeIndex()]; + + if (!item.disabled()) { + this.inputs.selectedIds.update(ids => ids.filter(id => id !== item.id())); + } } /** Toggles the item at the current active index. */ - async toggle() { - return (await this.controller).toggle(); + toggle() { + const item = this.inputs.items()[this.inputs.navigation.inputs.activeIndex()]; + this.inputs.selectedIds().includes(item.id()) ? this.deselect() : this.select(); } /** Toggles only the item at the current active index. */ - async toggleOne() { - return (await this.controller).toggleOne(); + toggleOne() { + const item = this.inputs.items()[this.inputs.navigation.inputs.activeIndex()]; + this.inputs.selectedIds().includes(item.id()) ? this.deselect() : this.selectOne(); } /** Selects all items in the list. */ - async selectAll() { - return (await this.controller).selectAll(); + selectAll() { + if (!this.inputs.multiselectable()) { + return; // Should we log a warning? + } + + for (const item of this.inputs.items()) { + this.select(item); + } + + this._anchor(); } /** Deselects all items in the list. */ - async deselectAll() { - return (await this.controller).deselectAll(); + deselectAll() { + for (const item of this.inputs.items()) { + this.deselect(item); + } } /** Selects the items in the list starting at the last selected item. */ - async selectFromAnchor() { - return (await this.controller).selectFromAnchor(); + selectFromAnchor() { + const anchorIndex = this.inputs.items().findIndex(i => this.anchorId() === i.id()); + this._selectFromIndex(anchorIndex); } /** Selects the items in the list starting at the last active item. */ - async selectFromActive() { - return (await this.controller).selectFromActive(); + selectFromActive() { + this._selectFromIndex(this.inputs.navigation.prevActiveIndex()); + } + + /** Selects the items in the list starting at the given index. */ + private _selectFromIndex(index: number) { + if (index === -1) { + return; + } + + const upper = Math.max(this.inputs.navigation.inputs.activeIndex(), index); + const lower = Math.min(this.inputs.navigation.inputs.activeIndex(), index); + + for (let i = lower; i <= upper; i++) { + this.select(this.inputs.items()[i]); + } } /** Sets the selection to only the current active item. */ - async selectOne() { - return (await this.controller).selectOne(); + selectOne() { + this.deselectAll(); + this.select(); + } + + /** Sets the anchor to the current active index. */ + private _anchor() { + const item = this.inputs.items()[this.inputs.navigation.inputs.activeIndex()]; + this.anchorId.set(item.id()); } } diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts index e43a0c2f4e58..91aa26b6c3e2 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Signal} from '@angular/core'; +import {signal, Signal} from '@angular/core'; import type {ListTypeaheadController} from './controller'; import {ListNavigationItem, ListNavigation} from '../list-navigation/list-navigation'; @@ -24,31 +24,64 @@ export interface ListTypeaheadInputs { /** Controls typeahead for a list of items. */ export class ListTypeahead { + /** A reference to the timeout for resetting the typeahead search. */ + timeout?: any; + /** The navigation controller of the parent list. */ navigation: ListNavigation; - get controller(): Promise> { - if (this._controller === null) { - return this.loadController(); - } - return Promise.resolve(this._controller); - } - private _controller: ListTypeaheadController | null = null; + /** Keeps track of the characters that typeahead search is being called with. */ + private query = signal(''); + + /** The index where that the typeahead search was initiated from. */ + private anchorIndex = signal(null); constructor(readonly inputs: ListTypeaheadInputs & {navigation: ListNavigation}) { this.navigation = inputs.navigation; } - /** Loads the controller for list typeahead. */ - async loadController(): Promise> { - return import('./controller').then(m => { - this._controller = new m.ListTypeaheadController(this); - return this._controller; - }); + /** Performs a typeahead search, appending the given character to the search string. */ + search(char: string) { + if (char.length !== 1) { + return; + } + + if (this.anchorIndex() === null) { + this.anchorIndex.set(this.navigation.inputs.activeIndex()); + } + + clearTimeout(this.timeout); + this.query.update(q => q + char.toLowerCase()); + const item = this._getItem(); + + if (item) { + this.navigation.goto(item); + } + + this.timeout = setTimeout(() => { + this.query.set(''); + this.anchorIndex.set(null); + }, this.inputs.typeaheadDelay() * 1000); } - /** Performs a typeahead search, appending the given character to the search string. */ - async search(char: string) { - return (await this.controller).search(char); + /** + * Returns the first item whose search term matches the + * current query starting from the the current anchor index. + */ + private _getItem() { + let items = this.navigation.inputs.items(); + const after = items.slice(this.anchorIndex()! + 1); + const before = items.slice(0, this.anchorIndex()!); + items = this.navigation.inputs.wrap() ? after.concat(before) : after; // TODO: Always wrap? + items.push(this.navigation.inputs.items()[this.anchorIndex()!]); + + const focusableItems = []; + for (const item of items) { + if (this.navigation.isFocusable(item)) { + focusableItems.push(item); + } + } + + return focusableItems.find(i => i.searchTerm().toLowerCase().startsWith(this.query())); } } diff --git a/src/cdk-experimental/ui-patterns/listbox/controller.ts b/src/cdk-experimental/ui-patterns/listbox/controller.ts deleted file mode 100644 index a1723c3ca5d1..000000000000 --- a/src/cdk-experimental/ui-patterns/listbox/controller.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * @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} from '../behaviors/event-manager/keyboard-event-manager'; -import {ModifierKey as Modifier} from '../behaviors/event-manager/event-manager'; -import {MouseEventManager} from '../behaviors/event-manager/mouse-event-manager'; -import {ListboxPattern} from './listbox'; - -/** The selection operations that the listbox can perform. */ -interface SelectOptions { - select?: boolean; - toggle?: boolean; - toggleOne?: boolean; - selectOne?: boolean; - selectAll?: boolean; - selectFromAnchor?: boolean; - selectFromActive?: boolean; -} - -/** Controls selection for a list of items. */ -export class ListboxController { - followFocus = computed(() => this.state.inputs.selectionMode() === 'follow'); - - /** The key used to navigate to the previous item in the list. */ - prevKey = computed(() => { - if (this.state.inputs.orientation() === 'vertical') { - return 'ArrowUp'; - } - return this.state.inputs.directionality() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; - }); - - /** The key used to navigate to the next item in the list. */ - nextKey = computed(() => { - if (this.state.inputs.orientation() === 'vertical') { - return 'ArrowDown'; - } - return this.state.inputs.directionality() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; - }); - - /** The regexp used to decide if a key should trigger typeahead. */ - typeaheadRegexp = /^.$/; // TODO: Ignore spaces? - - /** The keydown event manager for the listbox. */ - keydown = computed(() => { - const manager = new KeyboardEventManager(); - - if (!this.followFocus()) { - manager - .on(this.prevKey, () => this.prev()) - .on(this.nextKey, () => this.next()) - .on('Home', () => this.first()) - .on('End', () => this.last()) - .on(this.typeaheadRegexp, e => this.typeahead(e.key)); - } - - if (this.followFocus()) { - manager - .on(this.prevKey, () => this.prev({selectOne: true})) - .on(this.nextKey, () => this.next({selectOne: true})) - .on('Home', () => this.first({selectOne: true})) - .on('End', () => this.last({selectOne: true})) - .on(this.typeaheadRegexp, e => this.typeahead(e.key, {selectOne: true})); - } - - if (this.state.inputs.multiselectable()) { - manager - .on(Modifier.Shift, ' ', () => this._updateSelection({selectFromAnchor: true})) - .on(Modifier.Shift, this.prevKey, () => this.prev({toggle: true})) - .on(Modifier.Shift, this.nextKey, () => this.next({toggle: true})) - .on(Modifier.Ctrl | Modifier.Shift, 'Home', () => this.first({selectFromActive: true})) - .on(Modifier.Ctrl | Modifier.Shift, 'End', () => this.last({selectFromActive: true})) - .on(Modifier.Ctrl, 'A', () => this._updateSelection({selectAll: true})); - } - - if (!this.followFocus() && this.state.inputs.multiselectable()) { - manager.on(' ', () => this._updateSelection({toggle: true})); - } - - if (!this.followFocus() && !this.state.inputs.multiselectable()) { - manager.on(' ', () => this._updateSelection({toggleOne: true})); - } - - if (this.state.inputs.multiselectable() && this.followFocus()) { - manager - .on(Modifier.Ctrl, this.prevKey, () => this.prev()) - .on(Modifier.Ctrl, this.nextKey, () => this.next()) - .on(Modifier.Ctrl, 'Home', () => this.first()) // TODO: Not in spec but prob should be. - .on(Modifier.Ctrl, 'End', () => this.last()); // TODO: Not in spec but prob should be. - } - - return manager; - }); - - /** The mousedown event manager for the listbox. */ - mousedown = computed(() => { - const manager = new MouseEventManager(); - - if (!this.followFocus()) { - manager.on((e: MouseEvent) => this.goto(e)); - } - - if (this.followFocus()) { - manager.on((e: MouseEvent) => this.goto(e, {selectOne: true})); - } - - if (this.state.inputs.multiselectable() && this.followFocus()) { - manager.on(Modifier.Ctrl, (e: MouseEvent) => this.goto(e)); - } - - if (this.state.inputs.multiselectable()) { - manager.on(Modifier.Shift, (e: MouseEvent) => this.goto(e, {selectFromActive: true})); - } - - return manager; - }); - - constructor(readonly state: ListboxPattern) {} - - /** Handles keydown events for the listbox. */ - onKeydown(event: KeyboardEvent) { - if (!this.state.disabled()) { - this.keydown().handle(event); - } - } - - onMousedown(event: MouseEvent) { - if (!this.state.disabled()) { - this.mousedown().handle(event); - } - } - - /** Navigates to the first option in the listbox. */ - async first(opts?: SelectOptions) { - await this.state.navigation.first(); - await this.state.focus.focus(); - await this._updateSelection(opts); - } - - /** Navigates to the last option in the listbox. */ - async last(opts?: SelectOptions) { - await this.state.navigation.last(); - await this.state.focus.focus(); - await this._updateSelection(opts); - } - - /** Navigates to the next option in the listbox. */ - async next(opts?: SelectOptions) { - await this.state.navigation.next(); - await this.state.focus.focus(); - await this._updateSelection(opts); - } - - /** Navigates to the previous option in the listbox. */ - async prev(opts?: SelectOptions) { - await this.state.navigation.prev(); - await this.state.focus.focus(); - await this._updateSelection(opts); - } - - /** Navigates to the given item in the listbox. */ - async goto(event: MouseEvent, opts?: SelectOptions) { - const item = this._getItem(event); - - if (item) { - await this.state.navigation.goto(item); - await this.state.focus.focus(); - await this._updateSelection(opts); - } - } - - /** Handles typeahead navigation for the listbox. */ - async typeahead(char: string, opts?: SelectOptions) { - await this.state.typeahead.search(char); - await this.state.focus.focus(); - await this._updateSelection(opts); - } - - /** Handles updating selection for the listbox. */ - private async _updateSelection(opts?: SelectOptions) { - if (opts?.select) { - await this.state.selection.select(); - } - if (opts?.toggle) { - await this.state.selection.toggle(); - } - if (opts?.toggleOne) { - await this.state.selection.toggleOne(); - } - if (opts?.selectOne) { - await this.state.selection.selectOne(); - } - if (opts?.selectAll) { - await this.state.selection.selectAll(); - } - if (opts?.selectFromAnchor) { - await this.state.selection.selectFromAnchor(); - } - if (opts?.selectFromActive) { - await this.state.selection.selectFromActive(); - } - } - - private _getItem(e: MouseEvent) { - if (!(e.target instanceof HTMLElement)) { - return; - } - - const element = e.target.closest('[cdkoption]'); // TODO: Use a different identifier. - return this.state.inputs.items().find(i => i.element() === element); - } -} diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index d7d52d241a6f..b85a2dcfc77f 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -6,13 +6,26 @@ * found in the LICENSE file at https://angular.dev/license */ +import {ModifierKey as Modifier} from '../behaviors/event-manager/event-manager'; +import {KeyboardEventManager} from '../behaviors/event-manager/keyboard-event-manager'; +import {MouseEventManager} from '../behaviors/event-manager/mouse-event-manager'; import {OptionPattern} from './option'; import {ListSelection, ListSelectionInputs} from '../behaviors/list-selection/list-selection'; import {ListTypeahead, ListTypeaheadInputs} from '../behaviors/list-typeahead/list-typeahead'; import {ListNavigation, ListNavigationInputs} from '../behaviors/list-navigation/list-navigation'; import {ListFocus, ListFocusInputs} from '../behaviors/list-focus/list-focus'; import {computed, Signal} from '@angular/core'; -import {ListboxController} from './controller'; + +/** The selection operations that the listbox can perform. */ +interface SelectOptions { + select?: boolean; + toggle?: boolean; + toggleOne?: boolean; + selectOne?: boolean; + selectAll?: boolean; + selectFromAnchor?: boolean; + selectFromActive?: boolean; +} /** The required inputs for the listbox. */ export type ListboxInputs = ListNavigationInputs & @@ -54,13 +67,103 @@ export class ListboxPattern { /** The number of items in the listbox. */ setsize = computed(() => this.navigation.inputs.items().length); - get controller(): Promise { - if (this._controller === null) { - return this.loadController(); + followFocus = computed(() => this.inputs.selectionMode() === 'follow'); + + /** The key used to navigate to the previous item in the list. */ + prevKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return 'ArrowUp'; } - return Promise.resolve(this._controller); - } - private _controller: ListboxController | null = null; + return this.inputs.directionality() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + }); + + /** The key used to navigate to the next item in the list. */ + nextKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return 'ArrowDown'; + } + return this.inputs.directionality() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + }); + + /** The regexp used to decide if a key should trigger typeahead. */ + typeaheadRegexp = /^.$/; // TODO: Ignore spaces? + + /** The keydown event manager for the listbox. */ + keydown = computed(() => { + const manager = new KeyboardEventManager(); + + console.log('prev key:', this.prevKey()); + console.log('next key:', this.nextKey()); + + if (!this.followFocus()) { + manager + .on(this.prevKey, () => this.prev()) + .on(this.nextKey, () => this.next()) + .on('Home', () => this.first()) + .on('End', () => this.last()) + .on(this.typeaheadRegexp, e => this.search(e.key)); + } + + if (this.followFocus()) { + manager + .on(this.prevKey, () => this.prev({selectOne: true})) + .on(this.nextKey, () => this.next({selectOne: true})) + .on('Home', () => this.first({selectOne: true})) + .on('End', () => this.last({selectOne: true})) + .on(this.typeaheadRegexp, e => this.search(e.key, {selectOne: true})); + } + + if (this.inputs.multiselectable()) { + manager + .on(Modifier.Shift, ' ', () => this._updateSelection({selectFromAnchor: true})) + .on(Modifier.Shift, this.prevKey, () => this.prev({toggle: true})) + .on(Modifier.Shift, this.nextKey, () => this.next({toggle: true})) + .on(Modifier.Ctrl | Modifier.Shift, 'Home', () => this.first({selectFromActive: true})) + .on(Modifier.Ctrl | Modifier.Shift, 'End', () => this.last({selectFromActive: true})) + .on(Modifier.Ctrl, 'A', () => this._updateSelection({selectAll: true})); + } + + if (!this.followFocus() && this.inputs.multiselectable()) { + manager.on(' ', () => this._updateSelection({toggle: true})); + } + + if (!this.followFocus() && !this.inputs.multiselectable()) { + manager.on(' ', () => this._updateSelection({toggleOne: true})); + } + + if (this.inputs.multiselectable() && this.followFocus()) { + manager + .on(Modifier.Ctrl, this.prevKey, () => this.prev()) + .on(Modifier.Ctrl, this.nextKey, () => this.next()) + .on(Modifier.Ctrl, 'Home', () => this.first()) // TODO: Not in spec but prob should be. + .on(Modifier.Ctrl, 'End', () => this.last()); // TODO: Not in spec but prob should be. + } + + return manager; + }); + + /** The mousedown event manager for the listbox. */ + mousedown = computed(() => { + const manager = new MouseEventManager(); + + if (!this.followFocus()) { + manager.on((e: MouseEvent) => this.goto(e)); + } + + if (this.followFocus()) { + manager.on((e: MouseEvent) => this.goto(e, {selectOne: true})); + } + + if (this.inputs.multiselectable() && this.followFocus()) { + manager.on(Modifier.Ctrl, (e: MouseEvent) => this.goto(e)); + } + + if (this.inputs.multiselectable()) { + manager.on(Modifier.Shift, (e: MouseEvent) => this.goto(e, {selectFromActive: true})); + } + + return manager; + }); constructor(readonly inputs: ListboxInputs) { this.disabled = inputs.disabled; @@ -76,32 +179,97 @@ export class ListboxPattern { this.activedescendant = this.focus.getActiveDescendant(); } - /** Loads the controller for the listbox. */ - async loadController(): Promise { - return import('./controller').then(module => { - this._controller = new module.ListboxController(this); - return this._controller; - }); + /** Handles keydown events for the listbox. */ + onKeydown(event: KeyboardEvent) { + console.log(this.orientation()); + if (!this.disabled()) { + this.keydown().handle(event); + } + } + + onMousedown(event: MouseEvent) { + if (!this.disabled()) { + this.mousedown().handle(event); + } + } + + /** Navigates to the first option in the listbox. */ + first(opts?: SelectOptions) { + this.navigation.first(); + this.focus.focus(); + this._updateSelection(opts); + } + + /** Navigates to the last option in the listbox. */ + last(opts?: SelectOptions) { + this.navigation.last(); + this.focus.focus(); + this._updateSelection(opts); + } + + /** Navigates to the next option in the listbox. */ + next(opts?: SelectOptions) { + this.navigation.next(); + this.focus.focus(); + this._updateSelection(opts); } - /** The keydown handler for the listbox. */ - async onKeydown(event: KeyboardEvent) { - (await this.controller).onKeydown(event); + /** Navigates to the previous option in the listbox. */ + prev(opts?: SelectOptions) { + this.navigation.prev(); + this.focus.focus(); + this._updateSelection(opts); } - /** The mousedown handler for the listbox. */ - async onMousedown(event: MouseEvent) { - (await this.controller).onMousedown(event); + /** Navigates to the given item in the listbox. */ + goto(event: MouseEvent, opts?: SelectOptions) { + const item = this._getItem(event); + + if (item) { + this.navigation.goto(item); + this.focus.focus(); + this._updateSelection(opts); + } + } + + /** Handles typeahead search navigation for the listbox. */ + search(char: string, opts?: SelectOptions) { + this.typeahead.search(char); + this.focus.focus(); + this._updateSelection(opts); + } + + /** Handles updating selection for the listbox. */ + private _updateSelection(opts?: SelectOptions) { + if (opts?.select) { + this.selection.select(); + } + if (opts?.toggle) { + this.selection.toggle(); + } + if (opts?.toggleOne) { + this.selection.toggleOne(); + } + if (opts?.selectOne) { + this.selection.selectOne(); + } + if (opts?.selectAll) { + this.selection.selectAll(); + } + if (opts?.selectFromAnchor) { + this.selection.selectFromAnchor(); + } + if (opts?.selectFromActive) { + this.selection.selectFromActive(); + } } - /** The focus handler for the listbox. */ - async onFocus() { - if (!this._controller) { - await this.loadController(); - await this.navigation.loadController(); - await this.selection.loadController(); - await this.typeahead.loadController(); - await this.focus.loadController(); + private _getItem(e: MouseEvent) { + if (!(e.target instanceof HTMLElement)) { + return; } + + const element = e.target.closest('[cdkoption]'); // TODO: Use a different identifier. + return this.inputs.items().find(i => i.element() === element); } } From 89cb225422bb40134645c3f1a3870d35c077c560 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Wed, 19 Feb 2025 16:03:49 -0500 Subject: [PATCH 03/11] refactor: event managers --- src/cdk-experimental/listbox/listbox.ts | 2 - .../behaviors/event-manager/event-manager.ts | 145 ++++-------------- .../event-manager/keyboard-event-manager.ts | 88 ++++------- .../event-manager/mouse-event-manager.ts | 78 +++------- .../behaviors/list-focus/list-focus.spec.ts | 4 +- .../list-navigation/list-navigation.spec.ts | 76 ++++----- .../list-selection/list-selection.spec.ts | 54 +++---- .../behaviors/list-typeahead/controller.ts | 69 --------- .../list-typeahead/list-typeahead.spec.ts | 34 ++-- .../list-typeahead/list-typeahead.ts | 23 ++- .../ui-patterns/listbox/listbox.ts | 4 - 11 files changed, 176 insertions(+), 401 deletions(-) delete mode 100644 src/cdk-experimental/ui-patterns/behaviors/list-typeahead/controller.ts diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts index ffbae2404942..c341f65f54a4 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -15,8 +15,6 @@ import { inject, input, model, - OnDestroy, - signal, } from '@angular/core'; import {ListboxPattern, OptionPattern} from '@angular/cdk-experimental/ui-patterns'; import {Directionality} from '@angular/cdk/bidi'; diff --git a/src/cdk-experimental/ui-patterns/behaviors/event-manager/event-manager.ts b/src/cdk-experimental/ui-patterns/behaviors/event-manager/event-manager.ts index 4b37004c0b5c..6f28962270cd 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/event-manager/event-manager.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/event-manager/event-manager.ts @@ -28,16 +28,19 @@ export interface EventHandlerOptions { preventDefault: boolean; } -/** - * A config that specifies how to handle a particular event. - */ +/** A basic event handler. */ +export type EventHandler = (event: T) => void; + +/** A function that determines whether an event is to be handled. */ +export type EventMatcher = (event: T) => boolean; + +/** A config that specifies how to handle a particular event. */ export interface EventHandlerConfig extends EventHandlerOptions { - handler: (event: T) => boolean | void; + matcher: EventMatcher; + handler: EventHandler; } -/** - * Bit flag representation of the possible modifier keys that can be present on an event. - */ +/** Bit flag representation of the possible modifier keys that can be present on an event. */ export enum ModifierKey { None = 0, Ctrl = 0b1, @@ -46,6 +49,8 @@ export enum ModifierKey { Meta = 0b1000, } +export type ModifierInputs = ModifierKey | ModifierKey[]; + /** * Abstract base class for all event managers. * @@ -53,127 +58,31 @@ export enum ModifierKey { * for common event handling gotchas like remembering to call preventDefault or stopPropagation. */ export abstract class EventManager { - private _submanagers: EventManager[] = []; - protected configs: EventHandlerConfig[] = []; - protected beforeFns: ((event: T) => void)[] = []; - protected afterFns: ((event: T) => void)[] = []; + abstract options: EventHandlerOptions; - protected defaultHandlerOptions: EventHandlerOptions = { - preventDefault: false, - stopPropagation: false, - }; + /** Runs the handlers that match with the given event. */ + handle(event: T): void { + for (const config of this.configs) { + if (config.matcher(event)) { + config.handler(event); - constructor(defaultHandlerOptions?: Partial) { - this.defaultHandlerOptions = { - ...this.defaultHandlerOptions, - ...defaultHandlerOptions, - }; - } - - /** - * Composes together multiple event managers into a single event manager that delegates to the - * individual managers. - */ - static compose(...managers: EventManager[]) { - const composedManager = new GenericEventManager(); - composedManager._submanagers = managers; - return composedManager; - } + if (config.preventDefault) { + event.preventDefault(); + } - /** - * Runs any handlers that have been configured to handle this event. If multiple handlers are - * configured for this event, they are run in the order they were configured. Returns - * `true` if the event has been handled, otherwise returns `undefined`. - * - * Note: the use of `undefined` instead of `false` in the unhandled case is necessary to avoid - * accidentally preventing the default behavior on an unhandled event. - */ - handle(event: T): true | undefined { - if (!this.isHandled(event)) { - return undefined; - } - for (const fn of this.beforeFns) { - fn(event); - } - for (const submanager of this._submanagers) { - submanager.handle(event); - } - for (const config of this.getHandlersForKey(event)) { - config.handler(event); - if (config.stopPropagation) { - event.stopPropagation(); + if (config.stopPropagation) { + event.stopPropagation(); + } } - if (config.preventDefault) { - event.preventDefault(); - } - } - for (const fn of this.afterFns) { - fn(event); } - return true; } - /** - * Configures the event manager to run a function immediately before it as about to handle - * any event. - */ - beforeHandling(fn: (event: T) => void): this { - this.beforeFns.push(fn); - return this; - } - - /** - * Configures the event manager to run a function immediately after it handles any event. - */ - afterHandling(fn: (event: T) => void): this { - this.afterFns.push(fn); - return this; - } - - /** - * Configures the event manager to handle specific events. (See subclasses for more). - */ + /** Configures the event manager to handle specific events. (See subclasses for more). */ abstract on(...args: [...unknown[]]): this; - - /** - * Gets all of the handler configs that are applicable to the given event. - */ - protected abstract getHandlersForKey(event: T): EventHandlerConfig[]; - - /** - * Checks whether this event manager is confugred to handle the given event. - */ - protected isHandled(event: T): boolean { - return ( - this.getHandlersForKey(event).length > 0 || this._submanagers.some(sm => sm.isHandled(event)) - ); - } -} - -/** - * A generic event manager that can work with any type of event. - */ -export class GenericEventManager extends EventManager { - /** - * Configures this event manager to handle all events with the given handler. - */ - on(handler: (event: T) => boolean | void): this { - this.configs.push({ - ...this.defaultHandlerOptions, - handler, - }); - return this; - } - - getHandlersForKey(_event: T): EventHandlerConfig[] { - return this.configs; - } } -/** - * Gets bit flag representation of the modifier keys present on the given event. - */ +/** Gets bit flag representation of the modifier keys present on the given event. */ export function getModifiers(event: EventWithModifiers): number { return ( (+event.ctrlKey && ModifierKey.Ctrl) | @@ -187,7 +96,7 @@ export function getModifiers(event: EventWithModifiers): number { * Checks if the given event has modifiers that are an exact match for any of the given modifier * flag combinations. */ -export function hasModifiers(event: EventWithModifiers, modifiers: number | number[]): boolean { +export function hasModifiers(event: EventWithModifiers, modifiers: ModifierInputs): boolean { const eventModifiers = getModifiers(event); const modifiersList = Array.isArray(modifiers) ? modifiers : [modifiers]; return modifiersList.some(modifiers => eventModifiers === modifiers); diff --git a/src/cdk-experimental/ui-patterns/behaviors/event-manager/keyboard-event-manager.ts b/src/cdk-experimental/ui-patterns/behaviors/event-manager/keyboard-event-manager.ts index 258147e35892..f3deafb8a240 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/event-manager/keyboard-event-manager.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/event-manager/keyboard-event-manager.ts @@ -8,92 +8,68 @@ import {Signal} from '@angular/core'; import { - EventHandlerConfig, + EventHandler, EventHandlerOptions, EventManager, hasModifiers, + ModifierInputs, ModifierKey, } from './event-manager'; /** - * A config that specifies how to handle a particular keyboard event. + * Used to represent a keycode. + * + * This is used to match whether an events keycode should be handled. The ability to match using a + * string, Signal, or Regexp gives us more flexibility when authoring event handlers. */ -export interface KeyboardEventHandlerConfig extends EventHandlerConfig { - key: string | Signal | RegExp; - modifiers: number | number[]; -} +type KeyCode = string | Signal | RegExp; /** * An event manager that is specialized for handling keyboard events. By default this manager stops * propagation and prevents default on all events it handles. */ -export class KeyboardEventManager extends EventManager { - override configs: KeyboardEventHandlerConfig[] = []; - - protected override defaultHandlerOptions: EventHandlerOptions = { +export class KeyboardEventManager extends EventManager { + options: EventHandlerOptions = { preventDefault: true, stopPropagation: true, }; - /** - * Configures this event manager to handle events with a specific modifer and key combination. - * - * @param modifiers The modifier combinations that this handler should run for. - * @param key The key that this handler should run for (or a predicate function that takes the - * event's key and returns whether to run this handler). - * @param handler The handler function - * @param options Options for whether to stop propagation or prevent default. - */ - on( - modifiers: number | number[], - key: string | Signal | RegExp, - handler: ((event: KeyboardEvent) => void) | ((event: KeyboardEvent) => boolean), - options?: Partial, - ): this; + /** Configures this event manager to handle events with a specific key and no modifiers. */ + on(key: KeyCode, handler: EventHandler): this; - /** - * Configures this event manager to handle events with a specific key and no modifiers. - * - * @param key The key that this handler should run for (or a predicate function that takes the - * event's key and returns whether to run this handler). - * @param handler The handler function - * @param options Options for whether to stop propagation or prevent default. - */ - on( - key: string | Signal | RegExp, - handler: ((event: KeyboardEvent) => void) | ((event: KeyboardEvent) => boolean), - options?: Partial, - ): this; + /** Configures this event manager to handle events with a specific modifer and key combination. */ + on(modifiers: ModifierInputs, key: KeyCode, handler: EventHandler): this; on(...args: any[]) { - const key = args.length === 3 ? args[1] : args[0]; - const handler = args.length === 3 ? args[2] : args[1]; - const modifiers = args.length === 3 ? args[0] : ModifierKey.None; - - // TODO: Add strict type checks again when finalizing this API. + const {modifiers, key, handler} = this._normalizeInputs(...args); this.configs.push({ - key, - handler, - modifiers, - ...this.defaultHandlerOptions, + handler: handler, + matcher: event => this._isMatch(event, key, modifiers), + ...this.options, }); return this; } - getHandlersForKey(event: KeyboardEvent) { - return this.configs.filter(config => this._isKeyMatch(config, event)); - } + private _normalizeInputs(...args: any[]) { + const key = args.length === 3 ? args[1] : args[0]; + const handler = args.length === 3 ? args[2] : args[1]; + const modifiers = args.length === 3 ? args[0] : ModifierKey.None; - // TODO: Make modifiers accept a signal as well. + return { + key: key as KeyCode, + handler: handler as EventHandler, + modifiers: modifiers as ModifierInputs, + }; + } - private _isKeyMatch(config: KeyboardEventHandlerConfig, event: KeyboardEvent) { - if (config.key instanceof RegExp) { - return config.key.test(event.key); + private _isMatch(event: T, key: KeyCode, modifiers: ModifierInputs) { + if (key instanceof RegExp) { + return key.test(event.key); } - const key = typeof config.key === 'string' ? config.key : config.key(); - return key.toLowerCase() === event.key.toLowerCase() && hasModifiers(event, config.modifiers); + const keyStr = typeof key === 'string' ? key : key(); + return keyStr.toLowerCase() === event.key.toLowerCase() && hasModifiers(event, modifiers); } } diff --git a/src/cdk-experimental/ui-patterns/behaviors/event-manager/mouse-event-manager.ts b/src/cdk-experimental/ui-patterns/behaviors/event-manager/mouse-event-manager.ts index 9c08e78ba2d3..22e3af87bc65 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/event-manager/mouse-event-manager.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/event-manager/mouse-event-manager.ts @@ -7,10 +7,12 @@ */ import { + EventHandler, EventHandlerConfig, EventHandlerOptions, EventManager, hasModifiers, + ModifierInputs, ModifierKey, } from './event-manager'; @@ -35,42 +37,22 @@ export interface MouseEventHandlerConfig extends EventHandlerConfig * An event manager that is specialized for handling mouse events. By default this manager stops * propagation and prevents default on all events it handles. */ -export class MouseEventManager extends EventManager { - override configs: MouseEventHandlerConfig[] = []; - - protected override defaultHandlerOptions: EventHandlerOptions = { - preventDefault: true, - stopPropagation: true, +export class MouseEventManager extends EventManager { + options: EventHandlerOptions = { + preventDefault: false, + stopPropagation: false, }; /** * Configures this event manager to handle events with a specific modifer and mouse button * combination. - * - * @param button The mouse button that this handler should run for. - * @param modifiers The modifier combinations that this handler should run for. - * @param handler The handler function - * @param options Options for whether to stop propagation or prevent default. */ - on( - button: MouseButton, - modifiers: number | number[], - handler: ((event: MouseEvent) => void) | ((event: MouseEvent) => boolean), - options?: EventHandlerOptions, - ): this; + on(button: MouseButton, modifiers: ModifierInputs, handler: EventHandler): this; /** * Configures this event manager to handle events with a specific mouse button and no modifiers. - * - * @param modifiers The modifier combinations that this handler should run for. - * @param handler The handler function - * @param options Options for whether to stop propagation or prevent default. */ - on( - modifiers: number | number[], - handler: ((event: MouseEvent) => void) | ((event: MouseEvent) => boolean), - options?: EventHandlerOptions, - ): this; + on(modifiers: ModifierInputs, handler: EventHandler): this; /** * Configures this event manager to handle events with the main mouse button and no modifiers. @@ -78,60 +60,44 @@ export class MouseEventManager extends EventManager { * @param handler The handler function * @param options Options for whether to stop propagation or prevent default. */ - on( - handler: ((event: MouseEvent) => void) | ((event: MouseEvent) => boolean), - options?: EventHandlerOptions, - ): this; + on(handler: EventHandler): this; on(...args: any[]) { - const {button, handler, modifiers, options} = this.normalizeHandlerOptions(...args); - - // TODO: Add strict type checks again when finalizing this API. + const {button, handler, modifiers} = this._normalizeInputs(...args); this.configs.push({ - button, handler, - modifiers, - ...this.defaultHandlerOptions, - ...options, + matcher: event => this._isMatch(event, button, modifiers), + ...this.options, }); return this; } - normalizeHandlerOptions(...args: any[]) { - if (typeof args[0] === 'number' && typeof args[1] === 'number') { + private _normalizeInputs(...args: any[]) { + if (args.length === 3) { return { - button: args[0], - modifiers: args[1], - handler: args[2], - options: args[3] || {}, + button: args[0] as MouseButton, + modifiers: args[1] as ModifierInputs, + handler: args[2] as EventHandler, }; } if (typeof args[0] === 'number' && typeof args[1] === 'function') { return { button: MouseButton.Main, - modifiers: args[0], - handler: args[1], - options: args[2] || {}, + modifiers: args[0] as ModifierInputs, + handler: args[1] as EventHandler, }; } return { button: MouseButton.Main, modifiers: ModifierKey.None, - handler: args[0], - options: args[1] || {}, + handler: args[0] as EventHandler, }; } - getHandlersForKey(event: MouseEvent) { - const configs: MouseEventHandlerConfig[] = []; - for (const config of this.configs) { - if (config.button === (event.button ?? 0) && hasModifiers(event, config.modifiers)) { - configs.push(config); - } - } - return configs; + _isMatch(event: MouseEvent, button: MouseButton, modifiers: ModifierInputs) { + return button === (event.button ?? 0) && hasModifiers(event, modifiers); } } diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts index 3533127a3fa5..8fe63d90f947 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts @@ -95,7 +95,7 @@ describe('List Focus', () => { i.tabindex = focus.getItemTabindex(i); }); - await nav.next(); + nav.next(); expect(items()[0].tabindex()).toBe(-1); expect(items()[1].tabindex()).toBe(0); @@ -152,7 +152,7 @@ describe('List Focus', () => { }); const activeId = focus.getActiveDescendant(); - await nav.next(); + nav.next(); expect(activeId()).toBe(items()[1].id()); }); }); 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 c4f8dd14f61c..574604a2f2fe 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 @@ -44,7 +44,7 @@ describe('List Navigation', () => { const nav = getNavigation(items); expect(nav.inputs.activeIndex()).toBe(0); - await nav.goto(items()[3]); + nav.goto(items()[3]); expect(nav.inputs.activeIndex()).toBe(3); }); }); @@ -52,7 +52,7 @@ describe('List Navigation', () => { describe('#next', () => { it('should navigate next', async () => { const nav = getNavigation(getItems(3)); - await nav.next(); // 0 -> 1 + nav.next(); // 0 -> 1 expect(nav.inputs.activeIndex()).toBe(1); }); @@ -61,9 +61,9 @@ describe('List Navigation', () => { wrap: signal(true), }); - await nav.next(); // 0 -> 1 - await nav.next(); // 1 -> 2 - await nav.next(); // 2 -> 0 + nav.next(); // 0 -> 1 + nav.next(); // 1 -> 2 + nav.next(); // 2 -> 0 expect(nav.inputs.activeIndex()).toBe(0); }); @@ -73,9 +73,9 @@ describe('List Navigation', () => { wrap: signal(false), }); - await nav.next(); // 0 -> 1 - await nav.next(); // 1 -> 2 - await nav.next(); // 2 -> 2 + nav.next(); // 0 -> 1 + nav.next(); // 1 -> 2 + nav.next(); // 2 -> 2 expect(nav.inputs.activeIndex()).toBe(2); }); @@ -86,7 +86,7 @@ describe('List Navigation', () => { }); nav.inputs.items()[1].disabled.set(true); - await nav.next(); // 0 -> 2 + nav.next(); // 0 -> 2 expect(nav.inputs.activeIndex()).toBe(2); }); @@ -96,7 +96,7 @@ describe('List Navigation', () => { }); nav.inputs.items()[1].disabled.set(true); - await nav.next(); // 0 -> 1 + nav.next(); // 0 -> 1 expect(nav.inputs.activeIndex()).toBe(1); }); @@ -107,8 +107,8 @@ describe('List Navigation', () => { }); nav.inputs.items()[2].disabled.set(true); - await nav.next(); // 0 -> 1 - await nav.next(); // 1 -> 0 + nav.next(); // 0 -> 1 + nav.next(); // 1 -> 0 expect(nav.inputs.activeIndex()).toBe(0); }); @@ -120,13 +120,13 @@ describe('List Navigation', () => { nav.inputs.items()[1].disabled.set(true); nav.inputs.items()[2].disabled.set(true); - await nav.next(); // 0 -> 0 + nav.next(); // 0 -> 0 expect(nav.inputs.activeIndex()).toBe(0); }); it('should do nothing if there are no other items to navigate to', async () => { const nav = getNavigation(getItems(1)); - await nav.next(); // 0 -> 0 + nav.next(); // 0 -> 0 expect(nav.inputs.activeIndex()).toBe(0); }); }); @@ -136,7 +136,7 @@ describe('List Navigation', () => { const nav = getNavigation(getItems(3), { activeIndex: signal(2), }); - await nav.prev(); // 2 -> 1 + nav.prev(); // 2 -> 1 expect(nav.inputs.activeIndex()).toBe(1); }); @@ -144,7 +144,7 @@ describe('List Navigation', () => { const nav = getNavigation(getItems(3), { wrap: signal(true), }); - await nav.prev(); // 0 -> 2 + nav.prev(); // 0 -> 2 expect(nav.inputs.activeIndex()).toBe(2); }); @@ -152,7 +152,7 @@ describe('List Navigation', () => { const nav = getNavigation(getItems(3), { wrap: signal(false), }); - await nav.prev(); // 0 -> 0 + nav.prev(); // 0 -> 0 expect(nav.inputs.activeIndex()).toBe(0); }); @@ -163,7 +163,7 @@ describe('List Navigation', () => { }); nav.inputs.items()[1].disabled.set(true); - await nav.prev(); // 2 -> 0 + nav.prev(); // 2 -> 0 expect(nav.inputs.activeIndex()).toBe(0); }); @@ -174,7 +174,7 @@ describe('List Navigation', () => { }); nav.inputs.items()[1].disabled.set(true); - await nav.prev(); // 2 -> 1 + nav.prev(); // 2 -> 1 expect(nav.inputs.activeIndex()).toBe(1); }); @@ -186,8 +186,8 @@ describe('List Navigation', () => { }); nav.inputs.items()[0].disabled.set(true); - await nav.prev(); // 2 -> 1 - await nav.prev(); // 1 -> 2 + nav.prev(); // 2 -> 1 + nav.prev(); // 1 -> 2 expect(nav.inputs.activeIndex()).toBe(2); }); @@ -200,13 +200,13 @@ describe('List Navigation', () => { nav.inputs.items()[0].disabled.set(true); nav.inputs.items()[1].disabled.set(true); - await nav.prev(); // 2 -> 2 + nav.prev(); // 2 -> 2 expect(nav.inputs.activeIndex()).toBe(2); }); it('should do nothing if there are no other items to navigate to', async () => { const nav = getNavigation(getItems(1)); - await nav.prev(); // 0 -> 0 + nav.prev(); // 0 -> 0 expect(nav.inputs.activeIndex()).toBe(0); }); }); @@ -217,7 +217,7 @@ describe('List Navigation', () => { activeIndex: signal(2), }); - await nav.first(); + nav.first(); expect(nav.inputs.activeIndex()).toBe(0); }); @@ -228,7 +228,7 @@ describe('List Navigation', () => { }); nav.inputs.items()[0].disabled.set(true); - await nav.first(); + nav.first(); expect(nav.inputs.activeIndex()).toBe(1); }); @@ -239,7 +239,7 @@ describe('List Navigation', () => { }); nav.inputs.items()[0].disabled.set(true); - await nav.first(); + nav.first(); expect(nav.inputs.activeIndex()).toBe(0); }); }); @@ -247,7 +247,7 @@ describe('List Navigation', () => { describe('#last', () => { it('should navigate to the last item', async () => { const nav = getNavigation(getItems(3)); - await nav.last(); + nav.last(); expect(nav.inputs.activeIndex()).toBe(2); }); @@ -257,7 +257,7 @@ describe('List Navigation', () => { }); nav.inputs.items()[2].disabled.set(true); - await nav.last(); + nav.last(); expect(nav.inputs.activeIndex()).toBe(1); }); @@ -267,7 +267,7 @@ describe('List Navigation', () => { }); nav.inputs.items()[2].disabled.set(true); - await nav.last(); + nav.last(); expect(nav.inputs.activeIndex()).toBe(2); }); }); @@ -278,9 +278,9 @@ describe('List Navigation', () => { skipDisabled: signal(true), }); - expect(await nav.isFocusable(nav.inputs.items()[0])).toBeTrue(); - expect(await nav.isFocusable(nav.inputs.items()[1])).toBeTrue(); - expect(await nav.isFocusable(nav.inputs.items()[2])).toBeTrue(); + expect(nav.isFocusable(nav.inputs.items()[0])).toBeTrue(); + expect(nav.isFocusable(nav.inputs.items()[1])).toBeTrue(); + expect(nav.isFocusable(nav.inputs.items()[2])).toBeTrue(); }); it('should return false for disabled items', async () => { @@ -289,9 +289,9 @@ describe('List Navigation', () => { }); nav.inputs.items()[1].disabled.set(true); - expect(await nav.isFocusable(nav.inputs.items()[0])).toBeTrue(); - expect(await nav.isFocusable(nav.inputs.items()[1])).toBeFalse(); - expect(await nav.isFocusable(nav.inputs.items()[2])).toBeTrue(); + expect(nav.isFocusable(nav.inputs.items()[0])).toBeTrue(); + expect(nav.isFocusable(nav.inputs.items()[1])).toBeFalse(); + expect(nav.isFocusable(nav.inputs.items()[2])).toBeTrue(); }); it('should return true for disabled items if skip disabled is false', async () => { @@ -300,9 +300,9 @@ describe('List Navigation', () => { }); nav.inputs.items()[1].disabled.set(true); - expect(await nav.isFocusable(nav.inputs.items()[0])).toBeTrue(); - expect(await nav.isFocusable(nav.inputs.items()[1])).toBeTrue(); - expect(await nav.isFocusable(nav.inputs.items()[2])).toBeTrue(); + expect(nav.isFocusable(nav.inputs.items()[0])).toBeTrue(); + expect(nav.isFocusable(nav.inputs.items()[1])).toBeTrue(); + expect(nav.isFocusable(nav.inputs.items()[2])).toBeTrue(); }); }); }); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts index a6b56df52cd2..bf49aab7ba17 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts @@ -62,7 +62,7 @@ describe('List Selection', () => { const nav = getNavigation(items); const selection = getSelection(items, nav); - await selection.select(); // [0] + selection.select(); // [0] expect(selection.inputs.selectedIds()).toEqual(['0']); }); @@ -71,9 +71,9 @@ describe('List Selection', () => { const nav = getNavigation(items); const selection = getSelection(items, nav); - await selection.select(); // [0] - await nav.next(); - await selection.select(); // [0, 1] + selection.select(); // [0] + nav.next(); + selection.select(); // [0, 1] expect(selection.inputs.selectedIds()).toEqual(['0', '1']); }); @@ -85,9 +85,9 @@ describe('List Selection', () => { multiselectable: signal(false), }); - await selection.select(); // [0] - await nav.next(); - await selection.select(); // [1] + selection.select(); // [0] + nav.next(); + selection.select(); // [1] expect(selection.inputs.selectedIds()).toEqual(['1']); }); @@ -98,7 +98,7 @@ describe('List Selection', () => { const selection = getSelection(items, nav); items()[0].disabled.set(true); - await selection.select(); // [] + selection.select(); // [] expect(selection.inputs.selectedIds()).toEqual([]); }); @@ -107,8 +107,8 @@ describe('List Selection', () => { const nav = getNavigation(items); const selection = getSelection(items, nav); - await selection.select(); // [0] - await selection.select(); // [0] + selection.select(); // [0] + selection.select(); // [0] expect(selection.inputs.selectedIds()).toEqual(['0']); }); @@ -119,7 +119,7 @@ describe('List Selection', () => { const items = getItems(5); const nav = getNavigation(items); const selection = getSelection(items, nav); - await selection.deselect(); // [] + selection.deselect(); // [] expect(selection.inputs.selectedIds().length).toBe(0); }); @@ -128,9 +128,9 @@ describe('List Selection', () => { const nav = getNavigation(items); const selection = getSelection(items, nav); - await selection.select(); // [0] + selection.select(); // [0] items()[0].disabled.set(true); - await selection.deselect(); // [0] + selection.deselect(); // [0] expect(selection.inputs.selectedIds()).toEqual(['0']); }); @@ -142,7 +142,7 @@ describe('List Selection', () => { const nav = getNavigation(items); const selection = getSelection(items, nav); - await selection.toggle(); // [0] + selection.toggle(); // [0] expect(selection.inputs.selectedIds()).toEqual(['0']); }); @@ -150,8 +150,8 @@ describe('List Selection', () => { const items = getItems(5); const nav = getNavigation(items); const selection = getSelection(items, nav); - await selection.select(); // [0] - await selection.toggle(); // [] + selection.select(); // [0] + selection.toggle(); // [] expect(selection.inputs.selectedIds().length).toBe(0); }); }); @@ -161,7 +161,7 @@ describe('List Selection', () => { const items = getItems(5); const nav = getNavigation(items); const selection = getSelection(items, nav); - await selection.selectAll(); + selection.selectAll(); expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2', '3', '4']); }); @@ -169,7 +169,7 @@ describe('List Selection', () => { const items = getItems(5); const nav = getNavigation(items); const selection = getSelection(items, nav); - await selection.selectAll(); + selection.selectAll(); expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2', '3', '4']); }); }); @@ -179,7 +179,7 @@ describe('List Selection', () => { const items = getItems(5); const nav = getNavigation(items); const selection = getSelection(items, nav); - await selection.deselectAll(); // [] + selection.deselectAll(); // [] expect(selection.inputs.selectedIds().length).toBe(0); }); }); @@ -190,10 +190,10 @@ describe('List Selection', () => { const nav = getNavigation(items); const selection = getSelection(items, nav); - await selection.select(); // [0] - await nav.next(); - await nav.next(); - await selection.selectFromAnchor(); // [0, 1, 2] + selection.select(); // [0] + nav.next(); + nav.next(); + selection.selectFromAnchor(); // [0, 1, 2] expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2']); }); @@ -205,10 +205,10 @@ describe('List Selection', () => { }); const selection = getSelection(items, nav); - await selection.select(); // [3] - await nav.prev(); - await nav.prev(); - await selection.selectFromAnchor(); // [3, 1, 2] + selection.select(); // [3] + nav.prev(); + nav.prev(); + selection.selectFromAnchor(); // [3, 1, 2] expect(selection.inputs.selectedIds()).toEqual(['3', '1', '2']); }); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/controller.ts b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/controller.ts deleted file mode 100644 index 17177370f52a..000000000000 --- a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/controller.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * @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} from '@angular/core'; -import {ListTypeaheadItem, ListTypeahead} from './list-typeahead'; - -/** Controls typeahead for a list of items. */ -export class ListTypeaheadController { - /** A reference to the timeout for resetting the typeahead search. */ - timeout?: any; - - /** Keeps track of the characters that typeahead search is being called with. */ - query = signal(''); - - /** The index where that the typeahead search was initiated from. */ - anchorIndex = signal(null); - - constructor(readonly state: ListTypeahead) {} - - /** Performs a typeahead search, appending the given character to the search string. */ - async search(char: string) { - if (char.length !== 1) { - return; - } - - if (this.anchorIndex() === null) { - this.anchorIndex.set(this.state.navigation.inputs.activeIndex()); - } - - clearTimeout(this.timeout); - this.query.update(q => q + char.toLowerCase()); - const item = await this._getItem(); - - if (item) { - await this.state.navigation.goto(item); - } - - this.timeout = setTimeout(() => { - this.query.set(''); - this.anchorIndex.set(null); - }, this.state.inputs.typeaheadDelay() * 1000); - } - - /** - * Returns the first item whose search term matches the - * current query starting from the the current anchor index. - */ - private async _getItem() { - let items = this.state.navigation.inputs.items(); - const after = items.slice(this.anchorIndex()! + 1); - const before = items.slice(0, this.anchorIndex()!); - items = this.state.navigation.inputs.wrap() ? after.concat(before) : after; // TODO: Always wrap? - items.push(this.state.navigation.inputs.items()[this.anchorIndex()!]); - - const focusableItems = []; - for (const item of items) { - if (await this.state.navigation.isFocusable(item)) { - focusableItems.push(item); - } - } - - return focusableItems.find(i => i.searchTerm().toLowerCase().startsWith(this.query())); - } -} diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts index 1b882f561aaa..c37fc5ad5049 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts @@ -43,14 +43,14 @@ describe('List Typeahead', () => { typeaheadDelay: signal(0.5), }); - await typeahead.search('i'); + typeahead.search('i'); expect(activeIndex()).toBe(1); - await typeahead.search('t'); - await typeahead.search('e'); - await typeahead.search('m'); - await typeahead.search(' '); - await typeahead.search('3'); + typeahead.search('t'); + typeahead.search('e'); + typeahead.search('m'); + typeahead.search(' '); + typeahead.search('3'); expect(activeIndex()).toBe(3); }); @@ -70,12 +70,12 @@ describe('List Typeahead', () => { typeaheadDelay: signal(0.5), }); - await typeahead.search('i'); + typeahead.search('i'); expect(activeIndex()).toBe(1); tick(500); - await typeahead.search('i'); + typeahead.search('i'); expect(activeIndex()).toBe(2); })); @@ -96,7 +96,7 @@ describe('List Typeahead', () => { }); items()[1].disabled.set(true); - await typeahead.search('i'); + typeahead.search('i'); expect(activeIndex()).toBe(2); }); @@ -117,7 +117,7 @@ describe('List Typeahead', () => { }); items()[1].disabled.set(true); - await typeahead.search('i'); + typeahead.search('i'); expect(activeIndex()).toBe(1); }); @@ -137,15 +137,15 @@ describe('List Typeahead', () => { typeaheadDelay: signal(0.5), }); - await typeahead.search('i'); - await typeahead.search('t'); - await typeahead.search('e'); + typeahead.search('i'); + typeahead.search('t'); + typeahead.search('e'); - await typeahead.search('Shift'); + typeahead.search('Shift'); - await typeahead.search('m'); - await typeahead.search(' '); - await typeahead.search('2'); + typeahead.search('m'); + typeahead.search(' '); + typeahead.search('2'); expect(activeIndex()).toBe(2); }); }); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts index 91aa26b6c3e2..67e92381e963 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts @@ -7,7 +7,6 @@ */ import {signal, Signal} from '@angular/core'; -import type {ListTypeaheadController} from './controller'; import {ListNavigationItem, ListNavigation} from '../list-navigation/list-navigation'; /** The required properties for typeahead items. */ @@ -31,10 +30,10 @@ export class ListTypeahead { navigation: ListNavigation; /** Keeps track of the characters that typeahead search is being called with. */ - private query = signal(''); + private _query = signal(''); /** The index where that the typeahead search was initiated from. */ - private anchorIndex = signal(null); + private _anchorIndex = signal(null); constructor(readonly inputs: ListTypeaheadInputs & {navigation: ListNavigation}) { this.navigation = inputs.navigation; @@ -46,12 +45,12 @@ export class ListTypeahead { return; } - if (this.anchorIndex() === null) { - this.anchorIndex.set(this.navigation.inputs.activeIndex()); + if (this._anchorIndex() === null) { + this._anchorIndex.set(this.navigation.inputs.activeIndex()); } clearTimeout(this.timeout); - this.query.update(q => q + char.toLowerCase()); + this._query.update(q => q + char.toLowerCase()); const item = this._getItem(); if (item) { @@ -59,8 +58,8 @@ export class ListTypeahead { } this.timeout = setTimeout(() => { - this.query.set(''); - this.anchorIndex.set(null); + this._query.set(''); + this._anchorIndex.set(null); }, this.inputs.typeaheadDelay() * 1000); } @@ -70,10 +69,10 @@ export class ListTypeahead { */ private _getItem() { let items = this.navigation.inputs.items(); - const after = items.slice(this.anchorIndex()! + 1); - const before = items.slice(0, this.anchorIndex()!); + const after = items.slice(this._anchorIndex()! + 1); + const before = items.slice(0, this._anchorIndex()!); items = this.navigation.inputs.wrap() ? after.concat(before) : after; // TODO: Always wrap? - items.push(this.navigation.inputs.items()[this.anchorIndex()!]); + items.push(this.navigation.inputs.items()[this._anchorIndex()!]); const focusableItems = []; for (const item of items) { @@ -82,6 +81,6 @@ export class ListTypeahead { } } - return focusableItems.find(i => i.searchTerm().toLowerCase().startsWith(this.query())); + return focusableItems.find(i => i.searchTerm().toLowerCase().startsWith(this._query())); } } diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index b85a2dcfc77f..aa00267c71e0 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -92,9 +92,6 @@ export class ListboxPattern { keydown = computed(() => { const manager = new KeyboardEventManager(); - console.log('prev key:', this.prevKey()); - console.log('next key:', this.nextKey()); - if (!this.followFocus()) { manager .on(this.prevKey, () => this.prev()) @@ -181,7 +178,6 @@ export class ListboxPattern { /** Handles keydown events for the listbox. */ onKeydown(event: KeyboardEvent) { - console.log(this.orientation()); if (!this.disabled()) { this.keydown().handle(event); } From b01fb41a86e0533dd75fee527263eac5e5c3a5ff Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Thu, 20 Feb 2025 13:41:06 -0500 Subject: [PATCH 04/11] fixup! refactor: event managers --- src/cdk-experimental/listbox/listbox.ts | 2 +- ...nt-manager.ts => pointer-event-manager.ts} | 20 ++++------------ .../ui-patterns/listbox/listbox.ts | 24 +++++++++---------- 3 files changed, 17 insertions(+), 29 deletions(-) rename src/cdk-experimental/ui-patterns/behaviors/event-manager/{mouse-event-manager.ts => pointer-event-manager.ts} (77%) diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts index c341f65f54a4..0b20117946b4 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -46,7 +46,7 @@ import {toSignal} from '@angular/core/rxjs-interop'; '[attr.aria-multiselectable]': 'pattern.multiselectable()', '[attr.aria-activedescendant]': 'pattern.activedescendant()', '(keydown)': 'pattern.onKeydown($event)', - '(mousedown)': 'pattern.onMousedown($event)', + '(pointerdown)': 'pattern.onPointerdown($event)', }, }) export class CdkListbox { diff --git a/src/cdk-experimental/ui-patterns/behaviors/event-manager/mouse-event-manager.ts b/src/cdk-experimental/ui-patterns/behaviors/event-manager/pointer-event-manager.ts similarity index 77% rename from src/cdk-experimental/ui-patterns/behaviors/event-manager/mouse-event-manager.ts rename to src/cdk-experimental/ui-patterns/behaviors/event-manager/pointer-event-manager.ts index 22e3af87bc65..7631f048e027 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/event-manager/mouse-event-manager.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/event-manager/pointer-event-manager.ts @@ -8,7 +8,6 @@ import { EventHandler, - EventHandlerConfig, EventHandlerOptions, EventManager, hasModifiers, @@ -17,7 +16,7 @@ import { } from './event-manager'; /** - * The different mouse buttons that may appear on a mouse event. + * The different mouse buttons that may appear on a pointer event. */ export enum MouseButton { Main = 0, @@ -25,19 +24,8 @@ export enum MouseButton { Secondary = 2, } -/** - * A config that specifies how to handle a particular mouse event. - */ -export interface MouseEventHandlerConfig extends EventHandlerConfig { - button: number; - modifiers: number | number[]; -} - -/** - * An event manager that is specialized for handling mouse events. By default this manager stops - * propagation and prevents default on all events it handles. - */ -export class MouseEventManager extends EventManager { +/** An event manager that is specialized for handling pointer events. */ +export class PointerEventManager extends EventManager { options: EventHandlerOptions = { preventDefault: false, stopPropagation: false, @@ -97,7 +85,7 @@ export class MouseEventManager extends EventManager { }; } - _isMatch(event: MouseEvent, button: MouseButton, modifiers: ModifierInputs) { + _isMatch(event: PointerEvent, button: MouseButton, modifiers: ModifierInputs) { return button === (event.button ?? 0) && hasModifiers(event, modifiers); } } diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index aa00267c71e0..2d8af336672e 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -8,7 +8,7 @@ import {ModifierKey as Modifier} from '../behaviors/event-manager/event-manager'; import {KeyboardEventManager} from '../behaviors/event-manager/keyboard-event-manager'; -import {MouseEventManager} from '../behaviors/event-manager/mouse-event-manager'; +import {PointerEventManager} from '../behaviors/event-manager/pointer-event-manager'; import {OptionPattern} from './option'; import {ListSelection, ListSelectionInputs} from '../behaviors/list-selection/list-selection'; import {ListTypeahead, ListTypeaheadInputs} from '../behaviors/list-typeahead/list-typeahead'; @@ -139,24 +139,24 @@ export class ListboxPattern { return manager; }); - /** The mousedown event manager for the listbox. */ - mousedown = computed(() => { - const manager = new MouseEventManager(); + /** The pointerdown event manager for the listbox. */ + pointerdown = computed(() => { + const manager = new PointerEventManager(); if (!this.followFocus()) { - manager.on((e: MouseEvent) => this.goto(e)); + manager.on((e: PointerEvent) => this.goto(e)); } if (this.followFocus()) { - manager.on((e: MouseEvent) => this.goto(e, {selectOne: true})); + manager.on((e: PointerEvent) => this.goto(e, {selectOne: true})); } if (this.inputs.multiselectable() && this.followFocus()) { - manager.on(Modifier.Ctrl, (e: MouseEvent) => this.goto(e)); + manager.on(Modifier.Ctrl, (e: PointerEvent) => this.goto(e)); } if (this.inputs.multiselectable()) { - manager.on(Modifier.Shift, (e: MouseEvent) => this.goto(e, {selectFromActive: true})); + manager.on(Modifier.Shift, (e: PointerEvent) => this.goto(e, {selectFromActive: true})); } return manager; @@ -183,9 +183,9 @@ export class ListboxPattern { } } - onMousedown(event: MouseEvent) { + onPointerdown(event: PointerEvent) { if (!this.disabled()) { - this.mousedown().handle(event); + this.pointerdown().handle(event); } } @@ -218,7 +218,7 @@ export class ListboxPattern { } /** Navigates to the given item in the listbox. */ - goto(event: MouseEvent, opts?: SelectOptions) { + goto(event: PointerEvent, opts?: SelectOptions) { const item = this._getItem(event); if (item) { @@ -260,7 +260,7 @@ export class ListboxPattern { } } - private _getItem(e: MouseEvent) { + private _getItem(e: PointerEvent) { if (!(e.target instanceof HTMLElement)) { return; } From 722b81b19d532f83786dee2fba1c3e371f023368 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Mon, 24 Feb 2025 14:48:06 -0500 Subject: [PATCH 05/11] fixup! feat(cdk-experimental/ui-patterns): listbox ui pattern --- .../ui-patterns/listbox/listbox.ts | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index 2d8af336672e..d660523ef2ca 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -113,6 +113,7 @@ export class ListboxPattern { if (this.inputs.multiselectable()) { manager .on(Modifier.Shift, ' ', () => this._updateSelection({selectFromAnchor: true})) + .on(Modifier.Shift, 'Enter', () => this._updateSelection({selectFromAnchor: true})) .on(Modifier.Shift, this.prevKey, () => this.prev({toggle: true})) .on(Modifier.Shift, this.nextKey, () => this.next({toggle: true})) .on(Modifier.Ctrl | Modifier.Shift, 'Home', () => this.first({selectFromActive: true})) @@ -122,10 +123,12 @@ export class ListboxPattern { if (!this.followFocus() && this.inputs.multiselectable()) { manager.on(' ', () => this._updateSelection({toggle: true})); + manager.on('Enter', () => this._updateSelection({toggle: true})); } if (!this.followFocus() && !this.inputs.multiselectable()) { manager.on(' ', () => this._updateSelection({toggleOne: true})); + manager.on('Enter', () => this._updateSelection({toggleOne: true})); } if (this.inputs.multiselectable() && this.followFocus()) { @@ -143,20 +146,12 @@ export class ListboxPattern { pointerdown = computed(() => { const manager = new PointerEventManager(); - if (!this.followFocus()) { - manager.on((e: PointerEvent) => this.goto(e)); - } - - if (this.followFocus()) { - manager.on((e: PointerEvent) => this.goto(e, {selectOne: true})); - } - - if (this.inputs.multiselectable() && this.followFocus()) { - manager.on(Modifier.Ctrl, (e: PointerEvent) => this.goto(e)); - } - if (this.inputs.multiselectable()) { - manager.on(Modifier.Shift, (e: PointerEvent) => this.goto(e, {selectFromActive: true})); + manager + .on(e => this.goto(e, {toggle: true})) + .on(Modifier.Shift, e => this.goto(e, {selectFromActive: true})); + } else { + manager.on(e => this.goto(e, {toggleOne: true})); } return manager; From 85568f425317c8a11aa1d6071c3f4b0aa02b4917 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Mon, 24 Feb 2025 18:06:38 -0500 Subject: [PATCH 06/11] fixup! feat(cdk-experimental/ui-patterns): listbox ui pattern --- src/cdk-experimental/listbox/listbox.ts | 12 ++-- .../behaviors/list-focus/list-focus.spec.ts | 13 ++--- .../behaviors/list-focus/list-focus.ts | 22 ++++---- .../list-navigation/list-navigation.spec.ts | 2 +- .../list-navigation/list-navigation.ts | 56 ++++++++++++------- .../list-selection/list-selection.spec.ts | 6 +- .../list-selection/list-selection.ts | 16 +++--- .../list-typeahead/list-typeahead.spec.ts | 10 ++-- .../list-typeahead/list-typeahead.ts | 26 +++++---- .../ui-patterns/listbox/listbox.ts | 31 +++++----- .../ui-patterns/listbox/option.ts | 12 ++-- .../cdk-listbox/cdk-listbox-example.css | 20 +++---- .../cdk-listbox/cdk-listbox-example.html | 5 +- 13 files changed, 129 insertions(+), 102 deletions(-) diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts index 0b20117946b4..67056e60d63e 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -51,14 +51,14 @@ import {toSignal} from '@angular/core/rxjs-interop'; }) export class CdkListbox { /** The directionality (LTR / RTL) context for the application (or a subtree of it). */ - private _dir = inject(Directionality); + private _directionality = inject(Directionality); /** The CdkOptions nested inside of the CdkListbox. */ private _cdkOptions = contentChildren(CdkOption, {descendants: true}); /** A signal wrapper for directionality. */ - protected directionality = toSignal(this._dir.change, { - initialValue: this._dir.value, + protected textDirection = toSignal(this._directionality.change, { + initialValue: this._directionality.value, }); /** The Option UIPatterns of the child CdkOptions. */ @@ -88,6 +88,7 @@ export class CdkListbox { /** Whether the listbox is disabled. */ disabled = input(false, {transform: booleanAttribute}); + // TODO(wagnermaciel): Figure out how we want to expose control over the current listbox value. /** The ids of the current selected items. */ selectedIds = model([]); @@ -98,11 +99,11 @@ export class CdkListbox { pattern: ListboxPattern = new ListboxPattern({ ...this, items: this.items, - directionality: this.directionality, + textDirection: this.textDirection, }); } -// TODO(wagnermaciel): Figure out how we actually want to do this. +// TODO(wagnermaciel): Figure out how we want to generate IDs. let count = 0; /** A selectable option in a CdkListbox. */ @@ -124,6 +125,7 @@ export class CdkOption { /** The parent CdkListbox. */ private _cdkListbox = inject(CdkListbox); + // TODO(wagnermaciel): Figure out how we want to generate IDs. /** A unique identifier for the option. */ protected id = computed(() => `${count++}`); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts index 8fe63d90f947..9e4a3ea44e4c 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts @@ -36,7 +36,7 @@ describe('List Focus', () => { wrap: signal(false), activeIndex: signal(0), skipDisabled: signal(false), - directionality: signal('ltr'), + textDirection: signal('ltr'), orientation: signal('vertical'), ...args, }); @@ -62,12 +62,11 @@ describe('List Focus', () => { expect(tabindex()).toBe(-1); }); - it('should set the activedescendant to null', () => { + it('should set the activedescendant to undefined', () => { const items = getItems(5); const nav = getNavigation(items); const focus = getFocus(nav); - const activeId = focus.getActiveDescendant(); - expect(activeId()).toBeNull(); + expect(focus.getActiveDescendant()).toBeUndefined(); }); it('should set the first items tabindex to 0', () => { @@ -122,8 +121,7 @@ describe('List Focus', () => { const focus = getFocus(nav, { focusMode: signal('activedescendant'), }); - const activeId = focus.getActiveDescendant(); - expect(activeId()).toBe(items()[0].id()); + expect(focus.getActiveDescendant()).toBe(items()[0].id()); }); it('should set the tabindex of all items to -1', () => { @@ -150,10 +148,9 @@ describe('List Focus', () => { const focus = getFocus(nav, { focusMode: signal('activedescendant'), }); - const activeId = focus.getActiveDescendant(); nav.next(); - expect(activeId()).toBe(items()[1].id()); + expect(focus.getActiveDescendant()).toBe(items()[1].id()); }); }); }); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts index b7ac964de3c3..c5987e8fa6ea 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts @@ -9,7 +9,7 @@ import {computed, Signal} from '@angular/core'; import {ListNavigation, ListNavigationItem} from '../list-navigation/list-navigation'; -/** The required properties for focus items. */ +/** Represents an item in a collection, such as a listbox option, than may receive focus. */ export interface ListFocusItem extends ListNavigationItem { /** A unique identifier for the item. */ id: Signal; @@ -18,7 +18,7 @@ export interface ListFocusItem extends ListNavigationItem { element: Signal; } -/** The required inputs for list focus. */ +/** Represents the required inputs for a collection that contains focusable items. */ export interface ListFocusInputs { /** The focus strategy used by the list. */ focusMode: Signal<'roving' | 'activedescendant'>; @@ -29,20 +29,18 @@ export class ListFocus { /** The navigation controller of the parent list. */ navigation: ListNavigation; + /** The id of the current active item. */ + getActiveDescendant = computed(() => { + if (this.inputs.focusMode() === 'roving') { + return undefined; + } + return this.navigation.inputs.items()[this.navigation.inputs.activeIndex()].id(); + }); + constructor(readonly inputs: ListFocusInputs & {navigation: ListNavigation}) { this.navigation = inputs.navigation; } - /** Returns the id of the current active item. */ - getActiveDescendant(): Signal { - return computed(() => { - if (this.inputs.focusMode() === 'roving') { - return null; - } - return this.navigation.inputs.items()[this.navigation.inputs.activeIndex()].id(); - }); - } - /** Returns a signal that keeps track of the tabindex for the list. */ getListTabindex(): Signal<-1 | 0> { return computed(() => (this.inputs.focusMode() === 'activedescendant' ? 0 : -1)); 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 574604a2f2fe..70529c869ed1 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 @@ -32,7 +32,7 @@ describe('List Navigation', () => { wrap: signal(false), activeIndex: signal(0), skipDisabled: signal(false), - directionality: signal('ltr'), + textDirection: signal('ltr'), orientation: signal('vertical'), ...args, }); 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 16c255c0d416..b44e8e9e7ca0 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 @@ -8,13 +8,13 @@ import {signal, Signal, WritableSignal} from '@angular/core'; -/** The required properties for navigation items. */ +/** Represents an item in a collection, such as a listbox option, than can be navigated to. */ export interface ListNavigationItem { /** Whether an item is disabled. */ disabled: Signal; } -/** The required inputs for list navigation. */ +/** Represents the required inputs for a collection that has navigable items. */ export interface ListNavigationInputs { /** Whether focus should wrap when navigating. */ wrap: Signal; @@ -32,7 +32,7 @@ export interface ListNavigationInputs { orientation: Signal<'vertical' | 'horizontal'>; /** The direction that text is read based on the users locale. */ - directionality: Signal<'rtl' | 'ltr'>; + textDirection: Signal<'rtl' | 'ltr'>; } /** Controls navigation for a list of items. */ @@ -56,26 +56,42 @@ export class ListNavigation { /** Navigates to the next item in the list. */ next() { const items = this.inputs.items(); - const after = items.slice(this.inputs.activeIndex() + 1); - const before = items.slice(0, this.inputs.activeIndex()); - const array = this.inputs.wrap() ? after.concat(before) : after; - const item = array.find(i => this.isFocusable(i)); - if (item) { - this.goto(item); + for (let i = this.inputs.activeIndex() + 1; i < items.length; i++) { + if (this.isFocusable(items[i])) { + this.goto(items[i]); + return; + } + } + + if (this.inputs.wrap()) { + for (let i = 0; i <= this.inputs.activeIndex(); i++) { + if (this.isFocusable(items[i])) { + this.goto(items[i]); + return; + } + } } } /** Navigates to the previous item in the list. */ prev() { const items = this.inputs.items(); - const after = items.slice(this.inputs.activeIndex() + 1).reverse(); - const before = items.slice(0, this.inputs.activeIndex()).reverse(); - const array = this.inputs.wrap() ? before.concat(after) : before; - const item = array.find(i => this.isFocusable(i)); - if (item) { - this.goto(item); + for (let i = this.inputs.activeIndex() - 1; i >= 0; i--) { + if (this.isFocusable(items[i])) { + this.goto(items[i]); + return; + } + } + + if (this.inputs.wrap()) { + for (let i = items.length - 1; i >= this.inputs.activeIndex(); i--) { + if (this.isFocusable(items[i])) { + this.goto(items[i]); + return; + } + } } } @@ -90,10 +106,12 @@ export class ListNavigation { /** Navigates to the last item in the list. */ last() { - const item = [...this.inputs.items()].reverse().find(i => this.isFocusable(i)); - - if (item) { - this.goto(item); + const items = this.inputs.items(); + for (let i = items.length - 1; i >= 0; i--) { + if (this.isFocusable(items[i])) { + this.goto(items[i]); + return; + } } } diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts index bf49aab7ba17..fd3ae7aead4b 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts @@ -35,7 +35,7 @@ describe('List Selection', () => { wrap: signal(false), activeIndex: signal(0), skipDisabled: signal(false), - directionality: signal('ltr'), + textDirection: signal('ltr'), orientation: signal('vertical'), ...args, }); @@ -193,7 +193,7 @@ describe('List Selection', () => { selection.select(); // [0] nav.next(); nav.next(); - selection.selectFromAnchor(); // [0, 1, 2] + selection.selectFromLastSelectedItem(); // [0, 1, 2] expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2']); }); @@ -208,7 +208,7 @@ describe('List Selection', () => { selection.select(); // [3] nav.prev(); nav.prev(); - selection.selectFromAnchor(); // [3, 1, 2] + selection.selectFromLastSelectedItem(); // [3, 1, 2] expect(selection.inputs.selectedIds()).toEqual(['3', '1', '2']); }); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts index 94c84e33234c..c676af70067d 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts @@ -9,7 +9,7 @@ import {signal, Signal, WritableSignal} from '@angular/core'; import {ListNavigation, ListNavigationItem} from '../list-navigation/list-navigation'; -/** The required properties for selection items. */ +/** Represents an item in a collection, such as a listbox option, than can be selected. */ export interface ListSelectionItem extends ListNavigationItem { /** A unique identifier for the item. */ id: Signal; @@ -18,7 +18,7 @@ export interface ListSelectionItem extends ListNavigationItem { disabled: Signal; } -/** The required inputs for list selection. */ +/** Represents the required inputs for a collection that contains selectable items. */ export interface ListSelectionInputs { /** The items in the list. */ items: Signal; @@ -35,8 +35,8 @@ export interface ListSelectionInputs { /** Controls selection for a list of items. */ export class ListSelection { - /** The id of the previous selected item. */ - anchorId = signal(null); + /** The id of the last selected item. */ + lastSelectedId = signal(undefined); /** The navigation controller of the parent list. */ navigation: ListNavigation; @@ -104,9 +104,9 @@ export class ListSelection { } /** Selects the items in the list starting at the last selected item. */ - selectFromAnchor() { - const anchorIndex = this.inputs.items().findIndex(i => this.anchorId() === i.id()); - this._selectFromIndex(anchorIndex); + selectFromLastSelectedItem() { + const lastSelectedId = this.inputs.items().findIndex(i => this.lastSelectedId() === i.id()); + this._selectFromIndex(lastSelectedId); } /** Selects the items in the list starting at the last active item. */ @@ -137,6 +137,6 @@ export class ListSelection { /** Sets the anchor to the current active index. */ private _anchor() { const item = this.inputs.items()[this.inputs.navigation.inputs.activeIndex()]; - this.anchorId.set(item.id()); + this.lastSelectedId.set(item.id()); } } diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts index c37fc5ad5049..b4b745f29eca 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts @@ -35,7 +35,7 @@ describe('List Typeahead', () => { activeIndex, wrap: signal(false), skipDisabled: signal(false), - directionality: signal('ltr'), + textDirection: signal('ltr'), orientation: signal('vertical'), }); const typeahead = new ListTypeahead({ @@ -62,7 +62,7 @@ describe('List Typeahead', () => { activeIndex, wrap: signal(false), skipDisabled: signal(false), - directionality: signal('ltr'), + textDirection: signal('ltr'), orientation: signal('vertical'), }); const typeahead = new ListTypeahead({ @@ -87,7 +87,7 @@ describe('List Typeahead', () => { activeIndex, wrap: signal(false), skipDisabled: signal(true), - directionality: signal('ltr'), + textDirection: signal('ltr'), orientation: signal('vertical'), }); const typeahead = new ListTypeahead({ @@ -108,7 +108,7 @@ describe('List Typeahead', () => { activeIndex, wrap: signal(false), skipDisabled: signal(false), - directionality: signal('ltr'), + textDirection: signal('ltr'), orientation: signal('vertical'), }); const typeahead = new ListTypeahead({ @@ -129,7 +129,7 @@ describe('List Typeahead', () => { activeIndex, wrap: signal(false), skipDisabled: signal(false), - directionality: signal('ltr'), + textDirection: signal('ltr'), orientation: signal('vertical'), }); const typeahead = new ListTypeahead({ diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts index 67e92381e963..64154eda550d 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts @@ -9,13 +9,19 @@ import {signal, Signal} from '@angular/core'; import {ListNavigationItem, ListNavigation} from '../list-navigation/list-navigation'; -/** The required properties for typeahead items. */ +/** + * Represents an item in a collection, such as a listbox option, than can be navigated to by + * typeahead. + */ export interface ListTypeaheadItem extends ListNavigationItem { /** The text used by the typeahead search. */ searchTerm: Signal; } -/** The required inputs for list typeahead. */ +/** + * Represents the required inputs for a collection that contains items that can be navigated to by + * typeahead. + */ export interface ListTypeaheadInputs { /** The amount of time before the typeahead search is reset. */ typeaheadDelay: Signal; @@ -24,7 +30,7 @@ export interface ListTypeaheadInputs { /** Controls typeahead for a list of items. */ export class ListTypeahead { /** A reference to the timeout for resetting the typeahead search. */ - timeout?: any; + timeout?: ReturnType | undefined; /** The navigation controller of the parent list. */ navigation: ListNavigation; @@ -33,7 +39,7 @@ export class ListTypeahead { private _query = signal(''); /** The index where that the typeahead search was initiated from. */ - private _anchorIndex = signal(null); + private _startIndex = signal(undefined); constructor(readonly inputs: ListTypeaheadInputs & {navigation: ListNavigation}) { this.navigation = inputs.navigation; @@ -45,8 +51,8 @@ export class ListTypeahead { return; } - if (this._anchorIndex() === null) { - this._anchorIndex.set(this.navigation.inputs.activeIndex()); + if (this._startIndex() === undefined) { + this._startIndex.set(this.navigation.inputs.activeIndex()); } clearTimeout(this.timeout); @@ -59,7 +65,7 @@ export class ListTypeahead { this.timeout = setTimeout(() => { this._query.set(''); - this._anchorIndex.set(null); + this._startIndex.set(undefined); }, this.inputs.typeaheadDelay() * 1000); } @@ -69,10 +75,10 @@ export class ListTypeahead { */ private _getItem() { let items = this.navigation.inputs.items(); - const after = items.slice(this._anchorIndex()! + 1); - const before = items.slice(0, this._anchorIndex()!); + const after = items.slice(this._startIndex()! + 1); + const before = items.slice(0, this._startIndex()!); items = this.navigation.inputs.wrap() ? after.concat(before) : after; // TODO: Always wrap? - items.push(this.navigation.inputs.items()[this._anchorIndex()!]); + items.push(this.navigation.inputs.items()[this._startIndex()!]); const focusableItems = []; for (const item of items) { diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index d660523ef2ca..4124ba321313 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -27,7 +27,7 @@ interface SelectOptions { selectFromActive?: boolean; } -/** The required inputs for the listbox. */ +/** Represents the required inputs for a listbox. */ export type ListboxInputs = ListNavigationInputs & ListSelectionInputs & ListTypeaheadInputs & @@ -47,7 +47,7 @@ export class ListboxPattern { typeahead: ListTypeahead; /** Controls focus for the listbox. */ - focus: ListFocus; + focusManager: ListFocus; /** Whether the list is vertically or horizontally oriented. */ orientation: Signal<'vertical' | 'horizontal'>; @@ -59,7 +59,7 @@ export class ListboxPattern { tabindex: Signal<-1 | 0>; /** The id of the current active item. */ - activedescendant: Signal; + activedescendant: Signal; /** Whether multiple items in the list can be selected at once. */ multiselectable: Signal; @@ -67,6 +67,7 @@ export class ListboxPattern { /** The number of items in the listbox. */ setsize = computed(() => this.navigation.inputs.items().length); + /** Whether the listbox selection follows focus. */ followFocus = computed(() => this.inputs.selectionMode() === 'follow'); /** The key used to navigate to the previous item in the list. */ @@ -74,7 +75,7 @@ export class ListboxPattern { if (this.inputs.orientation() === 'vertical') { return 'ArrowUp'; } - return this.inputs.directionality() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; }); /** The key used to navigate to the next item in the list. */ @@ -82,7 +83,7 @@ export class ListboxPattern { if (this.inputs.orientation() === 'vertical') { return 'ArrowDown'; } - return this.inputs.directionality() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; }); /** The regexp used to decide if a key should trigger typeahead. */ @@ -165,10 +166,10 @@ export class ListboxPattern { this.navigation = new ListNavigation(inputs); this.selection = new ListSelection({...inputs, navigation: this.navigation}); this.typeahead = new ListTypeahead({...inputs, navigation: this.navigation}); - this.focus = new ListFocus({...inputs, navigation: this.navigation}); + this.focusManager = new ListFocus({...inputs, navigation: this.navigation}); - this.tabindex = this.focus.getListTabindex(); - this.activedescendant = this.focus.getActiveDescendant(); + this.tabindex = this.focusManager.getListTabindex(); + this.activedescendant = this.focusManager.getActiveDescendant; } /** Handles keydown events for the listbox. */ @@ -187,28 +188,28 @@ export class ListboxPattern { /** Navigates to the first option in the listbox. */ first(opts?: SelectOptions) { this.navigation.first(); - this.focus.focus(); + this.focusManager.focus(); this._updateSelection(opts); } /** Navigates to the last option in the listbox. */ last(opts?: SelectOptions) { this.navigation.last(); - this.focus.focus(); + this.focusManager.focus(); this._updateSelection(opts); } /** Navigates to the next option in the listbox. */ next(opts?: SelectOptions) { this.navigation.next(); - this.focus.focus(); + this.focusManager.focus(); this._updateSelection(opts); } /** Navigates to the previous option in the listbox. */ prev(opts?: SelectOptions) { this.navigation.prev(); - this.focus.focus(); + this.focusManager.focus(); this._updateSelection(opts); } @@ -218,7 +219,7 @@ export class ListboxPattern { if (item) { this.navigation.goto(item); - this.focus.focus(); + this.focusManager.focus(); this._updateSelection(opts); } } @@ -226,7 +227,7 @@ export class ListboxPattern { /** Handles typeahead search navigation for the listbox. */ search(char: string, opts?: SelectOptions) { this.typeahead.search(char); - this.focus.focus(); + this.focusManager.focus(); this._updateSelection(opts); } @@ -248,7 +249,7 @@ export class ListboxPattern { this.selection.selectAll(); } if (opts?.selectFromAnchor) { - this.selection.selectFromAnchor(); + this.selection.selectFromLastSelectedItem(); } if (opts?.selectFromActive) { this.selection.selectFromActive(); diff --git a/src/cdk-experimental/ui-patterns/listbox/option.ts b/src/cdk-experimental/ui-patterns/listbox/option.ts index 6c88f9758866..cd12d94c17da 100644 --- a/src/cdk-experimental/ui-patterns/listbox/option.ts +++ b/src/cdk-experimental/ui-patterns/listbox/option.ts @@ -12,13 +12,17 @@ import {ListTypeaheadItem} from '../behaviors/list-typeahead/list-typeahead'; import {ListNavigation, ListNavigationItem} from '../behaviors/list-navigation/list-navigation'; import {ListFocus, ListFocusItem} from '../behaviors/list-focus/list-focus'; +/** + * Represents the properties exposed by a listbox that need to be accessed by an option. + * This exists to avoid circular dependency errors between the listbox and option. + */ interface ListboxPattern { - focus: ListFocus; + focusManager: ListFocus; selection: ListSelection; navigation: ListNavigation; } -/** The required inputs to options. */ +/** Represents the required inputs for an option in a listbox. */ export interface OptionInputs extends ListNavigationItem, ListSelectionItem, @@ -27,7 +31,7 @@ export interface OptionInputs listbox: Signal; } -/** An option in a listbox. */ +/** Represents an option in a listbox. */ export class OptionPattern { /** A unique identifier for the option. */ id: Signal; @@ -64,6 +68,6 @@ export class OptionPattern { this.element = args.element; this.disabled = args.disabled; this.searchTerm = args.searchTerm; - this.tabindex = this.listbox().focus.getItemTabindex(this); + this.tabindex = this.listbox().focusManager.getItemTabindex(this); } } diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.css b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.css index 64aa89f9dae4..b09935f9dfe6 100644 --- a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.css +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.css @@ -5,7 +5,7 @@ padding-bottom: 16px; } -ul { +.example-listbox { gap: 8px; margin: 0; padding: 8px; @@ -18,24 +18,24 @@ ul { overflow: scroll; } -ul[aria-orientation='horizontal'] { +.example-listbox[aria-orientation='horizontal'] { flex-direction: row; } -ul[aria-orientation='horizontal'] li::before { +.example-listbox[aria-orientation='horizontal'] .example-option::before { display: none; } -ul[aria-orientation='horizontal'] li[aria-selected='true']::before { +.example-listbox[aria-orientation='horizontal'] .example-option[aria-selected='true']::before { display: block; } -label { +.example-label { padding: 16px; flex-shrink: 0; } -li { +.example-option { gap: 16px; padding: 16px; display: flex; @@ -44,17 +44,17 @@ li { border-radius: var(--mat-sys-corner-extra-small); } -li:hover, -li[tabindex='0'] { +.example-option:hover, +.example-option[tabindex='0'] { outline: 1px solid var(--mat-sys-outline); background: var(--mat-sys-surface-container); } -li:focus-within { +.example-option:focus-within { outline: 2px solid var(--mat-sys-primary); background: var(--mat-sys-surface-container); } -li[aria-selected='true'] { +.example-option[aria-selected='true'] { background-color: var(--mat-sys-secondary-container); } diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html index b726966d2814..4bc60d28b3ec 100644 --- a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html @@ -39,13 +39,14 @@ [orientation]="orientation" [focusMode]="focusMode" [selectionMode]="selectionMode" + class="example-listbox" > - + @for (fruit of fruits; track fruit) { @let checked = option.pattern.selected() ? 'checked' : 'unchecked'; -
  • +
  • {{ fruit }}
  • From 0de6c06a2df0d0fb5ba654e27eb8c24b367d24f4 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Tue, 25 Feb 2025 08:39:34 -0500 Subject: [PATCH 07/11] fixup! feat(cdk-experimental/ui-patterns): listbox ui pattern --- src/cdk-experimental/listbox/listbox.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts index 67056e60d63e..a2e90043474b 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -129,6 +129,8 @@ export class CdkOption { /** A unique identifier for the option. */ protected id = computed(() => `${count++}`); + // TODO(wagnermaciel): See if we want to change how we handle this since textContent is not + // reactive. See https://github.com/angular/components/pull/30495#discussion_r1961260216. /** The text used by the typeahead search. */ protected searchTerm = computed(() => this.label() ?? this.element().textContent); From 6bcf75183d6b87a6a21481005fbbb0a829c6fff8 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Wed, 26 Feb 2025 13:57:15 -0500 Subject: [PATCH 08/11] fixup! feat(cdk-experimental/ui-patterns): listbox ui pattern --- src/cdk-experimental/listbox/BUILD.bazel | 1 + src/cdk-experimental/listbox/listbox.ts | 18 +++++----- .../behaviors/list-focus/list-focus.spec.ts | 12 +++---- .../behaviors/list-focus/list-focus.ts | 34 +++++++++---------- .../list-navigation/list-navigation.ts | 33 +++++++----------- .../list-selection/list-selection.spec.ts | 4 +-- .../list-selection/list-selection.ts | 12 +++---- .../ui-patterns/listbox/listbox.ts | 9 ++--- .../ui-patterns/listbox/option.ts | 3 +- 9 files changed, 57 insertions(+), 69 deletions(-) diff --git a/src/cdk-experimental/listbox/BUILD.bazel b/src/cdk-experimental/listbox/BUILD.bazel index 66c19f59857b..2eaa728c73ae 100644 --- a/src/cdk-experimental/listbox/BUILD.bazel +++ b/src/cdk-experimental/listbox/BUILD.bazel @@ -10,6 +10,7 @@ ng_module( ), deps = [ "//src/cdk-experimental/ui-patterns", + "//src/cdk/a11y", "//src/cdk/bidi", ], ) diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts index a2e90043474b..089ae4348c27 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -19,6 +19,7 @@ import { import {ListboxPattern, OptionPattern} from '@angular/cdk-experimental/ui-patterns'; import {Directionality} from '@angular/cdk/bidi'; import {toSignal} from '@angular/core/rxjs-interop'; +import {_IdGenerator} from '@angular/cdk/a11y'; /** * A listbox container. @@ -51,10 +52,10 @@ import {toSignal} from '@angular/core/rxjs-interop'; }) export class CdkListbox { /** The directionality (LTR / RTL) context for the application (or a subtree of it). */ - private _directionality = inject(Directionality); + private readonly _directionality = inject(Directionality); /** The CdkOptions nested inside of the CdkListbox. */ - private _cdkOptions = contentChildren(CdkOption, {descendants: true}); + private readonly _cdkOptions = contentChildren(CdkOption, {descendants: true}); /** A signal wrapper for directionality. */ protected textDirection = toSignal(this._directionality.change, { @@ -103,9 +104,6 @@ export class CdkListbox { }); } -// TODO(wagnermaciel): Figure out how we want to generate IDs. -let count = 0; - /** A selectable option in a CdkListbox. */ @Directive({ selector: '[cdkOption]', @@ -120,14 +118,16 @@ let count = 0; }) export class CdkOption { /** A reference to the option element. */ - private _elementRef = inject(ElementRef); + private readonly _elementRef = inject(ElementRef); /** The parent CdkListbox. */ - private _cdkListbox = inject(CdkListbox); + private readonly _cdkListbox = inject(CdkListbox); + + /** A unique identifier for the option. */ + private readonly _generatedId = inject(_IdGenerator).getId('cdk-option-'); - // TODO(wagnermaciel): Figure out how we want to generate IDs. /** A unique identifier for the option. */ - protected id = computed(() => `${count++}`); + protected id = computed(() => this._generatedId); // TODO(wagnermaciel): See if we want to change how we handle this since textContent is not // reactive. See https://github.com/angular/components/pull/30495#discussion_r1961260216. diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts index 9e4a3ea44e4c..3883958064d7 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Signal, signal} from '@angular/core'; +import {computed, Signal, signal} from '@angular/core'; import {ListNavigation, ListNavigationInputs} from '../list-navigation/list-navigation'; import {ListFocus, ListFocusInputs, ListFocusItem} from './list-focus'; @@ -58,7 +58,7 @@ describe('List Focus', () => { const items = getItems(5); const nav = getNavigation(items); const focus = getFocus(nav); - const tabindex = focus.getListTabindex(); + const tabindex = computed(() => focus.getListTabindex()); expect(tabindex()).toBe(-1); }); @@ -75,7 +75,7 @@ describe('List Focus', () => { const focus = getFocus(nav); items().forEach(i => { - i.tabindex = focus.getItemTabindex(i); + i.tabindex = computed(() => focus.getItemTabindex(i)); }); expect(items()[0].tabindex()).toBe(0); @@ -91,7 +91,7 @@ describe('List Focus', () => { const focus = getFocus(nav); items().forEach(i => { - i.tabindex = focus.getItemTabindex(i); + i.tabindex = computed(() => focus.getItemTabindex(i)); }); nav.next(); @@ -111,7 +111,7 @@ describe('List Focus', () => { const focus = getFocus(nav, { focusMode: signal('activedescendant'), }); - const tabindex = focus.getListTabindex(); + const tabindex = computed(() => focus.getListTabindex()); expect(tabindex()).toBe(0); }); @@ -132,7 +132,7 @@ describe('List Focus', () => { }); items().forEach(i => { - i.tabindex = focus.getItemTabindex(i); + i.tabindex = computed(() => focus.getItemTabindex(i)); }); expect(items()[0].tabindex()).toBe(-1); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts index c5987e8fa6ea..eba11d1316e4 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {computed, Signal} from '@angular/core'; +import {Signal} from '@angular/core'; import {ListNavigation, ListNavigationItem} from '../list-navigation/list-navigation'; /** Represents an item in a collection, such as a listbox option, than may receive focus. */ @@ -29,32 +29,30 @@ export class ListFocus { /** The navigation controller of the parent list. */ navigation: ListNavigation; + constructor(readonly inputs: ListFocusInputs & {navigation: ListNavigation}) { + this.navigation = inputs.navigation; + } + /** The id of the current active item. */ - getActiveDescendant = computed(() => { + getActiveDescendant(): String | undefined { if (this.inputs.focusMode() === 'roving') { return undefined; } return this.navigation.inputs.items()[this.navigation.inputs.activeIndex()].id(); - }); - - constructor(readonly inputs: ListFocusInputs & {navigation: ListNavigation}) { - this.navigation = inputs.navigation; } - /** Returns a signal that keeps track of the tabindex for the list. */ - getListTabindex(): Signal<-1 | 0> { - return computed(() => (this.inputs.focusMode() === 'activedescendant' ? 0 : -1)); + /** The tabindex for the list. */ + getListTabindex(): -1 | 0 { + return this.inputs.focusMode() === 'activedescendant' ? 0 : -1; } - /** Returns a signal that keeps track of the tabindex for the given item. */ - getItemTabindex(item: T): Signal<-1 | 0> { - return computed(() => { - if (this.inputs.focusMode() === 'activedescendant') { - return -1; - } - const index = this.navigation.inputs.items().indexOf(item); - return this.navigation.inputs.activeIndex() === index ? 0 : -1; - }); + /** Returns the tabindex for the given item. */ + getItemTabindex(item: T): -1 | 0 { + if (this.inputs.focusMode() === 'activedescendant') { + return -1; + } + const index = this.navigation.inputs.items().indexOf(item); + return this.navigation.inputs.activeIndex() === index ? 0 : -1; } /** Focuses the current active item. */ 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 b44e8e9e7ca0..344b898fd80e 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 @@ -56,43 +56,30 @@ export class ListNavigation { /** Navigates to the next item in the list. */ next() { const items = this.inputs.items(); + const itemCount = items.length; + const startIndex = this.inputs.activeIndex(); + const step = (i: number) => this._stepIndex(i, 1); - for (let i = this.inputs.activeIndex() + 1; i < items.length; i++) { + for (let i = step(startIndex); i !== startIndex && i < itemCount; step(i)) { if (this.isFocusable(items[i])) { this.goto(items[i]); return; } } - - if (this.inputs.wrap()) { - for (let i = 0; i <= this.inputs.activeIndex(); i++) { - if (this.isFocusable(items[i])) { - this.goto(items[i]); - return; - } - } - } } /** Navigates to the previous item in the list. */ prev() { const items = this.inputs.items(); + const startIndex = this.inputs.activeIndex(); + const step = (i: number) => this._stepIndex(i, -1); - for (let i = this.inputs.activeIndex() - 1; i >= 0; i--) { + for (let i = step(startIndex); i !== startIndex && i >= 0; step(i)) { if (this.isFocusable(items[i])) { this.goto(items[i]); return; } } - - if (this.inputs.wrap()) { - for (let i = items.length - 1; i >= this.inputs.activeIndex(); i--) { - if (this.isFocusable(items[i])) { - this.goto(items[i]); - return; - } - } - } } /** Navigates to the first item in the list. */ @@ -119,4 +106,10 @@ export class ListNavigation { isFocusable(item: T): boolean { return !item.disabled() || !this.inputs.skipDisabled(); } + + private _stepIndex(index: number, step: -1 | 1) { + return this.inputs.wrap() + ? (index + step + this.inputs.items().length) % this.inputs.items().length + : index + step; + } } diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts index fd3ae7aead4b..3a89d2ca573a 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts @@ -193,7 +193,7 @@ describe('List Selection', () => { selection.select(); // [0] nav.next(); nav.next(); - selection.selectFromLastSelectedItem(); // [0, 1, 2] + selection.selectFromPrevSelectedItem(); // [0, 1, 2] expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2']); }); @@ -208,7 +208,7 @@ describe('List Selection', () => { selection.select(); // [3] nav.prev(); nav.prev(); - selection.selectFromLastSelectedItem(); // [3, 1, 2] + selection.selectFromPrevSelectedItem(); // [3, 1, 2] expect(selection.inputs.selectedIds()).toEqual(['3', '1', '2']); }); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts index c676af70067d..ac3de73fe325 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts @@ -35,8 +35,8 @@ export interface ListSelectionInputs { /** Controls selection for a list of items. */ export class ListSelection { - /** The id of the last selected item. */ - lastSelectedId = signal(undefined); + /** The id of the most recently selected item. */ + previousSelectedId = signal(undefined); /** The navigation controller of the parent list. */ navigation: ListNavigation; @@ -104,9 +104,9 @@ export class ListSelection { } /** Selects the items in the list starting at the last selected item. */ - selectFromLastSelectedItem() { - const lastSelectedId = this.inputs.items().findIndex(i => this.lastSelectedId() === i.id()); - this._selectFromIndex(lastSelectedId); + selectFromPrevSelectedItem() { + const prevSelectedId = this.inputs.items().findIndex(i => this.previousSelectedId() === i.id()); + this._selectFromIndex(prevSelectedId); } /** Selects the items in the list starting at the last active item. */ @@ -137,6 +137,6 @@ export class ListSelection { /** Sets the anchor to the current active index. */ private _anchor() { const item = this.inputs.items()[this.inputs.navigation.inputs.activeIndex()]; - this.lastSelectedId.set(item.id()); + this.previousSelectedId.set(item.id()); } } diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index 4124ba321313..69b0f53a624d 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -56,10 +56,10 @@ export class ListboxPattern { disabled: Signal; /** The tabindex of the listbox. */ - tabindex: Signal<-1 | 0>; + tabindex = computed(() => this.focusManager.getListTabindex()); /** The id of the current active item. */ - activedescendant: Signal; + activedescendant = computed(() => this.focusManager.getActiveDescendant()); /** Whether multiple items in the list can be selected at once. */ multiselectable: Signal; @@ -167,9 +167,6 @@ export class ListboxPattern { this.selection = new ListSelection({...inputs, navigation: this.navigation}); this.typeahead = new ListTypeahead({...inputs, navigation: this.navigation}); this.focusManager = new ListFocus({...inputs, navigation: this.navigation}); - - this.tabindex = this.focusManager.getListTabindex(); - this.activedescendant = this.focusManager.getActiveDescendant; } /** Handles keydown events for the listbox. */ @@ -249,7 +246,7 @@ export class ListboxPattern { this.selection.selectAll(); } if (opts?.selectFromAnchor) { - this.selection.selectFromLastSelectedItem(); + this.selection.selectFromPrevSelectedItem(); } if (opts?.selectFromActive) { this.selection.selectFromActive(); diff --git a/src/cdk-experimental/ui-patterns/listbox/option.ts b/src/cdk-experimental/ui-patterns/listbox/option.ts index cd12d94c17da..a8bd269621be 100644 --- a/src/cdk-experimental/ui-patterns/listbox/option.ts +++ b/src/cdk-experimental/ui-patterns/listbox/option.ts @@ -57,7 +57,7 @@ export class OptionPattern { listbox: Signal; /** The tabindex of the option. */ - tabindex: Signal<-1 | 0>; + tabindex = computed(() => this.listbox().focusManager.getItemTabindex(this)); /** The html element that should receive focus. */ element: Signal; @@ -68,6 +68,5 @@ export class OptionPattern { this.element = args.element; this.disabled = args.disabled; this.searchTerm = args.searchTerm; - this.tabindex = this.listbox().focusManager.getItemTabindex(this); } } From b6e4cf7dfa2ad87a98b105fb35fcba4f8fd66dfe Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Wed, 26 Feb 2025 15:39:34 -0500 Subject: [PATCH 09/11] fixup! feat(cdk-experimental/ui-patterns): listbox ui pattern --- .../behaviors/list-focus/list-focus.spec.ts | 4 +- .../list-navigation/list-navigation.spec.ts | 52 +++++++++---------- .../list-navigation/list-navigation.ts | 9 ++-- .../list-selection/list-selection.spec.ts | 28 +++++----- .../list-typeahead/list-typeahead.spec.ts | 10 ++-- 5 files changed, 51 insertions(+), 52 deletions(-) diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts index 3883958064d7..40dcdccf6185 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts @@ -85,7 +85,7 @@ describe('List Focus', () => { expect(items()[4].tabindex()).toBe(-1); }); - it('should update the tabindex of the active item when navigating', async () => { + it('should update the tabindex of the active item when navigating', () => { const items = getItems(5); const nav = getNavigation(items); const focus = getFocus(nav); @@ -142,7 +142,7 @@ describe('List Focus', () => { expect(items()[4].tabindex()).toBe(-1); }); - it('should update the activedescendant of the list when navigating', async () => { + it('should update the activedescendant of the list when navigating', () => { const items = getItems(5); const nav = getNavigation(items); const focus = getFocus(nav, { 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 70529c869ed1..bdbbc3b1f19a 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 @@ -39,7 +39,7 @@ describe('List Navigation', () => { } describe('#goto', () => { - it('should navigate to an item', async () => { + it('should navigate to an item', () => { const items = getItems(5); const nav = getNavigation(items); @@ -50,13 +50,13 @@ describe('List Navigation', () => { }); describe('#next', () => { - it('should navigate next', async () => { + it('should navigate next', () => { const nav = getNavigation(getItems(3)); nav.next(); // 0 -> 1 expect(nav.inputs.activeIndex()).toBe(1); }); - it('should wrap', async () => { + it('should wrap', () => { const nav = getNavigation(getItems(3), { wrap: signal(true), }); @@ -68,7 +68,7 @@ describe('List Navigation', () => { expect(nav.inputs.activeIndex()).toBe(0); }); - it('should not wrap', async () => { + it('should not wrap', () => { const nav = getNavigation(getItems(3), { wrap: signal(false), }); @@ -80,7 +80,7 @@ describe('List Navigation', () => { expect(nav.inputs.activeIndex()).toBe(2); }); - it('should skip disabled items', async () => { + it('should skip disabled items', () => { const nav = getNavigation(getItems(3), { skipDisabled: signal(true), }); @@ -90,7 +90,7 @@ describe('List Navigation', () => { expect(nav.inputs.activeIndex()).toBe(2); }); - it('should not skip disabled items', async () => { + it('should not skip disabled items', () => { const nav = getNavigation(getItems(3), { skipDisabled: signal(false), }); @@ -100,7 +100,7 @@ describe('List Navigation', () => { expect(nav.inputs.activeIndex()).toBe(1); }); - it('should wrap and skip disabled items', async () => { + it('should wrap and skip disabled items', () => { const nav = getNavigation(getItems(3), { wrap: signal(true), skipDisabled: signal(true), @@ -113,7 +113,7 @@ describe('List Navigation', () => { expect(nav.inputs.activeIndex()).toBe(0); }); - it('should do nothing if other items are disabled', async () => { + it('should do nothing if other items are disabled', () => { const nav = getNavigation(getItems(3), { skipDisabled: signal(true), }); @@ -124,7 +124,7 @@ describe('List Navigation', () => { expect(nav.inputs.activeIndex()).toBe(0); }); - it('should do nothing if there are no other items to navigate to', async () => { + it('should do nothing if there are no other items to navigate to', () => { const nav = getNavigation(getItems(1)); nav.next(); // 0 -> 0 expect(nav.inputs.activeIndex()).toBe(0); @@ -132,7 +132,7 @@ describe('List Navigation', () => { }); describe('#prev', () => { - it('should navigate prev', async () => { + it('should navigate prev', () => { const nav = getNavigation(getItems(3), { activeIndex: signal(2), }); @@ -140,7 +140,7 @@ describe('List Navigation', () => { expect(nav.inputs.activeIndex()).toBe(1); }); - it('should wrap', async () => { + it('should wrap', () => { const nav = getNavigation(getItems(3), { wrap: signal(true), }); @@ -148,7 +148,7 @@ describe('List Navigation', () => { expect(nav.inputs.activeIndex()).toBe(2); }); - it('should not wrap', async () => { + it('should not wrap', () => { const nav = getNavigation(getItems(3), { wrap: signal(false), }); @@ -156,7 +156,7 @@ describe('List Navigation', () => { expect(nav.inputs.activeIndex()).toBe(0); }); - it('should skip disabled items', async () => { + it('should skip disabled items', () => { const nav = getNavigation(getItems(3), { activeIndex: signal(2), skipDisabled: signal(true), @@ -167,7 +167,7 @@ describe('List Navigation', () => { expect(nav.inputs.activeIndex()).toBe(0); }); - it('should not skip disabled items', async () => { + it('should not skip disabled items', () => { const nav = getNavigation(getItems(3), { activeIndex: signal(2), skipDisabled: signal(false), @@ -178,7 +178,7 @@ describe('List Navigation', () => { expect(nav.inputs.activeIndex()).toBe(1); }); - it('should wrap and skip disabled items', async () => { + it('should wrap and skip disabled items', () => { const nav = getNavigation(getItems(3), { wrap: signal(true), activeIndex: signal(2), @@ -192,7 +192,7 @@ describe('List Navigation', () => { expect(nav.inputs.activeIndex()).toBe(2); }); - it('should do nothing if other items are disabled', async () => { + it('should do nothing if other items are disabled', () => { const nav = getNavigation(getItems(3), { activeIndex: signal(2), skipDisabled: signal(true), @@ -204,7 +204,7 @@ describe('List Navigation', () => { expect(nav.inputs.activeIndex()).toBe(2); }); - it('should do nothing if there are no other items to navigate to', async () => { + it('should do nothing if there are no other items to navigate to', () => { const nav = getNavigation(getItems(1)); nav.prev(); // 0 -> 0 expect(nav.inputs.activeIndex()).toBe(0); @@ -212,7 +212,7 @@ describe('List Navigation', () => { }); describe('#first', () => { - it('should navigate to the first item', async () => { + it('should navigate to the first item', () => { const nav = getNavigation(getItems(3), { activeIndex: signal(2), }); @@ -221,7 +221,7 @@ describe('List Navigation', () => { expect(nav.inputs.activeIndex()).toBe(0); }); - it('should skip disabled items', async () => { + it('should skip disabled items', () => { const nav = getNavigation(getItems(3), { activeIndex: signal(2), skipDisabled: signal(true), @@ -232,7 +232,7 @@ describe('List Navigation', () => { expect(nav.inputs.activeIndex()).toBe(1); }); - it('should not skip disabled items', async () => { + it('should not skip disabled items', () => { const nav = getNavigation(getItems(3), { activeIndex: signal(2), skipDisabled: signal(false), @@ -245,13 +245,13 @@ describe('List Navigation', () => { }); describe('#last', () => { - it('should navigate to the last item', async () => { + it('should navigate to the last item', () => { const nav = getNavigation(getItems(3)); nav.last(); expect(nav.inputs.activeIndex()).toBe(2); }); - it('should skip disabled items', async () => { + it('should skip disabled items', () => { const nav = getNavigation(getItems(3), { skipDisabled: signal(true), }); @@ -261,7 +261,7 @@ describe('List Navigation', () => { expect(nav.inputs.activeIndex()).toBe(1); }); - it('should not skip disabled items', async () => { + it('should not skip disabled items', () => { const nav = getNavigation(getItems(3), { skipDisabled: signal(false), }); @@ -273,7 +273,7 @@ describe('List Navigation', () => { }); describe('#isFocusable', () => { - it('should return true for enabled items', async () => { + it('should return true for enabled items', () => { const nav = getNavigation(getItems(3), { skipDisabled: signal(true), }); @@ -283,7 +283,7 @@ describe('List Navigation', () => { expect(nav.isFocusable(nav.inputs.items()[2])).toBeTrue(); }); - it('should return false for disabled items', async () => { + it('should return false for disabled items', () => { const nav = getNavigation(getItems(3), { skipDisabled: signal(true), }); @@ -294,7 +294,7 @@ describe('List Navigation', () => { expect(nav.isFocusable(nav.inputs.items()[2])).toBeTrue(); }); - it('should return true for disabled items if skip disabled is false', async () => { + it('should return true for disabled items if skip disabled is false', () => { const nav = getNavigation(getItems(3), { skipDisabled: signal(false), }); 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 344b898fd80e..83f300bbf71e 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 @@ -60,7 +60,7 @@ export class ListNavigation { const startIndex = this.inputs.activeIndex(); const step = (i: number) => this._stepIndex(i, 1); - for (let i = step(startIndex); i !== startIndex && i < itemCount; step(i)) { + for (let i = step(startIndex); i !== startIndex && i < itemCount; i = step(i)) { if (this.isFocusable(items[i])) { this.goto(items[i]); return; @@ -74,7 +74,7 @@ export class ListNavigation { const startIndex = this.inputs.activeIndex(); const step = (i: number) => this._stepIndex(i, -1); - for (let i = step(startIndex); i !== startIndex && i >= 0; step(i)) { + for (let i = step(startIndex); i !== startIndex && i >= 0; i = step(i)) { if (this.isFocusable(items[i])) { this.goto(items[i]); return; @@ -108,8 +108,7 @@ export class ListNavigation { } private _stepIndex(index: number, step: -1 | 1) { - return this.inputs.wrap() - ? (index + step + this.inputs.items().length) % this.inputs.items().length - : index + step; + const itemCount = this.inputs.items().length; + return this.inputs.wrap() ? (index + step + itemCount) % itemCount : index + step; } } diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts index 3a89d2ca573a..18a8f614e692 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts @@ -57,7 +57,7 @@ describe('List Selection', () => { } describe('#select', () => { - it('should select an item', async () => { + it('should select an item', () => { const items = getItems(5); const nav = getNavigation(items); const selection = getSelection(items, nav); @@ -66,7 +66,7 @@ describe('List Selection', () => { expect(selection.inputs.selectedIds()).toEqual(['0']); }); - it('should select multiple options', async () => { + it('should select multiple options', () => { const items = getItems(5); const nav = getNavigation(items); const selection = getSelection(items, nav); @@ -78,7 +78,7 @@ describe('List Selection', () => { expect(selection.inputs.selectedIds()).toEqual(['0', '1']); }); - it('should not select multiple options', async () => { + it('should not select multiple options', () => { const items = getItems(5); const nav = getNavigation(items); const selection = getSelection(items, nav, { @@ -92,7 +92,7 @@ describe('List Selection', () => { expect(selection.inputs.selectedIds()).toEqual(['1']); }); - it('should not select disabled items', async () => { + it('should not select disabled items', () => { const items = getItems(5); const nav = getNavigation(items); const selection = getSelection(items, nav); @@ -102,7 +102,7 @@ describe('List Selection', () => { expect(selection.inputs.selectedIds()).toEqual([]); }); - it('should do nothing to already selected items', async () => { + it('should do nothing to already selected items', () => { const items = getItems(5); const nav = getNavigation(items); const selection = getSelection(items, nav); @@ -115,7 +115,7 @@ describe('List Selection', () => { }); describe('#deselect', () => { - it('should deselect an item', async () => { + it('should deselect an item', () => { const items = getItems(5); const nav = getNavigation(items); const selection = getSelection(items, nav); @@ -123,7 +123,7 @@ describe('List Selection', () => { expect(selection.inputs.selectedIds().length).toBe(0); }); - it('should not deselect disabled items', async () => { + it('should not deselect disabled items', () => { const items = getItems(5); const nav = getNavigation(items); const selection = getSelection(items, nav); @@ -137,7 +137,7 @@ describe('List Selection', () => { }); describe('#toggle', () => { - it('should select an unselected item', async () => { + it('should select an unselected item', () => { const items = getItems(5); const nav = getNavigation(items); const selection = getSelection(items, nav); @@ -146,7 +146,7 @@ describe('List Selection', () => { expect(selection.inputs.selectedIds()).toEqual(['0']); }); - it('should deselect a selected item', async () => { + it('should deselect a selected item', () => { const items = getItems(5); const nav = getNavigation(items); const selection = getSelection(items, nav); @@ -157,7 +157,7 @@ describe('List Selection', () => { }); describe('#selectAll', () => { - it('should select all items', async () => { + it('should select all items', () => { const items = getItems(5); const nav = getNavigation(items); const selection = getSelection(items, nav); @@ -165,7 +165,7 @@ describe('List Selection', () => { expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2', '3', '4']); }); - it('should do nothing if a list is not multiselectable', async () => { + it('should do nothing if a list is not multiselectable', () => { const items = getItems(5); const nav = getNavigation(items); const selection = getSelection(items, nav); @@ -175,7 +175,7 @@ describe('List Selection', () => { }); describe('#deselectAll', () => { - it('should deselect all items', async () => { + it('should deselect all items', () => { const items = getItems(5); const nav = getNavigation(items); const selection = getSelection(items, nav); @@ -185,7 +185,7 @@ describe('List Selection', () => { }); describe('#selectFromAnchor', () => { - it('should select all items from an anchor at a lower index', async () => { + it('should select all items from an anchor at a lower index', () => { const items = getItems(5); const nav = getNavigation(items); const selection = getSelection(items, nav); @@ -198,7 +198,7 @@ describe('List Selection', () => { expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2']); }); - it('should select all items from an anchor at a higher index', async () => { + it('should select all items from an anchor at a higher index', () => { const items = getItems(5); const nav = getNavigation(items, { activeIndex: signal(3), diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts index b4b745f29eca..df4c8b853720 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts @@ -27,7 +27,7 @@ describe('List Typeahead', () => { } describe('#search', () => { - it('should navigate to an item', async () => { + it('should navigate to an item', () => { const items = getItems(5); const activeIndex = signal(0); const navigation = new ListNavigation({ @@ -54,7 +54,7 @@ describe('List Typeahead', () => { expect(activeIndex()).toBe(3); }); - it('should reset after a delay', fakeAsync(async () => { + it('should reset after a delay', fakeAsync(() => { const items = getItems(5); const activeIndex = signal(0); const navigation = new ListNavigation({ @@ -79,7 +79,7 @@ describe('List Typeahead', () => { expect(activeIndex()).toBe(2); })); - it('should skip disabled items', async () => { + it('should skip disabled items', () => { const items = getItems(5); const activeIndex = signal(0); const navigation = new ListNavigation({ @@ -100,7 +100,7 @@ describe('List Typeahead', () => { expect(activeIndex()).toBe(2); }); - it('should not skip disabled items', async () => { + it('should not skip disabled items', () => { const items = getItems(5); const activeIndex = signal(0); const navigation = new ListNavigation({ @@ -121,7 +121,7 @@ describe('List Typeahead', () => { expect(activeIndex()).toBe(1); }); - it('should ignore keys like shift', async () => { + it('should ignore keys like shift', () => { const items = getItems(5); const activeIndex = signal(0); const navigation = new ListNavigation({ From 8f07db689723eb7eb6a73e198027141991004a24 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Thu, 27 Feb 2025 10:34:53 -0500 Subject: [PATCH 10/11] fixup! feat(cdk-experimental/ui-patterns): listbox ui pattern --- src/cdk-experimental/listbox/listbox.ts | 1 + .../list-navigation/list-navigation.ts | 43 ++++++++----------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts index 089ae4348c27..f7e910faac80 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -126,6 +126,7 @@ export class CdkOption { /** A unique identifier for the option. */ private readonly _generatedId = inject(_IdGenerator).getId('cdk-option-'); + // TODO(wagnermaciel): https://github.com/angular/components/pull/30495#discussion_r1972601144. /** A unique identifier for the option. */ protected id = computed(() => this._generatedId); 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 83f300bbf71e..e02f4540fc3b 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 @@ -55,31 +55,12 @@ export class ListNavigation { /** Navigates to the next item in the list. */ next() { - const items = this.inputs.items(); - const itemCount = items.length; - const startIndex = this.inputs.activeIndex(); - const step = (i: number) => this._stepIndex(i, 1); - - for (let i = step(startIndex); i !== startIndex && i < itemCount; i = step(i)) { - if (this.isFocusable(items[i])) { - this.goto(items[i]); - return; - } - } + this.advance(1); } /** Navigates to the previous item in the list. */ prev() { - const items = this.inputs.items(); - const startIndex = this.inputs.activeIndex(); - const step = (i: number) => this._stepIndex(i, -1); - - for (let i = step(startIndex); i !== startIndex && i >= 0; i = step(i)) { - if (this.isFocusable(items[i])) { - this.goto(items[i]); - return; - } - } + this.advance(-1); } /** Navigates to the first item in the list. */ @@ -107,8 +88,22 @@ export class ListNavigation { return !item.disabled() || !this.inputs.skipDisabled(); } - private _stepIndex(index: number, step: -1 | 1) { - const itemCount = this.inputs.items().length; - return this.inputs.wrap() ? (index + step + itemCount) % itemCount : index + step; + /** Advances to the next or previous focusable item in the list based on the given delta. */ + private advance(delta: 1 | -1) { + const items = this.inputs.items(); + const itemCount = items.length; + const startIndex = this.inputs.activeIndex(); + const step = (i: number) => + this.inputs.wrap() ? (i + delta + itemCount) % itemCount : i + delta; + + // If wrapping is enabled, this loop ultimately terminates when `i` gets back to `startIndex` + // in the case that all options are disabled. If wrapping is disabled, the loop terminates + // when the index goes out of bounds. + for (let i = step(startIndex); i !== startIndex && i < itemCount && i >= 0; i = step(i)) { + if (this.isFocusable(items[i])) { + this.goto(items[i]); + return; + } + } } } From 9f8ef21570fbec458b31c558839862f9c7fdeb19 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Thu, 27 Feb 2025 11:08:07 -0500 Subject: [PATCH 11/11] fixup! feat(cdk-experimental/ui-patterns): listbox ui pattern --- .../behaviors/list-navigation/list-navigation.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 e02f4540fc3b..a7cc3897ded4 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 @@ -55,12 +55,12 @@ export class ListNavigation { /** Navigates to the next item in the list. */ next() { - this.advance(1); + this._advance(1); } /** Navigates to the previous item in the list. */ prev() { - this.advance(-1); + this._advance(-1); } /** Navigates to the first item in the list. */ @@ -89,7 +89,7 @@ export class ListNavigation { } /** Advances to the next or previous focusable item in the list based on the given delta. */ - private advance(delta: 1 | -1) { + private _advance(delta: 1 | -1) { const items = this.inputs.items(); const itemCount = items.length; const startIndex = this.inputs.activeIndex();