Skip to content

Commit 762cabf

Browse files
ntilwallicursoragentautofix-ci[bot]claudeKevinVandy
authored
fix: handle dead keys in matchesKeyboardEvent (#40)
* 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. Adds 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., apostrophe on US-International, where event.code is 'Quote') correctly return false since their codes don't match letter or digit patterns. Includes 10 tests covering dead key scenarios for letter keys, digit keys, modifier combinations, mismatches, and missing/invalid codes. Co-authored-by: Cursor <cursoragent@cursor.com> * ci: apply automated fixes * refactor: deduplicate event.code fallback for dead keys and single-char mismatches Address review feedback by consolidating the dead key handler and the existing single-character event.code fallback into a single code path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: apply automated fixes * eslint ignore for event.code null * ci: apply automated fixes --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Kevin Van Cott <kevinvandy656@gmail.com>
1 parent e48ff52 commit 762cabf

7 files changed

Lines changed: 127 additions & 22 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@tanstack/hotkeys': patch
3+
---
4+
5+
fix: handle dead keys in `matchesKeyboardEvent`
6+
7+
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.
8+
9+
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.
10+
11+
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.

docs/reference/functions/createHotkeyHandler.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ function createHotkeyHandler(
1212
options): (event) => void;
1313
```
1414

15-
Defined in: [match.ts:122](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L122)
15+
Defined in: [match.ts:128](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L128)
1616

1717
Creates a keyboard event handler that calls the callback when the hotkey matches.
1818

docs/reference/functions/createMultiHotkeyHandler.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ title: createMultiHotkeyHandler
99
function createMultiHotkeyHandler(handlers, options): (event) => void;
1010
```
1111

12-
Defined in: [match.ts:173](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L173)
12+
Defined in: [match.ts:179](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L179)
1313

1414
Creates a handler that matches multiple hotkeys.
1515

docs/reference/functions/matchesKeyboardEvent.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,19 @@ function matchesKeyboardEvent(
1212
platform): boolean;
1313
```
1414

15-
Defined in: [match.ts:32](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L32)
15+
Defined in: [match.ts:37](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L37)
1616

1717
Checks if a KeyboardEvent matches a hotkey.
1818

1919
Uses the `key` property from KeyboardEvent for matching, with a fallback to `code`
2020
for letter keys (A-Z) and digit keys (0-9) when `key` produces special characters
2121
(e.g., macOS Option+letter or Shift+number). Letter keys are matched case-insensitively.
2222

23+
Also handles "dead key" events where `event.key` is `'Dead'` instead of the expected
24+
character. This commonly occurs on macOS with Option+letter combinations (e.g., Option+E,
25+
Option+I, Option+U, Option+N) and on Windows/Linux with international keyboard layouts.
26+
In these cases, `event.code` is used to determine the physical key.
27+
2328
## Parameters
2429

2530
### event

docs/reference/interfaces/CreateHotkeyHandlerOptions.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ title: CreateHotkeyHandlerOptions
55

66
# Interface: CreateHotkeyHandlerOptions
77

8-
Defined in: [match.ts:95](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L95)
8+
Defined in: [match.ts:101](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L101)
99

1010
Options for creating a hotkey handler.
1111

@@ -17,7 +17,7 @@ Options for creating a hotkey handler.
1717
optional platform: "mac" | "windows" | "linux";
1818
```
1919

20-
Defined in: [match.ts:101](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L101)
20+
Defined in: [match.ts:107](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L107)
2121

2222
The target platform for resolving 'Mod'
2323

@@ -29,7 +29,7 @@ The target platform for resolving 'Mod'
2929
optional preventDefault: boolean;
3030
```
3131

32-
Defined in: [match.ts:97](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L97)
32+
Defined in: [match.ts:103](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L103)
3333

3434
Prevent the default browser action when the hotkey matches. Defaults to true
3535

@@ -41,6 +41,6 @@ Prevent the default browser action when the hotkey matches. Defaults to true
4141
optional stopPropagation: boolean;
4242
```
4343

44-
Defined in: [match.ts:99](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L99)
44+
Defined in: [match.ts:105](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L105)
4545

4646
Stop event propagation when the hotkey matches. Defaults to true

