Skip to content
Open
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-alt-punctuation-macos.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/hotkeys': patch
---

Fix Alt+punctuation hotkeys not firing on macOS due to Option key character composition

On macOS, the Option (Alt) key acts as a character composer for punctuation keys (e.g., Option+- produces an en-dash '–'), causing `event.key` to differ from the expected character. Added a `event.code` fallback for punctuation keys (Minus, Equal, Slash, BracketLeft, BracketRight, Backslash, Comma, Period, Backquote, Semicolon), matching the existing fallback pattern for letter and digit keys.
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:149](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L149)
Defined in: [match.ts:157](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L157)

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:200](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L200)
Defined in: [match.ts:208](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L208)

Creates a handler that matches multiple hotkeys.

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/functions/isSingleLetterKey.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ title: isSingleLetterKey
function isSingleLetterKey(key): boolean;
```

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

Normalizes a key name to its canonical form.

Expand Down
7 changes: 4 additions & 3 deletions docs/reference/functions/matchesKeyboardEvent.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ function matchesKeyboardEvent(
platform): boolean;
```

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

Checks if a KeyboardEvent matches a hotkey.

Uses the `key` property from KeyboardEvent for matching, with a fallback to `code`
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.
for letter keys, digit keys (0-9), and punctuation keys when `key` produces special
characters (e.g., macOS Option+letter, Shift+number, or Option+punctuation).
Letter keys are matched case-insensitively.

Also handles "dead key" events where `event.key` is `'Dead'` instead of the expected
character. This commonly occurs on macOS with Option+letter combinations (e.g., Option+E,
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/functions/normalizeKeyName.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ title: normalizeKeyName
function normalizeKeyName(key): string;
```

Defined in: [constants.ts:426](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L426)
Defined in: [constants.ts:448](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L448)

## Parameters

Expand Down
1 change: 1 addition & 0 deletions docs/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ title: "@tanstack/hotkeys"
- [MODIFIER\_ORDER](variables/MODIFIER_ORDER.md)
- [NAVIGATION\_KEYS](variables/NAVIGATION_KEYS.md)
- [NUMBER\_KEYS](variables/NUMBER_KEYS.md)
- [PUNCTUATION\_CODE\_MAP](variables/PUNCTUATION_CODE_MAP.md)
- [PUNCTUATION\_KEYS](variables/PUNCTUATION_KEYS.md)
- [STANDARD\_MODIFIER\_LABELS](variables/STANDARD_MODIFIER_LABELS.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:122](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L122)
Defined in: [match.ts:130](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L130)

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:128](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L128)
Defined in: [match.ts:136](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L136)

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:124](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L124)
Defined in: [match.ts:132](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L132)

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:126](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L126)
Defined in: [match.ts:134](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/match.ts#L134)

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

Defined in: [constants.ts:317](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L317)
Defined in: [constants.ts:339](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L339)

Set of all valid non-modifier keys.

Expand Down
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:512](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L512)
Defined in: [constants.ts:534](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L534)

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:468](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L468)
Defined in: [constants.ts:490](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L490)

Modifier key symbols for macOS display.

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

# Variable: PUNCTUATION\_CODE\_MAP

```ts
const PUNCTUATION_CODE_MAP: Record<string, string>;
```

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

Maps `KeyboardEvent.code` values for punctuation keys to their canonical characters.

On macOS, holding the Option (Alt) key transforms punctuation keys into special characters
(e.g., Option+Minus → en-dash '–'), causing `event.key` to differ from the expected character.
However, `event.code` still reports the physical key (e.g., 'Minus'). This map enables
falling back to `event.code` for punctuation keys, similar to the existing `Key*`/`Digit*`
fallbacks for letters and digits.
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:490](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L490)
Defined in: [constants.ts:512](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/constants.ts#L512)

Modifier key labels for Windows/Linux display.

Expand Down
22 changes: 22 additions & 0 deletions packages/hotkeys/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,28 @@ export const PUNCTUATION_KEYS = new Set<PunctuationKey>([
'`',
])

/**
* Maps `KeyboardEvent.code` values for punctuation keys to their canonical characters.
*
* On macOS, holding the Option (Alt) key transforms punctuation keys into special characters
* (e.g., Option+Minus → en-dash '–'), causing `event.key` to differ from the expected character.
* However, `event.code` still reports the physical key (e.g., 'Minus'). This map enables
* falling back to `event.code` for punctuation keys, similar to the existing `Key*`/`Digit*`
* fallbacks for letters and digits.
*/
export const PUNCTUATION_CODE_MAP: Record<string, string> = {
Minus: '-',
Equal: '=',
Slash: '/',
BracketLeft: '[',
BracketRight: ']',
Backslash: '\\',
Comma: ',',
Period: '.',
Backquote: '`',
Semicolon: ';',
}
Comment on lines +312 to +323
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify whether semicolon is part of canonical punctuation types/sets and test coverage.
rg -n -C2 'type PunctuationKey|PUNCTUATION_KEYS|PUNCTUATION_CODE_MAP|Semicolon|Alt\+;|;\x27' \
  packages/hotkeys/src \
  packages/hotkeys/tests

