From 10a9ec5ef235a656ac2fe1dd2a774d5c17a7bb60 Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Sun, 27 Sep 2026 13:42:03 -0500 Subject: [PATCH] fix: order macOS shortcut modifiers for display --- .changeset/heavy-spiders-learn.md | 5 ++ .../angular/guides/formatting-display.md | 2 +- .../lit/guides/formatting-display.md | 2 +- .../preact/guides/formatting-display.md | 2 +- .../react/guides/formatting-display.md | 2 +- .../solid/guides/formatting-display.md | 2 +- .../svelte/guides/formatting-display.md | 2 +- .../vue/guides/formatting-display.md | 2 +- docs/reference/functions/formatForDisplay.md | 21 ++++---- docs/reference/functions/formatWithLabels.md | 2 +- .../interfaces/FormatDisplayOptions.md | 14 ++--- packages/hotkeys/src/format.ts | 18 +++++-- packages/hotkeys/tests/format.test.ts | 52 +++++++++++++++---- 13 files changed, 90 insertions(+), 36 deletions(-) create mode 100644 .changeset/heavy-spiders-learn.md diff --git a/.changeset/heavy-spiders-learn.md b/.changeset/heavy-spiders-learn.md new file mode 100644 index 00000000..09bddc1e --- /dev/null +++ b/.changeset/heavy-spiders-learn.md @@ -0,0 +1,5 @@ +--- +'@tanstack/hotkeys': patch +--- + +Display macOS shortcut modifiers in Control, Option, Shift, Command order without changing normalized binding identities. diff --git a/docs/framework/angular/guides/formatting-display.md b/docs/framework/angular/guides/formatting-display.md index 96df7667..7f4fb163 100644 --- a/docs/framework/angular/guides/formatting-display.md +++ b/docs/framework/angular/guides/formatting-display.md @@ -18,7 +18,7 @@ formatForDisplay('Mod+[Digit2]', { platform: 'windows' }) // 'Ctrl+2' The same label can represent different bindings. `Mod+S` follows a logical letter; `Mod+[KeyS]` follows a physical position. Physical labels shorten `KeyS` to `S` and `Digit2` to `2`, preserve readable numpad labels, and reuse punctuation and special-key symbols. This does not change the stored code or infer the user's layout. -Omit `platform` to use detection. On macOS the default joins modifier symbols with spaces; Windows and Linux use labels joined with `+`. +Omit `platform` to use detection. On macOS the default joins modifier symbols with spaces; Windows and Linux use labels joined with `+`. macOS display orders modifiers as Control, Option, Shift, Command: `Mod+Shift+S` displays as `⇧ ⌘ S`. The normalized binding remains `Mod+Shift+S`. ## Render individual keycaps diff --git a/docs/framework/lit/guides/formatting-display.md b/docs/framework/lit/guides/formatting-display.md index bab21d90..1632eb43 100644 --- a/docs/framework/lit/guides/formatting-display.md +++ b/docs/framework/lit/guides/formatting-display.md @@ -18,7 +18,7 @@ formatForDisplay('Mod+[Digit2]', { platform: 'windows' }) // 'Ctrl+2' The same label can represent different bindings. `Mod+S` follows a logical letter; `Mod+[KeyS]` follows a physical position. Physical labels shorten `KeyS` to `S` and `Digit2` to `2`, preserve readable numpad labels, and reuse punctuation and special-key symbols. This does not change the stored code or infer the user's layout. -Omit `platform` to use detection. On macOS the default joins modifier symbols with spaces; Windows and Linux use labels joined with `+`. +Omit `platform` to use detection. On macOS the default joins modifier symbols with spaces; Windows and Linux use labels joined with `+`. macOS display orders modifiers as Control, Option, Shift, Command: `Mod+Shift+S` displays as `⇧ ⌘ S`. The normalized binding remains `Mod+Shift+S`. ## Render individual keycaps diff --git a/docs/framework/preact/guides/formatting-display.md b/docs/framework/preact/guides/formatting-display.md index 82218ec9..fe272752 100644 --- a/docs/framework/preact/guides/formatting-display.md +++ b/docs/framework/preact/guides/formatting-display.md @@ -18,7 +18,7 @@ formatForDisplay('Mod+[Digit2]', { platform: 'windows' }) // 'Ctrl+2' The same label can represent different bindings. `Mod+S` follows a logical letter; `Mod+[KeyS]` follows a physical position. Physical labels shorten `KeyS` to `S` and `Digit2` to `2`, preserve readable numpad labels, and reuse punctuation and special-key symbols. This does not change the stored code or infer the user's layout. -Omit `platform` to use detection. On macOS the default joins modifier symbols with spaces; Windows and Linux use labels joined with `+`. +Omit `platform` to use detection. On macOS the default joins modifier symbols with spaces; Windows and Linux use labels joined with `+`. macOS display orders modifiers as Control, Option, Shift, Command: `Mod+Shift+S` displays as `⇧ ⌘ S`. The normalized binding remains `Mod+Shift+S`. ## Render individual keycaps diff --git a/docs/framework/react/guides/formatting-display.md b/docs/framework/react/guides/formatting-display.md index 68e60f0f..b10aa57a 100644 --- a/docs/framework/react/guides/formatting-display.md +++ b/docs/framework/react/guides/formatting-display.md @@ -18,7 +18,7 @@ formatForDisplay('Mod+[Digit2]', { platform: 'windows' }) // 'Ctrl+2' The same label can represent different bindings. `Mod+S` follows a logical letter; `Mod+[KeyS]` follows a physical position. Physical labels shorten `KeyS` to `S` and `Digit2` to `2`, preserve readable numpad labels, and reuse punctuation and special-key symbols. This does not change the stored code or infer the user's layout. -Omit `platform` to use detection. On macOS the default joins modifier symbols with spaces; Windows and Linux use labels joined with `+`. +Omit `platform` to use detection. On macOS the default joins modifier symbols with spaces; Windows and Linux use labels joined with `+`. macOS display orders modifiers as Control, Option, Shift, Command: `Mod+Shift+S` displays as `⇧ ⌘ S`. The normalized binding remains `Mod+Shift+S`. ## Render individual keycaps diff --git a/docs/framework/solid/guides/formatting-display.md b/docs/framework/solid/guides/formatting-display.md index c6248176..73b21212 100644 --- a/docs/framework/solid/guides/formatting-display.md +++ b/docs/framework/solid/guides/formatting-display.md @@ -18,7 +18,7 @@ formatForDisplay('Mod+[Digit2]', { platform: 'windows' }) // 'Ctrl+2' The same label can represent different bindings. `Mod+S` follows a logical letter; `Mod+[KeyS]` follows a physical position. Physical labels shorten `KeyS` to `S` and `Digit2` to `2`, preserve readable numpad labels, and reuse punctuation and special-key symbols. This does not change the stored code or infer the user's layout. -Omit `platform` to use detection. On macOS the default joins modifier symbols with spaces; Windows and Linux use labels joined with `+`. +Omit `platform` to use detection. On macOS the default joins modifier symbols with spaces; Windows and Linux use labels joined with `+`. macOS display orders modifiers as Control, Option, Shift, Command: `Mod+Shift+S` displays as `⇧ ⌘ S`. The normalized binding remains `Mod+Shift+S`. ## Render individual keycaps diff --git a/docs/framework/svelte/guides/formatting-display.md b/docs/framework/svelte/guides/formatting-display.md index 3b9482a5..f7e4a8fa 100644 --- a/docs/framework/svelte/guides/formatting-display.md +++ b/docs/framework/svelte/guides/formatting-display.md @@ -18,7 +18,7 @@ formatForDisplay('Mod+[Digit2]', { platform: 'windows' }) // 'Ctrl+2' The same label can represent different bindings. `Mod+S` follows a logical letter; `Mod+[KeyS]` follows a physical position. Physical labels shorten `KeyS` to `S` and `Digit2` to `2`, preserve readable numpad labels, and reuse punctuation and special-key symbols. This does not change the stored code or infer the user's layout. -Omit `platform` to use detection. On macOS the default joins modifier symbols with spaces; Windows and Linux use labels joined with `+`. +Omit `platform` to use detection. On macOS the default joins modifier symbols with spaces; Windows and Linux use labels joined with `+`. macOS display orders modifiers as Control, Option, Shift, Command: `Mod+Shift+S` displays as `⇧ ⌘ S`. The normalized binding remains `Mod+Shift+S`. ## Render individual keycaps diff --git a/docs/framework/vue/guides/formatting-display.md b/docs/framework/vue/guides/formatting-display.md index baca7bf5..46c42dfc 100644 --- a/docs/framework/vue/guides/formatting-display.md +++ b/docs/framework/vue/guides/formatting-display.md @@ -18,7 +18,7 @@ formatForDisplay('Mod+[Digit2]', { platform: 'windows' }) // 'Ctrl+2' The same label can represent different bindings. `Mod+S` follows a logical letter; `Mod+[KeyS]` follows a physical position. Physical labels shorten `KeyS` to `S` and `Digit2` to `2`, preserve readable numpad labels, and reuse punctuation and special-key symbols. This does not change the stored code or infer the user's layout. -Omit `platform` to use detection. On macOS the default joins modifier symbols with spaces; Windows and Linux use labels joined with `+`. +Omit `platform` to use detection. On macOS the default joins modifier symbols with spaces; Windows and Linux use labels joined with `+`. macOS display orders modifiers as Control, Option, Shift, Command: `Mod+Shift+S` displays as `⇧ ⌘ S`. The normalized binding remains `Mod+Shift+S`. ## Render individual keycaps diff --git a/docs/reference/functions/formatForDisplay.md b/docs/reference/functions/formatForDisplay.md index 31991c4d..88c1aef8 100644 --- a/docs/reference/functions/formatForDisplay.md +++ b/docs/reference/functions/formatForDisplay.md @@ -9,11 +9,12 @@ title: formatForDisplay function formatForDisplay(hotkey, options): string[]; ``` -Defined in: [format.ts:100](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/format.ts#L100) +Defined in: [format.ts:101](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/format.ts#L101) Formats a hotkey for display in a user interface. -On macOS, uses symbols (⌘⇧S) in the same modifier order as [normalizeHotkeyFromParsed](normalizeHotkeyFromParsed.md). +On macOS, uses symbols (⇧⌘S) in Control, Option, Shift, Command order. +Display ordering does not change the normalized hotkey used for registration. On Windows/Linux, uses text (Ctrl+Shift+S) with `+` separators. The separator can be customized with `separatorToken`. Physical codes use conventional display labels: `[KeyS]` becomes `S` and @@ -45,7 +46,7 @@ Display text, or individual key labels when parts is true ```ts formatForDisplay('Mod+Shift+S', { platform: 'mac' }) -// Returns: '⌘ ⇧ S' (symbols separated by spaces on macOS) +// Returns: '⇧ ⌘ S' (symbols separated by spaces on macOS) formatForDisplay('Mod+Shift+S', { platform: 'windows' }) // Returns: 'Ctrl+Shift+S' @@ -60,11 +61,12 @@ formatForDisplay('Escape') function formatForDisplay(hotkey, options?): string; ``` -Defined in: [format.ts:104](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/format.ts#L104) +Defined in: [format.ts:105](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/format.ts#L105) Formats a hotkey for display in a user interface. -On macOS, uses symbols (⌘⇧S) in the same modifier order as [normalizeHotkeyFromParsed](normalizeHotkeyFromParsed.md). +On macOS, uses symbols (⇧⌘S) in Control, Option, Shift, Command order. +Display ordering does not change the normalized hotkey used for registration. On Windows/Linux, uses text (Ctrl+Shift+S) with `+` separators. The separator can be customized with `separatorToken`. Physical codes use conventional display labels: `[KeyS]` becomes `S` and @@ -96,7 +98,7 @@ Display text, or individual key labels when parts is true ```ts formatForDisplay('Mod+Shift+S', { platform: 'mac' }) -// Returns: '⌘ ⇧ S' (symbols separated by spaces on macOS) +// Returns: '⇧ ⌘ S' (symbols separated by spaces on macOS) formatForDisplay('Mod+Shift+S', { platform: 'windows' }) // Returns: 'Ctrl+Shift+S' @@ -111,11 +113,12 @@ formatForDisplay('Escape') function formatForDisplay(hotkey, options): string | string[]; ``` -Defined in: [format.ts:108](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/format.ts#L108) +Defined in: [format.ts:109](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/format.ts#L109) Formats a hotkey for display in a user interface. -On macOS, uses symbols (⌘⇧S) in the same modifier order as [normalizeHotkeyFromParsed](normalizeHotkeyFromParsed.md). +On macOS, uses symbols (⇧⌘S) in Control, Option, Shift, Command order. +Display ordering does not change the normalized hotkey used for registration. On Windows/Linux, uses text (Ctrl+Shift+S) with `+` separators. The separator can be customized with `separatorToken`. Physical codes use conventional display labels: `[KeyS]` becomes `S` and @@ -147,7 +150,7 @@ Display text, or individual key labels when parts is true ```ts formatForDisplay('Mod+Shift+S', { platform: 'mac' }) -// Returns: '⌘ ⇧ S' (symbols separated by spaces on macOS) +// Returns: '⇧ ⌘ S' (symbols separated by spaces on macOS) formatForDisplay('Mod+Shift+S', { platform: 'windows' }) // Returns: 'Ctrl+Shift+S' diff --git a/docs/reference/functions/formatWithLabels.md b/docs/reference/functions/formatWithLabels.md index 4cc6d88e..9e76b4fc 100644 --- a/docs/reference/functions/formatWithLabels.md +++ b/docs/reference/functions/formatWithLabels.md @@ -7,7 +7,7 @@ title: formatWithLabels function formatWithLabels(hotkey, options?): string; ``` -Defined in: [format.ts:173](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/format.ts#L173) +Defined in: [format.ts:185](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/format.ts#L185) ## Parameters diff --git a/docs/reference/interfaces/FormatDisplayOptions.md b/docs/reference/interfaces/FormatDisplayOptions.md index af2386b4..5376a47d 100644 --- a/docs/reference/interfaces/FormatDisplayOptions.md +++ b/docs/reference/interfaces/FormatDisplayOptions.md @@ -3,7 +3,7 @@ id: FormatDisplayOptions title: FormatDisplayOptions --- -Defined in: [format.ts:183](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/format.ts#L183) +Defined in: [format.ts:195](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/format.ts#L195) Options for formatting hotkeys for display. @@ -15,7 +15,7 @@ Options for formatting hotkeys for display. optional keyLabels?: Readonly>; ``` -Defined in: [format.ts:198](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/format.ts#L198) +Defined in: [format.ts:210](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/format.ts#L210) Final display overrides for logical keys or physical codes; take precedence over layoutMap. Never changes matching. @@ -27,7 +27,7 @@ Final display overrides for logical keys or physical codes; take precedence over optional layoutMap?: object; ``` -Defined in: [format.ts:196](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/format.ts#L196) +Defined in: [format.ts:208](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/format.ts#L208) An already-resolved code-to-key map, such as KeyboardLayoutMap or Map. Used only for physical bindings. Mapped keys receive normal display formatting; @@ -58,7 +58,7 @@ get: (code) => string | undefined; optional parts?: boolean; ``` -Defined in: [format.ts:189](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/format.ts#L189) +Defined in: [format.ts:201](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/format.ts#L201) Return individual key labels instead of a joined string. Default: false. @@ -70,7 +70,7 @@ Return individual key labels instead of a joined string. Default: false. optional platform?: "mac" | "windows" | "linux"; ``` -Defined in: [format.ts:185](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/format.ts#L185) +Defined in: [format.ts:197](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/format.ts#L197) The target platform. Defaults to auto-detection. @@ -82,7 +82,7 @@ The target platform. Defaults to auto-detection. optional separatorToken?: string | null; ``` -Defined in: [format.ts:200](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/format.ts#L200) +Defined in: [format.ts:212](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/format.ts#L212) Override the separator between display tokens. Defaults to platform-specific formatting when null/undefined. @@ -99,6 +99,6 @@ optional useSymbols?: }; ``` -Defined in: [format.ts:187](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/format.ts#L187) +Defined in: [format.ts:199](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/format.ts#L199) Whether to use symbols for the display. Defaults to true. diff --git a/packages/hotkeys/src/format.ts b/packages/hotkeys/src/format.ts index 765e96b7..b7abcee6 100644 --- a/packages/hotkeys/src/format.ts +++ b/packages/hotkeys/src/format.ts @@ -73,7 +73,8 @@ export type DisplayHotkey = RegisterableHotkey | ParsedHotkey | (string & {}) /** * Formats a hotkey for display in a user interface. * - * On macOS, uses symbols (⌘⇧S) in the same modifier order as {@link normalizeHotkeyFromParsed}. + * On macOS, uses symbols (⇧⌘S) in Control, Option, Shift, Command order. + * Display ordering does not change the normalized hotkey used for registration. * On Windows/Linux, uses text (Ctrl+Shift+S) with `+` separators. * The separator can be customized with `separatorToken`. * Physical codes use conventional display labels: `[KeyS]` becomes `S` and @@ -88,7 +89,7 @@ export type DisplayHotkey = RegisterableHotkey | ParsedHotkey | (string & {}) * @example * ```ts * formatForDisplay('Mod+Shift+S', { platform: 'mac' }) - * // Returns: '⌘ ⇧ S' (symbols separated by spaces on macOS) + * // Returns: '⇧ ⌘ S' (symbols separated by spaces on macOS) * * formatForDisplay('Mod+Shift+S', { platform: 'windows' }) * // Returns: 'Ctrl+Shift+S' @@ -126,7 +127,18 @@ export function formatForDisplay( typeof hotkey === 'object' && 'modifiers' in hotkey ? normalizeHotkeyFromParsed(hotkey, platform) : normalizeRegisterableHotkey(hotkey as RegisterableHotkey, platform) - const parts = splitHotkeyParts(normalized).map((segment) => { + const segments = splitHotkeyParts(normalized) + if (platform === 'mac') { + // Canonical registration puts Mod first; display resolves it to Command. + const modifierIndex = (segment: string) => { + const index = MODIFIER_ORDER.indexOf( + (segment === 'Mod' ? 'Meta' : segment) as CanonicalModifier, + ) + return index === -1 ? MODIFIER_ORDER.length : index + } + segments.sort((a, b) => modifierIndex(a) - modifierIndex(b)) + } + const parts = segments.map((segment) => { if (isModifierKey(segment)) { const modifier = (MODIFIER_ALIASES[segment] ?? MODIFIER_ALIASES[segment.toLowerCase()]) as CanonicalModifier | 'Mod' diff --git a/packages/hotkeys/tests/format.test.ts b/packages/hotkeys/tests/format.test.ts index 176156c7..967a3ff4 100644 --- a/packages/hotkeys/tests/format.test.ts +++ b/packages/hotkeys/tests/format.test.ts @@ -3,7 +3,9 @@ import { formatForDisplay, formatHotkey, formatHotkeySequence, + formatWithLabels, } from '../src/format' +import { normalizeRegisterableHotkey } from '../src/parse' import type { ParsedHotkey, RegisterableHotkey } from '../src/hotkey.types' /** Strings that parse correctly but are not in the `Hotkey` template union. */ @@ -86,6 +88,38 @@ describe('formatHotkey', () => { describe('formatForDisplay', () => { describe('macOS format', () => { + it.each([ + ['Mod+Alt+Shift+S', '⌥ ⇧ ⌘ S'], + ['Meta+Shift+Alt+Control+S', '⌃ ⌥ ⇧ ⌘ S'], + ['Mod+Shift++', '⇧ ⌘ +'], + ['Mod+Shift', '⇧ ⌘'], + ])('orders %s for display without changing its key', (hotkey, display) => { + expect(formatForDisplay(hotkey, { platform: 'mac' })).toBe(display) + }) + + it('orders raw physical bindings and parts without changing registration identity', () => { + const binding = { code: 'KeyS', mod: true, alt: true, shift: true } + expect( + formatForDisplay(binding, { + platform: 'mac', + parts: true, + useSymbols: false, + keyLabels: { KeyS: 'Save' }, + }), + ).toEqual(['Option', 'Shift', 'Cmd', 'Save']) + expect(normalizeRegisterableHotkey(binding, 'mac')).toBe( + 'Mod+Alt+Shift+[KeyS]', + ) + expect(formatWithLabels('Mod+Shift+S', { platform: 'mac' })).toBe( + 'Shift+Cmd+S', + ) + for (const platform of ['windows', 'linux'] as const) { + expect(formatForDisplay('Mod+Alt+Shift+S', { platform })).toBe( + 'Ctrl+Alt+Shift+S', + ) + } + }) + it('should use symbols for modifiers with spaces between segments', () => { expect(formatForDisplay('Control+A', { platform: 'mac' })).toBe('⌃ A') expect(formatForDisplay('Shift+A', { platform: 'mac' })).toBe('⇧ A') @@ -98,7 +132,7 @@ describe('formatForDisplay', () => { '⌃ ⇧ A', ) expect(formatForDisplay(hk('Command+Shift+S'), { platform: 'mac' })).toBe( - '⌘ ⇧ S', + '⇧ ⌘ S', ) }) @@ -108,24 +142,24 @@ describe('formatForDisplay', () => { platform: 'mac', separatorToken: '', }), - ).toBe('⌘⇧S') + ).toBe('⇧⌘S') expect( formatForDisplay('Mod+Shift+S', { platform: 'mac', separatorToken: ' + ', }), - ).toBe('⌘ + ⇧ + S') + ).toBe('⇧ + ⌘ + S') expect( formatForDisplay('Mod+Shift+S', { platform: 'mac', separatorToken: null, }), - ).toBe('⌘ ⇧ S') + ).toBe('⇧ ⌘ S') }) it('should resolve Mod to Command symbol', () => { expect(formatForDisplay('Mod+S', { platform: 'mac' })).toBe('⌘ S') - expect(formatForDisplay('Mod+Shift+S', { platform: 'mac' })).toBe('⌘ ⇧ S') + expect(formatForDisplay('Mod+Shift+S', { platform: 'mac' })).toBe('⇧ ⌘ S') }) it('should use symbols for special keys', () => { @@ -195,10 +229,10 @@ describe('formatForDisplay', () => { meta: true, modifiers: ['Shift', 'Meta'], } - expect(formatForDisplay(parsed, { platform: 'mac' })).toBe('⌘ ⇧ S') + expect(formatForDisplay(parsed, { platform: 'mac' })).toBe('⇧ ⌘ S') expect( formatForDisplay(parsed, { platform: 'mac', useSymbols: false }), - ).toBe('Cmd+Shift+S') + ).toBe('Shift+Cmd+S') }) }) @@ -245,13 +279,13 @@ describe('formatForDisplay', () => { ).toBe('Ctrl+S') }) - it('should handle multiple modifiers in canonical order (Mod first)', () => { + it('should use platform-specific display order', () => { expect( formatForDisplay('Mod+Shift+S', { platform: 'mac', useSymbols: false, }), - ).toBe('Cmd+Shift+S') + ).toBe('Shift+Cmd+S') expect( formatForDisplay('Mod+Shift+S', { platform: 'windows',