packages/hotkeys/src/match.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ import type {
1414
* for letter keys (A-Z) and digit keys (0-9) when `key` produces special characters
1515
* (e.g., macOS Option+letter or Shift+number). Letter keys are matched case-insensitively.
1616
*
17+
* Also handles "dead key" events where `event.key` is `'Dead'` instead of the expected
18+
* character. This commonly occurs on macOS with Option+letter combinations (e.g., Option+E,
19+
* Option+I, Option+U, Option+N) and on Windows/Linux with international keyboard layouts.
20+
* In these cases, `event.code` is used to determine the physical key.
21+
*
1722
* @param event - The KeyboardEvent to check
1823
* @param hotkey - The hotkey string or ParsedHotkey to match against
1924
* @param platform - The target platform for resolving 'Mod' (defaults to auto-detection)
@@ -55,33 +60,34 @@ export function matchesKeyboardEvent(
5560
const eventKey = normalizeKeyName(event.key)
5661
const hotkeyKey = parsed.key
5762

58-
// For single letters, compare case-insensitively
59-
if (eventKey.length === 1 && hotkeyKey.length === 1) {
60-
// First try matching with event.key
63+
// For single-character keys (not dead keys), try direct event.key match first
64+
if (eventKey !== 'Dead' && eventKey.length === 1 && hotkeyKey.length === 1) {
6165
if (eventKey.toUpperCase() === hotkeyKey.toUpperCase()) {
6266
return true
6367
}
68+
}
6469

65-
// Fallback to event.code for letter keys when event.key doesn't match
66-
// This handles cases like Command+Option+T on macOS where event.key is '†' instead of 'T'
67-
// event.code format for letter keys is "KeyA", "KeyB", etc. (always uppercase in browsers)
68-
if (event.code && event.code.startsWith('Key')) {
69-
const codeLetter = event.code.slice(3) // Remove "Key" prefix
70+
// Fallback to event.code for dead keys or single-char mismatches.
71+
// Dead keys: Option+letter on macOS, international layouts produce event.key === 'Dead'
72+
// Single-char mismatches: Cmd+Option+T gives '†' instead of 'T', Shift+4 gives '$'
73+
if (
74+
eventKey === 'Dead' ||
75+
(eventKey.length === 1 && hotkeyKey.length === 1)
76+
) {
77+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- event.code can be undefined in older browsers/mobile
78+
if (event.code?.startsWith('Key')) {
79+
const codeLetter = event.code.slice(3)
7080
if (codeLetter.length === 1 && /^[A-Za-z]$/.test(codeLetter)) {
7181
return codeLetter.toUpperCase() === hotkeyKey.toUpperCase()
7282
}
7383
}
74-
75-
// Fallback to event.code for digit keys when event.key doesn't match
76-
// This handles cases like Shift+4 where event.key is '$' instead of '4'
77-
// event.code format for digit keys is "Digit0", "Digit1", etc.
78-
if (event.code && event.code.startsWith('Digit')) {
79-
const codeDigit = event.code.slice(5) // Remove "Digit" prefix
84+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- event.code can be undefined in older browsers/mobile
85+
if (event.code?.startsWith('Digit')) {
86+
const codeDigit = event.code.slice(5)
8087
if (codeDigit.length === 1 && /^[0-9]$/.test(codeDigit)) {
8188
return codeDigit === hotkeyKey
8289
}
8390
}
84-
8591
return false
8692
}
8793

packages/hotkeys/tests/match.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,89 @@ describe('matchesKeyboardEvent', () => {
265265
})
266266
})
267267

268+
describe('dead key fallback (macOS Option+letter)', () => {
269+
it('should match Alt+E when event.key is Dead (macOS dead key for accent)', () => {
270+
const event = createKeyboardEvent('Dead', {
271+
altKey: true,
272+
code: 'KeyE',
273+
})
274+
expect(matchesKeyboardEvent(event, 'Alt+E')).toBe(true)
275+
})
276+
277+
it('should match Alt+I when event.key is Dead', () => {
278+
const event = createKeyboardEvent('Dead', {
279+
altKey: true,
280+
code: 'KeyI',
281+
})
282+
expect(matchesKeyboardEvent(event, 'Alt+I')).toBe(true)
283+
})
284+
285+
it('should match Alt+U when event.key is Dead', () => {
286+
const event = createKeyboardEvent('Dead', {
287+
altKey: true,
288+
code: 'KeyU',
289+
})
290+
expect(matchesKeyboardEvent(event, 'Alt+U')).toBe(true)
291+
})
292+
293+
it('should match Alt+N when event.key is Dead', () => {
294+
const event = createKeyboardEvent('Dead', {
295+
altKey: true,
296+
code: 'KeyN',
297+
})
298+
expect(matchesKeyboardEvent(event, 'Alt+N')).toBe(true)
299+
})
300+
301+
it('should match Mod+Alt with dead key on Mac', () => {
302+
const event = createKeyboardEvent('Dead', {
303+
altKey: true,
304+
metaKey: true,
305+
code: 'KeyE',
306+
})
307+
expect(matchesKeyboardEvent(event, 'Mod+Alt+E', 'mac')).toBe(true)
308+
})
309+
310+
it('should not match dead key when code does not match hotkey', () => {
311+
const event = createKeyboardEvent('Dead', {
312+
altKey: true,
313+
code: 'KeyE',
314+
})
315+
expect(matchesKeyboardEvent(event, 'Alt+T')).toBe(false)
316+
})
317+
318+
it('should not match dead key when modifiers do not match', () => {
319+
const event = createKeyboardEvent('Dead', {
320+
altKey: true,
321+
code: 'KeyE',
322+
})
323+
expect(matchesKeyboardEvent(event, 'Control+E')).toBe(false)
324+
})
325+
326+
it('should not match dead key when event.code is missing', () => {
327+
const event = createKeyboardEvent('Dead', {
328+
altKey: true,
329+
code: undefined,
330+
})
331+
expect(matchesKeyboardEvent(event, 'Alt+E')).toBe(false)
332+
})
333+
334+
it('should not match dead key when event.code has invalid format', () => {
335+
const event = createKeyboardEvent('Dead', {
336+
altKey: true,
337+
code: 'InvalidCode',
338+
})
339+
expect(matchesKeyboardEvent(event, 'Alt+E')).toBe(false)
340+
})
341+
342+
it('should handle dead key with digit code fallback', () => {
343+
const event = createKeyboardEvent('Dead', {
344+
altKey: true,
345+
code: 'Digit4',
346+
})
347+
expect(matchesKeyboardEvent(event, 'Alt+4')).toBe(true)
348+
})
349+
})
350+
268351
describe('event.code fallback for digit keys', () => {
269352
it('should fallback to event.code when event.key produces special character (Shift+4 -> $)', () => {
270353
// Simulate Shift+4 where event.key is '$' but event.code is 'Digit4'

0 commit comments

Comments
 (0)