Skip to content
Open
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: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.

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:197](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L197)

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:119](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L119)

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:125](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L125)

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: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

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:123](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L123)

Stop event propagation when the hotkey matches. Defaults to true
24 changes: 24 additions & 0 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,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
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