Skip to content

Commit 0f46514

Browse files
committed
feat(aria): 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 5daf97b commit 0f46514

File tree

3 files changed

+393
-0
lines changed

3 files changed

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

0 commit comments

Comments
 (0)