diff --git a/.changeset/fix-dead-key-fallback.md b/.changeset/fix-dead-key-fallback.md new file mode 100644 index 0000000..cecfaf6 --- /dev/null +++ b/.changeset/fix-dead-key-fallback.md @@ -0,0 +1,11 @@ +--- +'@tanstack/hotkeys': patch +--- + +fix: handle dead keys in `matchesKeyboardEvent` + +When `event.key` is `'Dead'` (length 4), the existing `event.code` fallback—gated behind `eventKey.length === 1`—was never reached, causing hotkeys to silently fail. + +This most commonly affects macOS, where `Option+letter` combinations like `Option+E`, `Option+I`, `Option+U`, and `Option+N` produce dead keys for accent composition. It also affects Windows and Linux users with international keyboard layouts (e.g., US-International, German, French) where certain key combinations produce dead keys. + +Added an early check: when `event.key` normalizes to `'Dead'`, immediately fall back to `event.code` to extract the physical key via the `Key*`/`Digit*` prefixes. Punctuation dead keys (e.g., `'` on US-International, where `event.code` is `'Quote'`) correctly return `false` since their codes don't match letter or digit patterns. diff --git a/docs/reference/functions/createHotkeyHandler.md b/docs/reference/functions/createHotkeyHandler.md index 07142d7..8be1c8f 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:122](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L122) +Defined in: [match.ts:146](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L146) 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 5426f39..52a9d24 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:173](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L173) +Defined in: [match.ts:197](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L197) Creates a handler that matches multiple hotkeys. diff --git a/docs/reference/functions/matchesKeyboardEvent.md b/docs/reference/functions/matchesKeyboardEvent.md index 58c4682..c5cefed 100644 --- a/docs/reference/functions/matchesKeyboardEvent.md +++ b/docs/reference/functions/matchesKeyboardEvent.md @@ -12,7 +12,7 @@ function matchesKeyboardEvent( platform): boolean; ``` -Defined in: [match.ts:32](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L32) +Defined in: [match.ts:37](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L37) Checks if a KeyboardEvent matches a hotkey. @@ -20,6 +20,11 @@ Uses the `key` property from KeyboardEvent for matching, with a fallback to `cod for letter keys (A-Z) and digit keys (0-9) when `key` produces special characters (e.g., macOS Option+letter or Shift+number). 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, +Option+I, Option+U, Option+N) and on Windows/Linux with international keyboard layouts. +In these cases, `event.code` is used to determine the physical key. + ## Parameters ### event diff --git a/docs/reference/interfaces/CreateHotkeyHandlerOptions.md b/docs/reference/interfaces/CreateHotkeyHandlerOptions.md index a7b824d..afcd16a 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:95](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L95) +Defined in: [match.ts:119](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L119) 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:101](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L101) +Defined in: [match.ts:125](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L125) The target platform for resolving 'Mod' @@ -29,7 +29,7 @@ The target platform for resolving 'Mod' optional preventDefault: boolean; ``` -Defined in: [match.ts:97](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L97) +Defined in: [match.ts:121](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L121) 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:99](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L99) +Defined in: [match.ts:123](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L123) Stop event propagation when the hotkey matches. Defaults to true diff --git a/packages/hotkeys/src/match.ts b/packages/hotkeys/src/match.ts index cfc7972..412d884 100644 --- a/packages/hotkeys/src/match.ts +++ b/packages/hotkeys/src/match.ts @@ -14,6 +14,11 @@ import type { * for letter keys (A-Z) and digit keys (0-9) when `key` produces special characters * (e.g., macOS Option+letter or Shift+number). 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, + * Option+I, Option+U, Option+N) and on Windows/Linux with international keyboard layouts. + * In these cases, `event.code` is used to determine the physical key. + * * @param event - The KeyboardEvent to check * @param hotkey - The hotkey string or ParsedHotkey to match against * @param platform - The target platform for resolving 'Mod' (defaults to auto-detection) @@ -55,6 +60,25 @@ export function matchesKeyboardEvent( const eventKey = normalizeKeyName(event.key) const hotkeyKey = parsed.key + // Handle dead keys: certain modifier+letter combos produce event.key === 'Dead' + // (e.g., macOS Option+E, or international layouts on Windows/Linux). + // In this case, event.key is unusable but event.code still identifies the physical key. + if (eventKey === 'Dead') { + if (event.code && event.code.startsWith('Key')) { + const codeLetter = event.code.slice(3) + if (codeLetter.length === 1 && /^[A-Za-z]$/.test(codeLetter)) { + return codeLetter.toUpperCase() === hotkeyKey.toUpperCase() + } + } + if (event.code && event.code.startsWith('Digit')) { + const codeDigit = event.code.slice(5) + if (codeDigit.length === 1 && /^[0-9]$/.test(codeDigit)) { + return codeDigit === hotkeyKey + } + } + return false + } + // For single letters, compare case-insensitively if (eventKey.length === 1 && hotkeyKey.length === 1) { // First try matching with event.key diff --git a/packages/hotkeys/tests/match.test.ts b/packages/hotkeys/tests/match.test.ts index a37421a..7b33281 100644 --- a/packages/hotkeys/tests/match.test.ts +++ b/packages/hotkeys/tests/match.test.ts @@ -265,6 +265,89 @@ describe('matchesKeyboardEvent', () => { }) }) + describe('dead key fallback (macOS Option+letter)', () => { + it('should match Alt+E when event.key is Dead (macOS dead key for accent)', () => { + const event = createKeyboardEvent('Dead', { + altKey: true, + code: 'KeyE', + }) + expect(matchesKeyboardEvent(event, 'Alt+E')).toBe(true) + }) + + it('should match Alt+I when event.key is Dead', () => { + const event = createKeyboardEvent('Dead', { + altKey: true, + code: 'KeyI', + }) + expect(matchesKeyboardEvent(event, 'Alt+I')).toBe(true) + }) + + it('should match Alt+U when event.key is Dead', () => { + const event = createKeyboardEvent('Dead', { + altKey: true, + code: 'KeyU', + }) + expect(matchesKeyboardEvent(event, 'Alt+U')).toBe(true) + }) + + it('should match Alt+N when event.key is Dead', () => { + const event = createKeyboardEvent('Dead', { + altKey: true, + code: 'KeyN', + }) + expect(matchesKeyboardEvent(event, 'Alt+N')).toBe(true) + }) + + it('should match Mod+Alt with dead key on Mac', () => { + const event = createKeyboardEvent('Dead', { + altKey: true, + metaKey: true, + code: 'KeyE', + }) + expect(matchesKeyboardEvent(event, 'Mod+Alt+E', 'mac')).toBe(true) + }) + + it('should not match dead key when code does not match hotkey', () => { + const event = createKeyboardEvent('Dead', { + altKey: true, + code: 'KeyE', + }) + expect(matchesKeyboardEvent(event, 'Alt+T')).toBe(false) + }) + + it('should not match dead key when modifiers do not match', () => { + const event = createKeyboardEvent('Dead', { + altKey: true, + code: 'KeyE', + }) + expect(matchesKeyboardEvent(event, 'Control+E')).toBe(false) + }) + + it('should not match dead key when event.code is missing', () => { + const event = createKeyboardEvent('Dead', { + altKey: true, + code: undefined, + }) + expect(matchesKeyboardEvent(event, 'Alt+E')).toBe(false) + }) + + it('should not match dead key when event.code has invalid format', () => { + const event = createKeyboardEvent('Dead', { + altKey: true, + code: 'InvalidCode', + }) + expect(matchesKeyboardEvent(event, 'Alt+E')).toBe(false) + }) + + it('should handle dead key with digit code fallback', () => { + const event = createKeyboardEvent('Dead', { + altKey: true, + code: 'Digit4', + }) + expect(matchesKeyboardEvent(event, 'Alt+4')).toBe(true) + }) + }) + describe('event.code fallback for digit keys', () => { it('should fallback to event.code when event.key produces special character (Shift+4 -> $)', () => { // Simulate Shift+4 where event.key is '$' but event.code is 'Digit4'