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..2eaa728c73ae --- /dev/null +++ b/src/cdk-experimental/listbox/BUILD.bazel @@ -0,0 +1,16 @@ +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/a11y", + "//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..f7e910faac80 --- /dev/null +++ b/src/cdk-experimental/listbox/listbox.ts @@ -0,0 +1,158 @@ +/** + * @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, +} 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'; +import {_IdGenerator} from '@angular/cdk/a11y'; + +/** + * 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()', + '(keydown)': 'pattern.onKeydown($event)', + '(pointerdown)': 'pattern.onPointerdown($event)', + }, +}) +export class CdkListbox { + /** The directionality (LTR / RTL) context for the application (or a subtree of it). */ + private readonly _directionality = inject(Directionality); + + /** The CdkOptions nested inside of the CdkListbox. */ + private readonly _cdkOptions = contentChildren(CdkOption, {descendants: true}); + + /** A signal wrapper for directionality. */ + protected textDirection = toSignal(this._directionality.change, { + initialValue: this._directionality.value, + }); + + /** 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}); + + // TODO(wagnermaciel): Figure out how we want to expose control over the current listbox value. + /** 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, + textDirection: this.textDirection, + }); +} + +/** 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 readonly _elementRef = inject(ElementRef); + + /** The parent CdkListbox. */ + private readonly _cdkListbox = inject(CdkListbox); + + /** 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); + + // 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); + + /** 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..6f28962270cd --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/event-manager/event-manager.ts @@ -0,0 +1,103 @@ +/** + * @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 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 { + matcher: EventMatcher; + handler: EventHandler; +} + +/** 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, +} + +export type ModifierInputs = ModifierKey | ModifierKey[]; + +/** + * 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 { + protected configs: EventHandlerConfig[] = []; + abstract options: EventHandlerOptions; + + /** 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); + + if (config.preventDefault) { + event.preventDefault(); + } + + if (config.stopPropagation) { + event.stopPropagation(); + } + } + } + } + + /** Configures the event manager to handle specific events. (See subclasses for more). */ + abstract on(...args: [...unknown[]]): this; +} + +/** 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: 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 new file mode 100644 index 000000000000..f3deafb8a240 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/event-manager/keyboard-event-manager.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Signal} from '@angular/core'; +import { + EventHandler, + EventHandlerOptions, + EventManager, + hasModifiers, + ModifierInputs, + ModifierKey, +} from './event-manager'; + +/** + * 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. + */ +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 { + options: EventHandlerOptions = { + preventDefault: true, + stopPropagation: true, + }; + + /** 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 modifer and key combination. */ + on(modifiers: ModifierInputs, key: KeyCode, handler: EventHandler): this; + + on(...args: any[]) { + const {modifiers, key, handler} = this._normalizeInputs(...args); + + this.configs.push({ + handler: handler, + matcher: event => this._isMatch(event, key, modifiers), + ...this.options, + }); + + return this; + } + + 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; + + return { + key: key as KeyCode, + handler: handler as EventHandler, + modifiers: modifiers as ModifierInputs, + }; + } + + private _isMatch(event: T, key: KeyCode, modifiers: ModifierInputs) { + if (key instanceof RegExp) { + return key.test(event.key); + } + + 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/pointer-event-manager.ts b/src/cdk-experimental/ui-patterns/behaviors/event-manager/pointer-event-manager.ts new file mode 100644 index 000000000000..7631f048e027 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/event-manager/pointer-event-manager.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + EventHandler, + EventHandlerOptions, + EventManager, + hasModifiers, + ModifierInputs, + ModifierKey, +} from './event-manager'; + +/** + * The different mouse buttons that may appear on a pointer event. + */ +export enum MouseButton { + Main = 0, + Auxiliary = 1, + Secondary = 2, +} + +/** An event manager that is specialized for handling pointer events. */ +export class PointerEventManager extends EventManager { + options: EventHandlerOptions = { + preventDefault: false, + stopPropagation: false, + }; + + /** + * Configures this event manager to handle events with a specific modifer and mouse button + * combination. + */ + on(button: MouseButton, modifiers: ModifierInputs, handler: EventHandler): this; + + /** + * Configures this event manager to handle events with a specific mouse button and no modifiers. + */ + on(modifiers: ModifierInputs, handler: EventHandler): 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: EventHandler): this; + + on(...args: any[]) { + const {button, handler, modifiers} = this._normalizeInputs(...args); + + this.configs.push({ + handler, + matcher: event => this._isMatch(event, button, modifiers), + ...this.options, + }); + return this; + } + + private _normalizeInputs(...args: any[]) { + if (args.length === 3) { + return { + 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] as ModifierInputs, + handler: args[1] as EventHandler, + }; + } + + return { + button: MouseButton.Main, + modifiers: ModifierKey.None, + handler: args[0] as EventHandler, + }; + } + + _isMatch(event: PointerEvent, button: MouseButton, modifiers: ModifierInputs) { + return button === (event.button ?? 0) && hasModifiers(event, modifiers); + } +} 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/list-focus.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts new file mode 100644 index 000000000000..40dcdccf6185 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.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 {computed, 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), + textDirection: 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 = computed(() => focus.getListTabindex()); + expect(tabindex()).toBe(-1); + }); + + it('should set the activedescendant to undefined', () => { + const items = getItems(5); + const nav = getNavigation(items); + const focus = getFocus(nav); + expect(focus.getActiveDescendant()).toBeUndefined(); + }); + + 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 = computed(() => 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', () => { + const items = getItems(5); + const nav = getNavigation(items); + const focus = getFocus(nav); + + items().forEach(i => { + i.tabindex = computed(() => focus.getItemTabindex(i)); + }); + + 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 = computed(() => 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'), + }); + expect(focus.getActiveDescendant()).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 = computed(() => 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', () => { + const items = getItems(5); + const nav = getNavigation(items); + const focus = getFocus(nav, { + focusMode: signal('activedescendant'), + }); + + nav.next(); + 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 new file mode 100644 index 000000000000..eba11d1316e4 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts @@ -0,0 +1,67 @@ +/** + * @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 {ListNavigation, ListNavigationItem} from '../list-navigation/list-navigation'; + +/** 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; + + /** The html element that should receive focus. */ + element: Signal; +} + +/** 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'>; +} + +/** Controls focus for a list of items. */ +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(): String | undefined { + if (this.inputs.focusMode() === 'roving') { + return undefined; + } + return this.navigation.inputs.items()[this.navigation.inputs.activeIndex()].id(); + } + + /** The tabindex for the list. */ + getListTabindex(): -1 | 0 { + return this.inputs.focusMode() === 'activedescendant' ? 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. */ + 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/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/list-navigation.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.spec.ts new file mode 100644 index 000000000000..bdbbc3b1f19a --- /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), + textDirection: signal('ltr'), + orientation: signal('vertical'), + ...args, + }); + } + + describe('#goto', () => { + it('should navigate to an item', () => { + const items = getItems(5); + const nav = getNavigation(items); + + expect(nav.inputs.activeIndex()).toBe(0); + nav.goto(items()[3]); + expect(nav.inputs.activeIndex()).toBe(3); + }); + }); + + describe('#next', () => { + it('should navigate next', () => { + const nav = getNavigation(getItems(3)); + nav.next(); // 0 -> 1 + expect(nav.inputs.activeIndex()).toBe(1); + }); + + it('should wrap', () => { + const nav = getNavigation(getItems(3), { + wrap: signal(true), + }); + + nav.next(); // 0 -> 1 + nav.next(); // 1 -> 2 + nav.next(); // 2 -> 0 + + expect(nav.inputs.activeIndex()).toBe(0); + }); + + it('should not wrap', () => { + const nav = getNavigation(getItems(3), { + wrap: signal(false), + }); + + nav.next(); // 0 -> 1 + nav.next(); // 1 -> 2 + nav.next(); // 2 -> 2 + + expect(nav.inputs.activeIndex()).toBe(2); + }); + + it('should skip disabled items', () => { + const nav = getNavigation(getItems(3), { + skipDisabled: signal(true), + }); + nav.inputs.items()[1].disabled.set(true); + + nav.next(); // 0 -> 2 + expect(nav.inputs.activeIndex()).toBe(2); + }); + + it('should not skip disabled items', () => { + const nav = getNavigation(getItems(3), { + skipDisabled: signal(false), + }); + nav.inputs.items()[1].disabled.set(true); + + nav.next(); // 0 -> 1 + expect(nav.inputs.activeIndex()).toBe(1); + }); + + it('should wrap and skip disabled items', () => { + const nav = getNavigation(getItems(3), { + wrap: signal(true), + skipDisabled: signal(true), + }); + nav.inputs.items()[2].disabled.set(true); + + nav.next(); // 0 -> 1 + nav.next(); // 1 -> 0 + + expect(nav.inputs.activeIndex()).toBe(0); + }); + + it('should do nothing if other items are disabled', () => { + const nav = getNavigation(getItems(3), { + skipDisabled: signal(true), + }); + nav.inputs.items()[1].disabled.set(true); + nav.inputs.items()[2].disabled.set(true); + + nav.next(); // 0 -> 0 + expect(nav.inputs.activeIndex()).toBe(0); + }); + + 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); + }); + }); + + describe('#prev', () => { + it('should navigate prev', () => { + const nav = getNavigation(getItems(3), { + activeIndex: signal(2), + }); + nav.prev(); // 2 -> 1 + expect(nav.inputs.activeIndex()).toBe(1); + }); + + it('should wrap', () => { + const nav = getNavigation(getItems(3), { + wrap: signal(true), + }); + nav.prev(); // 0 -> 2 + expect(nav.inputs.activeIndex()).toBe(2); + }); + + it('should not wrap', () => { + const nav = getNavigation(getItems(3), { + wrap: signal(false), + }); + nav.prev(); // 0 -> 0 + expect(nav.inputs.activeIndex()).toBe(0); + }); + + it('should skip disabled items', () => { + const nav = getNavigation(getItems(3), { + activeIndex: signal(2), + skipDisabled: signal(true), + }); + nav.inputs.items()[1].disabled.set(true); + + nav.prev(); // 2 -> 0 + expect(nav.inputs.activeIndex()).toBe(0); + }); + + it('should not skip disabled items', () => { + const nav = getNavigation(getItems(3), { + activeIndex: signal(2), + skipDisabled: signal(false), + }); + nav.inputs.items()[1].disabled.set(true); + + nav.prev(); // 2 -> 1 + expect(nav.inputs.activeIndex()).toBe(1); + }); + + it('should wrap and skip disabled items', () => { + const nav = getNavigation(getItems(3), { + wrap: signal(true), + activeIndex: signal(2), + skipDisabled: signal(true), + }); + nav.inputs.items()[0].disabled.set(true); + + nav.prev(); // 2 -> 1 + nav.prev(); // 1 -> 2 + + expect(nav.inputs.activeIndex()).toBe(2); + }); + + it('should do nothing if other items are disabled', () => { + 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); + + nav.prev(); // 2 -> 2 + expect(nav.inputs.activeIndex()).toBe(2); + }); + + 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); + }); + }); + + describe('#first', () => { + it('should navigate to the first item', () => { + const nav = getNavigation(getItems(3), { + activeIndex: signal(2), + }); + + nav.first(); + expect(nav.inputs.activeIndex()).toBe(0); + }); + + it('should skip disabled items', () => { + const nav = getNavigation(getItems(3), { + activeIndex: signal(2), + skipDisabled: signal(true), + }); + nav.inputs.items()[0].disabled.set(true); + + nav.first(); + expect(nav.inputs.activeIndex()).toBe(1); + }); + + it('should not skip disabled items', () => { + const nav = getNavigation(getItems(3), { + activeIndex: signal(2), + skipDisabled: signal(false), + }); + nav.inputs.items()[0].disabled.set(true); + + nav.first(); + expect(nav.inputs.activeIndex()).toBe(0); + }); + }); + + describe('#last', () => { + 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', () => { + const nav = getNavigation(getItems(3), { + skipDisabled: signal(true), + }); + nav.inputs.items()[2].disabled.set(true); + + nav.last(); + expect(nav.inputs.activeIndex()).toBe(1); + }); + + it('should not skip disabled items', () => { + const nav = getNavigation(getItems(3), { + skipDisabled: signal(false), + }); + nav.inputs.items()[2].disabled.set(true); + + nav.last(); + expect(nav.inputs.activeIndex()).toBe(2); + }); + }); + + describe('#isFocusable', () => { + it('should return true for enabled items', () => { + const nav = getNavigation(getItems(3), { + skipDisabled: signal(true), + }); + + 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', () => { + const nav = getNavigation(getItems(3), { + skipDisabled: signal(true), + }); + nav.inputs.items()[1].disabled.set(true); + + 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', () => { + const nav = getNavigation(getItems(3), { + skipDisabled: signal(false), + }); + nav.inputs.items()[1].disabled.set(true); + + 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-navigation/list-navigation.ts b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.ts new file mode 100644 index 000000000000..a7cc3897ded4 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.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'; + +/** 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; +} + +/** Represents the required inputs for a collection that has navigable items. */ +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. */ + textDirection: Signal<'rtl' | 'ltr'>; +} + +/** Controls navigation for a list of items. */ +export class ListNavigation { + /** The last index that was active. */ + prevActiveIndex = signal(0); + + constructor(readonly inputs: ListNavigationInputs) { + this.prevActiveIndex.set(inputs.activeIndex()); + } + + /** Navigates to the given 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. */ + next() { + this._advance(1); + } + + /** Navigates to the previous item in the list. */ + prev() { + this._advance(-1); + } + + /** Navigates to the first item in the list. */ + first() { + const item = this.inputs.items().find(i => this.isFocusable(i)); + + if (item) { + this.goto(item); + } + } + + /** Navigates to the last item in the list. */ + last() { + const items = this.inputs.items(); + for (let i = items.length - 1; i >= 0; i--) { + if (this.isFocusable(items[i])) { + this.goto(items[i]); + return; + } + } + } + + /** Returns true if the given item can be navigated to. */ + isFocusable(item: T): boolean { + return !item.disabled() || !this.inputs.skipDisabled(); + } + + /** 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; + } + } + } +} 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/list-selection.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts new file mode 100644 index 000000000000..18a8f614e692 --- /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), + textDirection: 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', () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + + selection.select(); // [0] + expect(selection.inputs.selectedIds()).toEqual(['0']); + }); + + it('should select multiple options', () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + + selection.select(); // [0] + nav.next(); + selection.select(); // [0, 1] + + expect(selection.inputs.selectedIds()).toEqual(['0', '1']); + }); + + it('should not select multiple options', () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav, { + multiselectable: signal(false), + }); + + selection.select(); // [0] + nav.next(); + selection.select(); // [1] + + expect(selection.inputs.selectedIds()).toEqual(['1']); + }); + + it('should not select disabled items', () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + items()[0].disabled.set(true); + + selection.select(); // [] + expect(selection.inputs.selectedIds()).toEqual([]); + }); + + it('should do nothing to already selected items', () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + + selection.select(); // [0] + selection.select(); // [0] + + expect(selection.inputs.selectedIds()).toEqual(['0']); + }); + }); + + describe('#deselect', () => { + it('should deselect an item', () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + selection.deselect(); // [] + expect(selection.inputs.selectedIds().length).toBe(0); + }); + + it('should not deselect disabled items', () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + + selection.select(); // [0] + items()[0].disabled.set(true); + selection.deselect(); // [0] + + expect(selection.inputs.selectedIds()).toEqual(['0']); + }); + }); + + describe('#toggle', () => { + it('should select an unselected item', () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + + selection.toggle(); // [0] + expect(selection.inputs.selectedIds()).toEqual(['0']); + }); + + it('should deselect a selected item', () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + selection.select(); // [0] + selection.toggle(); // [] + expect(selection.inputs.selectedIds().length).toBe(0); + }); + }); + + describe('#selectAll', () => { + it('should select all items', () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + selection.selectAll(); + expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2', '3', '4']); + }); + + it('should do nothing if a list is not multiselectable', () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + selection.selectAll(); + expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2', '3', '4']); + }); + }); + + describe('#deselectAll', () => { + it('should deselect all items', () => { + const items = getItems(5); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + selection.deselectAll(); // [] + expect(selection.inputs.selectedIds().length).toBe(0); + }); + }); + + describe('#selectFromAnchor', () => { + 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); + + selection.select(); // [0] + nav.next(); + nav.next(); + selection.selectFromPrevSelectedItem(); // [0, 1, 2] + + expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2']); + }); + + it('should select all items from an anchor at a higher index', () => { + const items = getItems(5); + const nav = getNavigation(items, { + activeIndex: signal(3), + }); + const selection = getSelection(items, nav); + + selection.select(); // [3] + nav.prev(); + nav.prev(); + 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 new file mode 100644 index 000000000000..ac3de73fe325 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts @@ -0,0 +1,142 @@ +/** + * @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 {ListNavigation, ListNavigationItem} from '../list-navigation/list-navigation'; + +/** 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; + + /** Whether an item is disabled. */ + disabled: Signal; +} + +/** Represents the required inputs for a collection that contains selectable items. */ +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 most recently selected item. */ + previousSelectedId = signal(undefined); + + /** The navigation controller of the parent list. */ + navigation: ListNavigation; + + constructor(readonly inputs: ListSelectionInputs & {navigation: ListNavigation}) { + this.navigation = inputs.navigation; + } + + /** Selects the item at the current active index. */ + 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. */ + 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. */ + 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. */ + 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. */ + 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. */ + deselectAll() { + for (const item of this.inputs.items()) { + this.deselect(item); + } + } + + /** Selects the items in the list starting at the last selected item. */ + 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. */ + 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. */ + 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.previousSelectedId.set(item.id()); + } +} 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/list-typeahead.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts new file mode 100644 index 000000000000..df4c8b853720 --- /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', () => { + const items = getItems(5); + const activeIndex = signal(0); + const navigation = new ListNavigation({ + items, + activeIndex, + wrap: signal(false), + skipDisabled: signal(false), + textDirection: signal('ltr'), + orientation: signal('vertical'), + }); + const typeahead = new ListTypeahead({ + navigation, + typeaheadDelay: signal(0.5), + }); + + typeahead.search('i'); + expect(activeIndex()).toBe(1); + + typeahead.search('t'); + typeahead.search('e'); + typeahead.search('m'); + typeahead.search(' '); + typeahead.search('3'); + expect(activeIndex()).toBe(3); + }); + + it('should reset after a delay', fakeAsync(() => { + const items = getItems(5); + const activeIndex = signal(0); + const navigation = new ListNavigation({ + items, + activeIndex, + wrap: signal(false), + skipDisabled: signal(false), + textDirection: signal('ltr'), + orientation: signal('vertical'), + }); + const typeahead = new ListTypeahead({ + navigation, + typeaheadDelay: signal(0.5), + }); + + typeahead.search('i'); + expect(activeIndex()).toBe(1); + + tick(500); + + typeahead.search('i'); + expect(activeIndex()).toBe(2); + })); + + it('should skip disabled items', () => { + const items = getItems(5); + const activeIndex = signal(0); + const navigation = new ListNavigation({ + items, + activeIndex, + wrap: signal(false), + skipDisabled: signal(true), + textDirection: signal('ltr'), + orientation: signal('vertical'), + }); + const typeahead = new ListTypeahead({ + navigation, + typeaheadDelay: signal(0.5), + }); + items()[1].disabled.set(true); + + typeahead.search('i'); + expect(activeIndex()).toBe(2); + }); + + it('should not skip disabled items', () => { + const items = getItems(5); + const activeIndex = signal(0); + const navigation = new ListNavigation({ + items, + activeIndex, + wrap: signal(false), + skipDisabled: signal(false), + textDirection: signal('ltr'), + orientation: signal('vertical'), + }); + const typeahead = new ListTypeahead({ + navigation, + typeaheadDelay: signal(0.5), + }); + items()[1].disabled.set(true); + + typeahead.search('i'); + expect(activeIndex()).toBe(1); + }); + + it('should ignore keys like shift', () => { + const items = getItems(5); + const activeIndex = signal(0); + const navigation = new ListNavigation({ + items, + activeIndex, + wrap: signal(false), + skipDisabled: signal(false), + textDirection: signal('ltr'), + orientation: signal('vertical'), + }); + const typeahead = new ListTypeahead({ + navigation, + typeaheadDelay: signal(0.5), + }); + + typeahead.search('i'); + typeahead.search('t'); + typeahead.search('e'); + + typeahead.search('Shift'); + + 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 new file mode 100644 index 000000000000..64154eda550d --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts @@ -0,0 +1,92 @@ +/** + * @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 {ListNavigationItem, ListNavigation} from '../list-navigation/list-navigation'; + +/** + * 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; +} + +/** + * 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; +} + +/** Controls typeahead for a list of items. */ +export class ListTypeahead { + /** A reference to the timeout for resetting the typeahead search. */ + timeout?: ReturnType | undefined; + + /** The navigation controller of the parent list. */ + navigation: ListNavigation; + + /** 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 _startIndex = signal(undefined); + + constructor(readonly inputs: ListTypeaheadInputs & {navigation: ListNavigation}) { + this.navigation = inputs.navigation; + } + + /** Performs a typeahead search, appending the given character to the search string. */ + search(char: string) { + if (char.length !== 1) { + return; + } + + if (this._startIndex() === undefined) { + this._startIndex.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._startIndex.set(undefined); + }, this.inputs.typeaheadDelay() * 1000); + } + + /** + * 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._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._startIndex()!]); + + 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/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/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts new file mode 100644 index 000000000000..69b0f53a624d --- /dev/null +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -0,0 +1,264 @@ +/** + * @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 {ModifierKey as Modifier} from '../behaviors/event-manager/event-manager'; +import {KeyboardEventManager} from '../behaviors/event-manager/keyboard-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'; +import {ListNavigation, ListNavigationInputs} from '../behaviors/list-navigation/list-navigation'; +import {ListFocus, ListFocusInputs} from '../behaviors/list-focus/list-focus'; +import {computed, Signal} from '@angular/core'; + +/** The selection operations that the listbox can perform. */ +interface SelectOptions { + select?: boolean; + toggle?: boolean; + toggleOne?: boolean; + selectOne?: boolean; + selectAll?: boolean; + selectFromAnchor?: boolean; + selectFromActive?: boolean; +} + +/** Represents the required inputs for a 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. */ + focusManager: 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 = computed(() => this.focusManager.getListTabindex()); + + /** The id of the current active item. */ + activedescendant = computed(() => this.focusManager.getActiveDescendant()); + + /** 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); + + /** Whether the listbox selection follows focus. */ + 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 this.inputs.textDirection() === '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.textDirection() === '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.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, '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})) + .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})); + 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()) { + 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 pointerdown event manager for the listbox. */ + pointerdown = computed(() => { + const manager = new PointerEventManager(); + + if (this.inputs.multiselectable()) { + 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; + }); + + 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.focusManager = new ListFocus({...inputs, navigation: this.navigation}); + } + + /** Handles keydown events for the listbox. */ + onKeydown(event: KeyboardEvent) { + if (!this.disabled()) { + this.keydown().handle(event); + } + } + + onPointerdown(event: PointerEvent) { + if (!this.disabled()) { + this.pointerdown().handle(event); + } + } + + /** Navigates to the first option in the listbox. */ + first(opts?: SelectOptions) { + this.navigation.first(); + this.focusManager.focus(); + this._updateSelection(opts); + } + + /** Navigates to the last option in the listbox. */ + last(opts?: SelectOptions) { + this.navigation.last(); + this.focusManager.focus(); + this._updateSelection(opts); + } + + /** Navigates to the next option in the listbox. */ + next(opts?: SelectOptions) { + this.navigation.next(); + this.focusManager.focus(); + this._updateSelection(opts); + } + + /** Navigates to the previous option in the listbox. */ + prev(opts?: SelectOptions) { + this.navigation.prev(); + this.focusManager.focus(); + this._updateSelection(opts); + } + + /** Navigates to the given item in the listbox. */ + goto(event: PointerEvent, opts?: SelectOptions) { + const item = this._getItem(event); + + if (item) { + this.navigation.goto(item); + this.focusManager.focus(); + this._updateSelection(opts); + } + } + + /** Handles typeahead search navigation for the listbox. */ + search(char: string, opts?: SelectOptions) { + this.typeahead.search(char); + this.focusManager.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.selectFromPrevSelectedItem(); + } + if (opts?.selectFromActive) { + this.selection.selectFromActive(); + } + } + + private _getItem(e: PointerEvent) { + 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); + } +} 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..a8bd269621be --- /dev/null +++ b/src/cdk-experimental/ui-patterns/listbox/option.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 {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'; + +/** + * 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 { + focusManager: ListFocus; + selection: ListSelection; + navigation: ListNavigation; +} + +/** Represents the required inputs for an option in a listbox. */ +export interface OptionInputs + extends ListNavigationItem, + ListSelectionItem, + ListTypeaheadItem, + ListFocusItem { + listbox: Signal; +} + +/** Represents 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 = computed(() => this.listbox().focusManager.getItemTabindex(this)); + + /** 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; + } +} 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..b09935f9dfe6 --- /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; +} + +.example-listbox { + 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; +} + +.example-listbox[aria-orientation='horizontal'] { + flex-direction: row; +} + +.example-listbox[aria-orientation='horizontal'] .example-option::before { + display: none; +} + +.example-listbox[aria-orientation='horizontal'] .example-option[aria-selected='true']::before { + display: block; +} + +.example-label { + padding: 16px; + flex-shrink: 0; +} + +.example-option { + gap: 16px; + padding: 16px; + display: flex; + cursor: pointer; + align-items: center; + border-radius: var(--mat-sys-corner-extra-small); +} + +.example-option:hover, +.example-option[tabindex='0'] { + outline: 1px solid var(--mat-sys-outline); + background: var(--mat-sys-surface-container); +} + +.example-option:focus-within { + outline: 2px solid var(--mat-sys-primary); + background: var(--mat-sys-surface-container); +} + +.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 new file mode 100644 index 000000000000..4bc60d28b3ec --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html @@ -0,0 +1,55 @@ +
+ 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"