diff --git a/.changeset/tangy-mangos-clap.md b/.changeset/tangy-mangos-clap.md new file mode 100644 index 00000000..c9832260 --- /dev/null +++ b/.changeset/tangy-mangos-clap.md @@ -0,0 +1,5 @@ +--- +'@tanstack/hotkeys': patch +--- + +`formatForDisplay` and `formatWithLabels` now order macOS modifier keys according to Apple's Human Interface Guidelines: Control → Option → Shift → Command. For example, `Mod+Shift+S` on macOS now renders as `⇧ ⌘ S` instead of `⌘ ⇧ S`. Windows and Linux output is unchanged. diff --git a/packages/hotkeys/src/format.ts b/packages/hotkeys/src/format.ts index 1ba03b90..d9ceb43f 100644 --- a/packages/hotkeys/src/format.ts +++ b/packages/hotkeys/src/format.ts @@ -8,6 +8,7 @@ import { PUNCTUATION_KEY_DISPLAY_LABELS, WINDOWS_MODIFIER_LABELS, detectPlatform, + resolveModifier, } from './constants' import { isModifierKey, @@ -22,6 +23,7 @@ import type { RegisterableHotkey, } from './hotkey' + /** * Converts a hotkey sequence array to a display string. * @@ -69,7 +71,7 @@ export function formatHotkey(parsed: 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 the same modifier order as {@link normalizeHotkeyFromParsed}. * On Windows/Linux, uses text (Ctrl+Shift+S) with `+` separators. * The separator can be customized with `separatorToken`. * @@ -80,7 +82,7 @@ export function formatHotkey(parsed: 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' @@ -101,7 +103,11 @@ export function formatForDisplay( hotkey as RegisterableHotkey, platform, ) - return normalizedHotkey + + const formattedHotkey = + platform === 'mac' ? toAppleHIGModifierOrder(normalizedHotkey) : normalizedHotkey + + return formattedHotkey .split('+') .map((segment) => { if (isModifierKey(segment)) { @@ -131,6 +137,32 @@ export function formatForDisplay( .join(separatorToken) } + +/** + * @paramaters hotkey - The hotkey sequence from the user + * @returns The hotkey sequence but removes the mod key alias and reorders to the Apple HIG modifier order + **/ + +function toAppleHIGModifierOrder(hotkey: string) { + let modifiers: Array = [] + let keys: Array = [] + hotkey.split('+').forEach((segment) => { + if (isModifierKey(segment)) { + const modifier = resolveModifier(segment as CanonicalModifier, 'mac'); + modifiers = [...modifiers, modifier] + } else { + keys = [...keys, segment] + } + }) + + modifiers.sort( + (a, b) => + MODIFIER_ORDER.indexOf(a as CanonicalModifier) - + MODIFIER_ORDER.indexOf(b as CanonicalModifier), + ) + return modifiers.concat(keys).join('+') +} + /** * @deprecated Use {@link formatForDisplay} instead with `useSymbols: false` option. */ diff --git a/packages/hotkeys/tests/format.test.ts b/packages/hotkeys/tests/format.test.ts index dc763f5c..bc531772 100644 --- a/packages/hotkeys/tests/format.test.ts +++ b/packages/hotkeys/tests/format.test.ts @@ -98,7 +98,7 @@ describe('formatForDisplay', () => { '⌃ ⇧ A', ) expect(formatForDisplay(hk('Command+Shift+S'), { platform: 'mac' })).toBe( - '⌘ ⇧ S', + '⇧ ⌘ S', ) }) @@ -108,24 +108,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 +195,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') }) }) @@ -248,16 +248,25 @@ describe('formatForDisplay', () => { it('should handle multiple modifiers in canonical order (Mod first)', () => { expect( formatForDisplay('Mod+Shift+S', { - platform: 'mac', + platform: 'windows', useSymbols: false, }), - ).toBe('Cmd+Shift+S') + ).toBe('Ctrl+Shift+S') + }) + it('when platform is mac, handling multiple modifiers should follow apple human interface guidelines', () => { + // Control → Option → Shift → Command expect( - formatForDisplay('Mod+Shift+S', { - platform: 'windows', + formatForDisplay('Meta+Shift+Alt+Control+A', { + platform: 'mac', useSymbols: false, }), - ).toBe('Ctrl+Shift+S') + ).toBe('Control+Option+Shift+Cmd+A') + expect( + formatForDisplay('Meta+Shift+Alt+Control+A', { + platform: 'mac', + useSymbols: true, + }), + ).toBe('⌃ ⌥ ⇧ ⌘ A') }) }) })