Skip to content

Commit 05a2780

Browse files
authored
Merge pull request #5 from humanlayer/publish-humanlayer
fix: macOS Option key hotkey matching + comma normalization
2 parents 7f9517f + a8d6732 commit 05a2780

4 files changed

Lines changed: 101 additions & 5 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@humanlayer/react-hotkeys-hook",
33
"description": "React hook for handling keyboard shortcuts (HumanLayer fork)",
4-
"version": "5.3.0",
4+
"version": "5.3.1",
55
"sideEffects": false,
66
"repository": {
77
"type": "git",

packages/react-hotkeys-hook/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"name": "react-hotkeys-hook",
3-
"version": "5.3.0",
2+
"name": "@humanlayer/react-hotkeys-hook",
3+
"version": "5.3.1",
44
"type": "module",
55
"scripts": {
66
"dev": "vite",

packages/react-hotkeys-hook/src/lib/validators.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@ import type { FormTags, Hotkey, Scopes, Trigger } from './types'
22
import { isHotkeyPressed, isReadonlyArray } from './isHotkeyPressed'
33
import { mapCode } from './parseHotkeys'
44

5+
// macOS Option key transforms letters to Unicode characters (US keyboard layout)
6+
// Reverse map to recover intended letter - same approach as GitHub's hotkey library
7+
const macOSOptionKeyReverseMap: Record<string, string> = {
8+
'å': 'a', '∫': 'b', 'ç': 'c', '∂': 'd', '´': 'e', 'ƒ': 'f', '©': 'g',
9+
'˙': 'h', 'ˆ': 'i', '∆': 'j', '˚': 'k', '¬': 'l', 'µ': 'm', '˜': 'n',
10+
'ø': 'o', 'π': 'p', 'œ': 'q', '®': 'r', 'ß': 's', '†': 't', '¨': 'u',
11+
'√': 'v', '∑': 'w', '≈': 'x', '¥': 'y', 'Ω': 'z'
12+
}
13+
514
export function maybePreventDefault(e: KeyboardEvent, hotkey: Hotkey, preventDefault?: Trigger): void {
615
if ((typeof preventDefault === 'function' && preventDefault(e, hotkey)) || preventDefault === true) {
716
e.preventDefault()
@@ -127,8 +136,24 @@ export const isHotkeyMatchingKeyboardEvent = (e: KeyboardEvent, hotkey: Hotkey,
127136
// If useKey is set, match against the produced key value instead of the key code
128137
// When useKey is true, we ONLY match produced keys — never fall through to code-based matching
129138
if (useKey) {
130-
// Normalize produced key: map ' ' to 'space' so hotkey string 'space' works with useKey
131-
const normalizedKey = producedKey === ' ' ? 'space' : producedKey.toLowerCase()
139+
// macOS Option key transforms event.key to Unicode characters (opt+y → '¥')
140+
// Try both: physical key (event.code) AND reverse-mapped Unicode for layout support
141+
if (altKey) {
142+
const reverseMappedKey = macOSOptionKeyReverseMap[producedKey] || producedKey.toLowerCase()
143+
if (keys?.length === 1) {
144+
return keys.includes(mappedCode) || keys.includes(reverseMappedKey)
145+
}
146+
if (keys && keys.length > 0) {
147+
return isHotkeyPressed(keys.map((k) => k.toLowerCase()))
148+
}
149+
return !keys || keys.length === 0
150+
}
151+
152+
// Non-alt path: use event.key (layout-aware matching)
153+
// Normalize produced key: map ' ' to 'space', ',' to 'comma'
154+
const normalizedKey = producedKey === ' ' ? 'space'
155+
: producedKey === ',' ? 'comma'
156+
: producedKey.toLowerCase()
132157
if (keys?.length === 1) {
133158
return keys.includes(normalizedKey)
134159
}

packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1636,6 +1636,77 @@ test('Should match mixed-case keys like PageUp with useKey option', async () =>
16361636
expect(callback).toHaveBeenCalledTimes(2)
16371637
})
16381638

1639+
test('Should match meta+comma when Cmd+, is pressed', async () => {
1640+
const callback = vi.fn()
1641+
1642+
renderHook(() => useHotkeys('meta+comma', callback))
1643+
1644+
document.dispatchEvent(
1645+
new KeyboardEvent('keydown', {
1646+
key: ',',
1647+
code: 'Comma',
1648+
metaKey: true,
1649+
bubbles: true,
1650+
})
1651+
)
1652+
1653+
expect(callback).toHaveBeenCalledTimes(1)
1654+
})
1655+
1656+
test('Should match alt+y when Option+Y produces Unicode character on macOS (QWERTY)', async () => {
1657+
const callback = vi.fn()
1658+
1659+
renderHook(() => useHotkeys('alt+y', callback))
1660+
1661+
// QWERTY: physical KeyY, event.key is '¥'
1662+
document.dispatchEvent(
1663+
new KeyboardEvent('keydown', {
1664+
key: '¥',
1665+
code: 'KeyY',
1666+
altKey: true,
1667+
bubbles: true,
1668+
})
1669+
)
1670+
1671+
expect(callback).toHaveBeenCalledTimes(1)
1672+
})
1673+
1674+
test('Should match alt+y via Unicode reverse-mapping on Dvorak', async () => {
1675+
const callback = vi.fn()
1676+
1677+
renderHook(() => useHotkeys('alt+y', callback))
1678+
1679+
// Dvorak: physical key is NOT KeyY, but event.key is '¥' (Unicode for opt+y on US layout)
1680+
// The reverse-mapping detects '¥' → 'y' and matches
1681+
document.dispatchEvent(
1682+
new KeyboardEvent('keydown', {
1683+
key: '¥',
1684+
code: 'KeyT', // Different physical key
1685+
altKey: true,
1686+
bubbles: true,
1687+
})
1688+
)
1689+
1690+
expect(callback).toHaveBeenCalledTimes(1)
1691+
})
1692+
1693+
test('Should match alt+a when Option+A produces Unicode character', async () => {
1694+
const callback = vi.fn()
1695+
1696+
renderHook(() => useHotkeys('alt+a', callback))
1697+
1698+
document.dispatchEvent(
1699+
new KeyboardEvent('keydown', {
1700+
key: 'å',
1701+
code: 'KeyA',
1702+
altKey: true,
1703+
bubbles: true,
1704+
})
1705+
)
1706+
1707+
expect(callback).toHaveBeenCalledTimes(1)
1708+
})
1709+
16391710
test('Should remove listener on AbortSignal', async () => {
16401711
const abortController = new AbortController()
16411712
const { signal } = abortController

0 commit comments

Comments
 (0)