From a7dce39a8389c4dd2b3b51f738bc0831ffa39397 Mon Sep 17 00:00:00 2001 From: HwangSB Date: Wed, 1 Apr 2026 15:34:59 +0900 Subject: [PATCH] feat: support physical key (event.code) matching via matchBy option (#101) --- .changeset/feat-match-by-code.md | 11 ++ docs/framework/react/guides/hotkeys.md | 27 +++++ packages/hotkeys/src/hotkey-manager.ts | 38 ++++--- packages/hotkeys/src/match.ts | 74 +++++++++++- packages/hotkeys/tests/match.test.ts | 149 +++++++++++++++++++++++++ 5 files changed, 282 insertions(+), 17 deletions(-) create mode 100644 .changeset/feat-match-by-code.md diff --git a/.changeset/feat-match-by-code.md b/.changeset/feat-match-by-code.md new file mode 100644 index 00000000..928013ca --- /dev/null +++ b/.changeset/feat-match-by-code.md @@ -0,0 +1,11 @@ +--- +'@tanstack/hotkeys': minor +--- + +feat: Add `matchBy` option for physical key (`event.code`) matching + +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. + +```ts +useHotkey('A', callback, { matchBy: 'code' }) +``` diff --git a/docs/framework/react/guides/hotkeys.md b/docs/framework/react/guides/hotkeys.md index fc13dc33..bea1c949 100644 --- a/docs/framework/react/guides/hotkeys.md +++ b/docs/framework/react/guides/hotkeys.md @@ -53,6 +53,7 @@ useHotkey('Mod+S', callback, { target: document, platform: undefined, // auto-detected conflictBehavior: 'warn', + matchBy: 'key', }) ``` @@ -205,6 +206,32 @@ Override the auto-detected platform. Useful for testing or for applications that useHotkey('Mod+S', () => save(), { platform: 'mac' }) ``` +### `matchBy` + +Controls whether hotkeys match by `event.key` (layout-aware) or `event.code` (physical key position). Defaults to `'key'`. + +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'`. + +```tsx +// Match by physical key position +useHotkey('A', () => handleA(), { matchBy: 'code' }) + +// Works with modifiers +useHotkey('Mod+S', () => save(), { matchBy: 'code' }) + +// Works with useHotkeys as a common option +useHotkeys( + [ + { hotkey: 'Mod+S', callback: () => save() }, + { hotkey: 'Mod+Z', callback: () => undo() }, + ], + { matchBy: 'code' }, +) +``` + +> [!NOTE] +> `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. + ## Stale Closure Prevention The `useHotkey` hook automatically syncs the callback on every render, so you never need to worry about stale closures: diff --git a/packages/hotkeys/src/hotkey-manager.ts b/packages/hotkeys/src/hotkey-manager.ts index 5d78a792..b20bb5a2 100644 --- a/packages/hotkeys/src/hotkey-manager.ts +++ b/packages/hotkeys/src/hotkey-manager.ts @@ -50,6 +50,8 @@ export interface HotkeyOptions { target?: HTMLElement | Document | Window | null /** Optional metadata (name, description, custom fields via declaration merging) */ meta?: HotkeyMeta + /** 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' */ + matchBy?: 'key' | 'code' } /** @@ -514,6 +516,7 @@ export class HotkeyManager { event, registration.parsedHotkey, registration.options.platform, + registration.options.matchBy, ) if (matches) { @@ -545,6 +548,7 @@ export class HotkeyManager { event, registration.parsedHotkey, registration.options.platform, + registration.options.matchBy, ) ) { this.#executeHotkeyCallback(registration, event) @@ -637,19 +641,6 @@ export class HotkeyManager { const parsed = registration.parsedHotkey const releasedKey = normalizeKeyName(event.key) - // Reset if the main key is released - // Compare case-insensitively for single-letter keys - const parsedKeyNormalized = - parsed.key.length === 1 ? parsed.key.toUpperCase() : parsed.key - const releasedKeyNormalized = - releasedKey.length === 1 ? releasedKey.toUpperCase() : releasedKey - - if (releasedKeyNormalized === parsedKeyNormalized) { - return true - } - - // Reset if any required modifier is released - // Use normalized key names and check against canonical modifier names if (parsed.ctrl && releasedKey === 'Control') { return true } @@ -663,6 +654,27 @@ export class HotkeyManager { return true } + // Reset if the main key is released + if (registration.options.matchBy === 'code') { + // For code-based matching, compare event.code against the expected physical key code + return matchesKeyboardEvent( + event, + { ...parsed, ctrl: false, shift: false, alt: false, meta: false, modifiers: [] }, + registration.options.platform, + 'code', + ) + } + + // Compare case-insensitively for single-letter keys + const parsedKeyNormalized = + parsed.key.length === 1 ? parsed.key.toUpperCase() : parsed.key + const releasedKeyNormalized = + releasedKey.length === 1 ? releasedKey.toUpperCase() : releasedKey + + if (releasedKeyNormalized === parsedKeyNormalized) { + return true + } + return false } diff --git a/packages/hotkeys/src/match.ts b/packages/hotkeys/src/match.ts index 46b61f1d..098680e8 100644 --- a/packages/hotkeys/src/match.ts +++ b/packages/hotkeys/src/match.ts @@ -12,6 +12,42 @@ import type { ParsedHotkey, } from './hotkey' +/** + * Reverse mapping from punctuation characters to their `KeyboardEvent.code` values. + * Built from `PUNCTUATION_CODE_MAP` for use with `matchBy: 'code'`. + */ +const KEY_TO_PUNCTUATION_CODE: Record = {} +for (const [code, key] of Object.entries(PUNCTUATION_CODE_MAP)) { + KEY_TO_PUNCTUATION_CODE[key] = code +} + +/** + * Converts a hotkey key name to its expected `KeyboardEvent.code` value. + * + * Used when `matchBy: 'code'` to compare against the physical key position + * rather than the character produced by the current keyboard layout/IME. + * + * @param key - The normalized hotkey key name (e.g., 'A', '4', '-', 'Escape') + * @returns The expected `event.code` value, or the key itself for special keys + */ +function keyToCode(key: string): string { + // Letter keys: A → KeyA + if (/^[A-Za-z]$/.test(key)) { + return `Key${key.toUpperCase()}` + } + // Digit keys: 4 → Digit4 + if (/^[0-9]$/.test(key)) { + return `Digit${key}` + } + // Punctuation keys: - → Minus, / → Slash, etc. + if (key in KEY_TO_PUNCTUATION_CODE) { + return KEY_TO_PUNCTUATION_CODE[key]! + } + // Special keys (Escape, Enter, Space, Tab, F1, ArrowUp, etc.) + // Their event.code matches the key name + return key +} + /** * Checks if a KeyboardEvent matches a hotkey. * @@ -28,6 +64,7 @@ import type { * @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) + * @param matchBy - How to match: 'key' (layout-aware, default) or 'code' (physical key position) * @returns True if the event matches the hotkey * * @example @@ -37,6 +74,11 @@ import type { * event.preventDefault() * handleSave() * } + * + * // Physical key matching for non-Latin IME + * if (matchesKeyboardEvent(event, 'A', undefined, 'code')) { + * handleA() // Works even when a non-Latin IME is active + * } * }) * ``` */ @@ -44,6 +86,7 @@ export function matchesKeyboardEvent( event: KeyboardEvent, hotkey: Hotkey | ParsedHotkey, platform: 'mac' | 'windows' | 'linux' = detectPlatform(), + matchBy: 'key' | 'code' = 'key', ): boolean { const parsed = typeof hotkey === 'string' ? parseHotkey(hotkey, platform) : hotkey @@ -62,6 +105,17 @@ export function matchesKeyboardEvent( return false } + // When matchBy is 'code', compare against event.code (physical key position) + // instead of event.key. Useful when a non-Latin IME is active and + // event.key produces non-Latin characters. + if (matchBy === 'code') { + if (!event.code) { + return false + } + const expectedCode = keyToCode(parsed.key) + return event.code === expectedCode + } + // Check key (case-insensitive for letters) const eventKey = normalizeKeyName(event.key) const hotkeyKey = parsed.key @@ -136,6 +190,8 @@ export interface CreateHotkeyHandlerOptions { stopPropagation?: boolean /** The target platform for resolving 'Mod' */ platform?: 'mac' | 'windows' | 'linux' + /** How to match: 'key' (layout-aware, default) or 'code' (physical key position) */ + matchBy?: 'key' | 'code' } /** @@ -161,7 +217,12 @@ export function createHotkeyHandler( callback: HotkeyCallback, options: CreateHotkeyHandlerOptions = {}, ): (event: KeyboardEvent) => void { - const { preventDefault = true, stopPropagation = true, platform } = options + const { + preventDefault = true, + stopPropagation = true, + platform, + matchBy, + } = options const resolvedPlatform = platform ?? detectPlatform() const hotkeyString: Hotkey = @@ -175,7 +236,7 @@ export function createHotkeyHandler( } return (event: KeyboardEvent) => { - if (matchesKeyboardEvent(event, parsed, resolvedPlatform)) { + if (matchesKeyboardEvent(event, parsed, resolvedPlatform, matchBy)) { if (preventDefault) { event.preventDefault() } @@ -211,7 +272,12 @@ export function createMultiHotkeyHandler( handlers: MultiHotkeyHandler, options: CreateHotkeyHandlerOptions = {}, ): (event: KeyboardEvent) => void { - const { preventDefault = true, stopPropagation = true, platform } = options + const { + preventDefault = true, + stopPropagation = true, + platform, + matchBy, + } = options const resolvedPlatform = platform ?? detectPlatform() // Pre-parse all hotkeys for efficiency @@ -228,7 +294,7 @@ export function createMultiHotkeyHandler( return (event: KeyboardEvent) => { for (const { parsed, handler, context } of parsedHandlers) { - if (matchesKeyboardEvent(event, parsed, resolvedPlatform)) { + if (matchesKeyboardEvent(event, parsed, resolvedPlatform, matchBy)) { if (preventDefault) { event.preventDefault() } diff --git a/packages/hotkeys/tests/match.test.ts b/packages/hotkeys/tests/match.test.ts index bd28b4e0..8a10db17 100644 --- a/packages/hotkeys/tests/match.test.ts +++ b/packages/hotkeys/tests/match.test.ts @@ -713,6 +713,155 @@ describe('matchesKeyboardEvent', () => { }) }) + describe('matchBy: code (physical key matching)', () => { + it('should match physical key A when non-Latin IME produces non-Latin character', () => { + // Non-Latin IME: pressing physical A key produces a non-Latin character in event.key + const event = createKeyboardEvent('ㅁ', { code: 'KeyA' }) + expect(matchesKeyboardEvent(event, 'A', undefined, 'code')).toBe(true) + }) + + it('should not match with default matchBy when non-Latin IME is active', () => { + // Without matchBy: 'code', the hotkey should NOT match + const event = createKeyboardEvent('ㅁ', { code: 'KeyA' }) + expect(matchesKeyboardEvent(event, 'A')).toBe(false) + }) + + it('should match physical key S when non-Latin IME is active', () => { + const event = createKeyboardEvent('と', { code: 'KeyS' }) + expect(matchesKeyboardEvent(event, 'S', undefined, 'code')).toBe(true) + }) + + it('should match physical key with modifiers', () => { + const event = createKeyboardEvent('ㅁ', { + ctrlKey: true, + code: 'KeyA', + }) + expect( + matchesKeyboardEvent(event, 'Control+A', undefined, 'code'), + ).toBe(true) + }) + + it('should match Mod+S with code matching on Mac', () => { + const event = createKeyboardEvent('ㄴ', { + metaKey: true, + code: 'KeyS', + }) + expect(matchesKeyboardEvent(event, 'Mod+S', 'mac', 'code')).toBe(true) + }) + + it('should match Mod+S with code matching on Windows', () => { + const event = createKeyboardEvent('ㄴ', { + ctrlKey: true, + code: 'KeyS', + }) + expect(matchesKeyboardEvent(event, 'Mod+S', 'windows', 'code')).toBe( + true, + ) + }) + + it('should not match if modifiers do not match', () => { + const event = createKeyboardEvent('ㅁ', { code: 'KeyA' }) + expect( + matchesKeyboardEvent(event, 'Control+A', undefined, 'code'), + ).toBe(false) + }) + + it('should not match different physical key', () => { + const event = createKeyboardEvent('ㅁ', { code: 'KeyA' }) + expect(matchesKeyboardEvent(event, 'B', undefined, 'code')).toBe(false) + }) + + it('should match digit keys by code', () => { + const event = createKeyboardEvent('!', { + shiftKey: true, + code: 'Digit1', + }) + expect( + matchesKeyboardEvent(event, 'Shift+1', undefined, 'code'), + ).toBe(true) + }) + + it('should match punctuation keys by code', () => { + const event = createKeyboardEvent('–', { + altKey: true, + code: 'Minus', + }) + expect( + matchesKeyboardEvent(event, 'Alt+-' as Hotkey, undefined, 'code'), + ).toBe(true) + }) + + it('should match special keys by code', () => { + const event = createKeyboardEvent('Escape', { code: 'Escape' }) + expect( + matchesKeyboardEvent(event, 'Escape', undefined, 'code'), + ).toBe(true) + }) + + it('should match function keys by code', () => { + const event = createKeyboardEvent('F5', { code: 'F5' }) + expect(matchesKeyboardEvent(event, 'F5', undefined, 'code')).toBe(true) + }) + + it('should not match if event.code is missing', () => { + const event = createKeyboardEvent('ㅁ', { code: undefined }) + expect(matchesKeyboardEvent(event, 'A', undefined, 'code')).toBe(false) + }) + + it('should match all letter keys A-Z by code', () => { + for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') { + const event = createKeyboardEvent('ㅁ', { + code: `Key${letter}`, + }) + expect( + matchesKeyboardEvent(event, letter as Hotkey, undefined, 'code'), + ).toBe(true) + } + }) + + it('should match with multiple modifiers by code', () => { + const event = createKeyboardEvent('ㅁ', { + ctrlKey: true, + shiftKey: true, + code: 'KeyA', + }) + expect( + matchesKeyboardEvent(event, 'Control+Shift+A', undefined, 'code'), + ).toBe(true) + }) + + it('should work with ParsedHotkey input', () => { + const event = createKeyboardEvent('ㅁ', { code: 'KeyA' }) + const parsed = { + key: 'A', + ctrl: false, + shift: false, + alt: false, + meta: false, + modifiers: [] as ('Control' | 'Shift' | 'Alt' | 'Meta')[], + } + expect(matchesKeyboardEvent(event, parsed, undefined, 'code')).toBe(true) + }) + + it('should still work with ASCII keys when matchBy is code', () => { + // Even with English input, matchBy: 'code' should work + const event = createKeyboardEvent('a', { code: 'KeyA' }) + expect(matchesKeyboardEvent(event, 'A', undefined, 'code')).toBe(true) + }) + + it('should match non-Latin IME input by code when event.key is ASCII', () => { + // Some IMEs pass through ASCII in event.key; code matching still works + const event = createKeyboardEvent('a', { code: 'KeyA' }) + expect(matchesKeyboardEvent(event, 'A', undefined, 'code')).toBe(true) + }) + + it('should match non-Latin keyboard layout by code', () => { + // Non-Latin layout: physical A key produces a non-Latin character + const event = createKeyboardEvent('ф', { code: 'KeyA' }) + expect(matchesKeyboardEvent(event, 'A', undefined, 'code')).toBe(true) + }) + }) + describe('edge cases', () => { it('should not match when event.key is Unidentified', () => { const event = createKeyboardEvent('Unidentified', { code: 'KeyA' })