From 105d63ac48254fad54251cd9761ccffcdc4fdb81 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Tue, 14 Oct 2025 18:35:40 -0400 Subject: [PATCH 1/6] 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. --- src/aria/ui-patterns/BUILD.bazel | 1 + .../behaviors/list-focus/list-focus.ts | 8 +- .../list-navigation/list-navigation.ts | 24 +- src/aria/ui-patterns/behaviors/list/list.ts | 31 +- src/aria/ui-patterns/listbox/listbox.ts | 2 +- src/aria/ui-patterns/menu/BUILD.bazel | 37 + src/aria/ui-patterns/menu/menu.ts | 640 ++++++++++++++++++ src/aria/ui-patterns/public-api.ts | 1 + 8 files changed, 714 insertions(+), 30 deletions(-) create mode 100644 src/aria/ui-patterns/menu/BUILD.bazel create mode 100644 src/aria/ui-patterns/menu/menu.ts diff --git a/src/aria/ui-patterns/BUILD.bazel b/src/aria/ui-patterns/BUILD.bazel index d8da5e05682d..a6050de4ec91 100644 --- a/src/aria/ui-patterns/BUILD.bazel +++ b/src/aria/ui-patterns/BUILD.bazel @@ -14,6 +14,7 @@ ts_project( "//src/aria/ui-patterns/behaviors/signal-like", "//src/aria/ui-patterns/combobox", "//src/aria/ui-patterns/listbox", + "//src/aria/ui-patterns/menu", "//src/aria/ui-patterns/radio-group", "//src/aria/ui-patterns/tabs", "//src/aria/ui-patterns/toolbar", diff --git a/src/aria/ui-patterns/behaviors/list-focus/list-focus.ts b/src/aria/ui-patterns/behaviors/list-focus/list-focus.ts index 9515e29a30ee..53e1c47fd174 100644 --- a/src/aria/ui-patterns/behaviors/list-focus/list-focus.ts +++ b/src/aria/ui-patterns/behaviors/list-focus/list-focus.ts @@ -97,7 +97,7 @@ export class ListFocus { } /** Moves focus to the given item if it is focusable. */ - focus(item: T): boolean { + focus(item: T, opts?: {focusElement?: boolean}): boolean { if (this.isListDisabled() || !this.isFocusable(item)) { return false; } @@ -105,7 +105,11 @@ export class ListFocus { this.prevActiveItem.set(this.inputs.activeItem()); this.inputs.activeItem.set(item); - this.inputs.focusMode() === 'roving' ? item.element().focus() : this.inputs.element()?.focus(); + if (opts?.focusElement || opts?.focusElement === undefined) { + this.inputs.focusMode() === 'roving' + ? item.element().focus() + : this.inputs.element()?.focus(); + } return true; } diff --git a/src/aria/ui-patterns/behaviors/list-navigation/list-navigation.ts b/src/aria/ui-patterns/behaviors/list-navigation/list-navigation.ts index 198880d8a585..7899eec6e180 100644 --- a/src/aria/ui-patterns/behaviors/list-navigation/list-navigation.ts +++ b/src/aria/ui-patterns/behaviors/list-navigation/list-navigation.ts @@ -29,13 +29,13 @@ export class ListNavigation { constructor(readonly inputs: ListNavigationInputs & {focusManager: ListFocus}) {} /** Navigates to the given item. */ - goto(item?: T): boolean { - return item ? this.inputs.focusManager.focus(item) : false; + goto(item?: T, opts?: {focusElement?: boolean}): boolean { + return item ? this.inputs.focusManager.focus(item, opts) : false; } /** Navigates to the next item in the list. */ - next(): boolean { - return this._advance(1); + next(opts?: {focusElement?: boolean}): boolean { + return this._advance(1, opts); } /** Peeks the next item in the list. */ @@ -44,8 +44,8 @@ export class ListNavigation { } /** Navigates to the previous item in the list. */ - prev(): boolean { - return this._advance(-1); + prev(opts?: {focusElement?: boolean}): boolean { + return this._advance(-1, opts); } /** Peeks the previous item in the list. */ @@ -54,26 +54,26 @@ export class ListNavigation { } /** Navigates to the first item in the list. */ - first(): boolean { + first(opts?: {focusElement?: boolean}): boolean { const item = this.inputs.items().find(i => this.inputs.focusManager.isFocusable(i)); - return item ? this.goto(item) : false; + return item ? this.goto(item, opts) : false; } /** Navigates to the last item in the list. */ - last(): boolean { + last(opts?: {focusElement?: boolean}): boolean { const items = this.inputs.items(); for (let i = items.length - 1; i >= 0; i--) { if (this.inputs.focusManager.isFocusable(items[i])) { - return this.goto(items[i]); + return this.goto(items[i], opts); } } return false; } /** Advances to the next or previous focusable item in the list based on the given delta. */ - private _advance(delta: 1 | -1): boolean { + private _advance(delta: 1 | -1, opts?: {focusElement?: boolean}): boolean { const item = this._peek(delta); - return item ? this.goto(item) : false; + return item ? this.goto(item, opts) : false; } /** Peeks the next or previous focusable item in the list based on the given delta. */ diff --git a/src/aria/ui-patterns/behaviors/list/list.ts b/src/aria/ui-patterns/behaviors/list/list.ts index cccaf34c6832..49576d5902ac 100644 --- a/src/aria/ui-patterns/behaviors/list/list.ts +++ b/src/aria/ui-patterns/behaviors/list/list.ts @@ -24,13 +24,14 @@ import { ListTypeaheadItem, } from '../list-typeahead/list-typeahead'; -/** The selection operations that the list can perform. */ -interface SelectOptions { +/** The operations that the list can perform after navigation. */ +interface NavOptions { toggle?: boolean; select?: boolean; selectOne?: boolean; selectRange?: boolean; anchor?: boolean; + focusElement?: boolean; } /** Represents an item in the list. */ @@ -105,28 +106,28 @@ export class List, V> { } /** Navigates to the first option in the list. */ - first(opts?: SelectOptions) { - this._navigate(opts, () => this.navigationBehavior.first()); + first(opts?: NavOptions) { + this._navigate(opts, () => this.navigationBehavior.first(opts)); } /** Navigates to the last option in the list. */ - last(opts?: SelectOptions) { - this._navigate(opts, () => this.navigationBehavior.last()); + last(opts?: NavOptions) { + this._navigate(opts, () => this.navigationBehavior.last(opts)); } /** Navigates to the next option in the list. */ - next(opts?: SelectOptions) { - this._navigate(opts, () => this.navigationBehavior.next()); + next(opts?: NavOptions) { + this._navigate(opts, () => this.navigationBehavior.next(opts)); } /** Navigates to the previous option in the list. */ - prev(opts?: SelectOptions) { - this._navigate(opts, () => this.navigationBehavior.prev()); + prev(opts?: NavOptions) { + this._navigate(opts, () => this.navigationBehavior.prev(opts)); } /** Navigates to the given item in the list. */ - goto(item: T, opts?: SelectOptions) { - this._navigate(opts, () => this.navigationBehavior.goto(item)); + goto(item: T, opts?: NavOptions) { + this._navigate(opts, () => this.navigationBehavior.goto(item, opts)); } /** Removes focus from the list. */ @@ -140,7 +141,7 @@ export class List, V> { } /** Handles typeahead search navigation for the list. */ - search(char: string, opts?: SelectOptions) { + search(char: string, opts?: NavOptions) { this._navigate(opts, () => this.typeaheadBehavior.search(char)); } @@ -190,7 +191,7 @@ export class List, V> { } /** Handles updating selection for the list. */ - updateSelection(opts: SelectOptions = {anchor: true}) { + updateSelection(opts: NavOptions = {anchor: true}) { if (opts.toggle) { this.selectionBehavior.toggle(); } @@ -217,7 +218,7 @@ export class List, V> { * Handles boilerplate calling of focus & selection operations. Also ensures these * additional operations are only called if the navigation operation moved focus to a new option. */ - private _navigate(opts: SelectOptions = {}, operation: () => boolean) { + private _navigate(opts: NavOptions = {}, operation: () => boolean) { if (opts?.selectRange) { this._wrap.set(false); this.selectionBehavior.rangeStartIndex.set(this._anchorIndex()); diff --git a/src/aria/ui-patterns/listbox/listbox.ts b/src/aria/ui-patterns/listbox/listbox.ts index 77e12bfc46ca..5f5c034e4866 100644 --- a/src/aria/ui-patterns/listbox/listbox.ts +++ b/src/aria/ui-patterns/listbox/listbox.ts @@ -72,7 +72,7 @@ export class ListboxPattern { dynamicSpaceKey = computed(() => (this.listBehavior.isTyping() ? '' : ' ')); /** The regexp used to decide if a key should trigger typeahead. */ - typeaheadRegexp = /^.$/; // TODO: Ignore spaces? + typeaheadRegexp = /^.$/; /** The keydown event manager for the listbox. */ keydown = computed(() => { diff --git a/src/aria/ui-patterns/menu/BUILD.bazel b/src/aria/ui-patterns/menu/BUILD.bazel new file mode 100644 index 000000000000..6609d161f54d --- /dev/null +++ b/src/aria/ui-patterns/menu/BUILD.bazel @@ -0,0 +1,37 @@ +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "menu", + srcs = [ + "menu.ts", + ], + deps = [ + "//:node_modules/@angular/core", + "//src/aria/ui-patterns/behaviors/event-manager", + "//src/aria/ui-patterns/behaviors/expansion", + "//src/aria/ui-patterns/behaviors/list", + "//src/aria/ui-patterns/behaviors/signal-like", + ], +) + +ng_project( + name = "unit_test_sources", + testonly = True, + srcs = [ + "menu.spec.ts", + ], + deps = [ + ":menu", + "//:node_modules/@angular/core", + "//src/aria/ui-patterns/behaviors/signal-like", + "//src/cdk/keycodes", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/aria/ui-patterns/menu/menu.ts b/src/aria/ui-patterns/menu/menu.ts new file mode 100644 index 000000000000..a8b0fdc9a145 --- /dev/null +++ b/src/aria/ui-patterns/menu/menu.ts @@ -0,0 +1,640 @@ +/** + * @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 {computed, Signal, signal} from '@angular/core'; +import {KeyboardEventManager} from '../behaviors/event-manager'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; +import {List, ListInputs, ListItem} from '../behaviors/list/list'; + +/** The inputs for the MenuBarPattern class. */ +export interface MenuBarInputs extends Omit, V>, 'disabled'> { + /** The menu items contained in the menu. */ + items: SignalLike[]>; + + /** Callback function triggered when a menu item is selected. */ + onSubmit?: (value: V) => void; +} + +/** The inputs for the MenuPattern class. */ +export interface MenuInputs extends Omit, V>, 'disabled'> { + /** The unique ID of the menu. */ + id: SignalLike; + + /** The menu items contained in the menu. */ + items: SignalLike[]>; + + /** A reference to the parent menu or menu trigger. */ + parent: SignalLike | MenuItemPattern | undefined>; + + /** Callback function triggered when a menu item is selected. */ + onSubmit?: (value: V) => void; +} + +/** The inputs for the MenuTriggerPattern class. */ +export interface MenuTriggerInputs { + /** A reference to the menu trigger element. */ + element: SignalLike; + + /** A reference to the submenu associated with the menu trigger. */ + submenu: SignalLike | undefined>; + + /** Callback function triggered when a menu item is selected. */ + onSubmit?: (value: V) => void; +} + +/** The inputs for the MenuItemPattern class. */ +export interface MenuItemInputs extends Omit, 'index'> { + /** A reference to the parent menu or menu trigger. */ + parent: SignalLike | MenuBarPattern | undefined>; + + /** A reference to the submenu associated with the menu item. */ + submenu: SignalLike | undefined>; +} + +/** The menu ui pattern class. */ +export class MenuPattern { + /** The unique ID of the menu. */ + id: SignalLike; + + /** The role of the menu. */ + role = () => 'menu'; + + /** Whether the menu is visible. */ + isVisible = computed(() => (this.inputs.parent() ? !!this.inputs.parent()?.expanded() : true)); + + /** Controls list behavior for the menu items. */ + listBehavior: List, V>; + + /** Whether the menu or any of its child elements are currently focused. */ + isFocused = signal(false); + + /** Whether the menu has received focus. */ + hasBeenFocused = signal(false); + + /** Whether the menu should be focused on mouse over. */ + shouldFocus = computed(() => { + const root = this.root(); + + if (root instanceof MenuTriggerPattern) { + return true; + } + + if (root instanceof MenuBarPattern || root instanceof MenuPattern) { + return root.isFocused(); + } + + return false; + }); + + /** The key used to expand sub-menus. */ + private _expandKey = computed(() => { + return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + }); + + /** The key used to collapse sub-menus. */ + private _collapseKey = computed(() => { + return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + }); + + /** Represents the space key. Does nothing when the user is actively using typeahead. */ + dynamicSpaceKey = computed(() => (this.listBehavior.isTyping() ? '' : ' ')); + + /** The regexp used to decide if a key should trigger typeahead. */ + typeaheadRegexp = /^.$/; + + /** The root of the menu. */ + root: Signal | MenuBarPattern | MenuPattern | undefined> = computed( + () => { + const parent = this.inputs.parent(); + + if (!parent) { + return this; + } + + if (parent instanceof MenuTriggerPattern) { + return parent; + } + + const grandparent = parent.inputs.parent(); + + if (grandparent instanceof MenuBarPattern) { + return grandparent; + } + + return grandparent?.root(); + }, + ); + + /** Handles keyboard events for the menu. */ + keydownManager = computed(() => { + return new KeyboardEventManager() + .on('ArrowDown', () => this.next()) + .on('ArrowUp', () => this.prev()) + .on('Home', () => this.first()) + .on('End', () => this.last()) + .on('Enter', () => this.trigger()) + .on('Escape', () => this.closeAll()) + .on(this._expandKey, () => this.expand()) + .on(this._collapseKey, () => this.collapse()) + .on(this.dynamicSpaceKey, () => this.trigger()) + .on(this.typeaheadRegexp, e => this.listBehavior.search(e.key)); + }); + + constructor(public readonly inputs: MenuInputs) { + this.id = inputs.id; + this.listBehavior = new List, V>({...inputs, disabled: () => false}); + } + + /** Sets the default state for the menu. */ + setDefaultState() { + if (!this.inputs.parent()) { + this.inputs.activeItem.set(this.inputs.items()[0]); + } + } + + /** Handles keyboard events for the menu. */ + onKeydown(event: KeyboardEvent) { + this.keydownManager().handle(event); + } + + /** Handles mouseover events for the menu. */ + onMouseOver(event: MouseEvent) { + if (!this.isVisible()) { + return; + } + + const item = this.inputs.items().find(i => i.element()?.contains(event.target as Node)); + + if (!item) { + return; + } + + const activeItem = this?.inputs.activeItem(); + + if (activeItem && activeItem !== item) { + activeItem.close(); + } + + if (item.expanded() && item.submenu()?.inputs.activeItem()) { + item.submenu()?.inputs.activeItem()?.close(); + item.submenu()?.listBehavior.unfocus(); + } + + item.open(); + this.listBehavior.goto(item, {focusElement: this.shouldFocus()}); + } + + /** Handles click events for the menu. */ + onClick(event: MouseEvent) { + const relatedTarget = event.target as Node | null; + const item = this.inputs.items().find(i => i.element()?.contains(relatedTarget)); + + if (item) { + this.listBehavior.goto(item); + this.submit(item); + } + } + + /** Handles focusin events for the menu. */ + onFocusIn() { + this.isFocused.set(true); + this.hasBeenFocused.set(true); + } + + /** Handles the focusout event for the menu. */ + onFocusOut(event: FocusEvent) { + const parent = this.inputs.parent(); + const parentEl = parent?.inputs.element(); + const relatedTarget = event.relatedTarget as Node | null; + + if (!relatedTarget) { + this.isFocused.set(false); + this.inputs.parent()?.close({refocus: true}); + } + + if (parent instanceof MenuItemPattern) { + const grandparent = parent.inputs.parent(); + const siblings = grandparent?.inputs.items().filter(i => i !== parent); + const item = siblings?.find(i => i.element().contains(relatedTarget)); + + if (item) { + return; + } + } + + if ( + this.isVisible() && + !parentEl?.contains(relatedTarget) && + !this.inputs.element()?.contains(relatedTarget) + ) { + this.isFocused.set(false); + this.inputs.parent()?.close(); + } + } + + /** Focuses the previous menu item. */ + prev() { + this.inputs.activeItem()?.close(); + this.listBehavior.prev(); + } + + /** Focuses the next menu item. */ + next() { + this.inputs.activeItem()?.close(); + this.listBehavior.next(); + } + + /** Focuses the first menu item. */ + first() { + this.inputs.activeItem()?.close(); + this.listBehavior.first(); + } + + /** Focuses the last menu item. */ + last() { + this.inputs.activeItem()?.close(); + this.listBehavior.last(); + } + + /** Triggers the active menu item. */ + trigger() { + this.inputs.activeItem()?.hasPopup() + ? this.inputs.activeItem()?.open({first: true}) + : this.submit(); + } + + /** Submits the menu. */ + submit(item = this.inputs.activeItem()) { + const root = this.root(); + + if (item && !item.disabled()) { + const isMenu = root instanceof MenuPattern; + const isMenuBar = root instanceof MenuBarPattern; + const isMenuTrigger = root instanceof MenuTriggerPattern; + + if (!item.submenu() && (isMenuTrigger || isMenuBar)) { + root.close({refocus: true}); + root?.inputs.onSubmit?.(item.value()); + } + + if (!item.submenu() && isMenu) { + root.inputs.activeItem()?.close({refocus: true}); + } + } + } + + /** Collapses the current menu or focuses the previous item in the menubar. */ + collapse() { + const root = this.root(); + const parent = this.inputs.parent(); + + if (parent instanceof MenuItemPattern && !(parent.inputs.parent() instanceof MenuBarPattern)) { + parent.close({refocus: true}); + } else if (root instanceof MenuBarPattern) { + root.prev(); + } + } + + /** Expands the current menu or focuses the next item in the menubar. */ + expand() { + const root = this.root(); + const activeItem = this.inputs.activeItem(); + + if (activeItem?.submenu()) { + activeItem.open({first: true}); + } else if (root instanceof MenuBarPattern) { + root.next(); + } + } + + /** Closes the menu and all parent menus. */ + closeAll() { + const root = this.root(); + + if (root instanceof MenuTriggerPattern) { + root.close({refocus: true}); + } + + if (root instanceof MenuBarPattern) { + root.close(); + } + } +} + +/** The menubar ui pattern class. */ +export class MenuBarPattern { + /** Controls list behavior for the menu items. */ + listBehavior: List, V>; + + /** The key used to navigate to the next item. */ + private _nextKey = computed(() => { + return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + }); + + /** The key used to navigate to the previous item. */ + private _previousKey = computed(() => { + return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + }); + + /** Represents the space key. Does nothing when the user is actively using typeahead. */ + dynamicSpaceKey = computed(() => (this.listBehavior.isTyping() ? '' : ' ')); + + /** The regexp used to decide if a key should trigger typeahead. */ + typeaheadRegexp = /^.$/; + + /** Whether the menubar or any of its children are currently focused. */ + isFocused = signal(false); + + /** Whether the menubar has been focused. */ + hasBeenFocused = signal(false); + + /** Handles keyboard events for the menu. */ + keydownManager = computed(() => { + return new KeyboardEventManager() + .on(this._nextKey, () => this.next()) + .on(this._previousKey, () => this.prev()) + .on('End', () => this.listBehavior.last()) + .on('Home', () => this.listBehavior.first()) + .on('Enter', () => this.inputs.activeItem()?.open({first: true})) + .on('ArrowUp', () => this.inputs.activeItem()?.open({last: true})) + .on('ArrowDown', () => this.inputs.activeItem()?.open({first: true})) + .on(this.dynamicSpaceKey, () => this.inputs.activeItem()?.open({first: true})) + .on(this.typeaheadRegexp, e => this.listBehavior.search(e.key)); + }); + + constructor(public readonly inputs: MenuBarInputs) { + this.listBehavior = new List, V>({...inputs, disabled: () => false}); + } + + /** Sets the default state for the menubar. */ + setDefaultState() { + this.inputs.activeItem.set(this.inputs.items()[0]); + } + + /** Handles keyboard events for the menu. */ + onKeydown(event: KeyboardEvent) { + this.keydownManager().handle(event); + } + + /** Handles click events for the menu bar. */ + onClick(event: MouseEvent) { + const item = this.inputs.items().find(i => i.element()?.contains(event.target as Node)); + + if (!item) { + return; + } + + this.goto(item); + item.expanded() ? item.close() : item.open(); + } + + /** Handles mouseover events for the menu bar. */ + onMouseOver(event: MouseEvent) { + const item = this.inputs.items().find(i => i.element()?.contains(event.target as Node)); + + if (item) { + this.goto(item, {focusElement: this.isFocused()}); + } + } + + /** Handles focusin events for the menu bar. */ + onFocusIn() { + this.isFocused.set(true); + this.hasBeenFocused.set(true); + } + + /** Handles focusout events for the menu bar. */ + onFocusOut(event: FocusEvent) { + const relatedTarget = event.relatedTarget as Node | null; + + if (!this.inputs.element()?.contains(relatedTarget)) { + this.isFocused.set(false); + this.close(); + } + } + + /** Goes to and optionally focuses the specified menu item. */ + goto(item: MenuItemPattern, opts?: {focusElement?: boolean}) { + const prevItem = this.inputs.activeItem(); + this.listBehavior.goto(item, opts); + + if (prevItem?.expanded()) { + prevItem?.close(); + this.inputs.activeItem()?.open(); + } + + if (item === prevItem) { + if (item.expanded() && item.submenu()?.inputs.activeItem()) { + item.submenu()?.inputs.activeItem()?.close(); + item.submenu()?.listBehavior.unfocus(); + } + } + } + + /** Focuses the next menu item. */ + next() { + const prevItem = this.inputs.activeItem(); + this.listBehavior.next(); + + if (prevItem?.expanded()) { + prevItem?.close(); + this.inputs.activeItem()?.open({first: true}); + } + } + + /** Focuses the previous menu item. */ + prev() { + const prevItem = this.inputs.activeItem(); + this.listBehavior.prev(); + + if (prevItem?.expanded()) { + prevItem?.close(); + this.inputs.activeItem()?.open({first: true}); + } + } + + /** Closes the menubar and refocuses the root menu bar item. */ + close() { + this.inputs.activeItem()?.close({refocus: this.isFocused()}); + } +} + +/** The menu trigger ui pattern class. */ +export class MenuTriggerPattern { + /** Whether the menu is expanded. */ + expanded = signal(false); + + /** The role of the menu trigger. */ + role = () => 'button'; + + /** Whether the menu trigger has a popup. */ + hasPopup = () => true; + + /** The submenu associated with the trigger. */ + submenu: SignalLike | undefined>; + + /** The tabindex of the menu trigger. */ + tabindex = computed(() => (this.expanded() && this.submenu()?.inputs.activeItem() ? -1 : 0)); + + /** Handles keyboard events for the menu trigger. */ + keydownManager = computed(() => { + return new KeyboardEventManager() + .on(' ', () => this.open({first: true})) + .on('Enter', () => this.open({first: true})) + .on('ArrowDown', () => this.open({first: true})) + .on('ArrowUp', () => this.open({last: true})) + .on('Escape', () => this.close({refocus: true})); + }); + + constructor(public readonly inputs: MenuTriggerInputs) { + this.submenu = this.inputs.submenu; + } + + /** Handles keyboard events for the menu trigger. */ + onKeydown(event: KeyboardEvent) { + this.keydownManager().handle(event); + } + + /** Handles click events for the menu trigger. */ + onClick() { + this.expanded() ? this.close() : this.open({first: true}); + } + + /** Handles focusout events for the menu trigger. */ + onFocusOut(event: FocusEvent) { + const element = this.inputs.element(); + const relatedTarget = event.relatedTarget as Node | null; + + if ( + this.expanded() && + !element?.contains(relatedTarget) && + !this.inputs.submenu()?.inputs.element()?.contains(relatedTarget) + ) { + this.close(); + } + } + + /** Opens the menu. */ + open(opts?: {first?: boolean; last?: boolean}) { + this.expanded.set(true); + + if (opts?.first) { + this.inputs.submenu()?.first(); + } else if (opts?.last) { + this.inputs.submenu()?.last(); + } + } + + /** Closes the menu. */ + close(opts: {refocus?: boolean} = {}) { + this.expanded.set(false); + this.submenu()?.listBehavior.unfocus(); + + if (opts.refocus) { + this.inputs.element()?.focus(); + } + + let menuitems = this.inputs.submenu()?.inputs.items() ?? []; + + while (menuitems.length) { + const menuitem = menuitems.pop(); + menuitem?._expanded.set(false); + menuitem?.inputs.parent()?.listBehavior.unfocus(); + menuitems = menuitems.concat(menuitem?.submenu()?.inputs.items() ?? []); + } + } +} + +/** The menu item ui pattern class. */ +export class MenuItemPattern implements ListItem { + /** The value of the menu item. */ + value: SignalLike; + + /** The unique ID of the menu item. */ + id: SignalLike; + + /** Whether the menu item is disabled. */ + disabled: SignalLike; + + /** The search term for the menu item. */ + searchTerm: SignalLike; + + /** The element of the menu item. */ + element: SignalLike; + + /** Whether the menu item is active. */ + isActive = computed(() => this.inputs.parent()?.inputs.activeItem() === this); + + /** The tabindex of the menu item. */ + tabindex = computed(() => { + if (this.submenu() && this.submenu()?.inputs.activeItem()) { + return -1; + } + return this.inputs.parent()?.listBehavior.getItemTabindex(this) ?? -1; + }); + + /** The position of the menu item in the menu. */ + index = computed(() => this.inputs.parent()?.inputs.items().indexOf(this) ?? -1); + + /** Whether the menu item is expanded. */ + expanded = computed(() => (this.submenu() ? this._expanded() : null)); + + /** Whether the menu item is expanded. */ + _expanded = signal(false); + + /** The ID of the menu that the menu item controls. */ + controls = signal(undefined); + + /** The role of the menu item. */ + role = () => 'menuitem'; + + /** Whether the menu item has a popup. */ + hasPopup = computed(() => !!this.submenu()); + + /** The submenu associated with the menu item. */ + submenu: SignalLike | undefined>; + + constructor(public readonly inputs: MenuItemInputs) { + this.id = inputs.id; + this.value = inputs.value; + this.element = inputs.element; + this.disabled = inputs.disabled; + this.submenu = this.inputs.submenu; + this.searchTerm = inputs.searchTerm; + } + + /** Opens the submenu. */ + open(opts?: {first?: boolean; last?: boolean}) { + this._expanded.set(true); + + if (opts?.first) { + this.submenu()?.first(); + } + if (opts?.last) { + this.submenu()?.last(); + } + } + + /** Closes the submenu. */ + close(opts: {refocus?: boolean} = {}) { + this._expanded.set(false); + + if (opts.refocus) { + this.inputs.parent()?.listBehavior.goto(this); + } + + let menuitems = this.inputs.submenu()?.inputs.items() ?? []; + + while (menuitems.length) { + const menuitem = menuitems.pop(); + menuitem?._expanded.set(false); + menuitem?.inputs.parent()?.listBehavior.unfocus(); + menuitems = menuitems.concat(menuitem?.submenu()?.inputs.items() ?? []); + } + } +} diff --git a/src/aria/ui-patterns/public-api.ts b/src/aria/ui-patterns/public-api.ts index 55ae55da51a1..344d7a045c72 100644 --- a/src/aria/ui-patterns/public-api.ts +++ b/src/aria/ui-patterns/public-api.ts @@ -10,6 +10,7 @@ export * from './combobox/combobox'; export * from './listbox/listbox'; export * from './listbox/option'; export * from './listbox/combobox-listbox'; +export * from './menu/menu'; export * from './radio-group/radio-group'; export * from './radio-group/radio-button'; export * from './radio-group/toolbar-radio-group'; From 5072f896d69fd058a298549bd23e74a363a6b099 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Tue, 14 Oct 2025 18:37:38 -0400 Subject: [PATCH 2/6] 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. --- src/aria/menu/BUILD.bazel | 17 ++ src/aria/menu/index.ts | 9 + src/aria/menu/menu.ts | 367 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 393 insertions(+) create mode 100644 src/aria/menu/BUILD.bazel create mode 100644 src/aria/menu/index.ts create mode 100644 src/aria/menu/menu.ts diff --git a/src/aria/menu/BUILD.bazel b/src/aria/menu/BUILD.bazel new file mode 100644 index 000000000000..9f0a3db019e6 --- /dev/null +++ b/src/aria/menu/BUILD.bazel @@ -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", + ], +) diff --git a/src/aria/menu/index.ts b/src/aria/menu/index.ts new file mode 100644 index 000000000000..135a0a300210 --- /dev/null +++ b/src/aria/menu/index.ts @@ -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'; diff --git a/src/aria/menu/menu.ts b/src/aria/menu/menu.ts new file mode 100644 index 000000000000..b1484775e8be --- /dev/null +++ b/src/aria/menu/menu.ts @@ -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 { + /** 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 | undefined>(undefined); + + /** A callback function triggered when a menu item is selected. */ + onSubmit = output(); + + /** The menu trigger ui pattern instance. */ + uiPattern: MenuTriggerPattern = 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 + * + * + *
+ *
Star
+ *
Edit
+ *
Delete
+ *
+ * ``` + */ +@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 { + /** The menu items contained in the menu. */ + readonly _allItems = contentChildren>(MenuItem, {descendants: true}); + + /** The menu items that are direct children of this menu. */ + readonly _items: Signal[]> = 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 | undefined>(undefined); + + /** The unique ID of the menu. */ + readonly id = input(Math.random().toString(36).substring(2, 10)); + + /** The value of the menu. */ + readonly value = model([]); + + /** Whether the menu should wrap its items. */ + readonly wrap = input(true); + + /** Whether the menu should skip disabled items. */ + readonly skipDisabled = input(false); + + /** The delay in seconds before the typeahead buffer is cleared. */ + readonly typeaheadDelay = input(0.5); // Picked arbitrarily. + + /** A reference to the parent menu item or menu trigger. */ + readonly parent = input | MenuItem>(); + + /** The menu ui pattern instance. */ + readonly uiPattern: MenuPattern; + + /** + * 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[]> = () => 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(); + + 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 { + /** The menu items contained in the menubar. */ + readonly _allItems = contentChildren>(MenuItem, {descendants: true}); + + readonly _items: SignalLike[]> = () => + 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([]); + + /** Whether the menu should wrap its items. */ + readonly wrap = input(true); + + /** Whether the menu should skip disabled items. */ + readonly skipDisabled = input(false); + + /** The delay in seconds before the typeahead buffer is cleared. */ + readonly typeaheadDelay = input(0.5); + + /** The menu ui pattern instance. */ + readonly uiPattern: MenuBarPattern; + + /** The menu items as a writable signal. */ + readonly items = signal[]>([]); + + /** A callback function triggered when a menu item is selected. */ + onSubmit = output(); + + 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 { + /** 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(Math.random().toString(36).substring(2, 10)); + + /** The value of the menu item. */ + readonly value = input.required(); + + /** Whether the menu item is disabled. */ + readonly disabled = input(false); + + // TODO(wagnermaciel): Discuss whether all inputs should be models. + + /** The search term associated with the menu item. */ + readonly searchTerm = model(''); + + /** A reference to the parent menu. */ + private readonly _menu = inject>(Menu, {optional: true}); + + /** A reference to the parent menu bar. */ + private readonly _menuBar = inject>(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 | undefined>(undefined); + + /** The menu item ui pattern instance. */ + readonly uiPattern: MenuItemPattern = new MenuItemPattern({ + 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), + }); +} From 12f3d893da9e90461ed59e2025c0992810696726 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Tue, 14 Oct 2025 18:39:41 -0400 Subject: [PATCH 3/6] feat(aria/menu): add examples for aria menu * Adds four examples for the new ARIA menu directives: - A menubar example demonstrating a typical application menu. - A menu trigger example showing a simple icon button that opens a menu. - A standalone menu example. - A context menu example that opens on right-click. * Also includes a set of simple wrapper directives to apply basic popover styles and behavior to the examples. --- src/components-examples/aria/menu/BUILD.bazel | 25 ++ src/components-examples/aria/menu/index.ts | 4 + .../aria/menu/menu-bar/menu-bar-example.html | 300 ++++++++++++++++++ .../aria/menu/menu-bar/menu-bar-example.ts | 31 ++ .../menu-context/menu-context-example.html | 41 +++ .../menu/menu-context/menu-context-example.ts | 53 ++++ .../aria/menu/menu-example.css | 125 ++++++++ .../menu-standalone-example.html | 61 ++++ .../menu-standalone-example.ts | 16 + .../menu-trigger/menu-trigger-example.html | 63 ++++ .../menu/menu-trigger/menu-trigger-example.ts | 14 + .../aria/menu/simple-menu.ts | 100 ++++++ src/dev-app/aria-menu/BUILD.bazel | 1 + src/dev-app/aria-menu/menu-demo.css | 16 + src/dev-app/aria-menu/menu-demo.html | 24 +- src/dev-app/aria-menu/menu-demo.ts | 8 + 16 files changed, 881 insertions(+), 1 deletion(-) create mode 100644 src/components-examples/aria/menu/BUILD.bazel create mode 100644 src/components-examples/aria/menu/index.ts create mode 100644 src/components-examples/aria/menu/menu-bar/menu-bar-example.html create mode 100644 src/components-examples/aria/menu/menu-bar/menu-bar-example.ts create mode 100644 src/components-examples/aria/menu/menu-context/menu-context-example.html create mode 100644 src/components-examples/aria/menu/menu-context/menu-context-example.ts create mode 100644 src/components-examples/aria/menu/menu-example.css create mode 100644 src/components-examples/aria/menu/menu-standalone/menu-standalone-example.html create mode 100644 src/components-examples/aria/menu/menu-standalone/menu-standalone-example.ts create mode 100644 src/components-examples/aria/menu/menu-trigger/menu-trigger-example.html create mode 100644 src/components-examples/aria/menu/menu-trigger/menu-trigger-example.ts create mode 100644 src/components-examples/aria/menu/simple-menu.ts diff --git a/src/components-examples/aria/menu/BUILD.bazel b/src/components-examples/aria/menu/BUILD.bazel new file mode 100644 index 000000000000..3b006c241567 --- /dev/null +++ b/src/components-examples/aria/menu/BUILD.bazel @@ -0,0 +1,25 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "menu", + srcs = glob(["**/*.ts"]), + assets = glob([ + "**/*.html", + "**/*.css", + ]), + deps = [ + "//:node_modules/@angular/core", + "//src/aria/menu", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.html", + "**/*.css", + "**/*.ts", + ]), +) diff --git a/src/components-examples/aria/menu/index.ts b/src/components-examples/aria/menu/index.ts new file mode 100644 index 000000000000..fd502b060d90 --- /dev/null +++ b/src/components-examples/aria/menu/index.ts @@ -0,0 +1,4 @@ +export {MenuBarExample} from './menu-bar/menu-bar-example'; +export {MenuContextExample} from './menu-context/menu-context-example'; +export {MenuTriggerExample} from './menu-trigger/menu-trigger-example'; +export {MenuStandaloneExample} from './menu-standalone/menu-standalone-example'; diff --git a/src/components-examples/aria/menu/menu-bar/menu-bar-example.html b/src/components-examples/aria/menu/menu-bar/menu-bar-example.html new file mode 100644 index 000000000000..5b772ddf3e91 --- /dev/null +++ b/src/components-examples/aria/menu/menu-bar/menu-bar-example.html @@ -0,0 +1,300 @@ +
+
File
+ +
+
+ article + New + ⌘N +
+ +
+ folder + Open + ⌘O +
+ +
+ file_copy + Make a copy +
+ + + +
+ person_add + Share + arrow_right +
+ +
+
+ person_add + Share with others +
+ +
+ public + Publish to web +
+
+ +
+ download + Download +
+ +
+ print + Print +
+ + + +
+ edit + Rename +
+ +
+ delete + Move to trash +
+
+ +
Edit
+ +
+
+ undo + Undo + ⌘Z +
+ +
+ redo + Redo + ⌘Y +
+ + + +
+ content_cut + Cut + ⌘X +
+ +
+ content_copy + Copy + ⌘C +
+ +
+ content_paste + Paste + ⌘V +
+ + + +
+ find_replace + Find and replace + ⇧⌘H +
+
+ +
View
+ +
+
+ check + Show print layout +
+ +
+ check + Show ruler +
+ + + +
+ Zoom in + ⌘+ +
+ +
+ Zoom out + ⌘- +
+ + + +
+ Full screen +
+
+ +
Insert
+ +
+
+ image + Image + arrow_right +
+ +
+
+ upload + Upload from computer +
+ +
+ search + Search the web +
+ +
+ link + By URL +
+
+ +
+ table_chart + Table +
+ +
+ insert_chart + Chart + arrow_right +
+ +
+
+ bar_chart + Bar +
+ +
+ insert_chart + Column +
+ +
+ show_chart + Line +
+ +
+ pie_chart + Pie +
+
+ +
+ horizontal_rule + Horizontal line +
+
+ +
Format
+ +
+
+ format_bold + Text + arrow_right +
+ +
+
+ format_bold + Bold + ⌘B +
+ +
+ format_italic + Italic + ⌘I +
+ +
+ format_underlined + Underline + ⌘U +
+ +
+ strikethrough_s + Strikethrough + ⇧⌘X +
+ + + +
+ Size + arrow_right +
+ +
+
+ Increase font size + ⇧⌘. +
+ +
+ Decrease font size + ⇧⌘, +
+
+
+ +
+ format_align_justify + Paragraph styles + arrow_right +
+ +
+
Normal text
+
Heading 1
+
Heading 2
+
+ +
+ format_indent_increase + Align & indent + arrow_right +
+ +
+
+ format_align_left + Align left +
+ +
+ format_align_center + Align center +
+ +
+ format_align_right + Align right +
+ +
+ format_align_justify + Justify +
+
+
+
diff --git a/src/components-examples/aria/menu/menu-bar/menu-bar-example.ts b/src/components-examples/aria/menu/menu-bar/menu-bar-example.ts new file mode 100644 index 000000000000..dca666e111e8 --- /dev/null +++ b/src/components-examples/aria/menu/menu-bar/menu-bar-example.ts @@ -0,0 +1,31 @@ +import {Component} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import { + SimpleMenu, + SimpleMenuBar, + SimpleMenuBarItem, + SimpleMenuItem, + SimpleMenuItemIcon, + SimpleMenuItemShortcut, + SimpleMenuItemText, +} from '../simple-menu'; + +/** @title Menu bar example. */ +@Component({ + selector: 'menu-bar-example', + exportAs: 'MenuBarExample', + templateUrl: 'menu-bar-example.html', + styleUrl: '../menu-example.css', + standalone: true, + imports: [ + SimpleMenu, + SimpleMenuBar, + SimpleMenuBarItem, + SimpleMenuItem, + SimpleMenuItemIcon, + SimpleMenuItemText, + SimpleMenuItemShortcut, + CommonModule, + ], +}) +export class MenuBarExample {} diff --git a/src/components-examples/aria/menu/menu-context/menu-context-example.html b/src/components-examples/aria/menu/menu-context/menu-context-example.html new file mode 100644 index 000000000000..7505025ffd42 --- /dev/null +++ b/src/components-examples/aria/menu/menu-context/menu-context-example.html @@ -0,0 +1,41 @@ +
+ Right-click for context menu +
+ +
+
+ content_cut + Cut + ⌘X +
+ +
+ content_copy + Copy + ⌘C +
+ +
+ content_paste + Paste + arrow_right +
+ +
+
+ Paste as plain text + ⌘⇧V +
+ +
+ Paste without formatting + ⌘⇧V +
+
+
diff --git a/src/components-examples/aria/menu/menu-context/menu-context-example.ts b/src/components-examples/aria/menu/menu-context/menu-context-example.ts new file mode 100644 index 000000000000..fa4fb4020311 --- /dev/null +++ b/src/components-examples/aria/menu/menu-context/menu-context-example.ts @@ -0,0 +1,53 @@ +import {Component, HostListener, viewChild} from '@angular/core'; +import {Menu} from '@angular/aria/menu'; +import { + SimpleMenu, + SimpleMenuItem, + SimpleMenuItemIcon, + SimpleMenuItemShortcut, + SimpleMenuItemText, +} from '../simple-menu'; + +/** @title Context menu example. */ +@Component({ + selector: 'menu-context-example', + exportAs: 'MenuContextExample', + templateUrl: 'menu-context-example.html', + styleUrl: '../menu-example.css', + standalone: true, + imports: [ + SimpleMenu, + SimpleMenuItem, + SimpleMenuItemText, + SimpleMenuItemIcon, + SimpleMenuItemShortcut, + ], +}) +export class MenuContextExample { + menu = viewChild>(Menu); + + close(event: FocusEvent) { + const menu = this.menu(); + const relatedTarget = event.relatedTarget as HTMLElement | null; + + if (menu && !menu.element.contains(relatedTarget)) { + menu.close(); + menu.element.style.visibility = 'hidden'; + } + } + + open(event: MouseEvent) { + const menu = this.menu(); + menu?.closeAll(); + + if (menu) { + event.preventDefault(); + + menu.element.style.visibility = 'visible'; + menu.element.style.top = `${event.clientY}px`; + menu.element.style.left = `${event.clientX}px`; + + setTimeout(() => menu.uiPattern.first()); + } + } +} diff --git a/src/components-examples/aria/menu/menu-example.css b/src/components-examples/aria/menu/menu-example.css new file mode 100644 index 000000000000..dc9cd0684768 --- /dev/null +++ b/src/components-examples/aria/menu/menu-example.css @@ -0,0 +1,125 @@ +.example-menu-trigger { + cursor: pointer; + border-radius: var(--mat-sys-corner-extra-large); + background: var(--mat-sys-surface); + border: 1px solid color-mix(in srgb, var(--mat-sys-outline) 40%, transparent); + display: flex; + align-items: center; + padding: 0.5rem; +} + +.example-menu-trigger .material-symbols-outlined { + font-size: 1.5rem; + opacity: 0.875; +} + +.example-menu { + margin: 0; + width: 15rem; + padding: 0.25rem; + border-radius: var(--mat-sys-corner-extra-small); + border: 1px solid color-mix(in srgb, var(--mat-sys-outline) 40%, transparent); +} + +.example-menu-bar { + display: flex; + cursor: pointer; + width: fit-content; + gap: 0.5rem; + padding: 0.25rem; + background: var(--mat-sys-surface); + border-radius: var(--mat-sys-corner-extra-small); + border: 1px solid color-mix(in srgb, var(--mat-sys-outline) 40%, transparent); +} + +.example-menu-bar-item { + outline: none; + padding: 0.25rem 0.5rem; + border-radius: var(--mat-sys-corner-extra-small); +} + +.example-menu-heading { + display: block; + font-weight: bold; + opacity: 0.6; + font-size: 0.75rem; + padding: 0.5rem 0.75rem; + letter-spacing: 0.05rem; +} + +.example-menu[data-visible='true'] { + display: block; + overflow: visible; + } + +.example-menu-item { + display: flex; + cursor: pointer; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + font-size: 0.875rem; + outline: none; + border-radius: var(--mat-sys-corner-extra-small); +} + +.example-menu-trigger:hover, +.example-menu-item[data-active='true'], +.example-menu-bar-item[data-active='true'] { + background: color-mix( + in srgb, + var(--mat-sys-on-surface) calc(var(--mat-sys-hover-state-layer-opacity) * 100%), + transparent + ); +} + +.example-menu-item:focus, +.example-menu-trigger:focus, +.example-menu-bar-item:focus { + outline: 2px solid var(--mat-sys-primary); +} + +.example-menu-item[aria-disabled='true'] { + cursor: default; + opacity: 0.38; +} + +.example-menu .material-symbols-outlined { + opacity: 0.875; + font-size: 1.25rem; +} + +.example-menu-item-text { + flex: 1; + opacity: 0.875; + font-size: 0.875rem; +} + +.example-menu-item-shortcut { + opacity: 0.5; + font-weight: bold; + letter-spacing: 0.2rem; +} + +.example-menu-item:not([aria-expanded='true']) .example-arrow { + opacity: 0.5; +} + +.example-menu-item-separator { + border-top: 1px solid var(--mat-sys-outline); + margin: 0.25rem 0; + opacity: 0.25; +} + +.example-context-menu-container { + width: 20rem; + height: 10rem; + border-radius: var(--mat-sys-corner-extra-small); + background: color-mix(in srgb, var(--mat-sys-on-surface) 5%, transparent); + border: 1px solid color-mix(in srgb, var(--mat-sys-outline) 40%, transparent); +} + +.example-context-menu { + position: absolute; + visibility: hidden; +} diff --git a/src/components-examples/aria/menu/menu-standalone/menu-standalone-example.html b/src/components-examples/aria/menu/menu-standalone/menu-standalone-example.html new file mode 100644 index 000000000000..6a3419830193 --- /dev/null +++ b/src/components-examples/aria/menu/menu-standalone/menu-standalone-example.html @@ -0,0 +1,61 @@ +
+ SECURITY + +
+
+ lock_open + Change password +
+ +
+ security_key + Two-factor authentication +
+ +
+ refresh + Reset + arrow_right +
+ +
+
+ email + Email address +
+ +
+ phone + Phone number +
+ +
+ vpn_key + Password +
+
+
+ + + + HELP + +
+
+ help + Support +
+ +
+ feedback + Feedback +
+
+ + + +
+ logout + Logout +
+
diff --git a/src/components-examples/aria/menu/menu-standalone/menu-standalone-example.ts b/src/components-examples/aria/menu/menu-standalone/menu-standalone-example.ts new file mode 100644 index 000000000000..84d4ab35611c --- /dev/null +++ b/src/components-examples/aria/menu/menu-standalone/menu-standalone-example.ts @@ -0,0 +1,16 @@ +import {afterRenderEffect, Component, viewChildren} from '@angular/core'; +import {Menu, MenuItem} from '@angular/aria/menu'; +import {SimpleMenu, SimpleMenuItem, SimpleMenuItemIcon, SimpleMenuItemText} from '../simple-menu'; + +/** + * @title Menu standalone example. + */ +@Component({ + selector: 'menu-standalone-example', + exportAs: 'MenuStandaloneExample', + templateUrl: 'menu-standalone-example.html', + styleUrl: '../menu-example.css', + standalone: true, + imports: [Menu, SimpleMenu, SimpleMenuItem, SimpleMenuItemIcon, SimpleMenuItemText], +}) +export class MenuStandaloneExample {} diff --git a/src/components-examples/aria/menu/menu-trigger/menu-trigger-example.html b/src/components-examples/aria/menu/menu-trigger/menu-trigger-example.html new file mode 100644 index 000000000000..4c857c37e6ca --- /dev/null +++ b/src/components-examples/aria/menu/menu-trigger/menu-trigger-example.html @@ -0,0 +1,63 @@ + + +
+
+ mark_email_read + Mark as read +
+ +
+ snooze + Snooze +
+ + + +
+ category + Categorize + arrow_right +
+ +
+
+ label_important + Mark as important +
+ +
+ star + Star +
+ +
+ label + Label +
+
+ + + +
+ archive + Archive +
+ +
+ report + Report spam +
+ +
+ delete + Delete +
+
diff --git a/src/components-examples/aria/menu/menu-trigger/menu-trigger-example.ts b/src/components-examples/aria/menu/menu-trigger/menu-trigger-example.ts new file mode 100644 index 000000000000..0cf84e867441 --- /dev/null +++ b/src/components-examples/aria/menu/menu-trigger/menu-trigger-example.ts @@ -0,0 +1,14 @@ +import {Component} from '@angular/core'; +import {MenuTrigger} from '@angular/aria/menu'; +import {SimpleMenu, SimpleMenuItem, SimpleMenuItemIcon, SimpleMenuItemText} from '../simple-menu'; + +/** @title Menu trigger example. */ +@Component({ + selector: 'menu-trigger-example', + exportAs: 'MenuTriggerExample', + templateUrl: 'menu-trigger-example.html', + styleUrl: '../menu-example.css', + standalone: true, + imports: [SimpleMenu, SimpleMenuItem, SimpleMenuItemIcon, SimpleMenuItemText, MenuTrigger], +}) +export class MenuTriggerExample {} diff --git a/src/components-examples/aria/menu/simple-menu.ts b/src/components-examples/aria/menu/simple-menu.ts new file mode 100644 index 000000000000..460cbe840c90 --- /dev/null +++ b/src/components-examples/aria/menu/simple-menu.ts @@ -0,0 +1,100 @@ +import {Menu, MenuBar, MenuItem, MenuTrigger} from '@angular/aria/menu'; +import {afterRenderEffect, Directive, effect, inject} from '@angular/core'; + +@Directive({ + selector: '[menu]', + hostDirectives: [{directive: Menu, inputs: ['parent']}], + host: { + class: 'example-menu', + popover: 'manual', + '(beforetoggle)': 'onBeforeToggle()', + }, +}) +export class SimpleMenu { + menu = inject(Menu); + + constructor() { + afterRenderEffect(() => { + this.menu.isVisible() ? this.menu.element.showPopover() : this.menu.element.hidePopover(); + }); + } + + onBeforeToggle() { + const parent = this.menu.parent() as MenuItem; + + if (!parent) { + return; + } + + const parentEl = parent.element; + const parentRect = parentEl.getBoundingClientRect(); + + if (parent.parent instanceof MenuBar) { + this.menu.element.style.left = `${parentRect.left}px`; + this.menu.element.style.top = `${parentRect.bottom + 6}px`; + } else if (parent instanceof MenuTrigger) { + this.menu.element.style.left = `${parentRect.left}px`; + this.menu.element.style.top = `${parentRect.bottom + 2}px`; + } else { + this.menu.element.style.left = `${parentRect.right + 6}px`; + this.menu.element.style.top = `${parentRect.top - 5}px`; + } + } +} + +@Directive({ + selector: '[menu-bar]', + hostDirectives: [{directive: MenuBar}], + host: {class: 'example-menu-bar'}, +}) +export class SimpleMenuBar {} + +@Directive({ + selector: '[menu-bar-item]', + hostDirectives: [{directive: MenuItem, inputs: ['value', 'submenu']}], + host: {class: 'example-menu-bar-item'}, +}) +export class SimpleMenuBarItem { + menuItem = inject(MenuItem); + + constructor() { + effect(() => this.menuItem.searchTerm.set(this.menuItem.value())); + } +} + +@Directive({ + selector: '[menu-item]', + hostDirectives: [{directive: MenuItem, inputs: ['value', 'disabled', 'submenu']}], + host: {class: 'example-menu-item'}, +}) +export class SimpleMenuItem { + menuItem = inject(MenuItem); + + constructor() { + effect(() => this.menuItem.searchTerm.set(this.menuItem.value())); + } +} + +@Directive({ + selector: '[menu-item-icon]', + host: { + 'aria-hidden': 'true', + class: 'material-symbols-outlined', + }, +}) +export class SimpleMenuItemIcon {} + +@Directive({ + selector: '[menu-item-text]', + host: {class: 'example-menu-item-text'}, +}) +export class SimpleMenuItemText {} + +@Directive({ + selector: '[menu-item-shortcut]', + host: { + 'aria-hidden': 'true', + class: 'example-menu-item-shortcut', + }, +}) +export class SimpleMenuItemShortcut {} diff --git a/src/dev-app/aria-menu/BUILD.bazel b/src/dev-app/aria-menu/BUILD.bazel index 27807f6c7f5b..b05b2dae53c0 100644 --- a/src/dev-app/aria-menu/BUILD.bazel +++ b/src/dev-app/aria-menu/BUILD.bazel @@ -11,5 +11,6 @@ ng_project( ], deps = [ "//:node_modules/@angular/core", + "//src/components-examples/aria/menu", ], ) diff --git a/src/dev-app/aria-menu/menu-demo.css b/src/dev-app/aria-menu/menu-demo.css index e69de29bb2d1..218db84dfcf1 100644 --- a/src/dev-app/aria-menu/menu-demo.css +++ b/src/dev-app/aria-menu/menu-demo.css @@ -0,0 +1,16 @@ +.example-menu-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(500px, 1fr)); + gap: 20px; +} + +.example-menu-container { + width: 500px; + display: flex; + flex-direction: column; + justify-content: flex-start; +} + +h2 { + height: 36px; +} diff --git a/src/dev-app/aria-menu/menu-demo.html b/src/dev-app/aria-menu/menu-demo.html index bc49b0f09a20..eb81a19febec 100644 --- a/src/dev-app/aria-menu/menu-demo.html +++ b/src/dev-app/aria-menu/menu-demo.html @@ -1 +1,23 @@ -

aria-menu coming soon!

+
+
+
+

Menu Bar Example

+ +
+ +
+

Menu Trigger Example

+ +
+ +
+

Standalone Menu Example

+ +
+ +
+

Context Menu Example

+ +
+
+
diff --git a/src/dev-app/aria-menu/menu-demo.ts b/src/dev-app/aria-menu/menu-demo.ts index 152b189d0a9f..4c00e016f503 100644 --- a/src/dev-app/aria-menu/menu-demo.ts +++ b/src/dev-app/aria-menu/menu-demo.ts @@ -7,11 +7,19 @@ */ import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core'; +import { + MenuBarExample, + MenuContextExample, + MenuTriggerExample, + MenuStandaloneExample, +} from '@angular/components-examples/aria/menu'; @Component({ templateUrl: 'menu-demo.html', styleUrl: 'menu-demo.css', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MenuBarExample, MenuContextExample, MenuTriggerExample, MenuStandaloneExample], + standalone: true, }) export class MenuDemo {} From 06b48d2f208e71cf4bfc23002874a65aebb49c84 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Fri, 17 Oct 2025 16:19:55 -0400 Subject: [PATCH 4/6] fixup! feat(aria): add initial menu directives --- src/aria/menu/menu.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/aria/menu/menu.ts b/src/aria/menu/menu.ts index b1484775e8be..6bc70bc7c538 100644 --- a/src/aria/menu/menu.ts +++ b/src/aria/menu/menu.ts @@ -97,6 +97,7 @@ export class MenuTrigger { '[attr.data-visible]': 'uiPattern.isVisible()', '(keydown)': 'uiPattern.onKeydown($event)', '(mouseover)': 'uiPattern.onMouseOver($event)', + '(mouseout)': 'uiPattern.onMouseOut($event)', '(focusout)': 'uiPattern.onFocusOut($event)', '(focusin)': 'uiPattern.onFocusIn()', '(click)': 'uiPattern.onClick($event)', @@ -131,9 +132,6 @@ export class Menu { /** The unique ID of the menu. */ readonly id = input(Math.random().toString(36).substring(2, 10)); - /** The value of the menu. */ - readonly value = model([]); - /** Whether the menu should wrap its items. */ readonly wrap = input(true); From 803ffd62abf212c60bdf2c85e39702e826124760 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Fri, 17 Oct 2025 16:20:08 -0400 Subject: [PATCH 5/6] fixup! feat(aria/ui-patterns): add initial menu pattern --- src/aria/ui-patterns/menu/menu.spec.ts | 827 +++++++++++++++++++++++++ src/aria/ui-patterns/menu/menu.ts | 40 +- 2 files changed, 865 insertions(+), 2 deletions(-) create mode 100644 src/aria/ui-patterns/menu/menu.spec.ts diff --git a/src/aria/ui-patterns/menu/menu.spec.ts b/src/aria/ui-patterns/menu/menu.spec.ts new file mode 100644 index 000000000000..689af41216f0 --- /dev/null +++ b/src/aria/ui-patterns/menu/menu.spec.ts @@ -0,0 +1,827 @@ +/** + * @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 {signal, WritableSignal} from '@angular/core'; +import {MenuPattern, MenuBarPattern, MenuItemPattern, MenuTriggerPattern} from './menu'; +import {createKeyboardEvent} from '@angular/cdk/testing/private'; +import {ModifierKeys} from '@angular/cdk/testing'; +import {fakeAsync, tick} from '@angular/core/testing'; + +// Test types +type TestMenuItem = MenuItemPattern & { + disabled: WritableSignal; + submenu: WritableSignal | undefined>; +}; + +// Keyboard event helpers +const up = () => createKeyboardEvent('keydown', 38, 'ArrowUp'); +const down = () => createKeyboardEvent('keydown', 40, 'ArrowDown'); +const home = () => createKeyboardEvent('keydown', 36, 'Home'); +const end = () => createKeyboardEvent('keydown', 35, 'End'); +const enter = () => createKeyboardEvent('keydown', 13, 'Enter'); +const escape = () => createKeyboardEvent('keydown', 27, 'Escape'); +const right = () => createKeyboardEvent('keydown', 39, 'ArrowRight'); +const left = () => createKeyboardEvent('keydown', 37, 'ArrowLeft'); +const space = () => createKeyboardEvent('keydown', 32, ' '); + +function clickMenuItem(items: MenuItemPattern[], index: number, mods?: ModifierKeys) { + return { + target: items[index].element(), + shiftKey: mods?.shift, + ctrlKey: mods?.control, + } as unknown as PointerEvent; +} + +function getMenuTriggerPattern() { + const element = signal(document.createElement('button')); + const submenu = signal | undefined>(undefined); + const trigger = new MenuTriggerPattern({ + element, + submenu, + }); + return trigger; +} + +function getMenuBarPattern(values: string[]) { + const items = signal([]); + + const menubar = new MenuBarPattern({ + items: items, + activeItem: signal(undefined), + orientation: signal('horizontal'), + textDirection: signal('ltr'), + multi: signal(false), + selectionMode: signal('explicit'), + value: signal([]), + wrap: signal(true), + typeaheadDelay: signal(0.5), + skipDisabled: signal(true), + focusMode: signal('activedescendant'), + element: signal(document.createElement('div')), + }); + + items.set( + values.map((v, index) => { + const element = document.createElement('div'); + element.role = 'menuitem'; + return new MenuItemPattern({ + value: signal(v), + id: signal(`menuitem-${index}`), + disabled: signal(false), + searchTerm: signal(v), + parent: signal(menubar), + element: signal(element), + submenu: signal(undefined), + }) as TestMenuItem; + }), + ); + + return menubar; +} + +function getMenuPattern( + parent: undefined | MenuItemPattern | MenuTriggerPattern, + values: string[], +) { + const items = signal([]); + + const menu = new MenuPattern({ + id: signal('menu-1'), + items: items, + parent: signal(parent) as any, + activeItem: signal(undefined), + typeaheadDelay: signal(0.5), + wrap: signal(true), + skipDisabled: signal(true), + multi: signal(false), + focusMode: signal('activedescendant'), + textDirection: signal('ltr'), + orientation: signal('vertical'), + selectionMode: signal('explicit'), + element: signal(document.createElement('div')), + }); + + items.set( + values.map((v, index) => { + const element = document.createElement('div'); + element.role = 'menuitem'; + menu.inputs.element()?.appendChild(element); + return new MenuItemPattern({ + value: signal(v), + id: signal(`menuitem-${index}`), + disabled: signal(false), + searchTerm: signal(v), + parent: signal(menu), + element: signal(element), + submenu: signal(undefined), + }) as TestMenuItem; + }), + ); + + if (parent) { + (parent.submenu as WritableSignal>).set(menu); + parent.inputs.element()?.appendChild(menu.inputs.element()!); + } + menu.inputs.activeItem.set(items()[0]); + return menu; +} + +describe('Standalone Menu Pattern', () => { + let menu: MenuPattern; + let submenu: MenuPattern; + + beforeEach(() => { + menu = getMenuPattern(undefined, ['a', 'b', 'c']); + submenu = getMenuPattern(menu.inputs.items()[0], ['d', 'e']); + }); + + describe('Navigation', () => { + it('should focus the first item by default', () => { + expect(menu.inputs.activeItem()).toBe(menu.inputs.items()[0]); + }); + + it('should move the active item with the up and down arrows', () => { + menu.onKeydown(down()); + expect(menu.inputs.activeItem()).toBe(menu.inputs.items()[1]); + menu.onKeydown(up()); + expect(menu.inputs.activeItem()).toBe(menu.inputs.items()[0]); + }); + + it('should wrap navigation when reaching the top or bottom', () => { + menu.onKeydown(up()); + expect(menu.inputs.activeItem()).toBe(menu.inputs.items()[2]); + menu.onKeydown(down()); + expect(menu.inputs.activeItem()).toBe(menu.inputs.items()[0]); + }); + + it('should move focus to the first and last items with home and end', () => { + menu.onKeydown(end()); + expect(menu.inputs.activeItem()).toBe(menu.inputs.items()[2]); + menu.onKeydown(home()); + expect(menu.inputs.activeItem()).toBe(menu.inputs.items()[0]); + }); + + it('should move focus on mouse over a menu item', () => { + const menuItem = menu.inputs.items()[1]; + menu.onMouseOver({target: menuItem.element()} as unknown as MouseEvent); + expect(menu.inputs.activeItem()).toBe(menuItem); + }); + + describe('Typeahead', () => { + it('should move the active item to the next item that starts with the typed character', fakeAsync(() => { + const menu = getMenuPattern(undefined, ['Apple', 'Banana', 'Cherry']); + const items = menu.inputs.items(); + + const b = createKeyboardEvent('keydown', 66, 'b'); + menu.onKeydown(b); + tick(500); + expect(menu.inputs.activeItem()).toBe(items[1]); + + const c = createKeyboardEvent('keydown', 67, 'c'); + menu.onKeydown(c); + tick(500); + expect(menu.inputs.activeItem()).toBe(items[2]); + })); + + it('should support multi-character typeahead', fakeAsync(() => { + const menu = getMenuPattern(undefined, ['Cabbage', 'Chard', 'Cherry', 'Cilantro']); + + const c = createKeyboardEvent('keydown', 67, 'c'); + const h = createKeyboardEvent('keydown', 72, 'h'); + const e = createKeyboardEvent('keydown', 69, 'e'); + + menu.onKeydown(c); + expect(menu.inputs.activeItem()?.value()).toBe('Chard'); + + menu.onKeydown(h); + expect(menu.inputs.activeItem()?.value()).toBe('Chard'); + + menu.onKeydown(e); + expect(menu.inputs.activeItem()?.value()).toBe('Cherry'); + + tick(500); + menu.onKeydown(c); + expect(menu.inputs.activeItem()?.value()).toBe('Cilantro'); + })); + + it('should wrap when reaching the end of the list during typeahead', fakeAsync(() => { + const menu = getMenuPattern(undefined, ['Apple', 'Banana', 'Avocado']); + const items = menu.inputs.items(); + menu.inputs.activeItem.set(items[1]); + + const a = createKeyboardEvent('keydown', 65, 'a'); + menu.onKeydown(a); + tick(500); + expect(menu.inputs.activeItem()).toBe(items[2]); + + menu.onKeydown(a); + tick(500); + expect(menu.inputs.activeItem()).toBe(items[0]); + })); + + it('should not move the active item if no item matches the typed character', fakeAsync(() => { + const menu = getMenuPattern(undefined, ['Apple', 'Banana', 'Cherry']); + const items = menu.inputs.items(); + menu.inputs.activeItem.set(items[0]); + + const z = createKeyboardEvent('keydown', 90, 'z'); + menu.onKeydown(z); + tick(500); + expect(menu.inputs.activeItem()).toBe(items[0]); + })); + }); + }); + + describe('Selection', () => { + it('should select an item on click', () => { + const items = menu.inputs.items(); + menu.inputs.onSubmit = jasmine.createSpy('onSubmit'); + menu.onClick(clickMenuItem(items, 1)); + expect(menu.inputs.onSubmit).toHaveBeenCalledWith('b'); + }); + + it('should select an item on enter', () => { + const items = menu.inputs.items(); + menu.inputs.activeItem.set(items[1]); + menu.inputs.onSubmit = jasmine.createSpy('onSubmit'); + + menu.onKeydown(enter()); + expect(menu.inputs.onSubmit).toHaveBeenCalledWith('b'); + }); + + it('should select an item on space', () => { + const items = menu.inputs.items(); + menu.inputs.activeItem.set(items[1]); + menu.inputs.onSubmit = jasmine.createSpy('onSubmit'); + + menu.onKeydown(space()); + expect(menu.inputs.onSubmit).toHaveBeenCalledWith('b'); + }); + + it('should not select a disabled item', () => { + const items = menu.inputs.items() as TestMenuItem[]; + items[1].disabled.set(true); + menu.inputs.activeItem.set(items[1]); + menu.inputs.onSubmit = jasmine.createSpy('onSubmit'); + + menu.onClick(clickMenuItem(items, 1)); + expect(menu.inputs.onSubmit).not.toHaveBeenCalled(); + + menu.onKeydown(enter()); + expect(menu.inputs.onSubmit).not.toHaveBeenCalled(); + + menu.onKeydown(space()); + expect(menu.inputs.onSubmit).not.toHaveBeenCalled(); + }); + }); + + describe('Expansion and Collapse', () => { + it('should be expanded by default', () => { + expect(menu.isVisible()).toBe(true); + expect(submenu.isVisible()).toBe(false); + }); + + it('should expand submenu on click', () => { + menu.onClick(clickMenuItem(menu.inputs.items(), 0)); + expect(submenu.isVisible()).toBe(true); + }); + + it('should open submenu on arrow right', () => { + menu.onKeydown(right()); + expect(submenu.isVisible()).toBe(true); + }); + + it('should close submenu on arrow left', () => { + menu.onKeydown(right()); + expect(submenu.isVisible()).toBe(true); + + submenu.onKeydown(left()); + expect(submenu.isVisible()).toBe(false); + }); + + it('should close submenu on click outside', () => { + menu.onClick(clickMenuItem(menu.inputs.items(), 0)); + expect(submenu.isVisible()).toBe(true); + + submenu.onFocusOut(new FocusEvent('focusout', {relatedTarget: null})); + expect(submenu.isVisible()).toBe(false); + }); + + it('should expand submenu on enter', () => { + menu.onKeydown(enter()); + expect(submenu.isVisible()).toBe(true); + }); + + it('should expand submenu on space', () => { + menu.onKeydown(space()); + expect(submenu.isVisible()).toBe(true); + }); + + it('should close submenu on escape', () => { + menu.onKeydown(right()); + expect(submenu.isVisible()).toBe(true); + + submenu.onKeydown(escape()); + expect(submenu.isVisible()).toBe(false); + }); + + it('should close submenu on arrow left', () => { + menu.onKeydown(right()); + expect(submenu.isVisible()).toBe(true); + + submenu.onKeydown(left()); + expect(submenu.isVisible()).toBe(false); + }); + + it('should open submenu on mouseover', () => { + const menuItem = menu.inputs.items()[0]; + menu.onMouseOver({target: menuItem.element()} as unknown as MouseEvent); + expect(submenu.isVisible()).toBe(true); + }); + + it('should close on selecting an item on click', () => { + menu.onClick(clickMenuItem(menu.inputs.items(), 0)); + expect(submenu.isVisible()).toBe(true); + + submenu.onClick(clickMenuItem(submenu.inputs.items(), 0)); + expect(submenu.isVisible()).toBe(false); + }); + + it('should close on selecting an item on enter', () => { + menu.onClick(clickMenuItem(menu.inputs.items(), 0)); + expect(submenu.isVisible()).toBe(true); + + submenu.onKeydown(enter()); + expect(submenu.isVisible()).toBe(false); + }); + + it('should close on selecting an item on space', () => { + menu.onClick(clickMenuItem(menu.inputs.items(), 0)); + expect(submenu.isVisible()).toBe(true); + + submenu.onKeydown(space()); + expect(submenu.isVisible()).toBe(false); + }); + + it('should close on focus out from the menu', () => { + menu.onClick(clickMenuItem(menu.inputs.items(), 0)); + expect(submenu.isVisible()).toBe(true); + + submenu.onFocusOut(new FocusEvent('focusout', {relatedTarget: null})); + expect(submenu.isVisible()).toBe(false); + }); + + it('should close a submenu on focus out', () => { + const parentMenuItem = menu.inputs.items()[0]; + menu.onMouseOver({target: parentMenuItem.element()} as unknown as MouseEvent); + expect(submenu.isVisible()).toBe(true); + expect(submenu.isFocused()).toBe(false); + + submenu.onFocusOut(new FocusEvent('focusout', {relatedTarget: document.body})); + expect(submenu.isVisible()).toBe(false); + }); + + it('should close an unfocused submenu on mouse out', () => { + menu.onMouseOver({target: menu.inputs.items()[0].element()} as unknown as MouseEvent); + expect(submenu.isVisible()).toBe(true); + + submenu.onMouseOut({relatedTarget: document.body} as unknown as MouseEvent); + expect(submenu.isVisible()).toBe(false); + }); + + it('should not close an unfocused submenu on mouse out if the parent menu is hovered', () => { + const parentMenuItem = menu.inputs.items()[0]; + menu.onMouseOver({target: parentMenuItem.element()} as unknown as MouseEvent); + expect(submenu.isVisible()).toBe(true); + submenu.onMouseOut({relatedTarget: parentMenuItem.element()} as unknown as MouseEvent); + expect(submenu.isVisible()).toBe(true); + }); + }); +}); + +describe('Menu Trigger Pattern', () => { + let menu: MenuPattern; + let trigger: MenuTriggerPattern; + let submenu: MenuPattern | undefined; + + beforeEach(() => { + trigger = getMenuTriggerPattern(); + menu = getMenuPattern(trigger, ['a', 'b', 'c']); + submenu = getMenuPattern(menu.inputs.items()[0], ['d', 'e']); + }); + + describe('Navigation', () => { + it('should navigate to the first item on click', () => { + trigger.onClick(); + expect(menu.inputs.activeItem()).toBe(menu.inputs.items()[0]); + }); + + it('should navigate to the first item on arrow down', () => { + trigger.onKeydown(down()); + expect(menu.inputs.activeItem()).toBe(menu.inputs.items()[0]); + }); + + it('should navigate to the last item on arrow up', () => { + trigger.onKeydown(up()); + expect(menu.inputs.activeItem()).toBe(menu.inputs.items()[2]); + }); + + it('should navigate to the first item on enter', () => { + trigger.onKeydown(enter()); + expect(menu.inputs.activeItem()).toBe(menu.inputs.items()[0]); + }); + + it('should navigate to the first item on space', () => { + trigger.onKeydown(space()); + expect(menu.inputs.activeItem()).toBe(menu.inputs.items()[0]); + }); + }); + + describe('Expansion and Collapse', () => { + it('should be closed by default', () => { + expect(trigger.expanded()).toBe(false); + expect(menu.isVisible()).toBe(false); + expect(submenu?.isVisible()).toBe(false); + }); + + it('should open on click', () => { + trigger.onClick(); + expect(trigger.expanded()).toBe(true); + expect(menu.isVisible()).toBe(true); + expect(submenu?.isVisible()).toBe(false); + }); + + it('should close on second click', () => { + trigger.onClick(); + expect(trigger.expanded()).toBe(true); + expect(menu.isVisible()).toBe(true); + + trigger.onClick(); + expect(trigger.expanded()).toBe(false); + expect(menu.isVisible()).toBe(false); + }); + + it('should open on arrow down', () => { + trigger.onKeydown(down()); + expect(trigger.expanded()).toBe(true); + expect(menu.isVisible()).toBe(true); + expect(submenu?.isVisible()).toBe(false); + }); + + it('should open on arrow up', () => { + trigger.onKeydown(up()); + expect(trigger.expanded()).toBe(true); + expect(menu.isVisible()).toBe(true); + expect(submenu?.isVisible()).toBe(false); + }); + + it('should open on space', () => { + trigger.onKeydown(space()); + expect(trigger.expanded()).toBe(true); + expect(menu.isVisible()).toBe(true); + expect(submenu?.isVisible()).toBe(false); + }); + + it('should open on enter', () => { + trigger.onKeydown(enter()); + expect(trigger.expanded()).toBe(true); + expect(menu.isVisible()).toBe(true); + expect(submenu?.isVisible()).toBe(false); + }); + + it('should close on escape', () => { + trigger.onKeydown(down()); + expect(trigger.expanded()).toBe(true); + expect(menu.isVisible()).toBe(true); + + menu.onKeydown(escape()); + expect(trigger.expanded()).toBe(false); + expect(menu.isVisible()).toBe(false); + }); + + it('should close on selecting an item on click', () => { + trigger.onClick(); + expect(trigger.expanded()).toBe(true); + expect(menu.isVisible()).toBe(true); + expect(submenu?.isVisible()).toBe(false); + + menu.onClick(clickMenuItem(menu.inputs.items(), 0)); + expect(trigger.expanded()).toBe(true); + expect(menu.isVisible()).toBe(true); + expect(submenu?.isVisible()).toBe(true); + + submenu?.onClick(clickMenuItem(submenu.inputs.items(), 0)); + expect(trigger.expanded()).toBe(false); + expect(menu.isVisible()).toBe(false); + expect(submenu?.isVisible()).toBe(false); + }); + + it('should close on selecting an item on enter', () => { + trigger.onKeydown(down()); + expect(trigger.expanded()).toBe(true); + expect(menu.isVisible()).toBe(true); + expect(submenu?.isVisible()).toBe(false); + + menu.onKeydown(right()); + expect(submenu?.isVisible()).toBe(true); + + submenu?.onKeydown(enter()); + expect(trigger.expanded()).toBe(false); + expect(menu.isVisible()).toBe(false); + expect(submenu?.isVisible()).toBe(false); + }); + + it('should close on selecting an item on space', () => { + trigger.onKeydown(down()); + expect(trigger.expanded()).toBe(true); + expect(menu.isVisible()).toBe(true); + expect(submenu?.isVisible()).toBe(false); + + menu.onKeydown(right()); + expect(submenu?.isVisible()).toBe(true); + + submenu?.onKeydown(space()); + expect(trigger.expanded()).toBe(false); + expect(menu.isVisible()).toBe(false); + expect(submenu?.isVisible()).toBe(false); + }); + + it('should close the trigger on focus out from the menu', () => { + trigger.onKeydown(down()); + menu.onFocusOut(new FocusEvent('focusout', {relatedTarget: null})); + expect(trigger.expanded()).toBe(false); + expect(menu.isVisible()).toBe(false); + expect(submenu?.isVisible()).toBe(false); + }); + }); +}); + +describe('Menu Bar Pattern', () => { + let menuA: MenuPattern; + let menuB: MenuPattern; + let menuC: MenuPattern; + let menubar: MenuBarPattern; + + beforeEach(() => { + menubar = getMenuBarPattern(['a', 'b', 'c']); + menuA = getMenuPattern(menubar.inputs.items()[0], ['apple', 'avocado']); + menuB = getMenuPattern(menubar.inputs.items()[1], ['banana', 'blueberry']); + menuC = getMenuPattern(menubar.inputs.items()[2], ['cherry', 'cranberry']); + }); + + describe('Navigation', () => { + it('should navigate to the first item on arrow down', () => { + const menubarItems = menubar.inputs.items(); + menubar.inputs.activeItem.set(menubarItems[0]); + menubar.onKeydown(down()); + + expect(menuA.isVisible()).toBe(true); + expect(menuA.inputs.activeItem()).toBe(menuA.inputs.items()[0]); + }); + + it('should navigate to the last item on arrow up', () => { + const menubarItems = menubar.inputs.items(); + menubar.inputs.activeItem.set(menubarItems[0]); + menubar.onKeydown(up()); + + expect(menuA.isVisible()).toBe(true); + expect(menuA.inputs.activeItem()).toBe(menuA.inputs.items()[1]); + }); + + it('should navigate to the first item on enter', () => { + const menubarItems = menubar.inputs.items(); + menubar.inputs.activeItem.set(menubarItems[0]); + menubar.onKeydown(enter()); + + expect(menuA.isVisible()).toBe(true); + expect(menuA.inputs.activeItem()).toBe(menuA.inputs.items()[0]); + }); + + it('should navigate to the first item on space', () => { + const menubarItems = menubar.inputs.items(); + menubar.inputs.activeItem.set(menubarItems[0]); + menubar.onKeydown(space()); + + expect(menuA.isVisible()).toBe(true); + expect(menuA.inputs.activeItem()).toBe(menuA.inputs.items()[0]); + }); + + it('should navigate to a menubar item on mouse over', () => { + const menubarItems = menubar.inputs.items(); + menubar.onClick(clickMenuItem(menubarItems, 0)); + expect(menuA.isVisible()).toBe(true); + expect(menuB.isVisible()).toBe(false); + + const mouseOverEvent = {target: menubarItems[1].element()} as unknown as MouseEvent; + menubar.onMouseOver(mouseOverEvent); + + expect(menuA.isVisible()).toBe(false); + expect(menuB.isVisible()).toBe(true); + expect(menubar.inputs.activeItem()).toBe(menubarItems[1]); + }); + + it('should focus the first item of the next menubar item on arrow right', () => { + const menubarItems = menubar.inputs.items(); + menubar.onClick(clickMenuItem(menubarItems, 0)); // open menuA + expect(menuA.isVisible()).toBe(true); + + menuA.onKeydown(right()); + + expect(menuA.isVisible()).toBe(false); + expect(menuB.isVisible()).toBe(true); + expect(menuB.inputs.activeItem()).toBe(menuB.inputs.items()[0]); + }); + + it('should focus the first item of the previous menubar item on arrow left', () => { + const menubarItems = menubar.inputs.items(); + menubar.onClick(clickMenuItem(menubarItems, 1)); // open menuB + expect(menuB.isVisible()).toBe(true); + + menuB.onKeydown(left()); + + expect(menuB.isVisible()).toBe(false); + expect(menuA.isVisible()).toBe(true); + expect(menuA.inputs.activeItem()).toBe(menuA.inputs.items()[0]); + }); + }); + + describe('Expansion and Collapse', () => { + it('should be collapsed by default', () => { + const menubarItems = menubar.inputs.items(); + expect(menubarItems[0].expanded()).toBe(false); + expect(menubarItems[1].expanded()).toBe(false); + expect(menubarItems[2].expanded()).toBe(false); + + expect(menuA.isVisible()).toBe(false); + expect(menuB.isVisible()).toBe(false); + expect(menuC.isVisible()).toBe(false); + }); + + it('should expand on click', () => { + const menubarItems = menubar.inputs.items(); + menubar.onClick(clickMenuItem(menubarItems, 0)); + + expect(menubarItems[0].expanded()).toBe(true); + expect(menubarItems[1].expanded()).toBe(false); + expect(menubarItems[2].expanded()).toBe(false); + + expect(menuA.isVisible()).toBe(true); + expect(menuB.isVisible()).toBe(false); + expect(menuC.isVisible()).toBe(false); + }); + + it('should collapse on second click', () => { + const menubarItems = menubar.inputs.items(); + menubar.onClick(clickMenuItem(menubarItems, 0)); + + expect(menubarItems[0].expanded()).toBe(true); + expect(menuA.isVisible()).toBe(true); + + menubar.onClick(clickMenuItem(menubarItems, 0)); + + expect(menubarItems[0].expanded()).toBe(false); + expect(menuA.isVisible()).toBe(false); + }); + + it('should expand on arrow down', () => { + const menubarItems = menubar.inputs.items(); + menubar.inputs.activeItem.set(menubarItems[0]); + menubar.onKeydown(down()); + + expect(menubarItems[0].expanded()).toBe(true); + expect(menuA.isVisible()).toBe(true); + }); + + it('should expand on arrow up', () => { + const menubarItems = menubar.inputs.items(); + menubar.inputs.activeItem.set(menubarItems[0]); + menubar.onKeydown(up()); + + expect(menubarItems[0].expanded()).toBe(true); + expect(menuA.isVisible()).toBe(true); + }); + + it('should expand on space', () => { + const menubarItems = menubar.inputs.items(); + menubar.inputs.activeItem.set(menubarItems[0]); + menubar.onKeydown(space()); + + expect(menubarItems[0].expanded()).toBe(true); + expect(menuA.isVisible()).toBe(true); + }); + + it('should expand on enter', () => { + const menubarItems = menubar.inputs.items(); + menubar.inputs.activeItem.set(menubarItems[0]); + menubar.onKeydown(enter()); + + expect(menubarItems[0].expanded()).toBe(true); + expect(menuA.isVisible()).toBe(true); + }); + + it('should close on escape', () => { + const menubarItems = menubar.inputs.items(); + menubar.onClick(clickMenuItem(menubarItems, 0)); + expect(menubarItems[0].expanded()).toBe(true); + expect(menuA.isVisible()).toBe(true); + + menuA.onKeydown(escape()); + + expect(menubarItems[0].expanded()).toBe(false); + expect(menuA.isVisible()).toBe(false); + }); + + it('should close on selecting an item on click', () => { + const menubarItems = menubar.inputs.items(); + menubar.onClick(clickMenuItem(menubarItems, 0)); + expect(menuA.isVisible()).toBe(true); + + menuA.onClick(clickMenuItem(menuA.inputs.items(), 0)); + + expect(menubarItems[0].expanded()).toBe(false); + expect(menuA.isVisible()).toBe(false); + }); + + it('should close on selecting an item on enter', () => { + const menubarItems = menubar.inputs.items(); + menubar.onClick(clickMenuItem(menubarItems, 0)); + expect(menuA.isVisible()).toBe(true); + + menuA.onKeydown(enter()); + + expect(menubarItems[0].expanded()).toBe(false); + expect(menuA.isVisible()).toBe(false); + }); + + it('should close on selecting an item on space', () => { + const menubarItems = menubar.inputs.items(); + menubar.onClick(clickMenuItem(menubarItems, 0)); + expect(menuA.isVisible()).toBe(true); + + menuA.onKeydown(space()); + + expect(menubarItems[0].expanded()).toBe(false); + expect(menuA.isVisible()).toBe(false); + }); + + it('should close on focus out from the menu', () => { + const menubarItems = menubar.inputs.items(); + menubar.onClick(clickMenuItem(menubarItems, 0)); + expect(menuA.isVisible()).toBe(true); + + menuA.onFocusOut(new FocusEvent('focusout', {relatedTarget: null})); + + expect(menubarItems[0].expanded()).toBe(false); + expect(menuA.isVisible()).toBe(false); + }); + + it('should close on arrow right on a leaf menu item', () => { + const menubarItems = menubar.inputs.items(); + menubar.onClick(clickMenuItem(menubarItems, 0)); + expect(menuA.isVisible()).toBe(true); + + menuA.onKeydown(right()); + + expect(menuA.isVisible()).toBe(false); + expect(menubarItems[0].expanded()).toBe(false); + }); + + it('should close on arrow left on a root menu item', () => { + const menubarItems = menubar.inputs.items(); + menubar.onClick(clickMenuItem(menubarItems, 1)); + expect(menuB.isVisible()).toBe(true); + + menuB.onKeydown(left()); + + expect(menuB.isVisible()).toBe(false); + expect(menubarItems[1].expanded()).toBe(false); + }); + + it('should expand the next menu bar item on arrow right on a leaf menu item', () => { + const menubarItems = menubar.inputs.items(); + menubar.onClick(clickMenuItem(menubarItems, 0)); + + menuA.onKeydown(right()); + + expect(menuB.isVisible()).toBe(true); + expect(menubarItems[1].expanded()).toBe(true); + expect(menubar.inputs.activeItem()).toBe(menubarItems[1]); + }); + + it('should expand the previous menu bar item on arrow left on a root menu item', () => { + const menubarItems = menubar.inputs.items(); + menubar.onClick(clickMenuItem(menubarItems, 1)); + + menuB.onKeydown(left()); + + expect(menuA.isVisible()).toBe(true); + expect(menubarItems[0].expanded()).toBe(true); + expect(menubar.inputs.activeItem()).toBe(menubarItems[0]); + }); + }); +}); diff --git a/src/aria/ui-patterns/menu/menu.ts b/src/aria/ui-patterns/menu/menu.ts index a8b0fdc9a145..2d135c73ed68 100644 --- a/src/aria/ui-patterns/menu/menu.ts +++ b/src/aria/ui-patterns/menu/menu.ts @@ -21,7 +21,8 @@ export interface MenuBarInputs extends Omit, V> } /** The inputs for the MenuPattern class. */ -export interface MenuInputs extends Omit, V>, 'disabled'> { +export interface MenuInputs + extends Omit, V>, 'value' | 'disabled'> { /** The unique ID of the menu. */ id: SignalLike; @@ -147,7 +148,11 @@ export class MenuPattern { constructor(public readonly inputs: MenuInputs) { this.id = inputs.id; - this.listBehavior = new List, V>({...inputs, disabled: () => false}); + this.listBehavior = new List, V>({ + ...inputs, + value: signal([]), + disabled: () => false, + }); } /** Sets the default state for the menu. */ @@ -189,12 +194,38 @@ export class MenuPattern { this.listBehavior.goto(item, {focusElement: this.shouldFocus()}); } + /** Handles mouseout events for the menu. */ + onMouseOut(event: MouseEvent) { + if (this.isFocused()) { + return; + } + + const root = this.root(); + const parent = this.inputs.parent(); + const relatedTarget = event.relatedTarget as Node | null; + + if (!root || !parent || parent instanceof MenuTriggerPattern) { + return; + } + + const grandparent = parent.inputs.parent(); + + if (!grandparent || grandparent instanceof MenuBarPattern) { + return; + } + + if (!grandparent.inputs.element()?.contains(relatedTarget)) { + parent.close(); + } + } + /** Handles click events for the menu. */ onClick(event: MouseEvent) { const relatedTarget = event.target as Node | null; const item = this.inputs.items().find(i => i.element()?.contains(relatedTarget)); if (item) { + item.open(); this.listBehavior.goto(item); this.submit(item); } @@ -284,6 +315,7 @@ export class MenuPattern { if (!item.submenu() && isMenu) { root.inputs.activeItem()?.close({refocus: true}); + root?.inputs.onSubmit?.(item.value()); } } } @@ -323,6 +355,10 @@ export class MenuPattern { if (root instanceof MenuBarPattern) { root.close(); } + + if (root instanceof MenuPattern) { + root.inputs.activeItem()?.close({refocus: true}); + } } } From 475b53316eb878c24e320e89364d4da823435852 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Fri, 17 Oct 2025 16:20:23 -0400 Subject: [PATCH 6/6] fixup! feat(aria/menu): add examples for aria menu --- .../aria/menu/menu-example.css | 4 ++++ .../aria/menu/simple-menu.ts | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/components-examples/aria/menu/menu-example.css b/src/components-examples/aria/menu/menu-example.css index dc9cd0684768..aa56586d43df 100644 --- a/src/components-examples/aria/menu/menu-example.css +++ b/src/components-examples/aria/menu/menu-example.css @@ -21,6 +21,10 @@ border: 1px solid color-mix(in srgb, var(--mat-sys-outline) 40%, transparent); } +.example-menu[popover] { + position: absolute; +} + .example-menu-bar { display: flex; cursor: pointer; diff --git a/src/components-examples/aria/menu/simple-menu.ts b/src/components-examples/aria/menu/simple-menu.ts index 460cbe840c90..11e5dd59a849 100644 --- a/src/components-examples/aria/menu/simple-menu.ts +++ b/src/components-examples/aria/menu/simple-menu.ts @@ -29,15 +29,21 @@ export class SimpleMenu { const parentEl = parent.element; const parentRect = parentEl.getBoundingClientRect(); + const scrollX = window.scrollX; + const scrollY = window.scrollY; + + const top = parentRect.y + scrollY - 5; + const bottom = parentRect.y + scrollY + parentRect.height + 6; + if (parent.parent instanceof MenuBar) { - this.menu.element.style.left = `${parentRect.left}px`; - this.menu.element.style.top = `${parentRect.bottom + 6}px`; + this.menu.element.style.left = `${parentRect.left + scrollX}px`; + this.menu.element.style.top = `${bottom}px`; } else if (parent instanceof MenuTrigger) { - this.menu.element.style.left = `${parentRect.left}px`; - this.menu.element.style.top = `${parentRect.bottom + 2}px`; + this.menu.element.style.left = `${parentRect.left + scrollX}px`; + this.menu.element.style.top = `${parentRect.bottom + scrollY + 2}px`; } else { - this.menu.element.style.left = `${parentRect.right + 6}px`; - this.menu.element.style.top = `${parentRect.top - 5}px`; + this.menu.element.style.left = `${parentRect.right + scrollX + 6}px`; + this.menu.element.style.top = `${top}px`; } } }