From bb97913f42eabbafbfe9eb1cc4f258636853c216 Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Tue, 3 Feb 2026 11:43:16 +0100 Subject: [PATCH] chore(hotkey): add new `@private` component for visualizing keyboard shortcuts as styled `` elements with platform-aware glyphs. --- etc/lime-elements.api.md | 23 ++ .../hotkey/examples/hotkey-basic.scss | 9 + .../hotkey/examples/hotkey-basic.tsx | 59 +++ .../hotkey/examples/hotkey-disabled.tsx | 24 ++ .../hotkey/format-display-token.spec.ts | 379 ++++++++++++++++++ src/components/hotkey/format-display-token.ts | 122 ++++++ src/components/hotkey/hotkey.scss | 33 ++ src/components/hotkey/hotkey.tsx | 66 +++ 8 files changed, 715 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/format-display-token.spec.ts create mode 100644 src/components/hotkey/format-display-token.ts 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/format-display-token.spec.ts b/src/components/hotkey/format-display-token.spec.ts new file mode 100644 index 0000000000..88c1227d20 --- /dev/null +++ b/src/components/hotkey/format-display-token.spec.ts @@ -0,0 +1,379 @@ +import { formatDisplayToken } from './format-display-token'; + +describe('formatDisplayToken', () => { + describe('empty / null input', () => { + it('returns empty display for empty string', () => { + expect(formatDisplayToken('', true)).toEqual({ + display: '', + isGlyph: false, + ariaName: '', + }); + }); + + it('returns empty display for null', () => { + expect(formatDisplayToken(null as any, true)).toEqual({ + display: '', + isGlyph: false, + ariaName: '', + }); + }); + + it('returns empty display for whitespace-only', () => { + expect(formatDisplayToken(' ', false)).toEqual({ + display: '', + isGlyph: false, + ariaName: '', + }); + }); + }); + + describe('literal + key', () => { + it('returns + as-is', () => { + expect(formatDisplayToken('+', true)).toEqual({ + display: '+', + isGlyph: false, + ariaName: 'plus', + }); + }); + }); + + describe('meta key (platform-dependent)', () => { + it('returns ⌘ on Apple', () => { + expect(formatDisplayToken('meta', true)).toEqual({ + display: '⌘', + isGlyph: true, + ariaName: 'Command', + }); + }); + + it('returns ⊞ Win on non-Apple', () => { + expect(formatDisplayToken('meta', false)).toEqual({ + display: '⊞ Win', + isGlyph: false, + ariaName: 'Windows', + }); + }); + }); + + describe('win / windows (same as meta)', () => { + it('returns ⌘ for win on Apple', () => { + expect(formatDisplayToken('win', true)).toEqual({ + display: '⌘', + isGlyph: true, + ariaName: 'Command', + }); + }); + + it('returns ⊞ Win for win on non-Apple', () => { + expect(formatDisplayToken('win', false)).toEqual({ + display: '⊞ Win', + isGlyph: false, + ariaName: 'Windows', + }); + }); + + it('returns ⊞ Win for windows on non-Apple', () => { + expect(formatDisplayToken('windows', false)).toEqual({ + display: '⊞ Win', + isGlyph: false, + ariaName: 'Windows', + }); + }); + }); + + describe('cmd / command (always ⌘)', () => { + it('returns ⌘ for cmd on Apple', () => { + expect(formatDisplayToken('cmd', true)).toEqual({ + display: '⌘', + isGlyph: true, + ariaName: 'Command', + }); + }); + + it('returns ⌘ for command on non-Apple', () => { + expect(formatDisplayToken('command', false)).toEqual({ + display: '⌘', + isGlyph: true, + ariaName: 'Command', + }); + }); + }); + + describe('alt / option (platform-dependent)', () => { + it('returns ⌥ on Apple', () => { + expect(formatDisplayToken('alt', true)).toEqual({ + display: '⌥', + isGlyph: true, + ariaName: 'Option', + }); + }); + + it('returns Alt on non-Apple', () => { + expect(formatDisplayToken('alt', false)).toEqual({ + display: 'Alt', + isGlyph: false, + ariaName: 'Alt', + }); + }); + + it('returns ⌥ for option on Apple', () => { + expect(formatDisplayToken('option', true)).toEqual({ + display: '⌥', + isGlyph: true, + ariaName: 'Option', + }); + }); + }); + + describe('shift', () => { + it('returns ⇧', () => { + expect(formatDisplayToken('shift', true)).toEqual({ + display: '⇧', + isGlyph: true, + ariaName: 'Shift', + }); + }); + }); + + describe('enter / return', () => { + it('returns ↩ for enter', () => { + expect(formatDisplayToken('enter', true)).toEqual({ + display: '↩', + isGlyph: true, + ariaName: 'Enter', + }); + }); + + it('returns ↩ for return', () => { + expect(formatDisplayToken('return', false)).toEqual({ + display: '↩', + isGlyph: true, + ariaName: 'Enter', + }); + }); + }); + + describe('tab', () => { + it('returns ⇥', () => { + expect(formatDisplayToken('tab', true)).toEqual({ + display: '⇥', + isGlyph: true, + ariaName: 'Tab', + }); + }); + }); + + describe('delete / del / backspace (platform-dependent)', () => { + it('returns ⌫ for delete on Apple', () => { + expect(formatDisplayToken('delete', true)).toEqual({ + display: '⌫', + isGlyph: true, + ariaName: 'Delete', + }); + }); + + it('returns ⌫ for backspace on Apple', () => { + expect(formatDisplayToken('backspace', true)).toEqual({ + display: '⌫', + isGlyph: true, + ariaName: 'Delete', + }); + }); + + it('returns Del for delete on non-Apple', () => { + expect(formatDisplayToken('delete', false)).toEqual({ + display: 'Del', + isGlyph: false, + ariaName: 'Delete', + }); + }); + + it('returns Del for del on non-Apple', () => { + expect(formatDisplayToken('del', false)).toEqual({ + display: 'Del', + isGlyph: false, + ariaName: 'Delete', + }); + }); + + it('returns Backspace for backspace on non-Apple', () => { + expect(formatDisplayToken('backspace', false)).toEqual({ + display: 'Backspace', + isGlyph: false, + ariaName: 'Backspace', + }); + }); + }); + + describe('ctrl / control (platform-dependent)', () => { + it('returns ⌃ for ctrl on Apple', () => { + expect(formatDisplayToken('ctrl', true)).toEqual({ + display: '⌃', + isGlyph: true, + ariaName: 'Control', + }); + }); + + it('returns Ctrl for ctrl on non-Apple', () => { + expect(formatDisplayToken('ctrl', false)).toEqual({ + display: 'Ctrl', + isGlyph: false, + ariaName: 'Control', + }); + }); + + it('returns ⌃ for control on Apple', () => { + expect(formatDisplayToken('control', true)).toEqual({ + display: '⌃', + isGlyph: true, + ariaName: 'Control', + }); + }); + + it('returns Ctrl for control on non-Apple', () => { + expect(formatDisplayToken('control', false)).toEqual({ + display: 'Ctrl', + isGlyph: false, + ariaName: 'Control', + }); + }); + }); + + describe('escape / esc', () => { + it('returns Esc for escape', () => { + expect(formatDisplayToken('escape', true)).toEqual({ + display: 'Esc', + isGlyph: false, + ariaName: 'Escape', + }); + }); + + it('returns Esc for esc', () => { + expect(formatDisplayToken('esc', false)).toEqual({ + display: 'Esc', + isGlyph: false, + ariaName: 'Escape', + }); + }); + }); + + describe('space / spacebar', () => { + it('returns ␣ for space', () => { + expect(formatDisplayToken('space', true)).toEqual({ + display: '␣', + isGlyph: true, + ariaName: 'Space', + }); + }); + + it('returns ␣ for spacebar', () => { + expect(formatDisplayToken('spacebar', false)).toEqual({ + display: '␣', + isGlyph: true, + ariaName: 'Space', + }); + }); + }); + + describe('arrow keys', () => { + it('returns ↑ for arrowup', () => { + expect(formatDisplayToken('arrowup', true)).toEqual({ + display: '↑', + isGlyph: true, + ariaName: 'Up', + }); + }); + + it('returns ↑ for up', () => { + expect(formatDisplayToken('up', true)).toEqual({ + display: '↑', + isGlyph: true, + ariaName: 'Up', + }); + }); + + it('returns ↓ for arrowdown', () => { + expect(formatDisplayToken('arrowdown', true)).toEqual({ + display: '↓', + isGlyph: true, + ariaName: 'Down', + }); + }); + + it('returns ↓ for down', () => { + expect(formatDisplayToken('down', true)).toEqual({ + display: '↓', + isGlyph: true, + ariaName: 'Down', + }); + }); + + it('returns ← for arrowleft', () => { + expect(formatDisplayToken('arrowleft', true)).toEqual({ + display: '←', + isGlyph: true, + ariaName: 'Left', + }); + }); + + it('returns ← for left', () => { + expect(formatDisplayToken('left', true)).toEqual({ + display: '←', + isGlyph: true, + ariaName: 'Left', + }); + }); + + it('returns → for arrowright', () => { + expect(formatDisplayToken('arrowright', true)).toEqual({ + display: '→', + isGlyph: true, + ariaName: 'Right', + }); + }); + + it('returns → for right', () => { + expect(formatDisplayToken('right', true)).toEqual({ + display: '→', + isGlyph: true, + ariaName: 'Right', + }); + }); + }); + + describe('unrecognized tokens', () => { + it('returns the token as-is for a letter', () => { + expect(formatDisplayToken('k', false)).toEqual({ + display: 'k', + isGlyph: false, + ariaName: 'k', + }); + }); + + it('returns the token as-is for a function key', () => { + expect(formatDisplayToken('f1', false)).toEqual({ + display: 'f1', + isGlyph: false, + ariaName: 'f1', + }); + }); + }); + + describe('case insensitivity', () => { + it('handles uppercase META', () => { + expect(formatDisplayToken('META', true)).toEqual({ + display: '⌘', + isGlyph: true, + ariaName: 'Command', + }); + }); + + it('handles mixed case Shift', () => { + expect(formatDisplayToken('Shift', false)).toEqual({ + display: '⇧', + isGlyph: true, + ariaName: 'Shift', + }); + }); + }); +}); diff --git a/src/components/hotkey/format-display-token.ts b/src/components/hotkey/format-display-token.ts new file mode 100644 index 0000000000..88b20ec514 --- /dev/null +++ b/src/components/hotkey/format-display-token.ts @@ -0,0 +1,122 @@ +// Note: this function handles the same key aliases as `KEY_ALIASES` in +// `../../util/hotkeys.ts`, but maps them to display strings rather than +// canonical names. Keep both in sync when adding new aliases. + +export interface DisplayToken { + display: string; + isGlyph: boolean; + ariaName: string; +} + +/** + * Maps a single hotkey token to its display representation. + * + * @param token - A single token from `tokenizeHotkeyString` (e.g. `"meta"`, `"k"`, `"+"`). + * @param isApple - Whether the current device is an Apple device. + * @returns The display string, whether it is a glyph (for styling), + * and a human-readable name for screen readers. + */ +export function formatDisplayToken( + token: string, + isApple: boolean +): DisplayToken { + const trimmed = (token ?? '').trim(); + if (!trimmed) { + return { display: '', isGlyph: false, ariaName: '' }; + } + + if (trimmed === '+') { + return { display: '+', isGlyph: false, ariaName: 'plus' }; + } + + const lower = trimmed.toLowerCase(); + + switch (lower) { + case 'meta': + case 'win': + case 'windows': { + return isApple + ? { display: '⌘', isGlyph: true, ariaName: 'Command' } + : { display: '⊞ Win', isGlyph: false, ariaName: 'Windows' }; + } + + case 'cmd': + case 'command': { + return { display: '⌘', isGlyph: true, ariaName: 'Command' }; + } + + case 'alt': + case 'option': { + return isApple + ? { display: '⌥', isGlyph: true, ariaName: 'Option' } + : { display: 'Alt', isGlyph: false, ariaName: 'Alt' }; + } + + case 'shift': { + return { display: '⇧', isGlyph: true, ariaName: 'Shift' }; + } + + case 'enter': + case 'return': { + return { display: '↩', isGlyph: true, ariaName: 'Enter' }; + } + + case 'tab': { + return { display: '⇥', isGlyph: true, ariaName: 'Tab' }; + } + + case 'delete': + case 'del': + case 'backspace': { + if (isApple) { + return { display: '⌫', isGlyph: true, ariaName: 'Delete' }; + } + return lower === 'backspace' + ? { + display: 'Backspace', + isGlyph: false, + ariaName: 'Backspace', + } + : { display: 'Del', isGlyph: false, ariaName: 'Delete' }; + } + + case 'ctrl': + case 'control': { + return isApple + ? { display: '⌃', isGlyph: true, ariaName: 'Control' } + : { display: 'Ctrl', isGlyph: false, ariaName: 'Control' }; + } + + case 'escape': + case 'esc': { + return { display: 'Esc', isGlyph: false, ariaName: 'Escape' }; + } + + case 'space': + case 'spacebar': { + return { display: '␣', isGlyph: true, ariaName: 'Space' }; + } + + case 'arrowup': + case 'up': { + return { display: '↑', isGlyph: true, ariaName: 'Up' }; + } + + case 'arrowdown': + case 'down': { + return { display: '↓', isGlyph: true, ariaName: 'Down' }; + } + + case 'arrowleft': + case 'left': { + return { display: '←', isGlyph: true, ariaName: 'Left' }; + } + + case 'arrowright': + case 'right': { + return { display: '→', isGlyph: true, ariaName: 'Right' }; + } + } + + return { display: trimmed, isGlyph: false, ariaName: trimmed }; +} 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..8c29a32861 --- /dev/null +++ b/src/components/hotkey/hotkey.tsx @@ -0,0 +1,66 @@ +import { Component, Host, Prop, h } from '@stencil/core'; +import { tokenizeHotkeyString } from '../../util/hotkeys'; +import { isAppleDevice } from '../../util/device'; +import { formatDisplayToken } from './format-display-token'; + +/** + * 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 { + private isApple: boolean; + + /** + * 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 componentWillLoad() { + this.isApple = isAppleDevice(); + } + + public render() { + const parts = tokenizeHotkeyString(this.value); + const displayParts = parts.map((part) => + formatDisplayToken(part, this.isApple) + ); + const ariaLabel = displayParts + .map((p) => p.ariaName) + .filter(Boolean) + .join(' '); + + return ( + + {displayParts.map(({ display, isGlyph }, index) => ( + + {display} + + ))} + + ); + } +}