diff --git a/etc/lime-elements.api.md b/etc/lime-elements.api.md index f442c7235d..dfe2fa7afd 100644 --- a/etc/lime-elements.api.md +++ b/etc/lime-elements.api.md @@ -527,6 +527,11 @@ export namespace Components { "length"?: number; "maxLength"?: number; } + // @internal + export interface LimelHotkey { + "disabled": boolean; + "value": string; + } export interface LimelIcon { "badge": boolean; "name": string; @@ -638,6 +643,8 @@ export namespace Components { export interface LimelMenuItemMeta { "badge"?: string | number; "commandText"?: string; + "disabled": boolean; + "hotkey"?: string; "showChevron": boolean; } // @internal (undocumented) @@ -1391,6 +1398,10 @@ export namespace JSX { // // (undocumented) "limel-helper-line": Omit & { [K in keyof LimelHelperLine & keyof LimelHelperLineAttributes]?: LimelHelperLine[K] } & { [K in keyof LimelHelperLine & keyof LimelHelperLineAttributes as `attr:${K}`]?: LimelHelperLineAttributes[K] } & { [K in keyof LimelHelperLine & keyof LimelHelperLineAttributes as `prop:${K}`]?: LimelHelperLine[K] }; + // Warning: (ae-incompatible-release-tags) The symbol ""limel-hotkey"" is marked as @public, but its signature references "JSX" which is marked as @internal + // + // (undocumented) + "limel-hotkey": Omit & { [K in keyof LimelHotkey & keyof LimelHotkeyAttributes]?: LimelHotkey[K] } & { [K in keyof LimelHotkey & keyof LimelHotkeyAttributes as `attr:${K}`]?: LimelHotkeyAttributes[K] } & { [K in keyof LimelHotkey & keyof LimelHotkeyAttributes as `prop:${K}`]?: LimelHotkey[K] }; // (undocumented) "limel-icon": Omit & { [K in keyof LimelIcon & keyof LimelIconAttributes]?: LimelIcon[K] } & { [K in keyof LimelIcon & keyof LimelIconAttributes as `attr:${K}`]?: LimelIconAttributes[K] } & { [K in keyof LimelIcon & keyof LimelIconAttributes as `prop:${K}`]?: LimelIcon[K] }; // (undocumented) @@ -2534,6 +2545,20 @@ export namespace JSX { "maxLength": number; } + // @internal + export interface LimelHotkey { + "disabled"?: boolean; + "value"?: string; + } + + // (undocumented) + export interface LimelHotkeyAttributes { + // (undocumented) + "disabled": boolean; + // (undocumented) + "value": string; + } + export interface LimelIcon { "badge"?: boolean; "name"?: string; @@ -2827,6 +2852,8 @@ export namespace JSX { export interface LimelMenuItemMeta { "badge"?: string | number; "commandText"?: string; + "disabled"?: boolean; + "hotkey"?: string; "showChevron"?: boolean; } @@ -2837,6 +2864,10 @@ export namespace JSX { // (undocumented) "commandText": string; // (undocumented) + "disabled": boolean; + // (undocumented) + "hotkey": string; + // (undocumented) "showChevron": boolean; } @@ -4153,6 +4184,7 @@ interface MenuItem { badge?: number | string; commandText?: string; disabled?: boolean; + hotkey?: string; icon?: string | Icon; // @deprecated iconColor?: Color; @@ -4192,6 +4224,7 @@ export type OpenDirection = 'left-start' | 'left' | 'left-end' | 'right-start' | // @public interface Option_2 { disabled?: boolean; + hotkey?: string; icon?: string | Icon; // @deprecated iconColor?: Color; diff --git a/src/components/hotkey/examples/hotkey-basic.scss b/src/components/hotkey/examples/hotkey-basic.scss new file mode 100644 index 0000000000..3a6d8aae18 --- /dev/null +++ b/src/components/hotkey/examples/hotkey-basic.scss @@ -0,0 +1,9 @@ +:host { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + gap: 2rem; +} + +limel-example-value { + grid-column: 1/-1; +} diff --git a/src/components/hotkey/examples/hotkey-basic.tsx b/src/components/hotkey/examples/hotkey-basic.tsx new file mode 100644 index 0000000000..49264b904e --- /dev/null +++ b/src/components/hotkey/examples/hotkey-basic.tsx @@ -0,0 +1,59 @@ +import { Component, h, Host } from '@stencil/core'; + +/** + * Basic example + * + * The value is passed as a string, indicating which hotkey to display. + * + * The component will automatically detect the operating system, and + * render the hotkey accordingly, using standard glyphs to save space. + * + * For example, the "meta" key will be rendered as on macOS, + * and as ⊞ Win on Windows/Linux. Or the "alt" key will be rendered + * as on macOS, and as Alt on Windows. + * + * :::note + * `meta` always means the actual Meta key. + * + * This component will render `meta` using platform conventions: + * - macOS/iOS/iPadOS: + * - Windows/Linux: ⊞ Win + * + * If you want a hotkey that differs between operating systems (for example + * ⌘+C on macOS and Ctrl+C on Windows/Linux), detect the OS in your application + * and pass the appropriate hotkey string. + * + * - `ctrl` means "Control specifically" on all platforms. + * - `cmd` or `command` always render as (even on Windows/Linux). + * ::: + * + * :::important + * This component is **display-only**. It does not listen for or handle + * any keyboard events. Keyboard event handling is the responsibility + * of the parent component (e.g. `limel-menu` or `limel-select`). + * ::: + */ +@Component({ + tag: 'limel-example-hotkey-basic', + shadow: true, + styleUrl: 'hotkey-basic.scss', +}) +export class HotkeyBasicExample { + public render() { + return ( + + + + + + + + + + + + + + ); + } +} diff --git a/src/components/hotkey/examples/hotkey-disabled.tsx b/src/components/hotkey/examples/hotkey-disabled.tsx new file mode 100644 index 0000000000..90378b8af0 --- /dev/null +++ b/src/components/hotkey/examples/hotkey-disabled.tsx @@ -0,0 +1,24 @@ +import { Component, h, Host } from '@stencil/core'; + +/** + * The `disabled` prop + * + * When set to `true`, the hotkey is rendered in a visually disabled state. + * This is useful when the action associated with the hotkey is temporarily + * unavailable (e.g. a disabled menu item). + */ +@Component({ + tag: 'limel-example-hotkey-disabled', + shadow: true, + styleUrl: 'hotkey-basic.scss', +}) +export class HotkeyDisabledExample { + public render() { + return ( + + + + + ); + } +} diff --git a/src/components/hotkey/hotkey.scss b/src/components/hotkey/hotkey.scss new file mode 100644 index 0000000000..5eb118f3b7 --- /dev/null +++ b/src/components/hotkey/hotkey.scss @@ -0,0 +1,33 @@ +@forward '../markdown/partial-styles/_kbd.scss'; +:host(limel-hotkey) { + display: flex; + align-items: center; + justify-content: center; + gap: 0.25rem; +} + +:host(limel-hotkey[disabled]:not([disabled='false'])) { + opacity: 0.5; +} + +kbd { + margin: 0; + font-size: 0.75rem; + box-shadow: + var(--button-shadow-pressed), + 0 0.625rem 0.375px -0.5rem rgb(var(--color-black), 0.02), + 0 0.025rem 0.5rem 0 rgb(var(--contrast-100)) inset; +} + +span { + display: inline-block; + &::first-letter { + text-transform: uppercase; + } +} + +kbd.is-glyph { + span { + transform: scale(1.2); + } +} diff --git a/src/components/hotkey/hotkey.tsx b/src/components/hotkey/hotkey.tsx new file mode 100644 index 0000000000..26ee995054 --- /dev/null +++ b/src/components/hotkey/hotkey.tsx @@ -0,0 +1,131 @@ +import { Component, Host, Prop, h } from '@stencil/core'; +import { tokenizeHotkeyString } from '../../util/hotkeys'; +import { isAppleDevice } from '../../util/device'; + +/** + * This is a display-only component used to visualize keyboard shortcuts. + * It renders hotkey strings as styled `` elements with + * platform-aware glyphs (e.g. `⌘` on macOS, `⊞ Win` on Windows). + * + * It does **not** listen for or handle any keyboard events. + * Keyboard event handling is the responsibility of the parent component + * (e.g. `limel-menu` or `limel-select`). + * + * @exampleComponent limel-example-hotkey-basic + * @exampleComponent limel-example-hotkey-disabled + * @private + */ +@Component({ + tag: 'limel-hotkey', + shadow: true, + styleUrl: 'hotkey.scss', +}) +export class Hotkey { + /** + * The hotkey string to visualize, e.g. `"meta+c"` or `"shift+enter"`. + */ + @Prop({ reflect: true }) + public value: string; + + /** + * When `true`, the hotkey is rendered in a visually disabled state. + */ + @Prop({ reflect: true }) + public disabled = false; + + public render() { + const isApple = isAppleDevice(); + const parts = tokenizeHotkeyString(this.value); + const ariaLabel = (this.value ?? '').trim(); + + return ( + + {parts.map((part, index) => { + const { display, isGlyph } = this.formatDisplayToken( + part, + isApple + ); + + return ( + + {display} + + ); + })} + + ); + } + + private formatDisplayToken( + token: string, + isApple: boolean + ): { + display: string; + isGlyph: boolean; + } { + const trimmed = (token ?? '').trim(); + if (!trimmed) { + return { display: '', isGlyph: false }; + } + + if (trimmed === '+') { + return { display: '+', isGlyph: false }; + } + + const lower = trimmed.toLowerCase(); + + switch (lower) { + case 'meta': { + return isApple + ? { display: '⌘', isGlyph: true } + : { display: '⊞ Win', isGlyph: false }; + } + + case 'cmd': + case 'command': { + return { display: '⌘', isGlyph: true }; + } + + case 'alt': + case 'option': { + return isApple + ? { display: '⌥', isGlyph: true } + : { display: 'Alt', isGlyph: false }; + } + + case 'shift': { + return { display: '⇧', isGlyph: true }; + } + + case 'enter': + case 'return': { + return { display: '↩', isGlyph: true }; + } + + case 'tab': { + return { display: '⇥', isGlyph: true }; + } + + case 'delete': + case 'del': + case 'backspace': { + if (isApple) { + return { display: '⌫', isGlyph: true }; + } + return lower === 'backspace' + ? { display: 'Backspace', isGlyph: false } + : { display: 'Del', isGlyph: false }; + } + + case 'ctrl': + case 'control': { + return { display: 'Ctrl', isGlyph: false }; + } + } + + return { display: trimmed, isGlyph: false }; + } +} 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..fe3a018560 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 @@ -19,9 +19,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 +57,12 @@ export class MenuItemMeta { } private renderCommandText() { + if (this.hotkey) { + return ( + + ); + } + if (!this.commandText) { return; } diff --git a/src/components/markdown/partial-styles/_kbd.scss b/src/components/markdown/partial-styles/_kbd.scss index aa97edeb10..f188456366 100644 --- a/src/components/markdown/partial-styles/_kbd.scss +++ b/src/components/markdown/partial-styles/_kbd.scss @@ -1,10 +1,10 @@ @use '../../../style/mixins'; kbd { + display: inline-block; @include mixins.font-family('monospace'); font-weight: 600; color: rgb(var(--contrast-1100)); - background-color: rgb(var(--contrast-200)); white-space: pre; word-spacing: normal; @@ -12,16 +12,23 @@ kbd { word-wrap: normal; line-height: normal; - padding: 0.125rem 0.5rem; + border: { + radius: 0.125rem; + style: solid; + color: rgb(var(--contrast-500)); + width: 0 1px 0.1875rem 1px; + } + + padding: 0.0625rem 0.375rem; margin: 0 0.25rem; + + background-color: rgb(var(--contrast-200)); box-shadow: var(--button-shadow-normal), - 0 0.03125rem 0.21875rem 0 rgba(var(--contrast-100), 0.5) inset; + 0 0.625rem 0.375px -0.5rem rgb(var(--color-black), 0.02), + 0 0.025rem 0.5rem 0 rgb(var(--contrast-100)) inset; - border: { - radius: 0.125rem; - style: solid; - color: rgba(var(--contrast-600), 0.8); - width: 0 1px 0.125rem 1px; + &::first-letter { + text-transform: uppercase; } } diff --git a/src/components/menu-list/menu-list-renderer.tsx b/src/components/menu-list/menu-list-renderer.tsx index 6f763eb7bb..acdc50fbcb 100644 --- a/src/components/menu-list/menu-list-renderer.tsx +++ b/src/components/menu-list/menu-list-renderer.tsx @@ -105,9 +105,11 @@ export class MenuListRenderer { } const hasSubMenu = this.hasSubItems(item); + const hasHotkey = 'hotkey' in item && !!(item as any).hotkey; const hasMeta = hasSubMenu || item.badge !== undefined || + hasHotkey || (!!('commandText' in item) && !!item.commandText); const primaryComponent = hasMeta @@ -115,6 +117,8 @@ export class MenuListRenderer { name: 'limel-menu-item-meta', props: { commandText: (item as any).commandText, + hotkey: (item as any).hotkey, + disabled: !!item.disabled, badge: (item as any).badge, showChevron: hasSubMenu, }, diff --git a/src/components/menu/examples/menu-hotkeys.tsx b/src/components/menu/examples/menu-hotkeys.tsx index 6e4a1ec2e3..41cce809c8 100644 --- a/src/components/menu/examples/menu-hotkeys.tsx +++ b/src/components/menu/examples/menu-hotkeys.tsx @@ -3,11 +3,39 @@ 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. The `hotkey` works only when the menu is open. Adding a hotkey does not + * trigger the action which is tied to a menu item selectable from + * anywhere in the application. + * 3. Some keys are reserved for menu interaction and will be ignored as hotkeys: + * `tab` and the arrow keys are always reserved; `enter`, `escape`, and `space` + * are reserved unless used with a modifier (e.g. `alt+enter`). + * ::: + * + * :::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 +46,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: 'Paste', commandText: 'alt + V' }, + { text: 'Copy link', hotkey: 'l' }, + { text: 'Copy message', hotkey: 'meta+c' }, + { separator: true }, + { + 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..e6df3739a2 --- /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 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..82f24336e0 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,225 @@ 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' }) + ); + }); + }); }); diff --git a/src/components/menu/menu.tsx b/src/components/menu/menu.tsx index f8c46e021b..88afab3691 100644 --- a/src/components/menu/menu.tsx +++ b/src/components/menu/menu.tsx @@ -33,6 +33,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; @@ -56,13 +61,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({ @@ -205,6 +211,7 @@ export class Menu { private breadcrumbs: HTMLLimelBreadcrumbsElement; private triggerElement: HTMLSlotElement; private selectedMenuItem?: MenuItem; + private readonly normalizedHotkeyCache = new Map(); constructor() { this.portalId = createRandomString(); @@ -265,19 +272,166 @@ 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.handleDocumentKeyDown, + true + ); this.setFocus(); } else { + document.removeEventListener( + 'keydown', + this.handleDocumentKeyDown, + true + ); this.clearSearch(); } } + private readonly handleDocumentKeyDown = (event: KeyboardEvent) => { + 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 (!('tagName' in (node as any))) { + continue; + } + + const element = node as HTMLElement; + + if (element.isContentEditable) { + return true; + } + + const tagName = element.tagName; + if ( + tagName === 'INPUT' || + tagName === 'TEXTAREA' || + tagName === 'SELECT' + ) { + return true; + } + } + + return false; + } + + private hasModifier(event: KeyboardEvent): boolean { + return event.ctrlKey || event.metaKey || event.altKey; + } + + 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; @@ -459,6 +613,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 && 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. */ diff --git a/src/components/select/examples/select-hotkeys.tsx b/src/components/select/examples/select-hotkeys.tsx new file mode 100644 index 0000000000..804045885e --- /dev/null +++ b/src/components/select/examples/select-hotkeys.tsx @@ -0,0 +1,89 @@ +import { + LimelSelectCustomEvent, + Option, + ListSeparator, +} from '@limetech/lime-elements'; +import { Component, h, Host, State } from '@stencil/core'; + +/** + * Select with option hotkeys. + * + * Use `hotkey` on options to bind keyboard interaction while the select + * dropdown is open. + * + * :::note + * 1. Hotkeys work only while the custom dropdown is open. + * 2. On mobile/iOS, `limel-select` uses a native `