Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/fix-dead-key-fallback.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/reference/functions/createHotkeyHandler.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:128](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L128)

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

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/functions/createMultiHotkeyHandler.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:179](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L179)

Creates a handler that matches multiple hotkeys.

Expand Down
7 changes: 6 additions & 1 deletion docs/reference/functions/matchesKeyboardEvent.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,19 @@ 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.

Uses the `key` property from KeyboardEvent for matching, with a fallback to `code`
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
Expand Down
8 changes: 4 additions & 4 deletions docs/reference/interfaces/CreateHotkeyHandlerOptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:101](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L101)

Options for creating a hotkey handler.

Expand All @@ -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:107](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L107)

The target platform for resolving 'Mod'

Expand All @@ -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:103](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L103)

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

Expand All @@ -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:105](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L105)

Stop event propagation when the hotkey matches. Defaults to true
36 changes: 21 additions & 15 deletions packages/hotkeys/src/match.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -55,33 +60,34 @@ export function matchesKeyboardEvent(
const eventKey = normalizeKeyName(event.key)
const hotkeyKey = parsed.key

// For single letters, compare case-insensitively
if (eventKey.length === 1 && hotkeyKey.length === 1) {
// First try matching with event.key
// For single-character keys (not dead keys), try direct event.key match first
if (eventKey !== 'Dead' && eventKey.length === 1 && hotkeyKey.length === 1) {
if (eventKey.toUpperCase() === hotkeyKey.toUpperCase()) {
return true
}
}

// Fallback to event.code for letter keys when event.key doesn't match
// This handles cases like Command+Option+T on macOS where event.key is '†' instead of 'T'
// event.code format for letter keys is "KeyA", "KeyB", etc. (always uppercase in browsers)
if (event.code && event.code.startsWith('Key')) {
const codeLetter = event.code.slice(3) // Remove "Key" prefix
// Fallback to event.code for dead keys or single-char mismatches.
// Dead keys: Option+letter on macOS, international layouts produce event.key === 'Dead'
// Single-char mismatches: Cmd+Option+T gives '†' instead of 'T', Shift+4 gives '$'
if (
eventKey === 'Dead' ||
(eventKey.length === 1 && hotkeyKey.length === 1)
) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- event.code can be undefined in older browsers/mobile
if (event.code?.startsWith('Key')) {
const codeLetter = event.code.slice(3)
if (codeLetter.length === 1 && /^[A-Za-z]$/.test(codeLetter)) {
return codeLetter.toUpperCase() === hotkeyKey.toUpperCase()
}
}

// Fallback to event.code for digit keys when event.key doesn't match
// This handles cases like Shift+4 where event.key is '$' instead of '4'
// event.code format for digit keys is "Digit0", "Digit1", etc.
if (event.code && event.code.startsWith('Digit')) {
const codeDigit = event.code.slice(5) // Remove "Digit" prefix
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- event.code can be undefined in older browsers/mobile
if (event.code?.startsWith('Digit')) {
const codeDigit = event.code.slice(5)
if (codeDigit.length === 1 && /^[0-9]$/.test(codeDigit)) {
return codeDigit === hotkeyKey
}
}

return false
}

Expand Down
83 changes: 83 additions & 0 deletions packages/hotkeys/tests/match.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading