Skip to content

Commit fc46997

Browse files
feat(cdk-experimental/ui-patterns): listbox ui pattern (#30495)
* 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 <[email protected]>
1 parent e2ffd95 commit fc46997

40 files changed

+2470
-1
lines changed

.ng-dev/commit-message.mts

+2
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ export const commitMessage: CommitMessageConfig = {
1111
'multiple', // For when a commit applies to multiple components.
1212
'cdk-experimental/column-resize',
1313
'cdk-experimental/combobox',
14+
'cdk-experimental/listbox',
1415
'cdk-experimental/popover-edit',
1516
'cdk-experimental/scrolling',
1617
'cdk-experimental/selection',
1718
'cdk-experimental/table-scroll-container',
19+
'cdk-experimental/ui-patterns',
1820
'cdk/a11y',
1921
'cdk/accordion',
2022
'cdk/bidi',

src/cdk-experimental/config.bzl

+2
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
CDK_EXPERIMENTAL_ENTRYPOINTS = [
33
"column-resize",
44
"combobox",
5+
"listbox",
56
"popover-edit",
67
"scrolling",
78
"selection",
89
"table-scroll-container",
10+
"ui-patterns",
911
]
1012

1113
# List of all entry-point targets of the Angular cdk-experimental package.
+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
load("//tools:defaults.bzl", "ng_module")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ng_module(
6+
name = "listbox",
7+
srcs = glob(
8+
["**/*.ts"],
9+
exclude = ["**/*.spec.ts"],
10+
),
11+
deps = [
12+
"//src/cdk-experimental/ui-patterns",
13+
"//src/cdk/a11y",
14+
"//src/cdk/bidi",
15+
],
16+
)

src/cdk-experimental/listbox/index.ts

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
export * from './public-api';
+158
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {
10+
booleanAttribute,
11+
computed,
12+
contentChildren,
13+
Directive,
14+
ElementRef,
15+
inject,
16+
input,
17+
model,
18+
} from '@angular/core';
19+
import {ListboxPattern, OptionPattern} from '@angular/cdk-experimental/ui-patterns';
20+
import {Directionality} from '@angular/cdk/bidi';
21+
import {toSignal} from '@angular/core/rxjs-interop';
22+
import {_IdGenerator} from '@angular/cdk/a11y';
23+
24+
/**
25+
* A listbox container.
26+
*
27+
* Listboxes are used to display a list of items for a user to select from. The CdkListbox is meant
28+
* to be used in conjunction with CdkOption as follows:
29+
*
30+
* ```html
31+
* <ul cdkListbox>
32+
* <li cdkOption>Item 1</li>
33+
* <li cdkOption>Item 2</li>
34+
* <li cdkOption>Item 3</li>
35+
* </ul>
36+
* ```
37+
*/
38+
@Directive({
39+
selector: '[cdkListbox]',
40+
exportAs: 'cdkListbox',
41+
host: {
42+
'role': 'listbox',
43+
'class': 'cdk-listbox',
44+
'[attr.tabindex]': 'pattern.tabindex()',
45+
'[attr.aria-disabled]': 'pattern.disabled()',
46+
'[attr.aria-orientation]': 'pattern.orientation()',
47+
'[attr.aria-multiselectable]': 'pattern.multiselectable()',
48+
'[attr.aria-activedescendant]': 'pattern.activedescendant()',
49+
'(keydown)': 'pattern.onKeydown($event)',
50+
'(pointerdown)': 'pattern.onPointerdown($event)',
51+
},
52+
})
53+
export class CdkListbox {
54+
/** The directionality (LTR / RTL) context for the application (or a subtree of it). */
55+
private readonly _directionality = inject(Directionality);
56+
57+
/** The CdkOptions nested inside of the CdkListbox. */
58+
private readonly _cdkOptions = contentChildren(CdkOption, {descendants: true});
59+
60+
/** A signal wrapper for directionality. */
61+
protected textDirection = toSignal(this._directionality.change, {
62+
initialValue: this._directionality.value,
63+
});
64+
65+
/** The Option UIPatterns of the child CdkOptions. */
66+
protected items = computed(() => this._cdkOptions().map(option => option.pattern));
67+
68+
/** Whether the list is vertically or horizontally oriented. */
69+
orientation = input<'vertical' | 'horizontal'>('vertical');
70+
71+
/** Whether multiple items in the list can be selected at once. */
72+
multiselectable = input(false, {transform: booleanAttribute});
73+
74+
/** Whether focus should wrap when navigating. */
75+
wrap = input(true, {transform: booleanAttribute});
76+
77+
/** Whether disabled items in the list should be skipped when navigating. */
78+
skipDisabled = input(true, {transform: booleanAttribute});
79+
80+
/** The focus strategy used by the list. */
81+
focusMode = input<'roving' | 'activedescendant'>('roving');
82+
83+
/** The selection strategy used by the list. */
84+
selectionMode = input<'follow' | 'explicit'>('follow');
85+
86+
/** The amount of time before the typeahead search is reset. */
87+
typeaheadDelay = input<number>(0.5); // Picked arbitrarily.
88+
89+
/** Whether the listbox is disabled. */
90+
disabled = input(false, {transform: booleanAttribute});
91+
92+
// TODO(wagnermaciel): Figure out how we want to expose control over the current listbox value.
93+
/** The ids of the current selected items. */
94+
selectedIds = model<string[]>([]);
95+
96+
/** The current index that has been navigated to. */
97+
activeIndex = model<number>(0);
98+
99+
/** The Listbox UIPattern. */
100+
pattern: ListboxPattern = new ListboxPattern({
101+
...this,
102+
items: this.items,
103+
textDirection: this.textDirection,
104+
});
105+
}
106+
107+
/** A selectable option in a CdkListbox. */
108+
@Directive({
109+
selector: '[cdkOption]',
110+
exportAs: 'cdkOption',
111+
host: {
112+
'role': 'option',
113+
'class': 'cdk-option',
114+
'[attr.tabindex]': 'pattern.tabindex()',
115+
'[attr.aria-selected]': 'pattern.selected()',
116+
'[attr.aria-disabled]': 'pattern.disabled()',
117+
},
118+
})
119+
export class CdkOption {
120+
/** A reference to the option element. */
121+
private readonly _elementRef = inject(ElementRef);
122+
123+
/** The parent CdkListbox. */
124+
private readonly _cdkListbox = inject(CdkListbox);
125+
126+
/** A unique identifier for the option. */
127+
private readonly _generatedId = inject(_IdGenerator).getId('cdk-option-');
128+
129+
// TODO(wagnermaciel): https://github.com/angular/components/pull/30495#discussion_r1972601144.
130+
/** A unique identifier for the option. */
131+
protected id = computed(() => this._generatedId);
132+
133+
// TODO(wagnermaciel): See if we want to change how we handle this since textContent is not
134+
// reactive. See https://github.com/angular/components/pull/30495#discussion_r1961260216.
135+
/** The text used by the typeahead search. */
136+
protected searchTerm = computed(() => this.label() ?? this.element().textContent);
137+
138+
/** The parent Listbox UIPattern. */
139+
protected listbox = computed(() => this._cdkListbox.pattern);
140+
141+
/** A reference to the option element to be focused on navigation. */
142+
protected element = computed(() => this._elementRef.nativeElement);
143+
144+
/** Whether an item is disabled. */
145+
disabled = input(false, {transform: booleanAttribute});
146+
147+
/** The text used by the typeahead search. */
148+
label = input<string>();
149+
150+
/** The Option UIPattern. */
151+
pattern = new OptionPattern({
152+
...this,
153+
id: this.id,
154+
listbox: this.listbox,
155+
element: this.element,
156+
searchTerm: this.searchTerm,
157+
});
158+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
export {CdkListbox, CdkOption} from './listbox';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
load("//tools:defaults.bzl", "ts_library")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ts_library(
6+
name = "ui-patterns",
7+
srcs = glob(
8+
["**/*.ts"],
9+
exclude = ["**/*.spec.ts"],
10+
),
11+
deps = [
12+
"//src/cdk-experimental/ui-patterns/listbox",
13+
"@npm//@angular/core",
14+
],
15+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
load("//tools:defaults.bzl", "ts_library")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ts_library(
6+
name = "event-manager",
7+
srcs = glob(
8+
["**/*.ts"],
9+
exclude = ["**/*.spec.ts"],
10+
),
11+
deps = ["@npm//@angular/core"],
12+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
/**
10+
* An event that supports modifier keys.
11+
*
12+
* Matches the native KeyboardEvent, MouseEvent, and TouchEvent.
13+
*/
14+
export interface EventWithModifiers extends Event {
15+
ctrlKey: boolean;
16+
shiftKey: boolean;
17+
altKey: boolean;
18+
metaKey: boolean;
19+
}
20+
21+
/**
22+
* Options that are applicable to all event handlers.
23+
*
24+
* This library has not yet had a need for stopPropagationImmediate.
25+
*/
26+
export interface EventHandlerOptions {
27+
stopPropagation: boolean;
28+
preventDefault: boolean;
29+
}
30+
31+
/** A basic event handler. */
32+
export type EventHandler<T extends Event> = (event: T) => void;
33+
34+
/** A function that determines whether an event is to be handled. */
35+
export type EventMatcher<T extends Event> = (event: T) => boolean;
36+
37+
/** A config that specifies how to handle a particular event. */
38+
export interface EventHandlerConfig<T extends Event> extends EventHandlerOptions {
39+
matcher: EventMatcher<T>;
40+
handler: EventHandler<T>;
41+
}
42+
43+
/** Bit flag representation of the possible modifier keys that can be present on an event. */
44+
export enum ModifierKey {
45+
None = 0,
46+
Ctrl = 0b1,
47+
Shift = 0b10,
48+
Alt = 0b100,
49+
Meta = 0b1000,
50+
}
51+
52+
export type ModifierInputs = ModifierKey | ModifierKey[];
53+
54+
/**
55+
* Abstract base class for all event managers.
56+
*
57+
* Event managers are designed to normalize how event handlers are authored and create a safety net
58+
* for common event handling gotchas like remembering to call preventDefault or stopPropagation.
59+
*/
60+
export abstract class EventManager<T extends Event> {
61+
protected configs: EventHandlerConfig<T>[] = [];
62+
abstract options: EventHandlerOptions;
63+
64+
/** Runs the handlers that match with the given event. */
65+
handle(event: T): void {
66+
for (const config of this.configs) {
67+
if (config.matcher(event)) {
68+
config.handler(event);
69+
70+
if (config.preventDefault) {
71+
event.preventDefault();
72+
}
73+
74+
if (config.stopPropagation) {
75+
event.stopPropagation();
76+
}
77+
}
78+
}
79+
}
80+
81+
/** Configures the event manager to handle specific events. (See subclasses for more). */
82+
abstract on(...args: [...unknown[]]): this;
83+
}
84+
85+
/** Gets bit flag representation of the modifier keys present on the given event. */
86+
export function getModifiers(event: EventWithModifiers): number {
87+
return (
88+
(+event.ctrlKey && ModifierKey.Ctrl) |
89+
(+event.shiftKey && ModifierKey.Shift) |
90+
(+event.altKey && ModifierKey.Alt) |
91+
(+event.metaKey && ModifierKey.Meta)
92+
);
93+
}
94+
95+
/**
96+
* Checks if the given event has modifiers that are an exact match for any of the given modifier
97+
* flag combinations.
98+
*/
99+
export function hasModifiers(event: EventWithModifiers, modifiers: ModifierInputs): boolean {
100+
const eventModifiers = getModifiers(event);
101+
const modifiersList = Array.isArray(modifiers) ? modifiers : [modifiers];
102+
return modifiersList.some(modifiers => eventModifiers === modifiers);
103+
}

0 commit comments

Comments
 (0)