Repository: TanStack/hotkeys

Length of output: 2554


🏁 Script executed:

cat -n packages/hotkeys/src/hotkey.ts | sed -n '117,135p'

Repository: TanStack/hotkeys

Length of output: 653


🏁 Script executed:

rg -n 'Semicolon|";"' packages/hotkeys/tests --type ts --type js

Repository: TanStack/hotkeys

Length of output: 42


🏁 Script executed:

sed -n '291,323p' packages/hotkeys/src/constants.ts

Repository: TanStack/hotkeys

Length of output: 928


Semicolon entry in fallback map is inconsistent with canonical punctuation key set.

The PUNCTUATION_CODE_MAP includes Semicolon: ';', but ; is not part of PUNCTUATION_KEYS (lines 291-301) or the PunctuationKey type. Either add ; to the canonical punctuation keys/types or remove the Semicolon entry from the map.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/hotkeys/src/constants.ts` around lines 312 - 323, The fallback map
PUNCTUATION_CODE_MAP contains a Semicolon: ';' entry that is inconsistent with
the canonical set PUNCTUATION_KEYS and the PunctuationKey type; pick one
consistent fix: either remove the Semicolon entry from PUNCTUATION_CODE_MAP, or
add ';' (and the corresponding "Semicolon" key) to PUNCTUATION_KEYS and update
the PunctuationKey type so the canonical key set and the fallback map match;
update only the symbols PUNCTUATION_CODE_MAP, PUNCTUATION_KEYS, and
PunctuationKey accordingly to keep them in sync.


/**
* Set of all valid non-modifier keys.
*
Expand Down
12 changes: 10 additions & 2 deletions packages/hotkeys/src/match.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
PUNCTUATION_CODE_MAP,
detectPlatform,
isSingleLetterKey,
normalizeKeyName,
Expand All @@ -15,8 +16,9 @@ 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 and digit keys (0-9) when `key` produces special characters
* (e.g., macOS Option+letter or Shift+number). Letter keys are matched case-insensitively.
* for letter keys, digit keys (0-9), and punctuation keys when `key` produces special
* characters (e.g., macOS Option+letter, Shift+number, or Option+punctuation).
* Letter keys are matched case-insensitively.
*
* Also handles "dead key" events where `event.key` is `'Dead'` instead of the expected
* character. This commonly occurs on macOS with Option+letter combinations (e.g., Option+E,
Expand Down Expand Up @@ -109,6 +111,12 @@ export function matchesKeyboardEvent(
return codeDigit === hotkeyKey
}
}
// Fallback for punctuation keys (e.g., Minus, Slash, BracketLeft).
// On macOS, Option+punctuation produces composed characters (e.g., Option+- → '–'),
// but event.code still reports the physical key.
if (event.code && event.code in PUNCTUATION_CODE_MAP) {
return PUNCTUATION_CODE_MAP[event.code] === hotkeyKey
}
return false
}

Expand Down
79 changes: 77 additions & 2 deletions packages/hotkeys/tests/match.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,16 +627,91 @@ describe('matchesKeyboardEvent', () => {
})
})

describe('dead key with non-Key/Digit codes', () => {
it('should not match dead key with non-letter, non-digit code', () => {
describe('dead key with punctuation codes', () => {
it('should match dead key with punctuation code via fallback', () => {
const event = createKeyboardEvent('Dead', {
altKey: true,
code: 'BracketLeft',
})
expect(matchesKeyboardEvent(event, 'Alt+[' as Hotkey)).toBe(true)
})

it('should not match dead key with unknown code', () => {
const event = createKeyboardEvent('Dead', {
altKey: true,
code: 'UnknownCode',
})
expect(matchesKeyboardEvent(event, 'Alt+[' as Hotkey)).toBe(false)
})
})

describe('event.code fallback for punctuation keys', () => {
it('should fallback to event.code when macOS Option+minus produces en-dash', () => {
const event = createKeyboardEvent('–', {
altKey: true,
code: 'Minus',
})
expect(matchesKeyboardEvent(event, 'Alt+-' as Hotkey)).toBe(true)
})

it('should fallback to event.code for all punctuation keys with Alt', () => {
const cases: Array<[string, string, string]> = [
['–', 'Minus', '-'], // Option+- → en-dash
['≠', 'Equal', '='], // Option+= → ≠
['÷', 'Slash', '/'], // Option+/ → ÷
['\u201c', 'BracketLeft', '['], // Option+[ → "
['\u2018', 'BracketRight', ']'], // Option+] → '
['«', 'Backslash', '\\'], // Option+\ → «
['≤', 'Comma', ','], // Option+, → ≤
['≥', 'Period', '.'], // Option+. → ≥
['`', 'Backquote', '`'], // Option+` → ` (dead key on some layouts)
]

for (const [composedChar, code, expectedKey] of cases) {
const event = createKeyboardEvent(composedChar, {
altKey: true,
code,
})
expect(
matchesKeyboardEvent(event, `Alt+${expectedKey}` as Hotkey),
).toBe(true)
}
})
Comment on lines +657 to +679
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Include Semicolon in the “all punctuation keys” matrix.

The case list is close, but it skips Semicolon, so the suite title currently overstates coverage.

✅ Suggested test addition
       const cases: Array<[string, string, string]> = [
         ['–', 'Minus', '-'], // Option+- → en-dash
         ['≠', 'Equal', '='], // Option+= → ≠
         ['÷', 'Slash', '/'], // Option+/ → ÷
         ['\u201c', 'BracketLeft', '['], // Option+[ → "
         ['\u2018', 'BracketRight', ']'], // Option+] → '
         ['«', 'Backslash', '\\'], // Option+\ → «
         ['≤', 'Comma', ','], // Option+, → ≤
         ['≥', 'Period', '.'], // Option+. → ≥
+        ['…', 'Semicolon', ';'], // Option+; → …
         ['`', 'Backquote', '`'], // Option+` → ` (dead key on some layouts)
       ]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should fallback to event.code for all punctuation keys with Alt', () => {
const cases: Array<[string, string, string]> = [
['–', 'Minus', '-'], // Option+- → en-dash
['≠', 'Equal', '='], // Option+= → ≠
['÷', 'Slash', '/'], // Option+/ → ÷
['\u201c', 'BracketLeft', '['], // Option+[ → "
['\u2018', 'BracketRight', ']'], // Option+] → '
['«', 'Backslash', '\\'], // Option+\ → «
['≤', 'Comma', ','], // Option+, → ≤
['≥', 'Period', '.'], // Option+. → ≥
['`', 'Backquote', '`'], // Option+` → ` (dead key on some layouts)
]
for (const [composedChar, code, expectedKey] of cases) {
const event = createKeyboardEvent(composedChar, {
altKey: true,
code,
})
expect(
matchesKeyboardEvent(event, `Alt+${expectedKey}` as Hotkey),
).toBe(true)
}
})
it('should fallback to event.code for all punctuation keys with Alt', () => {
const cases: Array<[string, string, string]> = [
['–', 'Minus', '-'], // Option+- → en-dash
['≠', 'Equal', '='], // Option+= → ≠
['÷', 'Slash', '/'], // Option+/ → ÷
['\u201c', 'BracketLeft', '['], // Option+[ → "
['\u2018', 'BracketRight', ']'], // Option+] → '
['«', 'Backslash', '\\'], // Option+\ → «
['≤', 'Comma', ','], // Option+, → ≤
['≥', 'Period', '.'], // Option+. → ≥
['…', 'Semicolon', ';'], // Option+; → …
['`', 'Backquote', '`'], // Option+` → ` (dead key on some layouts)
]
for (const [composedChar, code, expectedKey] of cases) {
const event = createKeyboardEvent(composedChar, {
altKey: true,
code,
})
expect(
matchesKeyboardEvent(event, `Alt+${expectedKey}` as Hotkey),
).toBe(true)
}
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/hotkeys/tests/match.test.ts` around lines 657 - 679, Add the missing
Semicolon entry to the punctuation test matrix so the suite title matches
coverage: update the cases array in packages/hotkeys/tests/match.test.ts (the
test using createKeyboardEvent and matchesKeyboardEvent) to include the
Semicolon mapping (composed character produced with Alt/Option + ; on relevant
layouts and the code 'Semicolon' with expectedKey ';'), then ensure the loop
still asserts matchesKeyboardEvent(event, `Alt+;`) is true.


it('should match punctuation keys with multiple modifiers', () => {
// Cmd+Alt+- on macOS
const event = createKeyboardEvent('–', {
altKey: true,
metaKey: true,
code: 'Minus',
})
expect(matchesKeyboardEvent(event, 'Mod+Alt+-' as Hotkey, 'mac')).toBe(
true,
)
})

it('should still match punctuation keys directly without fallback', () => {
const event = createKeyboardEvent('-', { code: 'Minus' })
expect(matchesKeyboardEvent(event, '-' as Hotkey)).toBe(true)
})

it('should not match punctuation fallback if code is missing', () => {
const event = createKeyboardEvent('–', {
altKey: true,
code: undefined,
})
expect(matchesKeyboardEvent(event, 'Alt+-' as Hotkey)).toBe(false)
})

it('should match Ctrl+punctuation without needing fallback (non-macOS)', () => {
const event = createKeyboardEvent('-', {
ctrlKey: true,
code: 'Minus',
})
expect(matchesKeyboardEvent(event, 'Control+-' as Hotkey)).toBe(true)
})
})

describe('edge cases', () => {
it('should not match when event.key is Unidentified', () => {
const event = createKeyboardEvent('Unidentified', { code: 'KeyA' })
Expand Down
Loading