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
7 changes: 7 additions & 0 deletions .changeset/fix-keyboard-layout-code-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/hotkeys': patch
---

fix: respect keyboard layout in event.code fallback for non-QWERTY layouts

The `matchesKeyboardEvent` function's `event.code` fallback now only activates when `event.key` is not a standard ASCII letter. Previously, the fallback would match based on physical key position even when `event.key` was a valid letter from a non-QWERTY layout (Dvorak, Colemak, AZERTY, etc.), causing hotkeys to trigger on wrong key presses.
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:128](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L128)
Defined in: [match.ts:142](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L142)

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:179](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L179)
Defined in: [match.ts:193](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L193)

Creates a handler that matches multiple hotkeys.

Expand Down
47 changes: 47 additions & 0 deletions docs/reference/functions/isSingleLetterKey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
id: isSingleLetterKey
title: isSingleLetterKey
---

# Function: isSingleLetterKey()

```ts
function isSingleLetterKey(key): boolean;
```

Defined in: [constants.ts:422](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L422)

Normalizes a key name to its canonical form.

Converts various key name formats (aliases, case variations) into the standard
canonical names used throughout the library. This enables a more forgiving API
where users can write keys in different ways and still get correct behavior.

Normalization rules:
1. Check aliases first (e.g., 'Esc' → 'Escape', 'Del' → 'Delete')
2. Single letters → uppercase (e.g., 'a' → 'A', 's' → 'S')
3. Function keys → uppercase (e.g., 'f1' → 'F1', 'F12' → 'F12')
4. Other keys → returned as-is (already canonical or unknown)

## Parameters

### key

`string`

The key name to normalize (can be an alias, lowercase, etc.)

## Returns

`boolean`

The canonical key name

## Example

```ts
normalizeKeyName('esc') // 'Escape'
normalizeKeyName('a') // 'A'
normalizeKeyName('f1') // 'F1'
normalizeKeyName('ArrowUp') // 'ArrowUp' (already canonical)
```
4 changes: 2 additions & 2 deletions docs/reference/functions/matchesKeyboardEvent.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ function matchesKeyboardEvent(
platform): boolean;
```

Defined in: [match.ts:37](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L37)
Defined in: [match.ts:41](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L41)

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
for letter keys 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
Expand Down
27 changes: 1 addition & 26 deletions docs/reference/functions/normalizeKeyName.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,39 +9,14 @@ title: normalizeKeyName
function normalizeKeyName(key): string;
```

Defined in: [constants.ts:422](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L422)

Normalizes a key name to its canonical form.

Converts various key name formats (aliases, case variations) into the standard
canonical names used throughout the library. This enables a more forgiving API
where users can write keys in different ways and still get correct behavior.

Normalization rules:
1. Check aliases first (e.g., 'Esc' → 'Escape', 'Del' → 'Delete')
2. Single letters → uppercase (e.g., 'a' → 'A', 's' → 'S')
3. Function keys → uppercase (e.g., 'f1' → 'F1', 'F12' → 'F12')
4. Other keys → returned as-is (already canonical or unknown)
Defined in: [constants.ts:426](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L426)

## Parameters

### key

`string`

The key name to normalize (can be an alias, lowercase, etc.)

## Returns

`string`

The canonical key name

## Example

```ts
normalizeKeyName('esc') // 'Escape'
normalizeKeyName('a') // 'A'
normalizeKeyName('f1') // 'F1'
normalizeKeyName('ArrowUp') // 'ArrowUp' (already canonical)
```
1 change: 1 addition & 0 deletions docs/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ title: "@tanstack/hotkeys"
- [hasNonModifierKey](functions/hasNonModifierKey.md)
- [isModifier](functions/isModifier.md)
- [isModifierKey](functions/isModifierKey.md)
- [isSingleLetterKey](functions/isSingleLetterKey.md)
- [keyboardEventToHotkey](functions/keyboardEventToHotkey.md)
- [matchesKeyboardEvent](functions/matchesKeyboardEvent.md)
- [normalizeHotkey](functions/normalizeHotkey.md)
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:101](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L101)
Defined in: [match.ts:115](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L115)

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:107](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L107)
Defined in: [match.ts:121](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L121)

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:103](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L103)
Defined in: [match.ts:117](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L117)

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

Stop event propagation when the hotkey matches. Defaults to true
2 changes: 1 addition & 1 deletion docs/reference/variables/KEY_DISPLAY_SYMBOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ title: KEY_DISPLAY_SYMBOLS
const KEY_DISPLAY_SYMBOLS: Record<string, string>;
```

Defined in: [constants.ts:505](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L505)
Defined in: [constants.ts:509](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L509)

Special key symbols for display formatting.

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/variables/MAC_MODIFIER_SYMBOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ title: MAC_MODIFIER_SYMBOLS
const MAC_MODIFIER_SYMBOLS: Record<CanonicalModifier, string>;
```

Defined in: [constants.ts:461](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L461)
Defined in: [constants.ts:465](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L465)

