Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.
5 changes: 5 additions & 0 deletions docs/reference/functions/matchesKeyboardEvent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's now some redundant code here. I think it can be refactored a bit. I'll check it out tonight.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, thanks for pointing that out. Fixed.

// 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
Loading