Skip to content

Commit 305ac29

Browse files
fix(hotkeys): use event.code fallback for Shift-affected punctuation keys
1 parent 3632818 commit 305ac29

7 files changed

Lines changed: 281 additions & 26 deletions

File tree

.changeset/add-question-mark-key.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,8 @@
22
'@tanstack/hotkeys': patch
33
---
44

5-
Add `?` to supported punctuation keys for `Mod+?` help shortcut
5+
Use `event.code` for punctuation key matching to handle Shift-affected keys
6+
7+
Punctuation keys like `/` produce different characters when Shift is pressed (`?`), causing `event.key` to mismatch. The matcher now falls back to `event.code` (e.g., `Slash`, `Comma`) to reliably identify the physical key, matching the existing approach for letters and digits.
8+
9+
Shifted punctuation in hotkey strings is also normalized automatically: `Mod+?` becomes `Mod+Shift+/`.

packages/hotkeys/src/constants.ts

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -279,18 +279,20 @@ export const EDITING_KEYS = new Set<EditingKey>([
279279
/**
280280
* Set of all valid punctuation keys commonly used in keyboard shortcuts.
281281
*
282-
* These are the literal characters as they appear in `KeyboardEvent.key` (layout-dependent,
283-
* typically US keyboard layout). Common shortcuts include:
282+
* These are the unshifted (base) characters as they appear in `KeyboardEvent.key`
283+
* on a US keyboard layout. Common shortcuts include:
284284
* - `Mod+/` - Toggle comment
285285
* - `Mod+[` / `Mod+]` - Indent/outdent
286286
* - `Mod+=` / `Mod+-` - Zoom in/out
287287
*
288-
* Note: Punctuation keys are affected by Shift (Shift+',' → '<' on US layout),
289-
* so they're excluded from Shift-based hotkey combinations to avoid layout-dependent behavior.
288+
* Shifted variants (e.g., `?` from `Shift+/`) are not listed separately.
289+
* Instead, use `Mod+Shift+/` to register the shifted form. The matcher uses
290+
* `event.code` to reliably identify the physical key regardless of shift state.
291+
*
292+
* @see {@link PUNCTUATION_CODE_TO_KEY} for the event.code → key mapping used in matching
290293
*/
291294
export const PUNCTUATION_KEYS = new Set<PunctuationKey>([
292295
'/',
293-
'?',
294296
'[',
295297
']',
296298
'\\',
@@ -299,8 +301,59 @@ export const PUNCTUATION_KEYS = new Set<PunctuationKey>([
299301
',',
300302
'.',
301303
'`',
304+
';',
305+
"'",
302306
])
303307

308+
/**
309+
* Maps `KeyboardEvent.code` values to their corresponding unshifted punctuation key.
310+
*
311+
* Used by the hotkey matcher as a fallback when `event.key` doesn't match
312+
* due to Shift changing the produced character (e.g., `Shift+/` produces `?`
313+
* in `event.key` but `event.code` remains `Slash`).
314+
*
315+
* This is analogous to the existing `event.code` fallbacks for letters (`KeyA`→`A`)
316+
* and digits (`Digit4`→`4`), extended to cover punctuation keys.
317+
*
318+
* Based on the US QWERTY layout, which is the standard for `event.code` values.
319+
*/
320+
export const PUNCTUATION_CODE_TO_KEY: Record<string, PunctuationKey> = {
321+
Slash: '/',
322+
BracketLeft: '[',
323+
BracketRight: ']',
324+
Backslash: '\\',
325+
Equal: '=',
326+
Minus: '-',
327+
Comma: ',',
328+
Period: '.',
329+
Backquote: '`',
330+
Semicolon: ';',
331+
Quote: "'",
332+
}
333+
334+
/**
335+
* Maps shifted punctuation characters to their base (unshifted) key.
336+
*
337+
* Used by the parser to normalize shifted punctuation in hotkey strings.
338+
* For example, writing `Mod+?` is automatically normalized to `Mod+Shift+/`,
339+
* since `?` is just the shifted form of `/` on a US keyboard.
340+
*
341+
* Based on the US QWERTY layout.
342+
*/
343+
export const SHIFTED_KEY_MAP: Record<string, PunctuationKey> = {
344+
'?': '/',
345+
'{': '[',
346+
'}': ']',
347+
'|': '\\',
348+
'+': '=',
349+
_: '-',
350+
'<': ',',
351+
'>': '.',
352+
'~': '`',
353+
':': ';',
354+
'"': "'",
355+
}
356+
304357
/**
305358
* Set of all valid non-modifier keys.
306359
*

packages/hotkeys/src/hotkey.ts

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,6 @@ export type EditingKey =
116116
*/
117117
export type PunctuationKey =
118118
| '/'
119-
| '?'
120119
| '['
121120
| ']'
122121
| '\\'
@@ -125,6 +124,8 @@ export type PunctuationKey =
125124
| ','
126125
| '.'
127126
| '`'
127+
| ';'
128+
| "'"
128129

129130
/**
130131
* Keys that don't change their value when Shift is pressed.
@@ -161,7 +162,6 @@ export type HeldKey = CanonicalModifier | Key
161162
/**
162163
* Single modifier + key combinations.
163164
* Uses canonical modifiers (4) + Mod (1) = 5 modifiers.
164-
* Shift combinations exclude PunctuationKey to avoid layout-dependent issues.
165165
*
166166
* The `Mod` modifier is platform-adaptive:
167167
* - **macOS**: Resolves to `Meta` (Command key ⌘)
@@ -173,13 +173,12 @@ export type HeldKey = CanonicalModifier | Key
173173
type SingleModifierHotkey =
174174
| `Control+${Key}`
175175
| `Alt+${Key}`
176-
| `Shift+${NonPunctuationKey}`
176+
| `Shift+${Key}`
177177
| `Meta+${Key}`
178178
| `Mod+${Key}`
179179

180180
/**
181181
* Two modifier + key combinations.
182-
* Shift combinations exclude Numbers and PunctuationKeys to avoid layout-dependent issues.
183182
*
184183
* **Platform-adaptive `Mod` combinations:**
185184
* - `Mod+Alt` and `Mod+Shift` are included (safe on all platforms)
@@ -189,17 +188,16 @@ type SingleModifierHotkey =
189188
*/
190189
type TwoModifierHotkey =
191190
| `Control+Alt+${Key}`
192-
| `Control+Shift+${NonPunctuationKey}`
191+
| `Control+Shift+${Key}`
193192
| `Control+Meta+${Key}`
194-
| `Alt+Shift+${NonPunctuationKey}`
193+
| `Alt+Shift+${Key}`
195194
| `Alt+Meta+${Key}`
196-
| `Shift+Meta+${NonPunctuationKey}`
195+
| `Shift+Meta+${Key}`
197196
| `Mod+Alt+${Key}`
198-
| `Mod+Shift+${NonPunctuationKey}`
197+
| `Mod+Shift+${Key}`
199198

200199
/**
201200
* Three modifier + key combinations.
202-
* Shift combinations exclude Numbers and PunctuationKeys to avoid layout-dependent issues.
203201
*
204202
* **Platform-adaptive `Mod` combinations:**
205203
* - `Mod+Alt+Shift` is included (safe on all platforms)
@@ -208,15 +206,14 @@ type TwoModifierHotkey =
208206
* - `Mod+Shift+Meta` duplicates `Meta` on macOS (Mod = Meta)
209207
*/
210208
type ThreeModifierHotkey =
211-
| `Control+Alt+Shift+${NonPunctuationKey}`
209+
| `Control+Alt+Shift+${Key}`
212210
| `Control+Alt+Meta+${Key}`
213-
| `Control+Shift+Meta+${NonPunctuationKey}`
214-
| `Alt+Shift+Meta+${NonPunctuationKey}`
215-
| `Mod+Alt+Shift+${NonPunctuationKey}`
211+
| `Control+Shift+Meta+${Key}`
212+
| `Alt+Shift+Meta+${Key}`
213+
| `Mod+Alt+Shift+${Key}`
216214

217215
/**
218216
* Four modifier + key combinations.
219-
* Shift combinations exclude Numbers and PunctuationKeys to avoid layout-dependent issues.
220217
*
221218
* Only the canonical `Control+Alt+Shift+Meta` combination is included.
222219
*
@@ -227,7 +224,7 @@ type ThreeModifierHotkey =
227224
* - `Mod+Control+Alt+Shift` → duplicates `Control` on Windows/Linux
228225
* - `Mod+Alt+Shift+Meta` → duplicates `Meta` on macOS
229226
*/
230-
type FourModifierHotkey = `Control+Alt+Shift+Meta+${NonPunctuationKey}`
227+
type FourModifierHotkey = `Control+Alt+Shift+Meta+${Key}`
231228

232229
/**
233230
* A type-safe hotkey string.

packages/hotkeys/src/match.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { detectPlatform, normalizeKeyName } from './constants'
1+
import {
2+
PUNCTUATION_CODE_TO_KEY,
3+
detectPlatform,
4+
normalizeKeyName,
5+
} from './constants'
26
import { parseHotkey } from './parse'
37
import type {
48
Hotkey,
@@ -11,8 +15,9 @@ import type {
1115
* Checks if a KeyboardEvent matches a hotkey.
1216
*
1317
* Uses the `key` property from KeyboardEvent for matching, with a fallback to `code`
14-
* for letter keys (A-Z) and digit keys (0-9) when `key` produces special characters
15-
* (e.g., macOS Option+letter or Shift+number). Letter keys are matched case-insensitively.
18+
* for letter keys (A-Z), digit keys (0-9), and punctuation keys when `key` produces
19+
* unexpected characters (e.g., macOS Option+letter, Shift+number, or Shift+punctuation).
20+
* Letter keys are matched case-insensitively.
1621
*
1722
* @param event - The KeyboardEvent to check
1823
* @param hotkey - The hotkey string or ParsedHotkey to match against
@@ -82,6 +87,16 @@ export function matchesKeyboardEvent(
8287
}
8388
}
8489

90+
// Fallback to event.code for punctuation keys when event.key doesn't match
91+
// This handles Shift-affected keys like Shift+/ producing '?' in event.key
92+
// while event.code remains 'Slash'
93+
if (event.code) {
94+
const baseKey = PUNCTUATION_CODE_TO_KEY[event.code]
95+
if (baseKey !== undefined && baseKey === hotkeyKey) {
96+
return true
97+
}
98+
}
99+
85100
return false
86101
}
87102

packages/hotkeys/src/parse.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
MODIFIER_ALIASES,
33
MODIFIER_ORDER,
4+
SHIFTED_KEY_MAP,
45
detectPlatform,
56
normalizeKeyName,
67
resolveModifier,
@@ -64,6 +65,13 @@ export function parseHotkey(
6465
key = normalizeKeyName(parts[parts.length - 1]!.trim())
6566
}
6667

68+
// Normalize shifted punctuation: e.g., '?' → '/' with Shift added
69+
const baseKey = SHIFTED_KEY_MAP[key]
70+
if (baseKey !== undefined) {
71+
key = baseKey
72+
modifiers.add('Shift')
73+
}
74+
6775
return {
6876
key,
6977
ctrl: modifiers.has('Control'),

packages/hotkeys/tests/match.test.ts

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
createMultiHotkeyHandler,
55
matchesKeyboardEvent,
66
} from '../src/match'
7-
import { Hotkey } from '../src'
7+
import type { Hotkey } from '../src'
88

99
/**
1010
* Helper to create a mock KeyboardEvent
@@ -160,7 +160,7 @@ describe('matchesKeyboardEvent', () => {
160160
shift: false,
161161
alt: false,
162162
meta: true,
163-
modifiers: ['Meta'] as ('Control' | 'Shift' | 'Alt' | 'Meta')[],
163+
modifiers: ['Meta'] as Array<'Control' | 'Shift' | 'Alt' | 'Meta'>,
164164
}
165165
expect(matchesKeyboardEvent(event, parsed)).toBe(true)
166166
})
@@ -334,6 +334,124 @@ describe('matchesKeyboardEvent', () => {
334334
}
335335
})
336336
})
337+
338+
describe('event.code fallback for punctuation keys', () => {
339+
it('should match Shift+/ when event.key is ? (Shift-affected punctuation)', () => {
340+
const event = createKeyboardEvent('?', {
341+
shiftKey: true,
342+
metaKey: true,
343+
code: 'Slash',
344+
})
345+
expect(matchesKeyboardEvent(event, 'Mod+Shift+/', 'mac')).toBe(true)
346+
})
347+
348+
it('should still match / without Shift', () => {
349+
const event = createKeyboardEvent('/', {
350+
metaKey: true,
351+
code: 'Slash',
352+
})
353+
expect(matchesKeyboardEvent(event, 'Mod+/', 'mac')).toBe(true)
354+
})
355+
356+
it('should not match Mod+/ when Shift is pressed (modifier mismatch)', () => {
357+
const event = createKeyboardEvent('?', {
358+
shiftKey: true,
359+
metaKey: true,
360+
code: 'Slash',
361+
})
362+
expect(matchesKeyboardEvent(event, 'Mod+/', 'mac')).toBe(false)
363+
})
364+
365+
it('should match Shift+, when event.key is < (shifted comma)', () => {
366+
const event = createKeyboardEvent('<', {
367+
shiftKey: true,
368+
code: 'Comma',
369+
})
370+
expect(matchesKeyboardEvent(event, 'Shift+,')).toBe(true)
371+
})
372+
373+
it('should match Shift+. when event.key is > (shifted period)', () => {
374+
const event = createKeyboardEvent('>', {
375+
shiftKey: true,
376+
code: 'Period',
377+
})
378+
expect(matchesKeyboardEvent(event, 'Shift+.')).toBe(true)
379+
})
380+
381+
it('should match Shift+= when event.key is + (shifted equal)', () => {
382+
const event = createKeyboardEvent('+', {
383+
shiftKey: true,
384+
code: 'Equal',
385+
})
386+
expect(matchesKeyboardEvent(event, 'Shift+=')).toBe(true)
387+
})
388+
389+
it('should match Shift+` when event.key is ~ (shifted backquote)', () => {
390+
const event = createKeyboardEvent('~', {
391+
shiftKey: true,
392+
code: 'Backquote',
393+
})
394+
expect(matchesKeyboardEvent(event, 'Shift+`')).toBe(true)
395+
})
396+
397+
it('should match Shift+[ when event.key is { (shifted bracket)', () => {
398+
const event = createKeyboardEvent('{', {
399+
shiftKey: true,
400+
code: 'BracketLeft',
401+
})
402+
expect(matchesKeyboardEvent(event, 'Shift+[')).toBe(true)
403+
})
404+
405+
it('should match Shift+] when event.key is } (shifted bracket)', () => {
406+
const event = createKeyboardEvent('}', {
407+
shiftKey: true,
408+
code: 'BracketRight',
409+
})
410+
expect(matchesKeyboardEvent(event, 'Shift+]')).toBe(true)
411+
})
412+
413+
it('should match Shift+\\ when event.key is | (shifted backslash)', () => {
414+
const event = createKeyboardEvent('|', {
415+
shiftKey: true,
416+
code: 'Backslash',
417+
})
418+
expect(matchesKeyboardEvent(event, 'Shift+\\')).toBe(true)
419+
})
420+
421+
it('should match Shift+- when event.key is _ (shifted minus)', () => {
422+
const event = createKeyboardEvent('_', {
423+
shiftKey: true,
424+
code: 'Minus',
425+
})
426+
expect(matchesKeyboardEvent(event, 'Shift+-')).toBe(true)
427+
})
428+
429+
it('should work with multiple modifiers', () => {
430+
const event = createKeyboardEvent('?', {
431+
shiftKey: true,
432+
ctrlKey: true,
433+
code: 'Slash',
434+
})
435+
expect(matchesKeyboardEvent(event, 'Control+Shift+/')).toBe(true)
436+
})
437+
438+
it('should not match when event.code is missing', () => {
439+
const event = createKeyboardEvent('?', {
440+
shiftKey: true,
441+
metaKey: true,
442+
code: undefined,
443+
})
444+
expect(matchesKeyboardEvent(event, 'Mod+Shift+/', 'mac')).toBe(false)
445+
})
446+
447+
it('should not match when event.code maps to a different key', () => {
448+
const event = createKeyboardEvent('?', {
449+
shiftKey: true,
450+
code: 'Slash',
451+
})
452+
expect(matchesKeyboardEvent(event, 'Shift+,')).toBe(false)
453+
})
454+
})
337455
})
338456

339457
describe('createHotkeyHandler', () => {

0 commit comments

Comments
 (0)