Skip to content

Commit edfc6f1

Browse files
committed
feat(cdk-experimental/ui-patterns): listbox ui pattern
1 parent 48058ff commit edfc6f1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+2786
-0
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

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
CDK_EXPERIMENTAL_ENTRYPOINTS = [
33
"column-resize",
44
"combobox",
5+
"listbox",
56
"popover-edit",
67
"scrolling",
78
"selection",
+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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/listbox",
13+
"//src/cdk/bidi",
14+
],
15+
)

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';
+137
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
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+
computed,
11+
contentChildren,
12+
Directive,
13+
ElementRef,
14+
inject,
15+
input,
16+
model,
17+
signal,
18+
} from '@angular/core';
19+
import {Option} from '../ui-patterns/listbox/option';
20+
import {ListboxInputs, ListboxPattern} from '../ui-patterns/listbox/listbox';
21+
import {Directionality} from '@angular/cdk/bidi';
22+
import {startWith, takeUntil} from 'rxjs/operators';
23+
import {Subject} from 'rxjs';
24+
25+
@Directive({
26+
selector: '[cdkListbox]',
27+
exportAs: 'cdkListbox',
28+
host: {
29+
'role': 'listbox',
30+
'class': 'cdk-listbox',
31+
'[attr.tabindex]': 'state.tabindex()',
32+
// '[attr.aria-disabled]': 'state.disabled()',
33+
'[attr.aria-multiselectable]': 'state.multiselectable()',
34+
'[attr.aria-activedescendant]': 'state.activedescendant()',
35+
'[attr.aria-orientation]': 'state.orientation()',
36+
'(focusin)': 'state.onFocus()',
37+
'(keydown)': 'state.onKeydown($event)',
38+
'(mousedown)': 'state.onMousedown($event)',
39+
// '(focusout)': '_handleFocusOut($event)',
40+
// '(focusin)': '_handleFocusIn()',
41+
},
42+
})
43+
export class CdkListbox implements ListboxInputs {
44+
/** Whether the list is vertically or horizontally oriented. */
45+
orientation = input<'vertical' | 'horizontal'>('vertical');
46+
47+
/** Whether multiple items in the list can be selected at once. */
48+
multiselectable = input<boolean>(false);
49+
50+
/** Whether focus should wrap when navigating. */
51+
wrap = input<boolean>(true);
52+
53+
/** Whether disabled items in the list should be skipped when navigating. */
54+
skipDisabled = input<boolean>(true);
55+
56+
/** The focus strategy used by the list. */
57+
focusStrategy = input<'roving tabindex' | 'activedescendant'>('roving tabindex');
58+
59+
/** The selection strategy used by the list. */
60+
selectionStrategy = input<'follow' | 'explicit'>('follow');
61+
62+
/** The amount of time before the typeahead search is reset. */
63+
delay = input<number>(0.5);
64+
65+
/** The ids of the current selected items. */
66+
selectedIds = model<string[]>([]);
67+
68+
/** The current index that has been navigated to. */
69+
activeIndex = model<number>(0);
70+
71+
/** The CdkOptions nested inside of the CdkListbox. */
72+
private cdkOptions = contentChildren(CdkOption, {descendants: true});
73+
74+
/** The Option UIPatterns of the child CdkOptions. */
75+
items = computed(() => this.cdkOptions().map(option => option.state));
76+
77+
/** The directionality (LTR / RTL) context for the application (or a subtree of it). */
78+
private dir = inject(Directionality);
79+
80+
/** A signal wrapper for directionality. */
81+
directionality = signal<'rtl' | 'ltr'>('rtl');
82+
83+
/** Emits when the list has been destroyed. */
84+
private readonly destroyed = new Subject<void>();
85+
86+
/** The Listbox UIPattern. */
87+
state: ListboxPattern = new ListboxPattern(this);
88+
89+
constructor() {
90+
this.dir.change
91+
.pipe(startWith(this.dir.value), takeUntil(this.destroyed))
92+
.subscribe(value => this.directionality.set(value));
93+
}
94+
95+
ngOnDestroy() {
96+
this.destroyed.complete();
97+
}
98+
}
99+
100+
@Directive({
101+
selector: '[cdkOption]',
102+
exportAs: 'cdkOption',
103+
host: {
104+
'role': 'option',
105+
'class': 'cdk-option',
106+
'[attr.aria-selected]': 'state.selected()',
107+
'[attr.tabindex]': 'state.tabindex()',
108+
'[attr.aria-disabled]': 'state.disabled()',
109+
// '[class.cdk-option-active]': 'isActive()',
110+
// '(click)': '_clicked.next($event)',
111+
// '(focus)': '_handleFocus()',
112+
},
113+
})
114+
export class CdkOption {
115+
/** Whether an item is disabled. */
116+
disabled = input<boolean>(false);
117+
118+
/** The text used by the typeahead search. */
119+
label = input<string>();
120+
121+
/** The text used by the typeahead search. */
122+
searchTerm = computed(() => this.label() ?? this.element().textContent);
123+
124+
/** A reference to the option element. */
125+
private elementRef = inject(ElementRef);
126+
127+
element = computed(() => this.elementRef.nativeElement);
128+
129+
/** The parent CdkListbox. */
130+
private cdkListbox = inject(CdkListbox);
131+
132+
/** The parent Listbox UIPattern. */
133+
listbox = computed(() => this.cdkListbox.state);
134+
135+
/** The Option UIPattern. */
136+
state: Option = new Option(this);
137+
}
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,9 @@
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(["**/*.ts"]),
8+
deps = ["@npm//@angular/core"],
9+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/**
2+
* An event that supports modifier keys.
3+
*/
4+
export interface EventWithModifiers extends Event {
5+
ctrlKey: boolean;
6+
shiftKey: boolean;
7+
altKey: boolean;
8+
metaKey: boolean;
9+
}
10+
11+
/**
12+
* Options that are applicable to all event handlers.
13+
*/
14+
export interface EventHandlerOptions {
15+
stopPropagation: boolean;
16+
preventDefault: boolean;
17+
}
18+
19+
/**
20+
* A config that specifies how to handle a particular event.
21+
*/
22+
export interface EventHandlerConfig<T extends Event> extends EventHandlerOptions {
23+
handler: (event: T) => Promise<boolean | void>;
24+
}
25+
26+
/**
27+
* Bit flag representation of the possible modifier keys that can be present on an event.
28+
*/
29+
export enum ModifierKey {
30+
None = 0,
31+
Ctrl = 0b1,
32+
Shift = 0b10,
33+
Alt = 0b100,
34+
Meta = 0b1000,
35+
}
36+
37+
/**
38+
* Abstract base class for all event managers.
39+
*/
40+
export abstract class EventManager<T extends Event> {
41+
private submanagers: EventManager<T>[] = [];
42+
43+
protected configs: EventHandlerConfig<T>[] = [];
44+
protected beforeFns: ((event: T) => void)[] = [];
45+
protected afterFns: ((event: T) => void)[] = [];
46+
47+
protected defaultHandlerOptions: EventHandlerOptions = {
48+
preventDefault: false,
49+
stopPropagation: false,
50+
};
51+
52+
constructor(defaultHandlerOptions?: Partial<EventHandlerOptions>) {
53+
this.defaultHandlerOptions = {
54+
...this.defaultHandlerOptions,
55+
...defaultHandlerOptions,
56+
};
57+
}
58+
59+
/**
60+
* Composes together multiple event managers into a single event manager that delegates to the
61+
* individual managers.
62+
*/
63+
static compose<T extends Event>(...managers: EventManager<T>[]) {
64+
const composedManager = new GenericEventManager<T>();
65+
composedManager.submanagers = managers;
66+
return composedManager;
67+
}
68+
69+
/**
70+
* Runs any handlers that have been configured to handle this event. If multiple handlers are
71+
* configured for this event, they are run in the order they were configured. Returns
72+
* `true` if the event has been handled, otherwise returns `undefined`.
73+
*
74+
* Note: the use of `undefined` instead of `false` in the unhandled case is necessary to avoid
75+
* accidentally preventing the default behavior on an unhandled event.
76+
*/
77+
async handle(event: T): Promise<true | undefined> {
78+
if (!this.isHandled(event)) {
79+
return undefined;
80+
}
81+
for (const fn of this.beforeFns) {
82+
fn(event);
83+
}
84+
for (const submanager of this.submanagers) {
85+
await submanager.handle(event);
86+
}
87+
for (const config of this.getConfigs(event)) {
88+
await config.handler(event);
89+
if (config.stopPropagation) {
90+
event.stopPropagation();
91+
}
92+
if (config.preventDefault) {
93+
event.preventDefault();
94+
}
95+
}
96+
for (const fn of this.afterFns) {
97+
fn(event);
98+
}
99+
return true;
100+
}
101+
102+
/**
103+
* Configures the event manager to run a function immediately before it as about to handle
104+
* any event.
105+
*/
106+
beforeHandling(fn: (event: T) => void): this {
107+
this.beforeFns.push(fn);
108+
return this;
109+
}
110+
111+
/**
112+
* Configures the event manager to run a function immediately after it handles any event.
113+
*/
114+
afterHandling(fn: (event: T) => void): this {
115+
this.afterFns.push(fn);
116+
return this;
117+
}
118+
119+
/**
120+
* Configures the event manager to handle specific events. (See subclasses for more).
121+
*/
122+
abstract on(...args: [...unknown[]]): this;
123+
124+
/**
125+
* Gets all of the handler configs that are applicable to the given event.
126+
*/
127+
protected abstract getConfigs(event: T): EventHandlerConfig<T>[];
128+
129+
/**
130+
* Checks whether this event manager is confugred to handle the given event.
131+
*/
132+
protected isHandled(event: T): boolean {
133+
return this.getConfigs(event).length > 0 || this.submanagers.some(sm => sm.isHandled(event));
134+
}
135+
}
136+
137+
/**
138+
* A generic event manager that can work with any type of event.
139+
*/
140+
export class GenericEventManager<T extends Event> extends EventManager<T> {
141+
/**
142+
* Configures this event manager to handle all events with the given handler.
143+
*/
144+
on(handler: (event: T) => Promise<boolean | void>): this {
145+
this.configs.push({
146+
...this.defaultHandlerOptions,
147+
handler,
148+
});
149+
return this;
150+
}
151+
152+
getConfigs(_event: T): EventHandlerConfig<T>[] {
153+
return this.configs;
154+
}
155+
}
156+
157+
/**
158+
* Gets bit flag representation of the modifier keys present on the given event.
159+
*/
160+
export function getModifiers(event: EventWithModifiers): number {
161+
return (
162+
(+event.ctrlKey && ModifierKey.Ctrl) |
163+
(+event.shiftKey && ModifierKey.Shift) |
164+
(+event.altKey && ModifierKey.Alt) |
165+
(+event.metaKey && ModifierKey.Meta)
166+
);
167+
}
168+
169+
/**
170+
* Checks if the given event has modifiers that are an exact match for any of the given modifier
171+
* flag combinations.
172+
*/
173+
export function hasModifiers(event: EventWithModifiers, modifiers: number | number[]): boolean {
174+
const eventModifiers = getModifiers(event);
175+
const modifiersList = Array.isArray(modifiers) ? modifiers : [modifiers];
176+
return modifiersList.some(modifiers => eventModifiers === modifiers);
177+
}

0 commit comments

Comments
 (0)