From 7326c80790b3da6d76b1b1243cefa7aa0d8e1220 Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Mon, 2 Feb 2026 16:38:20 +0100 Subject: [PATCH] feat(menu): add `hotkey` prop for items & improve keyboard navigation --- etc/lime-elements.api.md | 9 + .../menu-item-meta/menu-item-meta.tsx | 22 +- .../menu-list/menu-list-renderer.tsx | 9 +- src/components/menu/examples/menu-hotkeys.tsx | 89 +++- .../menu/examples/menu-searchable-hotkeys.tsx | 160 +++++++ src/components/menu/menu.e2e.tsx | 432 ++++++++++++++++++ src/components/menu/menu.tsx | 190 +++++++- src/components/menu/menu.types.ts | 15 + 8 files changed, 894 insertions(+), 32 deletions(-) create mode 100644 src/components/menu/examples/menu-searchable-hotkeys.tsx diff --git a/etc/lime-elements.api.md b/etc/lime-elements.api.md index 535e9ca0de..1c496524ed 100644 --- a/etc/lime-elements.api.md +++ b/etc/lime-elements.api.md @@ -643,6 +643,8 @@ export namespace Components { export interface LimelMenuItemMeta { "badge"?: string | number; "commandText"?: string; + "disabled": boolean; + "hotkey"?: string; "showChevron": boolean; } // @internal (undocumented) @@ -2850,6 +2852,8 @@ export namespace JSX { export interface LimelMenuItemMeta { "badge"?: string | number; "commandText"?: string; + "disabled"?: boolean; + "hotkey"?: string; "showChevron"?: boolean; } @@ -2860,6 +2864,10 @@ export namespace JSX { // (undocumented) "commandText": string; // (undocumented) + "disabled": boolean; + // (undocumented) + "hotkey": string; + // (undocumented) "showChevron": boolean; } @@ -4176,6 +4184,7 @@ interface MenuItem { badge?: number | string; commandText?: string; disabled?: boolean; + hotkey?: string; icon?: string | Icon; // @deprecated iconColor?: Color; diff --git a/src/components/list-item/menu-item-meta/menu-item-meta.tsx b/src/components/list-item/menu-item-meta/menu-item-meta.tsx index b68043f7bb..1711137d23 100644 --- a/src/components/list-item/menu-item-meta/menu-item-meta.tsx +++ b/src/components/list-item/menu-item-meta/menu-item-meta.tsx @@ -1,4 +1,5 @@ import { Component, Host, Prop, h } from '@stencil/core'; +import { normalizeHotkeyString } from '../../../util/hotkeys'; /** * Meta content for menu list items @@ -19,9 +20,21 @@ export class MenuItemMeta { /** * Use to display optional keyboard shortcut or command hint, e.g. `⌘ + K` */ - @Prop() + @Prop({ reflect: true }) public commandText?: string; + /** + * Hotkey to display. When provided, `commandText` is ignored. + */ + @Prop({ reflect: true }) + public hotkey?: string; + + /** + * Will be set to `true` when the menu item is disabled. + */ + @Prop({ reflect: true }) + public disabled = false; + /** * Optional badge value */ @@ -45,6 +58,13 @@ export class MenuItemMeta { } private renderCommandText() { + if (this.hotkey) { + const hotkey = normalizeHotkeyString(this.hotkey); + if (hotkey) { + return ; + } + } + if (!this.commandText) { return; } diff --git a/src/components/menu-list/menu-list-renderer.tsx b/src/components/menu-list/menu-list-renderer.tsx index 6f763eb7bb..8be59c253e 100644 --- a/src/components/menu-list/menu-list-renderer.tsx +++ b/src/components/menu-list/menu-list-renderer.tsx @@ -108,14 +108,17 @@ export class MenuListRenderer { const hasMeta = hasSubMenu || item.badge !== undefined || - (!!('commandText' in item) && !!item.commandText); + !!item.hotkey || + !!item.commandText; const primaryComponent = hasMeta ? { name: 'limel-menu-item-meta', props: { - commandText: (item as any).commandText, - badge: (item as any).badge, + commandText: item.commandText, + hotkey: item.hotkey, + disabled: !!item.disabled, + badge: item.badge, showChevron: hasSubMenu, }, } diff --git a/src/components/menu/examples/menu-hotkeys.tsx b/src/components/menu/examples/menu-hotkeys.tsx index 6e4a1ec2e3..0d048e6b72 100644 --- a/src/components/menu/examples/menu-hotkeys.tsx +++ b/src/components/menu/examples/menu-hotkeys.tsx @@ -3,11 +3,40 @@ import { ListSeparator, LimelMenuCustomEvent, } from '@limetech/lime-elements'; -import { Component, h, State } from '@stencil/core'; +import { Component, h, Host, State } from '@stencil/core'; /** * Menu with supporting hotkeys * + * Use `hotkey` to bind actual keyboard interaction while the menu is open. + * + * :::note + * 1. `commandText` is ignored and won't render, when `hotkey` is defined. + * 2. Hotkeys only work while the menu is open. They do not register + * global keyboard shortcuts — the shortcut has no effect when the + * menu is closed. + * 3. Some keys are reserved for menu interaction and will be ignored as + * hotkeys: `tab`, `enter`, `escape`, `space`, and arrow keys are reserved + * unless used with a modifier (e.g. `ctrl+enter`, `meta+arrowdown`). + * ::: + * + * :::important + * 1. `meta` means the Meta key. + * It is rendered as on Apple devices, and ⊞ Win on + * Windows/Linux. (`cmd`, `command`, `win`, `windows` are aliases for `meta`.) + * + * If you want "primary modifier" hotkeys (e.g. on macOS and + * Ctrl on Windows/Linux), detect OS in your application and + * provide different `hotkey` values. + * + * `ctrl` means the Control key on all platforms. + * + * 2. All browsers and operating systems have some built-in hotkeys + * that may conflict with your defined hotkeys. + * For example, `cmd+p` is often used to print the current page. + * Make sure to choose hotkeys that do not conflict with common browser + * hotkeys, and user's expected behavior. + * ::: */ @Component({ tag: 'limel-example-menu-hotkeys', @@ -18,24 +47,54 @@ export class MenuHotkeysExample { private lastSelectedItem: string; private items: Array = [ - { text: 'Copy', commandText: 'alt + C' }, - { text: 'Cut', commandText: 'alt + X' }, + { text: 'Edit message', commandText: 'Is ignored', hotkey: 'e' }, + { text: 'Mark unread', hotkey: 'u', disabled: true }, + { + text: 'Remind me', + items: [ + { text: 'Later today' }, + { text: 'Tomorrow' }, + { text: 'Next week' }, + ], + }, + { separator: true }, + { text: 'Copy link', hotkey: 'l' }, + { text: 'Copy message', hotkey: 'meta+c' }, { separator: true }, - { text: 'Paste', commandText: 'alt + V' }, + { + text: 'Organize', + hotkey: 'o', + items: [ + { text: 'Move to', hotkey: 'm' }, + { text: 'Label as', hotkey: 'shift+l' }, + { text: 'Mute', hotkey: 'alt+m' }, + ], + }, + { + text: 'Connect to apps', + items: [{ text: 'Trello' }, { text: 'Asana' }], + }, + { separator: true }, + { text: 'Delete message…', hotkey: 'backspace' }, ]; public render() { - console.log(this.items); - - return [ - - - , - , - ]; + return ( + + + + + + + ); } private handleSelect = (event: LimelMenuCustomEvent) => { diff --git a/src/components/menu/examples/menu-searchable-hotkeys.tsx b/src/components/menu/examples/menu-searchable-hotkeys.tsx new file mode 100644 index 0000000000..a48cf16568 --- /dev/null +++ b/src/components/menu/examples/menu-searchable-hotkeys.tsx @@ -0,0 +1,160 @@ +import { + MenuItem, + ListSeparator, + LimelMenuCustomEvent, +} from '@limetech/lime-elements'; +import { Component, Host, State, h } from '@stencil/core'; + +const isApple = /Mac|iPhone|iPad|iPod/i.test( + (navigator as any).userAgentData?.platform ?? navigator.platform ?? '' +); + +type SuggestionValue = { + propertyName?: string; + searchTerm?: string; + applyAll?: boolean; +}; + +const FILTERABLE_PROPERTIES = [ + 'Name', + 'Company', + 'City', + 'Country', + 'Email', + 'Phone', + 'Website', +]; + +/** + * Searchable menu with hotkeys + * + * When it comes to menus that have a search functionality, + * hotkeys can be a great way to speed up selection, but it can also be + * tricky to implement in a way that feels intuitive and doesn't interfere with + * the search experience. + * + * Consider that when the menu is opened, they focus is on the search input, + * and the user is either already typing something to filter the results + * or has typed something and is seeing the filtered results, while the focus is + * still in the input field. + * + * Therefore, using a single hotkey for each item in the search results is not + * a good idea, as it can interfere with the user's ability to type their search query. + * For such scenarios, hotkey combinations that require a modifier + * key (e.g. Ctrl or Cmd) can be a good solution, + * as they allow the user to quickly select an item from the search results + * without interfering with their ability to type. + * + * In this example, the first search result gets: + * - Apple devices: + + * - Others: Ctrl + + * + * The final “Apply all” item (shown only when there are many results) gets: + * - Apple devices: + + + * - Others: Ctrl + Alt + + */ +@Component({ + tag: 'limel-example-menu-searchable-hotkeys', + shadow: true, +}) +export class MenuSearchableHotkeysExample { + @State() + private lastSelectedItem = ''; + + private get primaryHotkey() { + return isApple ? 'meta+enter' : 'ctrl+enter'; + } + + private get applyAllHotkey() { + return isApple ? 'meta+alt+enter' : 'ctrl+alt+enter'; + } + + public render() { + return ( + + + + + + + ); + } + + private readonly handleSearch = async ( + queryString: string + ): Promise> => { + const searchTerm = queryString?.trim() ?? ''; + if (searchTerm.length === 0) { + return []; + } + + const menuItems = FILTERABLE_PROPERTIES.filter((item) => { + return item.toLowerCase().includes(searchTerm.toLowerCase()); + }).map((propertyName) => { + return { + text: propertyName, + value: { + propertyName, + searchTerm, + } satisfies SuggestionValue, + } as MenuItem; + }); + + if (menuItems.length === 0) { + return []; + } + + menuItems[0] = { + ...menuItems[0], + hotkey: this.primaryHotkey, + }; + + if (menuItems.length <= 1) { + return menuItems; + } + + const applyAllMenuItem: MenuItem = { + text: 'Apply all', + hotkey: this.applyAllHotkey, + value: { + applyAll: true, + searchTerm, + } satisfies SuggestionValue, + }; + + return [...menuItems, { separator: true }, applyAllMenuItem]; + }; + + private handleSelect = ( + event: LimelMenuCustomEvent> + ) => { + const selectedItem = event.detail; + if (!selectedItem?.value) { + return; + } + + if (selectedItem.value.applyAll) { + this.lastSelectedItem = `Apply all (${selectedItem.value.searchTerm})`; + + return; + } + + this.lastSelectedItem = `${selectedItem.value.propertyName}`; + }; +} diff --git a/src/components/menu/menu.e2e.tsx b/src/components/menu/menu.e2e.tsx index 78df13e36b..64d15f3aa5 100644 --- a/src/components/menu/menu.e2e.tsx +++ b/src/components/menu/menu.e2e.tsx @@ -1,4 +1,5 @@ import { render, h } from '@stencil/vitest'; +import { vi } from 'vitest'; describe('limel-menu', () => { const items = [ @@ -122,4 +123,435 @@ describe('limel-menu', () => { // Focus restoration and keyboard navigation tests are skipped — // they require real browser focus management (page.keyboard, page.focus) // which isn't available in the Stencil vitest render API. + + describe('hotkeys', () => { + it('selects the matching item when a hotkey is pressed while the menu is open', async () => { + const hotkeyItems = [ + { text: 'Copy', hotkey: 'alt+c' }, + { text: 'Cut', hotkey: 'alt+x' }, + ]; + + const { root, waitForChanges } = await render( + + + + ); + await waitForChanges(); + + const handler = vi.fn(); + root.addEventListener('select', (e: Event) => + handler((e as CustomEvent).detail) + ); + + // Open the menu + const trigger = root.querySelector( + 'button[slot="trigger"]' + ) as HTMLElement; + trigger.click(); + await waitForChanges(); + + // Find the menu surface (rendered via portal on document.body) + const surface = document.querySelector('limel-menu-surface'); + expect(surface).toBeTruthy(); + + // Dispatch the hotkey on the surface + surface!.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'c', + code: 'KeyC', + altKey: true, + bubbles: true, + composed: true, + }) + ); + await waitForChanges(); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ text: 'Copy' }) + ); + + // Menu should close after selection + expect((root as any).open).toBeFalsy(); + }); + + it('does not allow reserved keys (Enter) to be used as item hotkeys', async () => { + const hotkeyItems = [ + { text: 'First' }, + { + text: 'Second (should not steal Enter)', + hotkey: 'enter', + }, + ]; + + const { root, waitForChanges } = await render( + + + + ); + await waitForChanges(); + + const handler = vi.fn(); + root.addEventListener('select', (e: Event) => + handler((e as CustomEvent).detail) + ); + + const surface = document.querySelector('limel-menu-surface'); + expect(surface).toBeTruthy(); + + // Enter is reserved — should NOT trigger the "Second" item's hotkey + surface!.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + bubbles: true, + composed: true, + }) + ); + await waitForChanges(); + + // Enter on a reserved key should not trigger the hotkey handler + // (it may activate the focused item via native menu behavior, + // but should not match the "enter" hotkey on the second item) + const calls = handler.mock.calls; + for (const [detail] of calls) { + expect(detail.text).not.toBe('Second (should not steal Enter)'); + } + }); + + it('does not trigger hotkey selection for reserved arrow keys', async () => { + const hotkeyItems = [ + { text: 'Down (reserved)', hotkey: 'arrowdown' }, + { text: 'Another item' }, + ]; + + const { root, waitForChanges } = await render( + + + + ); + await waitForChanges(); + + const handler = vi.fn(); + root.addEventListener('select', (e: Event) => + handler((e as CustomEvent).detail) + ); + + const surface = document.querySelector('limel-menu-surface'); + expect(surface).toBeTruthy(); + + // ArrowDown is reserved for navigation, not hotkey activation + surface!.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown', + code: 'ArrowDown', + bubbles: true, + composed: true, + }) + ); + await waitForChanges(); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('allows modified arrow key combos as item hotkeys', async () => { + const hotkeyItems = [ + { text: 'Alt+Down action', hotkey: 'alt+arrowdown' }, + { text: 'Another item' }, + ]; + + const { root, waitForChanges } = await render( + + + + ); + await waitForChanges(); + + const handler = vi.fn(); + root.addEventListener('select', (e: Event) => + handler((e as CustomEvent).detail) + ); + + const surface = document.querySelector('limel-menu-surface'); + expect(surface).toBeTruthy(); + + surface!.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown', + code: 'ArrowDown', + altKey: true, + bubbles: true, + composed: true, + }) + ); + await waitForChanges(); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ text: 'Alt+Down action' }) + ); + }); + + it('triggers modified hotkeys while search input is focused', async () => { + const searchableItems = [ + { text: 'Apply first', hotkey: 'ctrl+enter' }, + ]; + const searcher = vi.fn().mockResolvedValue(searchableItems); + + const { root, waitForChanges } = await render( + + + + ); + await waitForChanges(); + + const handler = vi.fn(); + root.addEventListener('select', (e: Event) => + handler((e as CustomEvent).detail) + ); + + const surface = document.querySelector('limel-menu-surface'); + expect(surface).toBeTruthy(); + + const input = + surface?.querySelector('limel-input-field') || + surface?.shadowRoot?.querySelector('limel-input-field'); + expect(input).toBeTruthy(); + + input!.dispatchEvent( + new CustomEvent('change', { + detail: 'a', + bubbles: true, + composed: true, + }) + ); + await waitForChanges(); + + input!.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + ctrlKey: true, + bubbles: true, + composed: true, + }) + ); + await waitForChanges(); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ text: 'Apply first' }) + ); + }); + + it('does not trigger hotkey selection for disabled items', async () => { + const hotkeyItems = [ + { text: 'Enabled action', hotkey: 'e' }, + { text: 'Disabled action', hotkey: 'd', disabled: true }, + ]; + + const { root, waitForChanges } = await render( + + + + ); + await waitForChanges(); + + const handler = vi.fn(); + root.addEventListener('select', (e: Event) => + handler((e as CustomEvent).detail) + ); + + const surface = document.querySelector('limel-menu-surface'); + expect(surface).toBeTruthy(); + + // Press the disabled item's hotkey + surface!.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'd', + code: 'KeyD', + bubbles: true, + composed: true, + }) + ); + await waitForChanges(); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('does not trigger alt-only hotkeys while search input is focused', async () => { + // Alt is excluded from hasModifier to support international + // keyboard input (Option+e for é, AltGr+e for €). This means + // alt-only hotkeys won't fire from text inputs — only Ctrl/Meta + // combos will. + const searchableItems = [{ text: 'Alt action', hotkey: 'alt+x' }]; + const searcher = vi.fn().mockResolvedValue(searchableItems); + + const { root, waitForChanges } = await render( + + + + ); + await waitForChanges(); + + const handler = vi.fn(); + root.addEventListener('select', (e: Event) => + handler((e as CustomEvent).detail) + ); + + const surface = document.querySelector('limel-menu-surface'); + expect(surface).toBeTruthy(); + + const input = + surface?.querySelector('limel-input-field') || + surface?.shadowRoot?.querySelector('limel-input-field'); + expect(input).toBeTruthy(); + + // Trigger search so items appear + input!.dispatchEvent( + new CustomEvent('change', { + detail: 'a', + bubbles: true, + composed: true, + }) + ); + await waitForChanges(); + + // Find the native inside limel-input-field's shadow DOM. + // isFromTextInput checks composedPath() for INPUT/TEXTAREA/SELECT, + // so the event must originate from the native element, not the + // custom element wrapper. + const nativeInput = + input!.shadowRoot?.querySelector('input') ?? input!; + + // Alt+X from within the text input should NOT trigger the hotkey + // because Alt alone is not treated as a modifier (to allow + // special character input on international keyboards) + nativeInput.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'x', + code: 'KeyX', + altKey: true, + bubbles: true, + composed: true, + }) + ); + await waitForChanges(); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('selects the first match when multiple items have the same hotkey', async () => { + const hotkeyItems = [ + { text: 'First duplicate', hotkey: 'k' }, + { text: 'Second duplicate', hotkey: 'k' }, + ]; + + const { root, waitForChanges } = await render( + + + + ); + await waitForChanges(); + + const handler = vi.fn(); + root.addEventListener('select', (e: Event) => + handler((e as CustomEvent).detail) + ); + + const surface = document.querySelector('limel-menu-surface'); + expect(surface).toBeTruthy(); + + surface!.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'k', + code: 'KeyK', + bubbles: true, + composed: true, + }) + ); + await waitForChanges(); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ text: 'First duplicate' }) + ); + }); + + it('only matches hotkeys against the currently visible menu level', async () => { + const hotkeyItems = [ + { + text: 'Parent with sub-menu', + hotkey: 'p', + items: [{ text: 'Child action', hotkey: 'c' }], + }, + { text: 'Root action', hotkey: 'c' }, + ]; + + const { root, waitForChanges } = await render( + + + + ); + await waitForChanges(); + + const handler = vi.fn(); + root.addEventListener('select', (e: Event) => + handler((e as CustomEvent).detail) + ); + + const surface = document.querySelector('limel-menu-surface'); + expect(surface).toBeTruthy(); + + // At root level, pressing 'c' should match "Root action", + // not the child item "Child action" which is not visible + surface!.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'c', + code: 'KeyC', + bubbles: true, + composed: true, + }) + ); + await waitForChanges(); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ text: 'Root action' }) + ); + }); + + it('does not trigger hotkey on repeated (held-down) key events', async () => { + const hotkeyItems = [{ text: 'Action', hotkey: 'k' }]; + + const { root, waitForChanges } = await render( + + + + ); + await waitForChanges(); + + const handler = vi.fn(); + root.addEventListener('select', (e: Event) => + handler((e as CustomEvent).detail) + ); + + const surface = document.querySelector('limel-menu-surface'); + expect(surface).toBeTruthy(); + + // Held-down keys fire with repeat: true — these should be ignored + surface!.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'k', + code: 'KeyK', + repeat: true, + bubbles: true, + composed: true, + }) + ); + await waitForChanges(); + + expect(handler).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/components/menu/menu.tsx b/src/components/menu/menu.tsx index 4096ee0c5a..d644e0e0ef 100644 --- a/src/components/menu/menu.tsx +++ b/src/components/menu/menu.tsx @@ -34,6 +34,11 @@ import { TAB, } from '../../util/keycodes'; import { focusTriggerElement } from '../../util/focus-trigger-element'; +import { + hotkeyFromKeyboardEvent, + normalizeHotkeyString, + tokenizeHotkeyString, +} from '../../util/hotkeys'; interface MenuCrumbItem extends BreadcrumbsItem { menuItem?: MenuItem; @@ -57,13 +62,14 @@ const DEFAULT_ROOT_BREADCRUMBS_ITEM: BreadcrumbsItem = { * @exampleComponent limel-example-menu-icons * @exampleComponent limel-example-menu-badge-icons * @exampleComponent limel-example-menu-grid - * @exampleComponent limel-example-menu-hotkeys * @exampleComponent limel-example-menu-secondary-text * @exampleComponent limel-example-menu-notification * @exampleComponent limel-example-menu-sub-menus * @exampleComponent limel-example-menu-sub-menu-lazy-loading * @exampleComponent limel-example-menu-sub-menu-lazy-loading-infinite * @exampleComponent limel-example-menu-searchable + * @exampleComponent limel-example-menu-hotkeys + * @exampleComponent limel-example-menu-searchable-hotkeys * @exampleComponent limel-example-menu-composite */ @Component({ @@ -207,6 +213,9 @@ export class Menu { private triggerElement: HTMLSlotElement; private selectedMenuItem?: MenuItem; private shouldRestoreFocusOnClose = false; + private readonly normalizedHotkeyCache = new Map(); + private cachedSubMenuSource: MenuItem | null = null; + private cachedSubMenuItems: Array | null = null; constructor() { this.portalId = createRandomString(); @@ -267,29 +276,179 @@ export class Menu { @Watch('items') protected itemsWatcher() { this.clearSearch(); + this.normalizedHotkeyCache.clear(); this.setFocus(); } + public connectedCallback() { + if (this.open) { + document.addEventListener( + 'keydown', + this.handleDocumentKeyDown, + true + ); + } + } + + public disconnectedCallback() { + document.removeEventListener( + 'keydown', + this.handleDocumentKeyDown, + true + ); + } + @Watch('open') protected openWatcher(newValue: boolean) { const opened = newValue; if (opened) { document.addEventListener( 'keydown', - this.handleEscapeCapture, + this.handleDocumentKeyDown, true ); this.setFocus(); } else { document.removeEventListener( 'keydown', - this.handleEscapeCapture, + this.handleDocumentKeyDown, true ); this.clearSearch(); } } + private readonly handleDocumentKeyDown = (event: KeyboardEvent) => { + if (event.key === ESCAPE && this.open) { + this.shouldRestoreFocusOnClose = true; + } + + if (!this.open || event.defaultPrevented || event.repeat) { + return; + } + + if (this.isFromTextInput(event) && !this.hasModifier(event)) { + return; + } + + const pressedHotkey = hotkeyFromKeyboardEvent(event); + if (!pressedHotkey) { + return; + } + + if (this.isReservedMenuHotkey(pressedHotkey)) { + return; + } + + const matchedItem = this.findMenuItemByHotkey(pressedHotkey); + if (!matchedItem) { + return; + } + + event.stopPropagation(); + event.preventDefault(); + this.handleSelect(matchedItem); + }; + + private isFromTextInput(event: KeyboardEvent): boolean { + const path = + typeof event.composedPath === 'function' + ? event.composedPath() + : []; + + for (const node of path) { + if (!(node instanceof HTMLElement)) { + continue; + } + + if (node.isContentEditable) { + return true; + } + + const tagName = node.tagName; + if ( + tagName === 'INPUT' || + tagName === 'TEXTAREA' || + tagName === 'SELECT' + ) { + return true; + } + } + + return false; + } + + // Only Ctrl and Meta count as "real" modifiers for the text-input bypass. + // Alt/Option is intentionally excluded because it is used for typing + // special characters on international keyboards and macOS (e.g. Option+e + // for é, AltGr+e for € on Windows). This means alt-only hotkeys like + // "alt+x" will NOT fire while a text input (e.g. the search field) is + // focused — only Ctrl/Meta combos will. AltGraph is also explicitly + // rejected because Windows synthesizes ctrlKey=true for AltGr keypresses. + private hasModifier(event: KeyboardEvent): boolean { + if (event.getModifierState?.('AltGraph')) { + return false; + } + + return event.ctrlKey || event.metaKey; + } + + private isReservedMenuHotkey(hotkey: string): boolean { + const tokens = tokenizeHotkeyString(hotkey); + const key = tokens.at(-1); + if (!key) { + return false; + } + + const hasModifiers = tokens.length > 1; + if (hasModifiers) { + return false; + } + + return ( + key === 'arrowup' || + key === 'arrowdown' || + key === 'arrowleft' || + key === 'arrowright' || + key === 'tab' || + key === 'enter' || + key === 'space' || + key === 'escape' + ); + } + + private findMenuItemByHotkey(pressedHotkey: string): MenuItem | null { + for (const item of this.visibleItems) { + if (!this.isMenuItem(item) || item.disabled) { + continue; + } + + const rawHotkey = item.hotkey; + if (!rawHotkey) { + continue; + } + + const normalized = this.getNormalizedHotkey(rawHotkey); + if (normalized && normalized === pressedHotkey) { + return item; + } + } + + return null; + } + + private getNormalizedHotkey(raw: string): string | null { + const cacheKey = raw.trim(); + if (this.normalizedHotkeyCache.has(cacheKey)) { + return this.normalizedHotkeyCache.get(cacheKey) ?? null; + } + + const normalized = normalizeHotkeyString(cacheKey); + this.normalizedHotkeyCache.set(cacheKey, normalized); + + return normalized; + } + private getBreadcrumbsItems() { const breadCrumbItems: MenuCrumbItem[] = []; let currentItem = this.currentSubMenu; @@ -471,6 +630,10 @@ export class Menu { // Will change focus to breadcrumbs (if present) or the first/last item // in the dropdown list to enable selection with the keyboard private readonly handleInputKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) { + return; + } + const isForwardTab = event.key === TAB && !event.altKey && @@ -683,12 +846,6 @@ export class Menu { } }; - private readonly handleEscapeCapture = (event: KeyboardEvent) => { - if (event.key === ESCAPE && this.open) { - this.shouldRestoreFocusOnClose = true; - } - }; - private readonly onClose = () => { const restoreFocus = this.shouldRestoreFocusOnClose; this.shouldRestoreFocusOnClose = false; @@ -993,10 +1150,17 @@ export class Menu { if (Array.isArray(this.searchResults) && this.searchValue) { return this.searchResults; } else if (Array.isArray(this.currentSubMenu?.items)) { - return this.currentSubMenu.items.map((item) => ({ - ...item, - parentItem: this.currentSubMenu, - })); + if (this.cachedSubMenuSource !== this.currentSubMenu) { + this.cachedSubMenuSource = this.currentSubMenu; + this.cachedSubMenuItems = this.currentSubMenu.items.map( + (item) => ({ + ...item, + parentItem: this.currentSubMenu, + }) + ); + } + + return this.cachedSubMenuItems; } return this.items; diff --git a/src/components/menu/menu.types.ts b/src/components/menu/menu.types.ts index 80cc74df0a..86b73244fb 100644 --- a/src/components/menu/menu.types.ts +++ b/src/components/menu/menu.types.ts @@ -61,6 +61,21 @@ export interface MenuItem { */ commandText?: string; + /** + * Hotkey(s) that can be used to select this item while the menu is open. + * This is for actual keyboard interaction. `commandText` is purely visual. + * + * Note: In a menu, some keys are reserved for built-in navigation and + * activation (e.g. `enter`, `escape`, `space`, `tab`, and arrow keys). + * These will be ignored as hotkeys, unless combined with other modifiers. + * + * Examples: + * - `"alt+c"` + * - `"ctrl+shift+p"` + * - `"cmd+k"` (alias for `meta+k`) + */ + hotkey?: string; + /** * Text to display in the menu item. */