Skip to content

Commit 684b234

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 a0f93e8 commit 684b234

File tree

3 files changed

+379
-0
lines changed

3 files changed

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

0 commit comments

Comments
 (0)