Modifier key symbols for macOS display.

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/variables/STANDARD_MODIFIER_LABELS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ title: STANDARD_MODIFIER_LABELS
const STANDARD_MODIFIER_LABELS: Record<CanonicalModifier, string>;
```

Defined in: [constants.ts:483](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L483)
Defined in: [constants.ts:487](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L487)

Modifier key labels for Windows/Linux display.

Expand Down
6 changes: 5 additions & 1 deletion packages/hotkeys/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,14 +419,18 @@ const KEY_ALIASES: Record<string, string> = {
* normalizeKeyName('ArrowUp') // 'ArrowUp' (already canonical)
* ```
*/
export function isSingleLetterKey(key: string): boolean {
return /^\p{Letter}$/u.test(key)
}

export function normalizeKeyName(key: string): string {
// Check aliases first
if (key in KEY_ALIASES) {
return KEY_ALIASES[key]!
}

// Check if it's a single letter (normalize to uppercase)
if (key.length === 1 && /^[a-zA-Z]$/.test(key)) {
if (isSingleLetterKey(key)) {
return key.toUpperCase()
}

Expand Down
20 changes: 17 additions & 3 deletions packages/hotkeys/src/match.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { detectPlatform, normalizeKeyName } from './constants'
import {
detectPlatform,
isSingleLetterKey,
normalizeKeyName,
} from './constants'
import { parseHotkey } from './parse'
import type {
Hotkey,
Expand All @@ -11,7 +15,7 @@ import type {
* 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
* for letter keys 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
Expand Down Expand Up @@ -65,9 +69,19 @@ export function matchesKeyboardEvent(
if (eventKey.toUpperCase() === hotkeyKey.toUpperCase()) {
return true
}

// If event.key is already a letter, trust the keyboard layout.
// Do NOT fall through to the event.code fallback, which matches based on
// physical key position and would break non-QWERTY layouts (Dvorak, Colemak,
// AZERTY, etc.). The code fallback is only needed when event.key produces a
// non-letter character (e.g., '†' from Option+T on macOS).
if (isSingleLetterKey(eventKey)) {
return false
}
}

// Fallback to event.code for dead keys or single-char mismatches.
// Fallback to event.code for dead keys or single-char mismatches where
// event.key is a non-letter special character.
// 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 (
Expand Down
72 changes: 72 additions & 0 deletions packages/hotkeys/tests/match.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,78 @@ describe('matchesKeyboardEvent', () => {
})
})

describe('non-QWERTY keyboard layout handling', () => {
it('should NOT match Mod+B when Dvorak layout produces event.key="x" on physical B key', () => {
// Dvorak: physical B position produces 'x'. With macOS "Use keyboard layout
// for shortcuts" enabled, Cmd+physical-B gives event.key='x', event.code='KeyB'.
// The library should trust event.key ('x') and NOT fall back to event.code ('KeyB').
const event = createKeyboardEvent('x', {
metaKey: true,
code: 'KeyB',
})
expect(matchesKeyboardEvent(event, 'Mod+B', 'mac')).toBe(false)
})

it('should match Mod+X when Dvorak layout produces event.key="x" on physical B key', () => {
// Same physical key press as above, but hotkey is Mod+X which matches event.key
const event = createKeyboardEvent('x', {
metaKey: true,
code: 'KeyB',
})
expect(matchesKeyboardEvent(event, 'Mod+X', 'mac')).toBe(true)
})

it('should NOT match Mod+A when Colemak layout produces event.key="r" on physical A-row key', () => {
// Colemak: event.key reflects the layout, event.code reflects physical position
const event = createKeyboardEvent('r', {
metaKey: true,
code: 'KeyS',
})
expect(matchesKeyboardEvent(event, 'Mod+S', 'mac')).toBe(false)
})

it('should NOT match Mod+Q when AZERTY layout produces event.key="a" on physical Q key', () => {
// AZERTY: physical Q position produces 'a'
const event = createKeyboardEvent('a', {
ctrlKey: true,
code: 'KeyQ',
})
expect(matchesKeyboardEvent(event, 'Control+Q', 'windows')).toBe(false)
})

it('should still fall back to event.code when event.key is a non-letter character', () => {
// Option+T on macOS producing '†' should still fall back to event.code
const event = createKeyboardEvent('†', {
altKey: true,
metaKey: true,
code: 'KeyT',
})
expect(matchesKeyboardEvent(event, 'Mod+Alt+T', 'mac')).toBe(true)
})

it('should NOT match Control+A when a non-ASCII letter comes from a different physical key', () => {
// Russian layout: event.key reflects the logical key, event.code the physical one.
const event = createKeyboardEvent('ф', {
ctrlKey: true,
code: 'KeyA',
})
expect(matchesKeyboardEvent(event, 'Control+A', 'windows')).toBe(false)
})

it('should match a non-ASCII hotkey string case-insensitively', () => {
const event = createKeyboardEvent('ф', {
ctrlKey: true,
code: 'KeyA',
})
expect(
matchesKeyboardEvent(event, 'Control+ф' as Hotkey, 'windows'),
).toBe(true)
expect(
matchesKeyboardEvent(event, 'Control+Ф' as Hotkey, 'windows'),
).toBe(true)
})
})

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', {
Expand Down
Loading