/
githubmirror
/
components
Обзор
Документация
Войти
/
githubmirror
/
components
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/aria/private/menu/menu.ts
866 строк
25 KB
Cheng-Hsuan Tsai
fix(aria/menu): allow menu item role override (#33264)
18 май 2026, 09:06
Не верифицирован
18 май 2026, 09:06
50e281a
Код
Авторство
О чём код?
/** * @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 {KeyboardEventManager} from '../behaviors/event-manager'; import {computed, signal, SignalLike} from '../behaviors/signal-like/signal-like'; import {List, ListInputs, ListItem} from '../behaviors/list/list'; /** The inputs for the MenuBarPattern class. */ export interface MenuBarInputs<V> extends ListInputs<MenuItemPattern<V>, V> { /** The menu items contained in the menu. */ items: SignalLike<MenuItemPattern<V>[]>; /** Callback function triggered when a menu item is selected. */ itemSelected?: (value: V) => void; /** The text direction of the menu bar. */ textDirection: SignalLike<'ltr' | 'rtl'>; } /** The inputs for the MenuPattern class. */ export interface MenuInputs<V> extends Omit<ListInputs<MenuItemPattern<V>, V>, 'value'> { /** The unique ID of the menu. */ id: SignalLike<string>; /** The menu items contained in the menu. */ items: SignalLike<MenuItemPattern<V>[]>; /** A reference to the parent menu or menu trigger. */ parent: SignalLike<MenuTriggerPattern<V> | MenuItemPattern<V> | undefined>; /** Callback function triggered when a menu item is selected. */ itemSelected?: (value: V) => void; /** The text direction of the menu bar. */ textDirection: SignalLike<'ltr' | 'rtl'>; /** The delay in milliseconds before expanding sub-menus on hover. */ expansionDelay: SignalLike<number>; } /** The inputs for the MenuTriggerPattern class. */ export interface MenuTriggerInputs<V> { /** A reference to the menu trigger element. */ element: SignalLike<HTMLElement | undefined>; /** A reference to the menu associated with the trigger. */ menu: SignalLike<MenuPattern<V> | undefined>; /** The text direction of the menu bar. */ textDirection: SignalLike<'ltr' | 'rtl'>; /** Whether the menu trigger is disabled. */ disabled: SignalLike<boolean>; } /** The inputs for the MenuItemPattern class. */ export interface MenuItemInputs<V> extends Omit<ListItem<V>, 'index' | 'selectable'> { /** A reference to the parent menu or menu trigger. */ parent: SignalLike<MenuPattern<V> | MenuBarPattern<V> | undefined>; /** A reference to the submenu associated with the menu item. */ submenu: SignalLike<MenuPattern<V> | undefined>; /** The role of the menu item. */ role: SignalLike<'menuitem' | 'menuitemradio' | 'menuitemcheckbox'>; } /** The menu ui pattern class. */ export class MenuPattern<V> { /** The unique ID of the menu. */ readonly id: SignalLike<string>; /** The role of the menu. */ readonly role = () => 'menu'; /** Whether the menu is disabled. */ readonly disabled = () => this.inputs.disabled(); /** Whether the menu is visible. */ readonly visible = computed(() => this.inputs.parent() ? !!this.inputs.parent()?.expanded() : true, ); /** Controls list behavior for the menu items. */ readonly listBehavior: List<MenuItemPattern<V>, V>; /** Whether the menu or any of its child elements are currently focused. */ readonly isFocused = signal(false); /** Whether the menu has received interaction. */ readonly hasBeenInteracted = signal(false); /** Whether the menu trigger has been hovered. */ readonly hasBeenHovered = signal(false); /** Timeout used to open sub-menus on hover. */ _openTimeout: any; /** Timeout used to close sub-menus on hover out. */ _closeTimeout: any; /** The tab index of the menu. */ readonly tabIndex = () => this.listBehavior.tabIndex(); /** Whether the menu should be focused on mouse over. */ readonly 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 readonly _expandKey = computed(() => { return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; }); /** The key used to collapse sub-menus. */ private readonly _collapseKey = computed(() => { return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; }); /** Represents the space key. Does nothing when the user is actively using typeahead. */ readonly dynamicSpaceKey = computed(() => (this.listBehavior.isTyping() ? '' : ' ')); /** The regexp used to decide if a key should trigger typeahead. */ readonly typeaheadRegexp = /^.$/; /** The root of the menu. */ readonly root: SignalLike< MenuTriggerPattern<V> | MenuBarPattern<V> | MenuPattern<V> | 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. */ readonly keydownManager = computed(() => { return new KeyboardEventManager() .on('ArrowDown', () => this.next(), {ignoreRepeat: false}) .on('ArrowUp', () => this.prev(), {ignoreRepeat: false}) .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(readonly inputs: MenuInputs<V>) { this.id = inputs.id; this.listBehavior = new List<MenuItemPattern<V>, V>({ ...inputs, value: signal([]), }); } /** Returns a set of violations */ validate(): string[] { const violations: string[] = []; const values = this.inputs.items().map(i => i.value()); const duplicates = values.filter((val, idx) => values.indexOf(val) !== idx); if (duplicates.length > 0) { violations.push(`Duplicate value '${duplicates[0]}' detected inside ngMenu.`); } return violations; } /** Sets the default state for the menu. */ setDefaultState() { if (!this.inputs.parent()) { const firstFocusable = this.listBehavior.navigationBehavior.peekFirst(); if (firstFocusable) { this.listBehavior.goto(firstFocusable, {focusElement: false}); } } } /** Sets the default active state of the menu before receiving interaction for the first time. */ setDefaultStateEffect(): void { if (this.hasBeenInteracted() || this.hasBeenHovered()) return; if (this.inputs.items().length > 0) { this.setDefaultState(); } } /** Handles keyboard events for the menu. */ onKeydown(event: KeyboardEvent) { this.hasBeenInteracted.set(true); this.keydownManager().handle(event); } /** Handles mouseover events for the menu. */ onMouseOver(event: MouseEvent) { if (!this.visible()) { return; } this.hasBeenHovered.set(true); const item = this.inputs.items().find(i => i.element()?.contains(event.target as Node)); if (!item) { return; } const parent = this.inputs.parent(); const activeItem = this?.inputs.activeItem(); if (parent instanceof MenuItemPattern) { const grandparent = parent.inputs.parent(); if (grandparent instanceof MenuPattern) { grandparent._clearTimeouts(); grandparent.listBehavior.goto(parent, {focusElement: false}); } } if (activeItem && activeItem !== item) { this._closeItem(activeItem); } if (item.expanded()) { this._clearCloseTimeout(); } this._openItem(item); this.listBehavior.goto(item, {focusElement: this.shouldFocus()}); } /** Closes the specified menu item after a delay. */ private _closeItem(item: MenuItemPattern<V>) { this._clearOpenTimeout(); if (!this._closeTimeout) { this._closeTimeout = setTimeout(() => { item.close(); this._closeTimeout = undefined; }, this.inputs.expansionDelay()); } } /** Opens the specified menu item after a delay. */ private _openItem(item: MenuItemPattern<V>) { this._clearOpenTimeout(); this._openTimeout = setTimeout(() => { item.open(); this._openTimeout = undefined; }, this.inputs.expansionDelay()); } /** Handles mouseout events for the menu. */ onMouseOut(event: MouseEvent) { this._clearOpenTimeout(); 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); } } /** Handles focusin events for the menu. */ onFocusIn() { this.isFocused.set(true); this.hasBeenInteracted.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.visible() && !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()) { if (!item || item.disabled() || item.submenu()) { return; } const root = this.root(); if (root instanceof MenuTriggerPattern) { root.close({refocus: true}); root?.inputs.menu()?.inputs.itemSelected?.(item.value()); } else if (root instanceof MenuBarPattern) { root.close(); root?.inputs.itemSelected?.(item.value()); } else if (root instanceof MenuPattern) { root.inputs.activeItem()?.close({refocus: true}); root?.inputs.itemSelected?.(item.value()); } } /** 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. */ close() { this.inputs.parent()?.close(); } /** 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(); } if (root instanceof MenuPattern) { root.inputs.activeItem()?.close({refocus: true}); } } /** Clears any open or close timeouts for sub-menus. */ _clearTimeouts() { this._clearOpenTimeout(); this._clearCloseTimeout(); } /** Clears the open timeout. */ _clearOpenTimeout() { if (this._openTimeout) { clearTimeout(this._openTimeout); this._openTimeout = undefined; } } /** Clears the close timeout. */ _clearCloseTimeout() { if (this._closeTimeout) { clearTimeout(this._closeTimeout); this._closeTimeout = undefined; } } } /** The menubar ui pattern class. */ export class MenuBarPattern<V> { /** Controls list behavior for the menu items. */ readonly listBehavior: List<MenuItemPattern<V>, V>; /** The tab index of the menu. */ readonly tabIndex = () => this.listBehavior.tabIndex(); /** The key used to navigate to the next item. */ private readonly _nextKey = computed(() => { return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; }); /** The key used to navigate to the previous item. */ private readonly _previousKey = computed(() => { return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; }); /** Represents the space key. Does nothing when the user is actively using typeahead. */ readonly dynamicSpaceKey = computed(() => (this.listBehavior.isTyping() ? '' : ' ')); /** The regexp used to decide if a key should trigger typeahead. */ readonly typeaheadRegexp = /^.$/; /** Whether the menubar or any of its children are currently focused. */ readonly isFocused = signal(false); /** Whether the menubar has been interacted with. */ readonly hasBeenInteracted = signal(false); /** Whether the menubar is disabled. */ readonly disabled = () => this.inputs.disabled(); /** Handles keyboard events for the menu. */ readonly keydownManager = computed(() => { return new KeyboardEventManager() .on(this._nextKey, () => this.next(), {ignoreRepeat: false}) .on(this._previousKey, () => this.prev(), {ignoreRepeat: false}) .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(readonly inputs: MenuBarInputs<V>) { this.listBehavior = new List<MenuItemPattern<V>, V>(inputs); } /** Sets the default state for the menubar. */ setDefaultState() { const firstFocusable = this.listBehavior.navigationBehavior.peekFirst(); if (firstFocusable) { this.inputs.activeItem.set(firstFocusable); } } /** Sets the default active state of the menubar before receiving interaction for the first time. */ setDefaultStateEffect(): void { if (this.hasBeenInteracted()) return; if (this.inputs.items().length > 0) { this.setDefaultState(); } } /** Handles keyboard events for the menu. */ onKeydown(event: KeyboardEvent) { this.hasBeenInteracted.set(true); 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.hasBeenInteracted.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<V>, 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<V> { /** Whether the menu trigger is expanded. */ readonly expanded = signal(false); /** Whether the menu trigger has received interaction. */ readonly hasBeenInteracted = signal(false); /** The pending focus target when the menu is opened before the menu instance is available. */ readonly pendingFocus = signal<'first' | 'last' | undefined>(undefined); /** The role of the menu trigger. */ readonly role = () => 'button'; /** Whether the menu trigger has a popup. */ readonly hasPopup = () => true; /** The menu associated with the trigger. */ readonly menu: SignalLike<MenuPattern<V> | undefined>; /** The tab index of the menu trigger. */ readonly tabIndex = computed(() => this.expanded() && this.menu()?.inputs.activeItem() ? -1 : 0, ); /** Whether the menu trigger is disabled. */ readonly disabled = () => this.inputs.disabled(); /** Handles keyboard events for the menu trigger. */ readonly 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(readonly inputs: MenuTriggerInputs<V>) { this.menu = this.inputs.menu; } /** Flushes any pending focus when the menu instance becomes available. */ pendingFocusEffect(): void { const menu = this.inputs.menu(); const intent = this.pendingFocus(); if (menu && intent) { if (intent === 'first') { menu.first(); } else if (intent === 'last') { menu.last(); } this.pendingFocus.set(undefined); } } /** Handles keyboard events for the menu trigger. */ onKeydown(event: KeyboardEvent) { if (!this.inputs.disabled()) { this.hasBeenInteracted.set(true); this.keydownManager().handle(event); } } /** Handles click events for the menu trigger. */ onClick() { if (!this.inputs.disabled()) { this.expanded() ? this.close() : this.open({first: true}); } } /** Handles focusin events for the menu trigger. */ onFocusIn() { this.hasBeenInteracted.set(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.menu()?.inputs.element()?.contains(relatedTarget) ) { this.close(); } } /** Opens the menu. */ open(opts?: {first?: boolean; last?: boolean}) { this.expanded.set(true); if (opts?.first) { this.pendingFocus.set('first'); } else if (opts?.last) { this.pendingFocus.set('last'); } } /** Closes the menu. */ close(opts: {refocus?: boolean} = {}) { this.expanded.set(false); this.pendingFocus.set(undefined); this.menu()?.listBehavior.unfocus(); if (opts.refocus) { this.inputs.element()?.focus(); } let menuitems = this.inputs.menu()?.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<V> implements ListItem<V> { /** The value of the menu item. */ readonly value: SignalLike<V>; /** The unique ID of the menu item. */ readonly id: SignalLike<string>; /** Whether the menu item is disabled. */ readonly disabled = () => this.inputs.parent()?.disabled() || this.inputs.disabled(); /** The search term for the menu item. */ readonly searchTerm: SignalLike<string>; /** The element of the menu item. */ readonly element: SignalLike<HTMLElement | undefined>; /** Whether the menu item is active. */ readonly active = computed(() => this.inputs.parent()?.inputs.activeItem() === this); /** Whether the menu item has received interaction. */ readonly hasBeenInteracted = signal(false); /** The tab index of the menu item. */ readonly 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. */ readonly index = computed(() => this.inputs.parent()?.inputs.items().indexOf(this) ?? -1); /** Whether the menu item is expanded. */ readonly expanded = computed(() => (this.submenu() ? this._expanded() : null)); /** Whether the menu item is expanded. */ readonly _expanded = signal(false); /** The ID of the menu that the menu item controls. */ readonly controls = signal<string | undefined>(undefined); /** The role of the menu item. */ readonly role = () => this.inputs.role(); /** Whether the menu item has a popup. */ readonly hasPopup = computed(() => !!this.submenu()); /** The submenu associated with the menu item. */ readonly submenu: SignalLike<MenuPattern<V> | undefined>; /** Whether the menu item is selectable. */ readonly selectable: SignalLike<boolean>; constructor(readonly inputs: MenuItemInputs<V>) { this.id = inputs.id; this.value = inputs.value; this.element = inputs.element; this.submenu = this.inputs.submenu; this.searchTerm = inputs.searchTerm; this.selectable = computed(() => !this.submenu()); } /** Opens the submenu. */ open(opts?: {first?: boolean; last?: boolean}) { if (this.disabled()) { return; } 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() ?? []); const parent = menuitem?.inputs.parent(); if (parent instanceof MenuPattern) { parent._clearTimeouts(); } } } /** Handles focusin events for the menu item. */ onFocusIn() { this.hasBeenInteracted.set(true); } }