diff --git a/.changeset/fix-keyboard-layout-code-fallback.md b/.changeset/fix-keyboard-layout-code-fallback.md new file mode 100644 index 0000000..dfd4dc7 --- /dev/null +++ b/.changeset/fix-keyboard-layout-code-fallback.md @@ -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. diff --git a/docs/reference/functions/createHotkeyHandler.md b/docs/reference/functions/createHotkeyHandler.md index cd830e6..eed0295 100644 --- a/docs/reference/functions/createHotkeyHandler.md +++ b/docs/reference/functions/createHotkeyHandler.md @@ -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. diff --git a/docs/reference/functions/createMultiHotkeyHandler.md b/docs/reference/functions/createMultiHotkeyHandler.md index 1e82b5b..de4e18a 100644 --- a/docs/reference/functions/createMultiHotkeyHandler.md +++ b/docs/reference/functions/createMultiHotkeyHandler.md @@ -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. diff --git a/docs/reference/functions/isSingleLetterKey.md b/docs/reference/functions/isSingleLetterKey.md new file mode 100644 index 0000000..21fc891 --- /dev/null +++ b/docs/reference/functions/isSingleLetterKey.md @@ -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) +``` diff --git a/docs/reference/functions/matchesKeyboardEvent.md b/docs/reference/functions/matchesKeyboardEvent.md index c5cefed..83cbb62 100644 --- a/docs/reference/functions/matchesKeyboardEvent.md +++ b/docs/reference/functions/matchesKeyboardEvent.md @@ -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 diff --git a/docs/reference/functions/normalizeKeyName.md b/docs/reference/functions/normalizeKeyName.md index d561d46..5b2f759 100644 --- a/docs/reference/functions/normalizeKeyName.md +++ b/docs/reference/functions/normalizeKeyName.md @@ -9,19 +9,7 @@ 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 @@ -29,19 +17,6 @@ Normalization rules: `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) -``` diff --git a/docs/reference/index.md b/docs/reference/index.md index 32a5c07..de67c76 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -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) diff --git a/docs/reference/interfaces/CreateHotkeyHandlerOptions.md b/docs/reference/interfaces/CreateHotkeyHandlerOptions.md index daf1339..70e0c1d 100644 --- a/docs/reference/interfaces/CreateHotkeyHandlerOptions.md +++ b/docs/reference/interfaces/CreateHotkeyHandlerOptions.md @@ -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. @@ -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' @@ -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 @@ -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 diff --git a/docs/reference/variables/KEY_DISPLAY_SYMBOLS.md b/docs/reference/variables/KEY_DISPLAY_SYMBOLS.md index 73ac054..656e808 100644 --- a/docs/reference/variables/KEY_DISPLAY_SYMBOLS.md +++ b/docs/reference/variables/KEY_DISPLAY_SYMBOLS.md @@ -9,7 +9,7 @@ title: KEY_DISPLAY_SYMBOLS const KEY_DISPLAY_SYMBOLS: Record; ``` -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. diff --git a/docs/reference/variables/MAC_MODIFIER_SYMBOLS.md b/docs/reference/variables/MAC_MODIFIER_SYMBOLS.md index 472c857..f15731a 100644 --- a/docs/reference/variables/MAC_MODIFIER_SYMBOLS.md +++ b/docs/reference/variables/MAC_MODIFIER_SYMBOLS.md @@ -9,7 +9,7 @@ title: MAC_MODIFIER_SYMBOLS const MAC_MODIFIER_SYMBOLS: Record; ``` -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. diff --git a/docs/reference/variables/STANDARD_MODIFIER_LABELS.md b/docs/reference/variables/STANDARD_MODIFIER_LABELS.md index a6c1303..7191e17 100644 --- a/docs/reference/variables/STANDARD_MODIFIER_LABELS.md +++ b/docs/reference/variables/STANDARD_MODIFIER_LABELS.md @@ -9,7 +9,7 @@ title: STANDARD_MODIFIER_LABELS const STANDARD_MODIFIER_LABELS: Record; ``` -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. diff --git a/packages/hotkeys/src/constants.ts b/packages/hotkeys/src/constants.ts index ae6631d..f7cb37c 100644 --- a/packages/hotkeys/src/constants.ts +++ b/packages/hotkeys/src/constants.ts @@ -419,6 +419,10 @@ const KEY_ALIASES: Record = { * 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) { @@ -426,7 +430,7 @@ export function normalizeKeyName(key: string): string { } // 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() } diff --git a/packages/hotkeys/src/match.ts b/packages/hotkeys/src/match.ts index aa7f6cf..6a8e0de 100644 --- a/packages/hotkeys/src/match.ts +++ b/packages/hotkeys/src/match.ts @@ -1,4 +1,8 @@ -import { detectPlatform, normalizeKeyName } from './constants' +import { + detectPlatform, + isSingleLetterKey, + normalizeKeyName, +} from './constants' import { parseHotkey } from './parse' import type { Hotkey, @@ -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 @@ -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 ( diff --git a/packages/hotkeys/tests/match.test.ts b/packages/hotkeys/tests/match.test.ts index 7b33281..19a9a99 100644 --- a/packages/hotkeys/tests/match.test.ts +++ b/packages/hotkeys/tests/match.test.ts @@ -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', {