From 588eb033ee401943eb075519e609e93ee41a1a65 Mon Sep 17 00:00:00 2001 From: Dan Yishai Date: Fri, 13 Mar 2026 00:01:19 +0200 Subject: [PATCH 1/2] fix: add event.code fallback for punctuation keys on macOS Option combos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS, the Option (Alt) key acts as a character composer for punctuation keys (e.g., Option+- → en-dash '–'), causing event.key to differ from the expected character. This prevented Alt+punctuation hotkeys from matching. Add a PUNCTUATION_CODE_MAP that maps event.code values (Minus, Equal, Slash, BracketLeft, etc.) back to their canonical characters, following the same fallback pattern already used for letter (Key*) and digit (Digit*) codes. --- .changeset/fix-alt-punctuation-macos.md | 7 +++ packages/hotkeys/src/constants.ts | 22 +++++++ packages/hotkeys/src/match.ts | 12 +++- packages/hotkeys/tests/match.test.ts | 77 ++++++++++++++++++++++++- 4 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-alt-punctuation-macos.md diff --git a/.changeset/fix-alt-punctuation-macos.md b/.changeset/fix-alt-punctuation-macos.md new file mode 100644 index 0000000..8e8aebc --- /dev/null +++ b/.changeset/fix-alt-punctuation-macos.md @@ -0,0 +1,7 @@ +--- +'@tanstack/hotkeys': patch +--- + +Fix Alt+punctuation hotkeys not firing on macOS due to Option key character composition + +On macOS, the Option (Alt) key acts as a character composer for punctuation keys (e.g., Option+- produces an en-dash '–'), causing `event.key` to differ from the expected character. Added a `event.code` fallback for punctuation keys (Minus, Equal, Slash, BracketLeft, BracketRight, Backslash, Comma, Period, Backquote, Semicolon), matching the existing fallback pattern for letter and digit keys. diff --git a/packages/hotkeys/src/constants.ts b/packages/hotkeys/src/constants.ts index 20334fa..a3ab1ad 100644 --- a/packages/hotkeys/src/constants.ts +++ b/packages/hotkeys/src/constants.ts @@ -300,6 +300,28 @@ export const PUNCTUATION_KEYS = new Set([ '`', ]) +/** + * Maps `KeyboardEvent.code` values for punctuation keys to their canonical characters. + * + * On macOS, holding the Option (Alt) key transforms punctuation keys into special characters + * (e.g., Option+Minus → en-dash '–'), causing `event.key` to differ from the expected character. + * However, `event.code` still reports the physical key (e.g., 'Minus'). This map enables + * falling back to `event.code` for punctuation keys, similar to the existing `Key*`/`Digit*` + * fallbacks for letters and digits. + */ +export const PUNCTUATION_CODE_MAP: Record = { + Minus: '-', + Equal: '=', + Slash: '/', + BracketLeft: '[', + BracketRight: ']', + Backslash: '\\', + Comma: ',', + Period: '.', + Backquote: '`', + Semicolon: ';', +} + /** * Set of all valid non-modifier keys. * diff --git a/packages/hotkeys/src/match.ts b/packages/hotkeys/src/match.ts index 23e157c..224791d 100644 --- a/packages/hotkeys/src/match.ts +++ b/packages/hotkeys/src/match.ts @@ -1,4 +1,5 @@ import { + PUNCTUATION_CODE_MAP, detectPlatform, isSingleLetterKey, normalizeKeyName, @@ -15,8 +16,9 @@ import type { * Checks if a KeyboardEvent matches a hotkey. * * Uses the `key` property from KeyboardEvent for matching, with a fallback to `code` - * for letter keys and digit keys (0-9) when `key` produces special characters - * (e.g., macOS Option+letter or Shift+number). Letter keys are matched case-insensitively. + * for letter keys, digit keys (0-9), and punctuation keys when `key` produces special + * characters (e.g., macOS Option+letter, Shift+number, or Option+punctuation). + * Letter keys are matched case-insensitively. * * Also handles "dead key" events where `event.key` is `'Dead'` instead of the expected * character. This commonly occurs on macOS with Option+letter combinations (e.g., Option+E, @@ -109,6 +111,12 @@ export function matchesKeyboardEvent( return codeDigit === hotkeyKey } } + // Fallback for punctuation keys (e.g., Minus, Slash, BracketLeft). + // On macOS, Option+punctuation produces composed characters (e.g., Option+- → '–'), + // but event.code still reports the physical key. + if (event.code && event.code in PUNCTUATION_CODE_MAP) { + return PUNCTUATION_CODE_MAP[event.code] === hotkeyKey + } return false } diff --git a/packages/hotkeys/tests/match.test.ts b/packages/hotkeys/tests/match.test.ts index 66d1bd2..ece28cc 100644 --- a/packages/hotkeys/tests/match.test.ts +++ b/packages/hotkeys/tests/match.test.ts @@ -627,16 +627,89 @@ describe('matchesKeyboardEvent', () => { }) }) - describe('dead key with non-Key/Digit codes', () => { - it('should not match dead key with non-letter, non-digit code', () => { + describe('dead key with punctuation codes', () => { + it('should match dead key with punctuation code via fallback', () => { const event = createKeyboardEvent('Dead', { altKey: true, code: 'BracketLeft', }) + expect(matchesKeyboardEvent(event, 'Alt+[' as Hotkey)).toBe(true) + }) + + it('should not match dead key with unknown code', () => { + const event = createKeyboardEvent('Dead', { + altKey: true, + code: 'UnknownCode', + }) expect(matchesKeyboardEvent(event, 'Alt+[' as Hotkey)).toBe(false) }) }) + describe('event.code fallback for punctuation keys', () => { + it('should fallback to event.code when macOS Option+minus produces en-dash', () => { + const event = createKeyboardEvent('–', { + altKey: true, + code: 'Minus', + }) + expect(matchesKeyboardEvent(event, 'Alt+-' as Hotkey)).toBe(true) + }) + + it('should fallback to event.code for all punctuation keys with Alt', () => { + const cases: Array<[string, string, string]> = [ + ['–', 'Minus', '-'], // Option+- → en-dash + ['≠', 'Equal', '='], // Option+= → ≠ + ['÷', 'Slash', '/'], // Option+/ → ÷ + ['\u201c', 'BracketLeft', '['], // Option+[ → " + ['\u2018', 'BracketRight', ']'], // Option+] → ' + ['«', 'Backslash', '\\'], // Option+\ → « + ['≤', 'Comma', ','], // Option+, → ≤ + ['≥', 'Period', '.'], // Option+. → ≥ + ['`', 'Backquote', '`'], // Option+` → ` (dead key on some layouts) + ] + + for (const [composedChar, code, expectedKey] of cases) { + const event = createKeyboardEvent(composedChar, { + altKey: true, + code, + }) + expect( + matchesKeyboardEvent(event, `Alt+${expectedKey}` as Hotkey), + ).toBe(true) + } + }) + + it('should match punctuation keys with multiple modifiers', () => { + // Cmd+Alt+- on macOS + const event = createKeyboardEvent('–', { + altKey: true, + metaKey: true, + code: 'Minus', + }) + expect(matchesKeyboardEvent(event, 'Mod+Alt+-' as Hotkey, 'mac')).toBe(true) + }) + + it('should still match punctuation keys directly without fallback', () => { + const event = createKeyboardEvent('-', { code: 'Minus' }) + expect(matchesKeyboardEvent(event, '-' as Hotkey)).toBe(true) + }) + + it('should not match punctuation fallback if code is missing', () => { + const event = createKeyboardEvent('–', { + altKey: true, + code: undefined, + }) + expect(matchesKeyboardEvent(event, 'Alt+-' as Hotkey)).toBe(false) + }) + + it('should match Ctrl+punctuation without needing fallback (non-macOS)', () => { + const event = createKeyboardEvent('-', { + ctrlKey: true, + code: 'Minus', + }) + expect(matchesKeyboardEvent(event, 'Control+-' as Hotkey)).toBe(true) + }) + }) + describe('edge cases', () => { it('should not match when event.key is Unidentified', () => { const event = createKeyboardEvent('Unidentified', { code: 'KeyA' }) From bb786bccc4810bca4ba302f4b33c36945acb68a1 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:20:28 +0000 Subject: [PATCH 2/2] ci: apply automated fixes --- .../functions/createHotkeyHandler.md | 2 +- .../functions/createMultiHotkeyHandler.md | 2 +- docs/reference/functions/isSingleLetterKey.md | 2 +- .../functions/matchesKeyboardEvent.md | 7 ++++--- docs/reference/functions/normalizeKeyName.md | 2 +- docs/reference/index.md | 1 + .../interfaces/CreateHotkeyHandlerOptions.md | 8 ++++---- docs/reference/variables/ALL_KEYS.md | 2 +- .../variables/KEY_DISPLAY_SYMBOLS.md | 2 +- .../variables/MAC_MODIFIER_SYMBOLS.md | 2 +- .../variables/PUNCTUATION_CODE_MAP.md | 20 +++++++++++++++++++ .../variables/STANDARD_MODIFIER_LABELS.md | 2 +- packages/hotkeys/tests/match.test.ts | 18 +++++++++-------- 13 files changed, 47 insertions(+), 23 deletions(-) create mode 100644 docs/reference/variables/PUNCTUATION_CODE_MAP.md diff --git a/docs/reference/functions/createHotkeyHandler.md b/docs/reference/functions/createHotkeyHandler.md index 05bcf50..3d88090 100644 --- a/docs/reference/functions/createHotkeyHandler.md +++ b/docs/reference/functions/createHotkeyHandler.md @@ -12,7 +12,7 @@ function createHotkeyHandler( options): (event) => void; ``` -Defined in: [match.ts:149](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L149) +Defined in: [match.ts:157](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L157) Creates a keyboard event handler that calls the callback when the hotkey matches. diff --git a/docs/reference/functions/createMultiHotkeyHandler.md b/docs/reference/functions/createMultiHotkeyHandler.md index 1485412..e2d8ce7 100644 --- a/docs/reference/functions/createMultiHotkeyHandler.md +++ b/docs/reference/functions/createMultiHotkeyHandler.md @@ -9,7 +9,7 @@ title: createMultiHotkeyHandler function createMultiHotkeyHandler(handlers, options): (event) => void; ``` -Defined in: [match.ts:200](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L200) +Defined in: [match.ts:208](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L208) Creates a handler that matches multiple hotkeys. diff --git a/docs/reference/functions/isSingleLetterKey.md b/docs/reference/functions/isSingleLetterKey.md index 21fc891..3cf5c93 100644 --- a/docs/reference/functions/isSingleLetterKey.md +++ b/docs/reference/functions/isSingleLetterKey.md @@ -9,7 +9,7 @@ title: isSingleLetterKey function isSingleLetterKey(key): boolean; ``` -Defined in: [constants.ts:422](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L422) +Defined in: [constants.ts:444](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L444) Normalizes a key name to its canonical form. diff --git a/docs/reference/functions/matchesKeyboardEvent.md b/docs/reference/functions/matchesKeyboardEvent.md index 83cbb62..f42c110 100644 --- a/docs/reference/functions/matchesKeyboardEvent.md +++ b/docs/reference/functions/matchesKeyboardEvent.md @@ -12,13 +12,14 @@ function matchesKeyboardEvent( platform): boolean; ``` -Defined in: [match.ts:41](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L41) +Defined in: [match.ts:43](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L43) Checks if a KeyboardEvent matches a hotkey. Uses the `key` property from KeyboardEvent for matching, with a fallback to `code` -for letter keys and digit keys (0-9) when `key` produces special characters -(e.g., macOS Option+letter or Shift+number). Letter keys are matched case-insensitively. +for letter keys, digit keys (0-9), and punctuation keys when `key` produces special +characters (e.g., macOS Option+letter, Shift+number, or Option+punctuation). +Letter keys are matched case-insensitively. Also handles "dead key" events where `event.key` is `'Dead'` instead of the expected character. This commonly occurs on macOS with Option+letter combinations (e.g., Option+E, diff --git a/docs/reference/functions/normalizeKeyName.md b/docs/reference/functions/normalizeKeyName.md index 5b2f759..f803909 100644 --- a/docs/reference/functions/normalizeKeyName.md +++ b/docs/reference/functions/normalizeKeyName.md @@ -9,7 +9,7 @@ title: normalizeKeyName function normalizeKeyName(key): string; ``` -Defined in: [constants.ts:426](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L426) +Defined in: [constants.ts:448](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L448) ## Parameters diff --git a/docs/reference/index.md b/docs/reference/index.md index de67c76..1478dac 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -62,6 +62,7 @@ title: "@tanstack/hotkeys" - [MODIFIER\_ORDER](variables/MODIFIER_ORDER.md) - [NAVIGATION\_KEYS](variables/NAVIGATION_KEYS.md) - [NUMBER\_KEYS](variables/NUMBER_KEYS.md) +- [PUNCTUATION\_CODE\_MAP](variables/PUNCTUATION_CODE_MAP.md) - [PUNCTUATION\_KEYS](variables/PUNCTUATION_KEYS.md) - [STANDARD\_MODIFIER\_LABELS](variables/STANDARD_MODIFIER_LABELS.md) diff --git a/docs/reference/interfaces/CreateHotkeyHandlerOptions.md b/docs/reference/interfaces/CreateHotkeyHandlerOptions.md index 1654190..29518c5 100644 --- a/docs/reference/interfaces/CreateHotkeyHandlerOptions.md +++ b/docs/reference/interfaces/CreateHotkeyHandlerOptions.md @@ -5,7 +5,7 @@ title: CreateHotkeyHandlerOptions # Interface: CreateHotkeyHandlerOptions -Defined in: [match.ts:122](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L122) +Defined in: [match.ts:130](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L130) Options for creating a hotkey handler. @@ -17,7 +17,7 @@ Options for creating a hotkey handler. optional platform: "mac" | "windows" | "linux"; ``` -Defined in: [match.ts:128](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L128) +Defined in: [match.ts:136](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L136) The target platform for resolving 'Mod' @@ -29,7 +29,7 @@ The target platform for resolving 'Mod' optional preventDefault: boolean; ``` -Defined in: [match.ts:124](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L124) +Defined in: [match.ts:132](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L132) Prevent the default browser action when the hotkey matches. Defaults to true @@ -41,6 +41,6 @@ Prevent the default browser action when the hotkey matches. Defaults to true optional stopPropagation: boolean; ``` -Defined in: [match.ts:126](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L126) +Defined in: [match.ts:134](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L134) Stop event propagation when the hotkey matches. Defaults to true diff --git a/docs/reference/variables/ALL_KEYS.md b/docs/reference/variables/ALL_KEYS.md index 818f62c..cffc5c0 100644 --- a/docs/reference/variables/ALL_KEYS.md +++ b/docs/reference/variables/ALL_KEYS.md @@ -15,7 +15,7 @@ const ALL_KEYS: Set< | PunctuationKey>; ``` -Defined in: [constants.ts:317](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L317) +Defined in: [constants.ts:339](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L339) Set of all valid non-modifier keys. diff --git a/docs/reference/variables/KEY_DISPLAY_SYMBOLS.md b/docs/reference/variables/KEY_DISPLAY_SYMBOLS.md index 0e8ad4b..55ab948 100644 --- a/docs/reference/variables/KEY_DISPLAY_SYMBOLS.md +++ b/docs/reference/variables/KEY_DISPLAY_SYMBOLS.md @@ -9,7 +9,7 @@ title: KEY_DISPLAY_SYMBOLS const KEY_DISPLAY_SYMBOLS: Record; ``` -Defined in: [constants.ts:512](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L512) +Defined in: [constants.ts:534](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L534) Special key symbols for display formatting. diff --git a/docs/reference/variables/MAC_MODIFIER_SYMBOLS.md b/docs/reference/variables/MAC_MODIFIER_SYMBOLS.md index 74e57aa..f1796e1 100644 --- a/docs/reference/variables/MAC_MODIFIER_SYMBOLS.md +++ b/docs/reference/variables/MAC_MODIFIER_SYMBOLS.md @@ -9,7 +9,7 @@ title: MAC_MODIFIER_SYMBOLS const MAC_MODIFIER_SYMBOLS: Record; ``` -Defined in: [constants.ts:468](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L468) +Defined in: [constants.ts:490](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L490) Modifier key symbols for macOS display. diff --git a/docs/reference/variables/PUNCTUATION_CODE_MAP.md b/docs/reference/variables/PUNCTUATION_CODE_MAP.md new file mode 100644 index 0000000..4cd67f9 --- /dev/null +++ b/docs/reference/variables/PUNCTUATION_CODE_MAP.md @@ -0,0 +1,20 @@ +--- +id: PUNCTUATION_CODE_MAP +title: PUNCTUATION_CODE_MAP +--- + +# Variable: PUNCTUATION\_CODE\_MAP + +```ts +const PUNCTUATION_CODE_MAP: Record; +``` + +Defined in: [constants.ts:312](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L312) + +Maps `KeyboardEvent.code` values for punctuation keys to their canonical characters. + +On macOS, holding the Option (Alt) key transforms punctuation keys into special characters +(e.g., Option+Minus → en-dash '–'), causing `event.key` to differ from the expected character. +However, `event.code` still reports the physical key (e.g., 'Minus'). This map enables +falling back to `event.code` for punctuation keys, similar to the existing `Key*`/`Digit*` +fallbacks for letters and digits. diff --git a/docs/reference/variables/STANDARD_MODIFIER_LABELS.md b/docs/reference/variables/STANDARD_MODIFIER_LABELS.md index 2b0e0f6..492587d 100644 --- a/docs/reference/variables/STANDARD_MODIFIER_LABELS.md +++ b/docs/reference/variables/STANDARD_MODIFIER_LABELS.md @@ -9,7 +9,7 @@ title: STANDARD_MODIFIER_LABELS const STANDARD_MODIFIER_LABELS: Record; ``` -Defined in: [constants.ts:490](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L490) +Defined in: [constants.ts:512](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L512) Modifier key labels for Windows/Linux display. diff --git a/packages/hotkeys/tests/match.test.ts b/packages/hotkeys/tests/match.test.ts index ece28cc..88f69dd 100644 --- a/packages/hotkeys/tests/match.test.ts +++ b/packages/hotkeys/tests/match.test.ts @@ -656,15 +656,15 @@ describe('matchesKeyboardEvent', () => { it('should fallback to event.code for all punctuation keys with Alt', () => { const cases: Array<[string, string, string]> = [ - ['–', 'Minus', '-'], // Option+- → en-dash - ['≠', 'Equal', '='], // Option+= → ≠ - ['÷', 'Slash', '/'], // Option+/ → ÷ - ['\u201c', 'BracketLeft', '['], // Option+[ → " + ['–', 'Minus', '-'], // Option+- → en-dash + ['≠', 'Equal', '='], // Option+= → ≠ + ['÷', 'Slash', '/'], // Option+/ → ÷ + ['\u201c', 'BracketLeft', '['], // Option+[ → " ['\u2018', 'BracketRight', ']'], // Option+] → ' ['«', 'Backslash', '\\'], // Option+\ → « - ['≤', 'Comma', ','], // Option+, → ≤ - ['≥', 'Period', '.'], // Option+. → ≥ - ['`', 'Backquote', '`'], // Option+` → ` (dead key on some layouts) + ['≤', 'Comma', ','], // Option+, → ≤ + ['≥', 'Period', '.'], // Option+. → ≥ + ['`', 'Backquote', '`'], // Option+` → ` (dead key on some layouts) ] for (const [composedChar, code, expectedKey] of cases) { @@ -685,7 +685,9 @@ describe('matchesKeyboardEvent', () => { metaKey: true, code: 'Minus', }) - expect(matchesKeyboardEvent(event, 'Mod+Alt+-' as Hotkey, 'mac')).toBe(true) + expect(matchesKeyboardEvent(event, 'Mod+Alt+-' as Hotkey, 'mac')).toBe( + true, + ) }) it('should still match punctuation keys directly without fallback', () => {