Skip to content

Commit

Permalink
feat(cdk-experimental/ui-patterns): listbox ui pattern (#30495)
Browse files Browse the repository at this point in the history
* feat(cdk-experimental/ui-patterns): listbox ui pattern

* refactor(cdk-experimental/ui-patterns): remove controllers & lazy-loading

* refactor: event managers

* fixup! refactor: event managers

* fixup! feat(cdk-experimental/ui-patterns): listbox ui pattern

* fixup! feat(cdk-experimental/ui-patterns): listbox ui pattern

* fixup! feat(cdk-experimental/ui-patterns): listbox ui pattern

* fixup! feat(cdk-experimental/ui-patterns): listbox ui pattern

* fixup! feat(cdk-experimental/ui-patterns): listbox ui pattern

* fixup! feat(cdk-experimental/ui-patterns): listbox ui pattern

* fixup! feat(cdk-experimental/ui-patterns): listbox ui pattern

---------

Co-authored-by: Jeremy Elbourn <jelbourn@google.com>
  • Loading branch information
wagnermaciel and jelbourn authored Feb 27, 2025
1 parent e2ffd95 commit fc46997
Show file tree
Hide file tree
Showing 40 changed files with 2,470 additions and 1 deletion.
2 changes: 2 additions & 0 deletions .ng-dev/commit-message.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions src/cdk-experimental/config.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions src/cdk-experimental/listbox/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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",
],
)
9 changes: 9 additions & 0 deletions src/cdk-experimental/listbox/index.ts
Original file line number Diff line number Diff line change
@@ -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';
158 changes: 158 additions & 0 deletions src/cdk-experimental/listbox/listbox.ts
Original file line number Diff line number Diff line change
@@ -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
* <ul cdkListbox>
* <li cdkOption>Item 1</li>
* <li cdkOption>Item 2</li>
* <li cdkOption>Item 3</li>
* </ul>
* ```
*/
@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<number>(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<string[]>([]);

/** The current index that has been navigated to. */
activeIndex = model<number>(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<string>();

/** The Option UIPattern. */
pattern = new OptionPattern({
...this,
id: this.id,
listbox: this.listbox,
element: this.element,
searchTerm: this.searchTerm,
});
}
9 changes: 9 additions & 0 deletions src/cdk-experimental/listbox/public-api.ts
Original file line number Diff line number Diff line change
@@ -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';
15 changes: 15 additions & 0 deletions src/cdk-experimental/ui-patterns/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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",
],
)
Original file line number Diff line number Diff line change
@@ -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"],
)
Original file line number Diff line number Diff line change
@@ -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<T extends Event> = (event: T) => void;

/** A function that determines whether an event is to be handled. */
export type EventMatcher<T extends Event> = (event: T) => boolean;

/** A config that specifies how to handle a particular event. */
export interface EventHandlerConfig<T extends Event> extends EventHandlerOptions {
matcher: EventMatcher<T>;
handler: EventHandler<T>;
}

/** 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<T extends Event> {
protected configs: EventHandlerConfig<T>[] = [];
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);
}
Loading

0 comments on commit fc46997

Please sign in to comment.