-
Notifications
You must be signed in to change notification settings - Fork 6.8k
feat(aria/menu): create the aria menu #32080
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
wagnermaciel
wants to merge
3
commits into
angular:main
Choose a base branch
from
wagnermaciel:aria-menu
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
load("//tools:defaults.bzl", "ng_project") | ||
|
||
package(default_visibility = ["//visibility:public"]) | ||
|
||
ng_project( | ||
name = "menu", | ||
srcs = glob( | ||
["**/*.ts"], | ||
exclude = ["**/*.spec.ts"], | ||
), | ||
deps = [ | ||
"//:node_modules/@angular/core", | ||
"//src/aria/ui-patterns", | ||
"//src/cdk/a11y", | ||
"//src/cdk/bidi", | ||
], | ||
) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
/** | ||
* @license | ||
* Copyright Google LLC All Rights Reserved. | ||
* | ||
* Use of this source code is governed by an MIT-style license that can be | ||
* found in the LICENSE file at https://angular.dev/license | ||
*/ | ||
|
||
export {Menu, MenuBar, MenuItem, MenuTrigger} from './menu'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,367 @@ | ||
/** | ||
* @license | ||
* Copyright Google LLC All Rights Reserved. | ||
* | ||
* Use of this source code is governed by an MIT-style license that can be | ||
* found in the LICENSE file at https://angular.dev/license | ||
*/ | ||
|
||
import { | ||
afterRenderEffect, | ||
computed, | ||
contentChildren, | ||
Directive, | ||
ElementRef, | ||
inject, | ||
input, | ||
model, | ||
output, | ||
Signal, | ||
signal, | ||
untracked, | ||
} from '@angular/core'; | ||
import { | ||
MenuBarPattern, | ||
MenuItemPattern, | ||
MenuPattern, | ||
MenuTriggerPattern, | ||
} from '../ui-patterns/menu/menu'; | ||
import {toSignal} from '@angular/core/rxjs-interop'; | ||
import {Directionality} from '@angular/cdk/bidi'; | ||
import {SignalLike} from '../ui-patterns'; | ||
|
||
/** | ||
* A trigger for a menu. | ||
* | ||
* The menu trigger is used to open and close menus, and can be placed on menu items to connect | ||
* sub-menus. | ||
*/ | ||
@Directive({ | ||
selector: 'button[ngMenuTrigger]', | ||
exportAs: 'ngMenuTrigger', | ||
host: { | ||
'class': 'ng-menu-trigger', | ||
'[attr.tabindex]': 'uiPattern.tabindex()', | ||
'[attr.aria-haspopup]': 'uiPattern.hasPopup()', | ||
'[attr.aria-expanded]': 'uiPattern.expanded()', | ||
'[attr.aria-controls]': 'uiPattern.submenu()?.id()', | ||
'(click)': 'uiPattern.onClick()', | ||
'(keydown)': 'uiPattern.onKeydown($event)', | ||
'(focusout)': 'uiPattern.onFocusOut($event)', | ||
}, | ||
}) | ||
export class MenuTrigger<V> { | ||
/** A reference to the menu trigger element. */ | ||
private readonly _elementRef = inject(ElementRef); | ||
|
||
/** A reference to the menu element. */ | ||
readonly element: HTMLButtonElement = this._elementRef.nativeElement; | ||
|
||
/** The submenu associated with the menu trigger. */ | ||
submenu = input<Menu<V> | undefined>(undefined); | ||
|
||
/** A callback function triggered when a menu item is selected. */ | ||
onSubmit = output<V>(); | ||
|
||
/** The menu trigger ui pattern instance. */ | ||
uiPattern: MenuTriggerPattern<V> = new MenuTriggerPattern({ | ||
onSubmit: (value: V) => this.onSubmit.emit(value), | ||
element: computed(() => this._elementRef.nativeElement), | ||
submenu: computed(() => this.submenu()?.uiPattern), | ||
}); | ||
} | ||
|
||
/** | ||
* A list of menu items. | ||
* | ||
* A menu is used to offer a list of menu item choices to users. Menus can be nested within other | ||
* menus to create sub-menus. | ||
* | ||
* ```html | ||
* <button ngMenuTrigger menu="menu">Options</button> | ||
* | ||
* <div ngMenu #menu="ngMenu"> | ||
* <div ngMenuItem>Star</div> | ||
* <div ngMenuItem>Edit</div> | ||
* <div ngMenuItem>Delete</div> | ||
* </div> | ||
* ``` | ||
*/ | ||
@Directive({ | ||
selector: '[ngMenu]', | ||
exportAs: 'ngMenu', | ||
host: { | ||
'role': 'menu', | ||
'class': 'ng-menu', | ||
'[attr.id]': 'uiPattern.id()', | ||
'[attr.data-visible]': 'uiPattern.isVisible()', | ||
'(keydown)': 'uiPattern.onKeydown($event)', | ||
'(mouseover)': 'uiPattern.onMouseOver($event)', | ||
'(focusout)': 'uiPattern.onFocusOut($event)', | ||
'(focusin)': 'uiPattern.onFocusIn()', | ||
'(click)': 'uiPattern.onClick($event)', | ||
}, | ||
}) | ||
export class Menu<V> { | ||
/** The menu items contained in the menu. */ | ||
readonly _allItems = contentChildren<MenuItem<V>>(MenuItem, {descendants: true}); | ||
|
||
/** The menu items that are direct children of this menu. */ | ||
readonly _items: Signal<MenuItem<V>[]> = computed(() => | ||
this._allItems().filter(i => i.parent === this), | ||
); | ||
|
||
/** A reference to the menu element. */ | ||
private readonly _elementRef = inject(ElementRef); | ||
|
||
/** A reference to the menu element. */ | ||
readonly element: HTMLElement = this._elementRef.nativeElement; | ||
|
||
/** The directionality (LTR / RTL) context for the application (or a subtree of it). */ | ||
private readonly _directionality = inject(Directionality); | ||
|
||
/** A signal wrapper for directionality. */ | ||
readonly textDirection = toSignal(this._directionality.change, { | ||
initialValue: this._directionality.value, | ||
}); | ||
|
||
/** The submenu associated with the menu. */ | ||
readonly submenu = input<Menu<V> | undefined>(undefined); | ||
|
||
/** The unique ID of the menu. */ | ||
readonly id = input<string>(Math.random().toString(36).substring(2, 10)); | ||
|
||
/** The value of the menu. */ | ||
readonly value = model<V[]>([]); | ||
|
||
/** Whether the menu should wrap its items. */ | ||
readonly wrap = input<boolean>(true); | ||
|
||
/** Whether the menu should skip disabled items. */ | ||
readonly skipDisabled = input<boolean>(false); | ||
|
||
/** The delay in seconds before the typeahead buffer is cleared. */ | ||
readonly typeaheadDelay = input<number>(0.5); // Picked arbitrarily. | ||
|
||
/** A reference to the parent menu item or menu trigger. */ | ||
readonly parent = input<MenuTrigger<V> | MenuItem<V>>(); | ||
|
||
/** The menu ui pattern instance. */ | ||
readonly uiPattern: MenuPattern<V>; | ||
|
||
/** | ||
* The menu items as a writable signal. | ||
* | ||
* TODO(wagnermaciel): This would normally be a computed, but using a computed causes a bug where | ||
* sometimes the items array is empty. The bug can be reproduced by switching this to use a | ||
* computed and then quickly opening and closing menus in the dev app. | ||
*/ | ||
readonly items: SignalLike<MenuItemPattern<V>[]> = () => this._items().map(i => i.uiPattern); | ||
|
||
/** Whether the menu is visible. */ | ||
isVisible = computed(() => this.uiPattern.isVisible()); | ||
|
||
/** A callback function triggered when a menu item is selected. */ | ||
onSubmit = output<V>(); | ||
|
||
constructor() { | ||
this.uiPattern = new MenuPattern({ | ||
...this, | ||
parent: computed(() => this.parent()?.uiPattern), | ||
multi: () => false, | ||
focusMode: () => 'roving', | ||
orientation: () => 'vertical', | ||
selectionMode: () => 'explicit', | ||
activeItem: signal(undefined), | ||
element: computed(() => this._elementRef.nativeElement), | ||
onSubmit: (value: V) => this.onSubmit.emit(value), | ||
}); | ||
|
||
// TODO(wagnermaciel): This is a redundancy needed for if the user uses display: none to hide | ||
// submenus. In those cases, the ui pattern is calling focus() before the ui has a chance to | ||
// update the display property. The result is focus() being called on an element that is not | ||
// focusable. This simply retries focusing the element after render. | ||
afterRenderEffect(() => { | ||
if (this.uiPattern.isVisible()) { | ||
const activeItem = untracked(() => this.uiPattern.inputs.activeItem()); | ||
this.uiPattern.listBehavior.goto(activeItem!); | ||
} | ||
}); | ||
|
||
afterRenderEffect(() => { | ||
if (!this.uiPattern.hasBeenFocused()) { | ||
this.uiPattern.setDefaultState(); | ||
} | ||
}); | ||
} | ||
|
||
// TODO(wagnermaciel): Author close, closeAll, and open methods for each directive. | ||
|
||
/** Closes the menu. */ | ||
close(opts?: {refocus?: boolean}) { | ||
this.uiPattern.inputs.parent()?.close(opts); | ||
} | ||
|
||
/** Closes all parent menus. */ | ||
closeAll(opts?: {refocus?: boolean}) { | ||
const root = this.uiPattern.root(); | ||
|
||
if (root instanceof MenuTriggerPattern) { | ||
root.close(opts); | ||
} | ||
|
||
if (root instanceof MenuPattern || root instanceof MenuBarPattern) { | ||
root.inputs.activeItem()?.close(opts); | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* A menu bar of menu items. | ||
* | ||
* Like the menu, a menubar is used to offer a list of menu item choices to users. However, a | ||
* menubar is used to display a persistent, top-level, | ||
* always-visible set of menu item choices. | ||
*/ | ||
@Directive({ | ||
selector: '[ngMenuBar]', | ||
exportAs: 'ngMenuBar', | ||
host: { | ||
'role': 'menubar', | ||
'class': 'ng-menu-bar', | ||
'(keydown)': 'uiPattern.onKeydown($event)', | ||
'(mouseover)': 'uiPattern.onMouseOver($event)', | ||
'(click)': 'uiPattern.onClick($event)', | ||
'(focusin)': 'uiPattern.onFocusIn()', | ||
'(focusout)': 'uiPattern.onFocusOut($event)', | ||
}, | ||
}) | ||
export class MenuBar<V> { | ||
/** The menu items contained in the menubar. */ | ||
readonly _allItems = contentChildren<MenuItem<V>>(MenuItem, {descendants: true}); | ||
|
||
readonly _items: SignalLike<MenuItem<V>[]> = () => | ||
this._allItems().filter(i => i.parent === this); | ||
|
||
/** A reference to the menu element. */ | ||
private readonly _elementRef = inject(ElementRef); | ||
|
||
/** A reference to the menubar element. */ | ||
readonly element: HTMLElement = this._elementRef.nativeElement; | ||
|
||
/** The directionality (LTR / RTL) context for the application (or a subtree of it). */ | ||
private readonly _directionality = inject(Directionality); | ||
|
||
/** A signal wrapper for directionality. */ | ||
readonly textDirection = toSignal(this._directionality.change, { | ||
initialValue: this._directionality.value, | ||
}); | ||
|
||
/** The value of the menu. */ | ||
readonly value = model<V[]>([]); | ||
|
||
/** Whether the menu should wrap its items. */ | ||
readonly wrap = input<boolean>(true); | ||
|
||
/** Whether the menu should skip disabled items. */ | ||
readonly skipDisabled = input<boolean>(false); | ||
|
||
/** The delay in seconds before the typeahead buffer is cleared. */ | ||
readonly typeaheadDelay = input<number>(0.5); | ||
|
||
/** The menu ui pattern instance. */ | ||
readonly uiPattern: MenuBarPattern<V>; | ||
|
||
/** The menu items as a writable signal. */ | ||
readonly items = signal<MenuItemPattern<V>[]>([]); | ||
|
||
/** A callback function triggered when a menu item is selected. */ | ||
onSubmit = output<V>(); | ||
|
||
constructor() { | ||
this.uiPattern = new MenuBarPattern({ | ||
...this, | ||
multi: () => false, | ||
focusMode: () => 'roving', | ||
orientation: () => 'horizontal', | ||
selectionMode: () => 'explicit', | ||
onSubmit: (value: V) => this.onSubmit.emit(value), | ||
activeItem: signal(undefined), | ||
element: computed(() => this._elementRef.nativeElement), | ||
}); | ||
|
||
afterRenderEffect(() => { | ||
this.items.set(this._items().map(i => i.uiPattern)); | ||
}); | ||
|
||
afterRenderEffect(() => { | ||
if (!this.uiPattern.hasBeenFocused()) { | ||
this.uiPattern.setDefaultState(); | ||
} | ||
}); | ||
} | ||
} | ||
|
||
/** | ||
* An item in a Menu. | ||
* | ||
* Menu items can be used in menus and menubars to represent a choice or action a user can take. | ||
*/ | ||
@Directive({ | ||
selector: '[ngMenuItem]', | ||
exportAs: 'ngMenuItem', | ||
host: { | ||
'role': 'menuitem', | ||
'class': 'ng-menu-item', | ||
'[attr.tabindex]': 'uiPattern.tabindex()', | ||
'[attr.data-active]': 'uiPattern.isActive()', | ||
'[attr.aria-haspopup]': 'uiPattern.hasPopup()', | ||
'[attr.aria-expanded]': 'uiPattern.expanded()', | ||
'[attr.aria-disabled]': 'uiPattern.disabled()', | ||
'[attr.aria-controls]': 'uiPattern.submenu()?.id()', | ||
}, | ||
}) | ||
export class MenuItem<V> { | ||
/** A reference to the menu item element. */ | ||
private readonly _elementRef = inject(ElementRef); | ||
|
||
/** A reference to the menu element. */ | ||
readonly element: HTMLElement = this._elementRef.nativeElement; | ||
|
||
/** The unique ID of the menu item. */ | ||
readonly id = input<string>(Math.random().toString(36).substring(2, 10)); | ||
|
||
/** The value of the menu item. */ | ||
readonly value = input.required<V>(); | ||
|
||
/** Whether the menu item is disabled. */ | ||
readonly disabled = input<boolean>(false); | ||
|
||
// TODO(wagnermaciel): Discuss whether all inputs should be models. | ||
|
||
/** The search term associated with the menu item. */ | ||
readonly searchTerm = model<string>(''); | ||
|
||
/** A reference to the parent menu. */ | ||
private readonly _menu = inject<Menu<V>>(Menu, {optional: true}); | ||
|
||
/** A reference to the parent menu bar. */ | ||
private readonly _menuBar = inject<MenuBar<V>>(MenuBar, {optional: true}); | ||
|
||
/** A reference to the parent menu or menubar. */ | ||
readonly parent = this._menu ?? this._menuBar; | ||
|
||
/** The submenu associated with the menu item. */ | ||
readonly submenu = input<Menu<V> | undefined>(undefined); | ||
|
||
/** The menu item ui pattern instance. */ | ||
readonly uiPattern: MenuItemPattern<V> = new MenuItemPattern<V>({ | ||
id: this.id, | ||
value: this.value, | ||
element: computed(() => this._elementRef.nativeElement), | ||
disabled: this.disabled, | ||
searchTerm: this.searchTerm, | ||
parent: computed(() => this.parent?.uiPattern), | ||
submenu: computed(() => this.submenu()?.uiPattern), | ||
}); | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this the new ID strategy?