Skip to content

Commit a7dce39

Browse files
committed
feat: support physical key (event.code) matching via matchBy option (#101)
1 parent 9041e8f commit a7dce39

5 files changed

Lines changed: 282 additions & 17 deletions

File tree

.changeset/feat-match-by-code.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@tanstack/hotkeys': minor
3+
---
4+
5+
feat: Add `matchBy` option for physical key (`event.code`) matching
6+
7+
Added a new `matchBy` option to `HotkeyOptions` that allows matching hotkeys by physical key position (`event.code`) instead of the default layout-aware matching (`event.key`). This enables hotkeys to work correctly when a non-Latin IME is active and `event.key` produces non-Latin characters.
8+
9+
```ts
10+
useHotkey('A', callback, { matchBy: 'code' })
11+
```

docs/framework/react/guides/hotkeys.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ useHotkey('Mod+S', callback, {
5353
target: document,
5454
platform: undefined, // auto-detected
5555
conflictBehavior: 'warn',
56+
matchBy: 'key',
5657
})
5758
```
5859

@@ -205,6 +206,32 @@ Override the auto-detected platform. Useful for testing or for applications that
205206
useHotkey('Mod+S', () => save(), { platform: 'mac' })
206207
```
207208

209+
### `matchBy`
210+
211+
Controls whether hotkeys match by `event.key` (layout-aware) or `event.code` (physical key position). Defaults to `'key'`.
212+
213+
Use `'code'` when a non-Latin IME is active and `event.key` produces non-Latin characters instead of the expected ASCII letter. For example, pressing the physical `A` key with a non-Latin IME produces a non-Latin character in `event.key`, but `event.code` still reports `'KeyA'`.
214+
215+
```tsx
216+
// Match by physical key position
217+
useHotkey('A', () => handleA(), { matchBy: 'code' })
218+
219+
// Works with modifiers
220+
useHotkey('Mod+S', () => save(), { matchBy: 'code' })
221+
222+
// Works with useHotkeys as a common option
223+
useHotkeys(
224+
[
225+
{ hotkey: 'Mod+S', callback: () => save() },
226+
{ hotkey: 'Mod+Z', callback: () => undo() },
227+
],
228+
{ matchBy: 'code' },
229+
)
230+
```
231+
232+
> [!NOTE]
233+
> `matchBy: 'code'` ignores the active keyboard layout. If your users rely on alternative Latin layouts (Dvorak, Colemak, AZERTY), keep the default `'key'` so shortcuts follow their remapped layout.
234+
208235
## Stale Closure Prevention
209236

210237
The `useHotkey` hook automatically syncs the callback on every render, so you never need to worry about stale closures:

packages/hotkeys/src/hotkey-manager.ts

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export interface HotkeyOptions {
5050
target?: HTMLElement | Document | Window | null
5151
/** Optional metadata (name, description, custom fields via declaration merging) */
5252
meta?: HotkeyMeta
53+
/** Whether to match by `event.key` (layout-aware, default) or `event.code` (physical key position). Use `'code'` when a non-Latin IME is active. Defaults to 'key' */
54+
matchBy?: 'key' | 'code'
5355
}
5456

5557
/**
@@ -514,6 +516,7 @@ export class HotkeyManager {
514516
event,
515517
registration.parsedHotkey,
516518
registration.options.platform,
519+
registration.options.matchBy,
517520
)
518521

519522
if (matches) {
@@ -545,6 +548,7 @@ export class HotkeyManager {
545548
event,
546549
registration.parsedHotkey,
547550
registration.options.platform,
551+
registration.options.matchBy,
548552
)
549553
) {
550554
this.#executeHotkeyCallback(registration, event)
@@ -637,19 +641,6 @@ export class HotkeyManager {
637641
const parsed = registration.parsedHotkey
638642
const releasedKey = normalizeKeyName(event.key)
639643

640-
// Reset if the main key is released
641-
// Compare case-insensitively for single-letter keys
642-
const parsedKeyNormalized =
643-
parsed.key.length === 1 ? parsed.key.toUpperCase() : parsed.key
644-
const releasedKeyNormalized =
645-
releasedKey.length === 1 ? releasedKey.toUpperCase() : releasedKey
646-
647-
if (releasedKeyNormalized === parsedKeyNormalized) {
648-
return true
649-
}
650-
651-
// Reset if any required modifier is released
652-
// Use normalized key names and check against canonical modifier names
653644
if (parsed.ctrl && releasedKey === 'Control') {
654645
return true
655646
}
@@ -663,6 +654,27 @@ export class HotkeyManager {
663654
return true
664655
}
665656

657+
// Reset if the main key is released
658+
if (registration.options.matchBy === 'code') {
659+
// For code-based matching, compare event.code against the expected physical key code
660+
return matchesKeyboardEvent(
661+
event,
662+
{ ...parsed, ctrl: false, shift: false, alt: false, meta: false, modifiers: [] },
663+
registration.options.platform,
664+
'code',
665+
)
666+
}
667+
668+
// Compare case-insensitively for single-letter keys
669+
const parsedKeyNormalized =
670+
parsed.key.length === 1 ? parsed.key.toUpperCase() : parsed.key
671+
const releasedKeyNormalized =
672+
releasedKey.length === 1 ? releasedKey.toUpperCase() : releasedKey
673+
674+
if (releasedKeyNormalized === parsedKeyNormalized) {
675+
return true
676+
}
677+
666678
return false
667679
}
668680

packages/hotkeys/src/match.ts

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,42 @@ import type {
1212
ParsedHotkey,
1313
} from './hotkey'
1414

15+
/**
16+
* Reverse mapping from punctuation characters to their `KeyboardEvent.code` values.
17+
* Built from `PUNCTUATION_CODE_MAP` for use with `matchBy: 'code'`.
18+
*/
19+
const KEY_TO_PUNCTUATION_CODE: Record<string, string> = {}
20+
for (const [code, key] of Object.entries(PUNCTUATION_CODE_MAP)) {
21+
KEY_TO_PUNCTUATION_CODE[key] = code
22+
}
23+
24+
/**
25+
* Converts a hotkey key name to its expected `KeyboardEvent.code` value.
26+
*
27+
* Used when `matchBy: 'code'` to compare against the physical key position
28+
* rather than the character produced by the current keyboard layout/IME.
29+
*
30+
* @param key - The normalized hotkey key name (e.g., 'A', '4', '-', 'Escape')
31+
* @returns The expected `event.code` value, or the key itself for special keys
32+
*/
33+
function keyToCode(key: string): string {
34+
// Letter keys: A → KeyA
35+
if (/^[A-Za-z]$/.test(key)) {
36+
return `Key${key.toUpperCase()}`
37+
}
38+
// Digit keys: 4 → Digit4
39+
if (/^[0-9]$/.test(key)) {
40+
return `Digit${key}`
41+
}
42+
// Punctuation keys: - → Minus, / → Slash, etc.
43+
if (key in KEY_TO_PUNCTUATION_CODE) {
44+
return KEY_TO_PUNCTUATION_CODE[key]!
45+
}
46+
// Special keys (Escape, Enter, Space, Tab, F1, ArrowUp, etc.)
47+
// Their event.code matches the key name
48+
return key
49+
}
50+
1551
/**
1652
* Checks if a KeyboardEvent matches a hotkey.
1753
*
@@ -28,6 +64,7 @@ import type {
2864
* @param event - The KeyboardEvent to check
2965
* @param hotkey - The hotkey string or ParsedHotkey to match against
3066
* @param platform - The target platform for resolving 'Mod' (defaults to auto-detection)
67+
* @param matchBy - How to match: 'key' (layout-aware, default) or 'code' (physical key position)
3168
* @returns True if the event matches the hotkey
3269
*
3370
* @example
@@ -37,13 +74,19 @@ import type {
3774
* event.preventDefault()
3875
* handleSave()
3976
* }
77+
*
78+
* // Physical key matching for non-Latin IME
79+
* if (matchesKeyboardEvent(event, 'A', undefined, 'code')) {
80+
* handleA() // Works even when a non-Latin IME is active
81+
* }
4082
* })
4183
* ```
4284
*/
4385
export function matchesKeyboardEvent(
4486
event: KeyboardEvent,
4587
hotkey: Hotkey | ParsedHotkey,
4688
platform: 'mac' | 'windows' | 'linux' = detectPlatform(),
89+
matchBy: 'key' | 'code' = 'key',
4790
): boolean {
4891
const parsed =
4992
typeof hotkey === 'string' ? parseHotkey(hotkey, platform) : hotkey
@@ -62,6 +105,17 @@ export function matchesKeyboardEvent(
62105
return false
63106
}
64107

108+
// When matchBy is 'code', compare against event.code (physical key position)
109+
// instead of event.key. Useful when a non-Latin IME is active and
110+
// event.key produces non-Latin characters.
111+
if (matchBy === 'code') {
112+
if (!event.code) {
113+
return false
114+
}
115+
const expectedCode = keyToCode(parsed.key)
116+
return event.code === expectedCode
117+
}
118+
65119
// Check key (case-insensitive for letters)
66120
const eventKey = normalizeKeyName(event.key)
67121
const hotkeyKey = parsed.key
@@ -136,6 +190,8 @@ export interface CreateHotkeyHandlerOptions {
136190
stopPropagation?: boolean
137191
/** The target platform for resolving 'Mod' */
138192
platform?: 'mac' | 'windows' | 'linux'
193+
/** How to match: 'key' (layout-aware, default) or 'code' (physical key position) */
194+
matchBy?: 'key' | 'code'
139195
}
140196

141197
/**
@@ -161,7 +217,12 @@ export function createHotkeyHandler(
161217
callback: HotkeyCallback,
162218
options: CreateHotkeyHandlerOptions = {},
163219
): (event: KeyboardEvent) => void {
164-
const { preventDefault = true, stopPropagation = true, platform } = options
220+
const {
221+
preventDefault = true,
222+
stopPropagation = true,
223+
platform,
224+
matchBy,
225+
} = options
165226
const resolvedPlatform = platform ?? detectPlatform()
166227

167228
const hotkeyString: Hotkey =
@@ -175,7 +236,7 @@ export function createHotkeyHandler(
175236
}
176237

177238
return (event: KeyboardEvent) => {
178-
if (matchesKeyboardEvent(event, parsed, resolvedPlatform)) {
239+
if (matchesKeyboardEvent(event, parsed, resolvedPlatform, matchBy)) {
179240
if (preventDefault) {
180241
event.preventDefault()
181242
}
@@ -211,7 +272,12 @@ export function createMultiHotkeyHandler(
211272
handlers: MultiHotkeyHandler,
212273
options: CreateHotkeyHandlerOptions = {},
213274
): (event: KeyboardEvent) => void {
214-
const { preventDefault = true, stopPropagation = true, platform } = options
275+
const {
276+
preventDefault = true,
277+
stopPropagation = true,
278+
platform,
279+
matchBy,
280+
} = options
215281
const resolvedPlatform = platform ?? detectPlatform()
216282

217283
// Pre-parse all hotkeys for efficiency
@@ -228,7 +294,7 @@ export function createMultiHotkeyHandler(
228294

229295
return (event: KeyboardEvent) => {
230296
for (const { parsed, handler, context } of parsedHandlers) {
231-
if (matchesKeyboardEvent(event, parsed, resolvedPlatform)) {
297+
if (matchesKeyboardEvent(event, parsed, resolvedPlatform, matchBy)) {
232298
if (preventDefault) {
233299
event.preventDefault()
234300
}

0 commit comments

Comments
 (0)