From 75c43961c8b9e676feb66e0fcaf975f527eedecd Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Mon, 2 Feb 2026 14:01:19 +0100 Subject: [PATCH 1/6] chore(util): add helpers for detecting & using keyboard hotkeys --- src/util/hotkeys.spec.ts | 159 +++++++++++++++++++++++++ src/util/hotkeys.ts | 242 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 401 insertions(+) create mode 100644 src/util/hotkeys.spec.ts create mode 100644 src/util/hotkeys.ts diff --git a/src/util/hotkeys.spec.ts b/src/util/hotkeys.spec.ts new file mode 100644 index 0000000000..6b37e115a7 --- /dev/null +++ b/src/util/hotkeys.spec.ts @@ -0,0 +1,159 @@ +import { hotkeyFromKeyboardEvent, normalizeHotkeyString } from './hotkeys'; + +describe('hotkeys util', () => { + describe('normalizeHotkeyString', () => { + it('keeps + as a key', () => { + expect(normalizeHotkeyString('+')).toBe('+'); + }); + + it('supports ++ as the + key token', () => { + expect(normalizeHotkeyString('meta++')).toBe('meta++'); + }); + + it('keeps backspace and delete as distinct keys', () => { + expect(normalizeHotkeyString('backspace')).toBe('backspace'); + expect(normalizeHotkeyString('delete')).toBe('delete'); + expect(normalizeHotkeyString('del')).toBe('delete'); + }); + }); + + describe('hotkeyFromKeyboardEvent', () => { + it('includes multiple modifiers', () => { + const pressed = hotkeyFromKeyboardEvent({ + key: 'a', + code: 'KeyA', + altKey: true, + ctrlKey: true, + metaKey: false, + shiftKey: false, + } as any); + + expect(pressed).toBe('ctrl+alt+a'); + }); + + it('handles function keys', () => { + expect( + hotkeyFromKeyboardEvent({ + key: 'F1', + code: 'F1', + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as any) + ).toBe('f1'); + + expect( + hotkeyFromKeyboardEvent({ + key: 'F12', + code: 'F12', + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as any) + ).toBe('f12'); + }); + + it('handles arrow keys', () => { + expect( + hotkeyFromKeyboardEvent({ + key: 'ArrowLeft', + code: 'ArrowLeft', + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as any) + ).toBe('arrowleft'); + + expect( + hotkeyFromKeyboardEvent({ + key: 'ArrowRight', + code: 'ArrowRight', + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as any) + ).toBe('arrowright'); + }); + + it('uses event.code for non-QWERTY letter normalization', () => { + const pressed = hotkeyFromKeyboardEvent({ + key: '@', + code: 'KeyQ', + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as any); + + // Uses code "KeyQ" to normalize to "q" (layout-independent) + expect(pressed).toBe('q'); + }); + + it('uses event.code for digit normalization', () => { + const pressed = hotkeyFromKeyboardEvent({ + key: ')', + code: 'Digit0', + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as any); + + // Uses code "Digit0" to normalize to "0" (layout-independent) + expect(pressed).toBe('0'); + }); + + it('does not include shift for the + key', () => { + const pressed = hotkeyFromKeyboardEvent({ + key: '+', + code: 'Equal', + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: true, + } as any); + + expect(pressed).toBe('+'); + }); + + it('still includes shift for letter keys', () => { + const pressed = hotkeyFromKeyboardEvent({ + key: 'A', + code: 'KeyA', + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: true, + } as any); + + expect(pressed).toBe('shift+a'); + }); + + it('keeps Backspace and Delete as distinct keys', () => { + const backspace = hotkeyFromKeyboardEvent({ + key: 'Backspace', + code: 'Backspace', + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as any); + + const del = hotkeyFromKeyboardEvent({ + key: 'Delete', + code: 'Delete', + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + } as any); + + expect(backspace).toBe('backspace'); + expect(del).toBe('delete'); + }); + }); +}); diff --git a/src/util/hotkeys.ts b/src/util/hotkeys.ts new file mode 100644 index 0000000000..a576b155ec --- /dev/null +++ b/src/util/hotkeys.ts @@ -0,0 +1,242 @@ +/** + * These helpers provide a consistent way to: + * - Normalize user-defined hotkey strings (e.g. "cmd+k", "ctrl+shift+p") + * - Normalize `KeyboardEvent`s into the same canonical hotkey format + * + * **Canonical format** + * - Modifiers are ordered: `meta`, `ctrl`, `alt`, `shift` + * - Tokens are joined with `+` + * - The final token is the non-modifier key + * + * Examples: + * - `"cmd+k"` → `"meta+k"` + * - `"return"` → `"enter"` + * - `"backspace"` → `"backspace"` + * - `"alt+shift+l"` → `"alt+shift+l"` + */ +const NORMALIZED_HOTKEY_SEPARATOR = '+'; + +const KEY_ALIASES: Record = { + cmd: 'meta', + command: 'meta', + win: 'meta', + windows: 'meta', + control: 'ctrl', + option: 'alt', + esc: 'escape', + return: 'enter', + del: 'delete', + up: 'arrowup', + down: 'arrowdown', + left: 'arrowleft', + right: 'arrowright', + spacebar: 'space', +}; + +export const tokenizeHotkeyString = (hotkey: string): string[] => { + const raw = (hotkey ?? '').trim(); + if (!raw) { + return []; + } + + // Allow `+` as a hotkey. + if (raw === '+') { + return ['+']; + } + + // Split on `+`, but treat `++` as the `+` key. + const tokens: string[] = []; + let current = ''; + let index = 0; + + while (index < raw.length) { + const char = raw[index]; + if (char !== '+') { + current += char; + index++; + continue; + } + + const nextChar = raw[index + 1]; + if (nextChar === '+') { + const token = current.trim(); + if (token) { + tokens.push(token); + } + tokens.push('+'); + current = ''; + index += 2; + continue; + } + + const token = current.trim(); + if (token) { + tokens.push(token); + } + current = ''; + index++; + } + + const tail = current.trim(); + if (tail) { + tokens.push(tail); + } + + return tokens; +}; + +const normalizeKey = (key: string): string | null => { + const normalized = key.trim().toLowerCase(); + if (!normalized) { + return null; + } + + if (KEY_ALIASES[normalized]) { + return KEY_ALIASES[normalized]; + } + + return normalized; +}; + +const normalizeModifiersAndKey = (input: { + key: string; + alt: boolean; + ctrl: boolean; + meta: boolean; + shift: boolean; +}): string | null => { + const normalizedKey = normalizeKey(input.key); + if (!normalizedKey) { + return null; + } + + // Ignore pure modifier presses + if (['shift', 'alt', 'ctrl', 'meta'].includes(normalizedKey)) { + return null; + } + + const parts: string[] = []; + + if (input.meta) { + parts.push('meta'); + } + if (input.ctrl) { + parts.push('ctrl'); + } + if (input.alt) { + parts.push('alt'); + } + if (input.shift) { + parts.push('shift'); + } + + parts.push(normalizedKey); + + return parts.join(NORMALIZED_HOTKEY_SEPARATOR); +}; + +/** + * Normalize a user-defined hotkey string to the canonical format. + * + * Returns `null` for empty/invalid inputs or if the string only contains + * modifiers (e.g. `"ctrl+shift"`). + * + * @param hotkey - User-provided hotkey string. + */ +export const normalizeHotkeyString = (hotkey: string): string | null => { + if (!hotkey) { + return null; + } + + const tokens = tokenizeHotkeyString(hotkey); + if (tokens.length === 0) { + return null; + } + + let alt = false; + let ctrl = false; + let meta = false; + let shift = false; + let key: string | null = null; + + for (const token of tokens) { + const normalized = normalizeKey(token); + if (!normalized) { + continue; + } + + if (normalized === 'alt') { + alt = true; + continue; + } + if (normalized === 'ctrl') { + ctrl = true; + continue; + } + if (normalized === 'meta') { + meta = true; + continue; + } + if (normalized === 'shift') { + shift = true; + continue; + } + + // Last non-modifier wins + key = normalized; + } + + if (!key) { + return null; + } + + return normalizeModifiersAndKey({ key, alt, ctrl, meta, shift }); +}; + +const normalizeEventKey = (event: KeyboardEvent): string | null => { + const code = (event.code || '').trim(); + if (/^Key[A-Z]$/.test(code)) { + return code.slice(3).toLowerCase(); + } + + if (/^Digit\d$/.test(code)) { + return code.slice(5); + } + + const key = event.key; + + if (key === ' ') { + return 'space'; + } + + return normalizeKey(key); +}; + +/** + * Convert a `KeyboardEvent` into a canonical hotkey string. + * + * Uses `event.code` when possible for letter/digit keys to avoid + * layout-dependent results. + * + * @param event - Keyboard event to normalize. + */ +export const hotkeyFromKeyboardEvent = ( + event: KeyboardEvent +): string | null => { + const key = normalizeEventKey(event); + if (!key) { + return null; + } + + // `+` typically requires Shift on many keyboard layouts, but users expect to + // write hotkeys like `meta++` (⌘+) without explicitly adding `shift`. + const shift = key === '+' ? false : event.shiftKey; + + return normalizeModifiersAndKey({ + key, + alt: event.altKey, + ctrl: event.ctrlKey, + meta: event.metaKey, + shift, + }); +}; From 5135781ce6c62a68737422c237faac8e18247b5c Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Tue, 3 Feb 2026 14:11:28 +0100 Subject: [PATCH 2/6] refactor(util): improve `devices.ts` helpers - Made it safe to import outside the browser (removed top-level `window.navigator.userAgent` access) - Added export function `isAppleDevice(): boolean` --- src/util/device.ts | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/util/device.ts b/src/util/device.ts index 6d06a49030..6c544ee26e 100644 --- a/src/util/device.ts +++ b/src/util/device.ts @@ -1,16 +1,38 @@ -const userAgent = window.navigator.userAgent; +function getUserAgent(): string { + return typeof navigator === 'undefined' ? '' : (navigator.userAgent ?? ''); +} + +function getPlatform(): string { + if (typeof navigator === 'undefined') { + return ''; + } + + return ( + (navigator as any).userAgentData?.platform ?? navigator.platform ?? '' + ); +} + +function getMaxTouchPoints(): number { + if (typeof navigator === 'undefined') { + return 0; + } + + return (navigator as any).maxTouchPoints ?? 0; +} /** * */ export function isIOSDevice() { - return /iPad|iPhone|iPod/i.test(userAgent) && !(window as any).MSStream; + const userAgent = getUserAgent(); + return /iPad|iPhone|iPod/i.test(userAgent) && !(globalThis as any).MSStream; } /** * */ export function isAndroidDevice() { + const userAgent = getUserAgent(); return /Android/i.test(userAgent); } @@ -20,3 +42,19 @@ export function isAndroidDevice() { export function isMobileDevice() { return isAndroidDevice() || isIOSDevice(); } + +/** + * Detects whether the user is on an Apple device (iOS/iPadOS/macOS). + */ +export function isAppleDevice(): boolean { + const ua = getUserAgent(); + const platform = getPlatform(); + + const isIPadIPhoneIPod = /iPad|iPhone|iPod/i.test(ua); + const isMacLike = /Mac/i.test(platform); + + // iPadOS can report itself as Mac; use touch points to detect it. + const isIPadOS13Plus = isMacLike && getMaxTouchPoints() > 1; + + return isIPadIPhoneIPod || isIPadOS13Plus || isMacLike; +} From 06e305ec632e00b0080e537c8d43c0ce6fa8346b Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Tue, 3 Feb 2026 11:43:16 +0100 Subject: [PATCH 3/6] chore(hotkey): add new `@private` component for rendering hotkeys, and emitting events when hotkeys are pressed. --- etc/lime-elements.api.md | 23 +++ .../hotkey/examples/hotkey-basic.scss | 9 ++ .../hotkey/examples/hotkey-basic.tsx | 59 ++++++++ .../hotkey/examples/hotkey-disabled.tsx | 24 ++++ src/components/hotkey/hotkey.scss | 33 +++++ src/components/hotkey/hotkey.tsx | 131 ++++++++++++++++++ 6 files changed, 279 insertions(+) create mode 100644 src/components/hotkey/examples/hotkey-basic.scss create mode 100644 src/components/hotkey/examples/hotkey-basic.tsx create mode 100644 src/components/hotkey/examples/hotkey-disabled.tsx create mode 100644 src/components/hotkey/hotkey.scss create mode 100644 src/components/hotkey/hotkey.tsx diff --git a/etc/lime-elements.api.md b/etc/lime-elements.api.md index f442c7235d..535e9ca0de 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; @@ -1391,6 +1396,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 +2543,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; 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 }; + } +} From bc627ef81aa1091f710290dd335f6e18c26f6431 Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Mon, 2 Feb 2026 16:38:20 +0100 Subject: [PATCH 4/6] feat(menu): add `hotkey` prop for items & improve keyboard navigation --- etc/lime-elements.api.md | 9 + .../menu-item-meta/menu-item-meta.tsx | 20 +- .../menu-list/menu-list-renderer.tsx | 4 + src/components/menu/examples/menu-hotkeys.tsx | 88 +++++-- .../menu/examples/menu-searchable-hotkeys.tsx | 160 +++++++++++++ src/components/menu/menu.e2e.tsx | 222 ++++++++++++++++++ src/components/menu/menu.tsx | 160 ++++++++++++- src/components/menu/menu.types.ts | 15 ++ 8 files changed, 661 insertions(+), 17 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..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/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. */ From b115951735bfa244ba205f77b5a17ac5bb16b24d Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Wed, 4 Feb 2026 10:37:09 +0100 Subject: [PATCH 5/6] fix(markdown): improve visual style of `kbd` elements --- .../markdown/partial-styles/_kbd.scss | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) 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; } } From d8f1c470dcfcc515011a859069269bdf0615e369 Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Mon, 2 Mar 2026 15:44:24 +0100 Subject: [PATCH 6/6] feat(select): add `hotkey` prop for items & improve keyboard navigation --- etc/lime-elements.api.md | 1 + .../select/examples/select-hotkeys.tsx | 89 +++++++++++ src/components/select/option.types.ts | 6 + src/components/select/select.template.tsx | 27 +++- src/components/select/select.tsx | 142 ++++++++++++++++++ 5 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 src/components/select/examples/select-hotkeys.tsx diff --git a/etc/lime-elements.api.md b/etc/lime-elements.api.md index 1c496524ed..dfe2fa7afd 100644 --- a/etc/lime-elements.api.md +++ b/etc/lime-elements.api.md @@ -4224,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/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 `