Skip to content

Commit 52e86a0

Browse files
committed
feat(aria/ui-patterns): add initial menu pattern
* Adds the initial implementation of the WAI-ARIA menu, menubar, and menuitem patterns. This includes the basic behaviors for keyboard navigation, opening and closing submenus, and typeahead support. * This also introduces a 'focusElement' option to the list navigation behaviors to allow for moving the active item without focusing it.
1 parent 03c5d34 commit 52e86a0

File tree

9 files changed

+1577
-30
lines changed

9 files changed

+1577
-30
lines changed

src/aria/ui-patterns/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ ts_project(
1414
"//src/aria/ui-patterns/behaviors/signal-like",
1515
"//src/aria/ui-patterns/combobox",
1616
"//src/aria/ui-patterns/listbox",
17+
"//src/aria/ui-patterns/menu",
1718
"//src/aria/ui-patterns/radio-group",
1819
"//src/aria/ui-patterns/tabs",
1920
"//src/aria/ui-patterns/toolbar",

src/aria/ui-patterns/behaviors/list-focus/list-focus.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,15 +97,19 @@ export class ListFocus<T extends ListFocusItem> {
9797
}
9898

9999
/** Moves focus to the given item if it is focusable. */
100-
focus(item: T): boolean {
100+
focus(item: T, opts?: {focusElement?: boolean}): boolean {
101101
if (this.isListDisabled() || !this.isFocusable(item)) {
102102
return false;
103103
}
104104

105105
this.prevActiveItem.set(this.inputs.activeItem());
106106
this.inputs.activeItem.set(item);
107107

108-
this.inputs.focusMode() === 'roving' ? item.element().focus() : this.inputs.element()?.focus();
108+
if (opts?.focusElement || opts?.focusElement === undefined) {
109+
this.inputs.focusMode() === 'roving'
110+
? item.element().focus()
111+
: this.inputs.element()?.focus();
112+
}
109113

110114
return true;
111115
}

src/aria/ui-patterns/behaviors/list-navigation/list-navigation.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,13 @@ export class ListNavigation<T extends ListNavigationItem> {
2929
constructor(readonly inputs: ListNavigationInputs<T> & {focusManager: ListFocus<T>}) {}
3030

3131
/** Navigates to the given item. */
32-
goto(item?: T): boolean {
33-
return item ? this.inputs.focusManager.focus(item) : false;
32+
goto(item?: T, opts?: {focusElement?: boolean}): boolean {
33+
return item ? this.inputs.focusManager.focus(item, opts) : false;
3434
}
3535

3636
/** Navigates to the next item in the list. */
37-
next(): boolean {
38-
return this._advance(1);
37+
next(opts?: {focusElement?: boolean}): boolean {
38+
return this._advance(1, opts);
3939
}
4040

4141
/** Peeks the next item in the list. */
@@ -44,8 +44,8 @@ export class ListNavigation<T extends ListNavigationItem> {
4444
}
4545

4646
/** Navigates to the previous item in the list. */
47-
prev(): boolean {
48-
return this._advance(-1);
47+
prev(opts?: {focusElement?: boolean}): boolean {
48+
return this._advance(-1, opts);
4949
}
5050

5151
/** Peeks the previous item in the list. */
@@ -54,26 +54,26 @@ export class ListNavigation<T extends ListNavigationItem> {
5454
}
5555

5656
/** Navigates to the first item in the list. */
57-
first(): boolean {
57+
first(opts?: {focusElement?: boolean}): boolean {
5858
const item = this.inputs.items().find(i => this.inputs.focusManager.isFocusable(i));
59-
return item ? this.goto(item) : false;
59+
return item ? this.goto(item, opts) : false;
6060
}
6161

6262
/** Navigates to the last item in the list. */
63-
last(): boolean {
63+
last(opts?: {focusElement?: boolean}): boolean {
6464
const items = this.inputs.items();
6565
for (let i = items.length - 1; i >= 0; i--) {
6666
if (this.inputs.focusManager.isFocusable(items[i])) {
67-
return this.goto(items[i]);
67+
return this.goto(items[i], opts);
6868
}
6969
}
7070
return false;
7171
}
7272

7373
/** Advances to the next or previous focusable item in the list based on the given delta. */
74-
private _advance(delta: 1 | -1): boolean {
74+
private _advance(delta: 1 | -1, opts?: {focusElement?: boolean}): boolean {
7575
const item = this._peek(delta);
76-
return item ? this.goto(item) : false;
76+
return item ? this.goto(item, opts) : false;
7777
}
7878

7979
/** Peeks the next or previous focusable item in the list based on the given delta. */

src/aria/ui-patterns/behaviors/list/list.ts

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,14 @@ import {
2424
ListTypeaheadItem,
2525
} from '../list-typeahead/list-typeahead';
2626

27-
/** The selection operations that the list can perform. */
28-
interface SelectOptions {
27+
/** The operations that the list can perform after navigation. */
28+
interface NavOptions {
2929
toggle?: boolean;
3030
select?: boolean;
3131
selectOne?: boolean;
3232
selectRange?: boolean;
3333
anchor?: boolean;
34+
focusElement?: boolean;
3435
}
3536

3637
/** Represents an item in the list. */
@@ -105,28 +106,28 @@ export class List<T extends ListItem<V>, V> {
105106
}
106107

107108
/** Navigates to the first option in the list. */
108-
first(opts?: SelectOptions) {
109-
this._navigate(opts, () => this.navigationBehavior.first());
109+
first(opts?: NavOptions) {
110+
this._navigate(opts, () => this.navigationBehavior.first(opts));
110111
}
111112

112113
/** Navigates to the last option in the list. */
113-
last(opts?: SelectOptions) {
114-
this._navigate(opts, () => this.navigationBehavior.last());
114+
last(opts?: NavOptions) {
115+
this._navigate(opts, () => this.navigationBehavior.last(opts));
115116
}
116117

117118
/** Navigates to the next option in the list. */
118-
next(opts?: SelectOptions) {
119-
this._navigate(opts, () => this.navigationBehavior.next());
119+
next(opts?: NavOptions) {
120+
this._navigate(opts, () => this.navigationBehavior.next(opts));
120121
}
121122

122123
/** Navigates to the previous option in the list. */
123-
prev(opts?: SelectOptions) {
124-
this._navigate(opts, () => this.navigationBehavior.prev());
124+
prev(opts?: NavOptions) {
125+
this._navigate(opts, () => this.navigationBehavior.prev(opts));
125126
}
126127

127128
/** Navigates to the given item in the list. */
128-
goto(item: T, opts?: SelectOptions) {
129-
this._navigate(opts, () => this.navigationBehavior.goto(item));
129+
goto(item: T, opts?: NavOptions) {
130+
this._navigate(opts, () => this.navigationBehavior.goto(item, opts));
130131
}
131132

132133
/** Removes focus from the list. */
@@ -140,7 +141,7 @@ export class List<T extends ListItem<V>, V> {
140141
}
141142

142143
/** Handles typeahead search navigation for the list. */
143-
search(char: string, opts?: SelectOptions) {
144+
search(char: string, opts?: NavOptions) {
144145
this._navigate(opts, () => this.typeaheadBehavior.search(char));
145146
}
146147

@@ -190,7 +191,7 @@ export class List<T extends ListItem<V>, V> {
190191
}
191192

192193
/** Handles updating selection for the list. */
193-
updateSelection(opts: SelectOptions = {anchor: true}) {
194+
updateSelection(opts: NavOptions = {anchor: true}) {
194195
if (opts.toggle) {
195196
this.selectionBehavior.toggle();
196197
}
@@ -217,7 +218,7 @@ export class List<T extends ListItem<V>, V> {
217218
* Handles boilerplate calling of focus & selection operations. Also ensures these
218219
* additional operations are only called if the navigation operation moved focus to a new option.
219220
*/
220-
private _navigate(opts: SelectOptions = {}, operation: () => boolean) {
221+
private _navigate(opts: NavOptions = {}, operation: () => boolean) {
221222
if (opts?.selectRange) {
222223
this._wrap.set(false);
223224
this.selectionBehavior.rangeStartIndex.set(this._anchorIndex());

src/aria/ui-patterns/listbox/listbox.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export class ListboxPattern<V> {
7272
dynamicSpaceKey = computed(() => (this.listBehavior.isTyping() ? '' : ' '));
7373

7474
/** The regexp used to decide if a key should trigger typeahead. */
75-
typeaheadRegexp = /^.$/; // TODO: Ignore spaces?
75+
typeaheadRegexp = /^.$/;
7676

7777
/** The keydown event manager for the listbox. */
7878
keydown = computed(() => {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ts_project(
6+
name = "menu",
7+
srcs = [
8+
"menu.ts",
9+
],
10+
deps = [
11+
"//:node_modules/@angular/core",
12+
"//src/aria/ui-patterns/behaviors/event-manager",
13+
"//src/aria/ui-patterns/behaviors/expansion",
14+
"//src/aria/ui-patterns/behaviors/list",
15+
"//src/aria/ui-patterns/behaviors/signal-like",
16+
],
17+
)
18+
19+
ng_project(
20+
name = "unit_test_sources",
21+
testonly = True,
22+
srcs = [
23+
"menu.spec.ts",
24+
],
25+
deps = [
26+
":menu",
27+
"//:node_modules/@angular/core",
28+
"//src/aria/ui-patterns/behaviors/signal-like",
29+
"//src/cdk/keycodes",
30+
"//src/cdk/testing/private",
31+
],
32+
)
33+
34+
ng_web_test_suite(
35+
name = "unit_tests",
36+
deps = [":unit_test_sources"],
37+
)

0 commit comments

Comments
 (0)