Skip to content

Commit 7e4f908

Browse files
committed
feat(aria/menu): add initial menu directives
* Adds the initial implementation of the 'ngMenu', 'ngMenuBar', 'ngMenuItem', and 'ngMenuTrigger' directives built on top of the menu UI patterns.
1 parent 52e86a0 commit 7e4f908

File tree

3 files changed

+389
-0
lines changed

3 files changed

+389
-0
lines changed

src/aria/menu/BUILD.bazel

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
load("//tools:defaults.bzl", "ng_project")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ng_project(
6+
name = "menu",
7+
srcs = glob(
8+
["**/*.ts"],
9+
exclude = ["**/*.spec.ts"],
10+
),
11+
deps = [
12+
"//:node_modules/@angular/core",
13+
"//src/aria/ui-patterns",
14+
"//src/cdk/a11y",
15+
"//src/cdk/bidi",
16+
],
17+
)

src/aria/menu/index.ts

Lines changed: 9 additions & 0 deletions
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 {Menu, MenuBar, MenuItem, MenuTrigger} from './menu';

src/aria/menu/menu.ts

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
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+
afterRenderEffect,
11+
computed,
12+
contentChildren,
13+
Directive,
14+
ElementRef,
15+
inject,
16+
input,
17+
model,
18+
output,
19+
Signal,
20+
signal,
21+
untracked,
22+
} from '@angular/core';
23+
import {
24+
SignalLike,
25+
MenuBarPattern,
26+
MenuItemPattern,
27+
MenuPattern,
28+
MenuTriggerPattern,
29+
} from '@angular/aria/ui-patterns';
30+
import {toSignal} from '@angular/core/rxjs-interop';
31+
import {Directionality} from '@angular/cdk/bidi';
32+
33+
/**
34+
* A trigger for a menu.
35+
*
36+
* The menu trigger is used to open and close menus, and can be placed on menu items to connect
37+
* sub-menus.
38+
*/
39+
@Directive({
40+
selector: 'button[ngMenuTrigger]',
41+
exportAs: 'ngMenuTrigger',
42+
host: {
43+
'class': 'ng-menu-trigger',
44+
'[attr.tabindex]': 'uiPattern.tabindex()',
45+
'[attr.aria-haspopup]': 'uiPattern.hasPopup()',
46+
'[attr.aria-expanded]': 'uiPattern.expanded()',
47+
'[attr.aria-controls]': 'uiPattern.submenu()?.id()',
48+
'(click)': 'uiPattern.onClick()',
49+
'(keydown)': 'uiPattern.onKeydown($event)',
50+
'(focusout)': 'uiPattern.onFocusOut($event)',
51+
},
52+
})
53+
export class MenuTrigger<V> {
54+
/** A reference to the menu trigger element. */
55+
private readonly _elementRef = inject(ElementRef);
56+
57+
/** A reference to the menu element. */
58+
readonly element: HTMLButtonElement = this._elementRef.nativeElement;
59+
60+
// TODO(wagnermaciel): See we can remove the need to pass in a submenu.
61+
62+
/** The submenu associated with the menu trigger. */
63+
submenu = input<Menu<V> | undefined>(undefined);
64+
65+
/** A callback function triggered when a menu item is selected. */
66+
onSubmit = output<V>();
67+
68+
/** The menu trigger ui pattern instance. */
69+
uiPattern: MenuTriggerPattern<V> = new MenuTriggerPattern({
70+
onSubmit: (value: V) => this.onSubmit.emit(value),
71+
element: computed(() => this._elementRef.nativeElement),
72+
submenu: computed(() => this.submenu()?.uiPattern),
73+
});
74+
}
75+
76+
/**
77+
* A list of menu items.
78+
*
79+
* A menu is used to offer a list of menu item choices to users. Menus can be nested within other
80+
* menus to create sub-menus.
81+
*
82+
* ```html
83+
* <button ngMenuTrigger menu="menu">Options</button>
84+
*
85+
* <div ngMenu #menu="ngMenu">
86+
* <div ngMenuItem>Star</div>
87+
* <div ngMenuItem>Edit</div>
88+
* <div ngMenuItem>Delete</div>
89+
* </div>
90+
* ```
91+
*/
92+
@Directive({
93+
selector: '[ngMenu]',
94+
exportAs: 'ngMenu',
95+
host: {
96+
'role': 'menu',
97+
'class': 'ng-menu',
98+
'[attr.id]': 'uiPattern.id()',
99+
'[attr.data-visible]': 'uiPattern.isVisible()',
100+
'(keydown)': 'uiPattern.onKeydown($event)',
101+
'(mouseover)': 'uiPattern.onMouseOver($event)',
102+
'(mouseout)': 'uiPattern.onMouseOut($event)',
103+
'(focusout)': 'uiPattern.onFocusOut($event)',
104+
'(focusin)': 'uiPattern.onFocusIn()',
105+
'(click)': 'uiPattern.onClick($event)',
106+
},
107+
})
108+
export class Menu<V> {
109+
/** The menu items contained in the menu. */
110+
readonly _allItems = contentChildren<MenuItem<V>>(MenuItem, {descendants: true});
111+
112+
/** The menu items that are direct children of this menu. */
113+
readonly _items: Signal<MenuItem<V>[]> = computed(() =>
114+
this._allItems().filter(i => i.parent === this),
115+
);
116+
117+
/** A reference to the menu element. */
118+
private readonly _elementRef = inject(ElementRef);
119+
120+
/** A reference to the menu element. */
121+
readonly element: HTMLElement = this._elementRef.nativeElement;
122+
123+
/** The directionality (LTR / RTL) context for the application (or a subtree of it). */
124+
private readonly _directionality = inject(Directionality);
125+
126+
/** A signal wrapper for directionality. */
127+
readonly textDirection = toSignal(this._directionality.change, {
128+
initialValue: this._directionality.value,
129+
});
130+
131+
/** The submenu associated with the menu. */
132+
readonly submenu = input<Menu<V> | undefined>(undefined);
133+
134+
/** The unique ID of the menu. */
135+
readonly id = input<string>(Math.random().toString(36).substring(2, 10));
136+
137+
/** Whether the menu should wrap its items. */
138+
readonly wrap = input<boolean>(true);
139+
140+
/** The delay in seconds before the typeahead buffer is cleared. */
141+
readonly typeaheadDelay = input<number>(0.5); // Picked arbitrarily.
142+
143+
/** A reference to the parent menu item or menu trigger. */
144+
readonly parent = input<MenuTrigger<V> | MenuItem<V>>();
145+
146+
/** The menu ui pattern instance. */
147+
readonly uiPattern: MenuPattern<V>;
148+
149+
/**
150+
* The menu items as a writable signal.
151+
*
152+
* TODO(wagnermaciel): This would normally be a computed, but using a computed causes a bug where
153+
* sometimes the items array is empty. The bug can be reproduced by switching this to use a
154+
* computed and then quickly opening and closing menus in the dev app.
155+
*/
156+
readonly items = () => this._items().map(i => i.uiPattern);
157+
158+
/** Whether the menu is visible. */
159+
isVisible = computed(() => this.uiPattern.isVisible());
160+
161+
/** A callback function triggered when a menu item is selected. */
162+
onSubmit = output<V>();
163+
164+
constructor() {
165+
this.uiPattern = new MenuPattern({
166+
...this,
167+
parent: computed(() => this.parent()?.uiPattern),
168+
multi: () => false,
169+
skipDisabled: () => false,
170+
focusMode: () => 'roving',
171+
orientation: () => 'vertical',
172+
selectionMode: () => 'explicit',
173+
activeItem: signal(undefined),
174+
element: computed(() => this._elementRef.nativeElement),
175+
onSubmit: (value: V) => this.onSubmit.emit(value),
176+
});
177+
178+
// TODO(wagnermaciel): This is a redundancy needed for if the user uses display: none to hide
179+
// submenus. In those cases, the ui pattern is calling focus() before the ui has a chance to
180+
// update the display property. The result is focus() being called on an element that is not
181+
// focusable. This simply retries focusing the element after render.
182+
afterRenderEffect(() => {
183+
if (this.uiPattern.isVisible()) {
184+
const activeItem = untracked(() => this.uiPattern.inputs.activeItem());
185+
this.uiPattern.listBehavior.goto(activeItem!);
186+
}
187+
});
188+
189+
afterRenderEffect(() => {
190+
if (!this.uiPattern.hasBeenFocused()) {
191+
this.uiPattern.setDefaultState();
192+
}
193+
});
194+
}
195+
196+
// TODO(wagnermaciel): Author close, closeAll, and open methods for each directive.
197+
198+
/** Closes the menu. */
199+
close(opts?: {refocus?: boolean}) {
200+
this.uiPattern.inputs.parent()?.close(opts);
201+
}
202+
203+
/** Closes all parent menus. */
204+
closeAll(opts?: {refocus?: boolean}) {
205+
const root = this.uiPattern.root();
206+
207+
if (root instanceof MenuTriggerPattern) {
208+
root.close(opts);
209+
}
210+
211+
if (root instanceof MenuPattern || root instanceof MenuBarPattern) {
212+
root.inputs.activeItem()?.close(opts);
213+
}
214+
}
215+
}
216+
217+
/**
218+
* A menu bar of menu items.
219+
*
220+
* Like the menu, a menubar is used to offer a list of menu item choices to users. However, a
221+
* menubar is used to display a persistent, top-level,
222+
* always-visible set of menu item choices.
223+
*/
224+
@Directive({
225+
selector: '[ngMenuBar]',
226+
exportAs: 'ngMenuBar',
227+
host: {
228+
'role': 'menubar',
229+
'class': 'ng-menu-bar',
230+
'(keydown)': 'uiPattern.onKeydown($event)',
231+
'(mouseover)': 'uiPattern.onMouseOver($event)',
232+
'(click)': 'uiPattern.onClick($event)',
233+
'(focusin)': 'uiPattern.onFocusIn()',
234+
'(focusout)': 'uiPattern.onFocusOut($event)',
235+
},
236+
})
237+
export class MenuBar<V> {
238+
/** The menu items contained in the menubar. */
239+
readonly _allItems = contentChildren<MenuItem<V>>(MenuItem, {descendants: true});
240+
241+
readonly _items: SignalLike<MenuItem<V>[]> = () =>
242+
this._allItems().filter(i => i.parent === this);
243+
244+
/** A reference to the menu element. */
245+
private readonly _elementRef = inject(ElementRef);
246+
247+
/** A reference to the menubar element. */
248+
readonly element: HTMLElement = this._elementRef.nativeElement;
249+
250+
/** The directionality (LTR / RTL) context for the application (or a subtree of it). */
251+
private readonly _directionality = inject(Directionality);
252+
253+
/** A signal wrapper for directionality. */
254+
readonly textDirection = toSignal(this._directionality.change, {
255+
initialValue: this._directionality.value,
256+
});
257+
258+
/** The value of the menu. */
259+
readonly value = model<V[]>([]);
260+
261+
/** Whether the menu should wrap its items. */
262+
readonly wrap = input<boolean>(true);
263+
264+
/** The delay in seconds before the typeahead buffer is cleared. */
265+
readonly typeaheadDelay = input<number>(0.5);
266+
267+
/** The menu ui pattern instance. */
268+
readonly uiPattern: MenuBarPattern<V>;
269+
270+
/** The menu items as a writable signal. */
271+
readonly items = signal<MenuItemPattern<V>[]>([]);
272+
273+
/** A callback function triggered when a menu item is selected. */
274+
onSubmit = output<V>();
275+
276+
constructor() {
277+
this.uiPattern = new MenuBarPattern({
278+
...this,
279+
multi: () => false,
280+
skipDisabled: () => false,
281+
focusMode: () => 'roving',
282+
orientation: () => 'horizontal',
283+
selectionMode: () => 'explicit',
284+
onSubmit: (value: V) => this.onSubmit.emit(value),
285+
activeItem: signal(undefined),
286+
element: computed(() => this._elementRef.nativeElement),
287+
});
288+
289+
afterRenderEffect(() => {
290+
this.items.set(this._items().map(i => i.uiPattern));
291+
});
292+
293+
afterRenderEffect(() => {
294+
if (!this.uiPattern.hasBeenFocused()) {
295+
this.uiPattern.setDefaultState();
296+
}
297+
});
298+
}
299+
}
300+
301+
/**
302+
* An item in a Menu.
303+
*
304+
* Menu items can be used in menus and menubars to represent a choice or action a user can take.
305+
*/
306+
@Directive({
307+
selector: '[ngMenuItem]',
308+
exportAs: 'ngMenuItem',
309+
host: {
310+
'role': 'menuitem',
311+
'class': 'ng-menu-item',
312+
'[attr.tabindex]': 'uiPattern.tabindex()',
313+
'[attr.data-active]': 'uiPattern.isActive()',
314+
'[attr.aria-haspopup]': 'uiPattern.hasPopup()',
315+
'[attr.aria-expanded]': 'uiPattern.expanded()',
316+
'[attr.aria-disabled]': 'uiPattern.disabled()',
317+
'[attr.aria-controls]': 'uiPattern.submenu()?.id()',
318+
},
319+
})
320+
export class MenuItem<V> {
321+
/** A reference to the menu item element. */
322+
private readonly _elementRef = inject(ElementRef);
323+
324+
/** A reference to the menu element. */
325+
readonly element: HTMLElement = this._elementRef.nativeElement;
326+
327+
/** The unique ID of the menu item. */
328+
readonly id = input<string>(Math.random().toString(36).substring(2, 10));
329+
330+
/** The value of the menu item. */
331+
readonly value = input.required<V>();
332+
333+
/** Whether the menu item is disabled. */
334+
readonly disabled = input<boolean>(false);
335+
336+
// TODO(wagnermaciel): Discuss whether all inputs should be models.
337+
338+
/** The search term associated with the menu item. */
339+
readonly searchTerm = model<string>('');
340+
341+
/** A reference to the parent menu. */
342+
private readonly _menu = inject<Menu<V>>(Menu, {optional: true});
343+
344+
/** A reference to the parent menu bar. */
345+
private readonly _menuBar = inject<MenuBar<V>>(MenuBar, {optional: true});
346+
347+
/** A reference to the parent menu or menubar. */
348+
readonly parent = this._menu ?? this._menuBar;
349+
350+
/** The submenu associated with the menu item. */
351+
readonly submenu = input<Menu<V> | undefined>(undefined);
352+
353+
/** The menu item ui pattern instance. */
354+
readonly uiPattern: MenuItemPattern<V> = new MenuItemPattern<V>({
355+
id: this.id,
356+
value: this.value,
357+
element: computed(() => this._elementRef.nativeElement),
358+
disabled: this.disabled,
359+
searchTerm: this.searchTerm,
360+
parent: computed(() => this.parent?.uiPattern),
361+
submenu: computed(() => this.submenu()?.uiPattern),
362+
});
363+
}

0 commit comments

Comments
 (0)