Skip to content

Commit fd97eb2

Browse files
committed
fix(useHotKey): try to derive latin keys from key codes of non-latin characters
Signed-off-by: Maksim Sukharev <antreesy.web@gmail.com>
1 parent b5388f3 commit fd97eb2

File tree

3 files changed

+200
-4
lines changed

3 files changed

+200
-4
lines changed

docs/composables/useHotKey.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ where:
3737
3838
<template>
3939
<div class="container">
40+
<p class="description">{{ loggedKey }}</p>
4041
<p class="description">Press <kbd>W</kbd> <kbd>S</kbd> <kbd>A</kbd> <kbd>D</kbd> keys to move the ball</p>
4142
<div class="square">
4243
<div class="circle" :style="{ left: `${circleX}px`, top: `${circleY}px` }"></div>
@@ -48,7 +49,7 @@ where:
4849
<div ref="push"
4950
class="push-square"
5051
:class="{ 'push-square--highlighted': highlighted }"></div>
51-
<p class="description">Use <kbd>Ctrl</kbd> + <kbd>F</kbd> to focus input</p>
52+
<p class="description">Use <kbd>Ctrl</kbd> + <kbd>f</kbd> to focus input (case-sensitive)</p>
5253
<input ref="input"/>
5354
</div>
5455
</template>
@@ -57,12 +58,36 @@ where:
5758
import { ref } from 'vue'
5859
import { useHotKey } from '../../src/composables/useHotKey/index.js'
5960
61+
const isMac = /mac|ipad|iphone|darwin/i.test(navigator.userAgent)
62+
6063
export default {
6164
setup() {
65+
const loggedKey = ref('Press any key')
6266
const circleX = ref(20)
6367
const circleY = ref(20)
6468
const highlighted = ref(false)
6569
70+
const updateLoggedKey = (event) => {
71+
if (['Ctrl', 'Meta', 'Alt', 'Shift'].includes(event.key)) {
72+
return
73+
}
74+
75+
loggedKey.value = `Key pressed: ${event.key} | `
76+
if (event.ctrlKey) {
77+
loggedKey.value += 'Ctrl + '
78+
}
79+
if (event.metaKey) {
80+
loggedKey.value += isMac ? 'Cmd + ' : 'Meta + '
81+
}
82+
if (event.altKey) {
83+
loggedKey.value += 'Alt + '
84+
}
85+
if (event.shiftKey) {
86+
loggedKey.value += 'Shift + '
87+
}
88+
loggedKey.value += event.code
89+
}
90+
6691
const moveUp = (event) => {
6792
circleY.value = Math.max(0, circleY.value - 10)
6893
}
@@ -79,13 +104,15 @@ where:
79104
highlighted.value = !highlighted.value
80105
}
81106
107+
useHotKey(true, updateLoggedKey)
82108
useHotKey('w', moveUp)
83109
useHotKey('s', moveDown)
84110
useHotKey('a', moveLeft)
85111
useHotKey('d', moveRight)
86112
const stop = useHotKey('m', toggleHighlighted, { push: true })
87113
88114
return {
115+
loggedKey,
89116
circleX,
90117
circleY,
91118
highlighted,
@@ -94,7 +121,7 @@ where:
94121
},
95122
96123
created() {
97-
useHotKey('f', this.focusInput, { ctrl: true, stop: true, prevent: true })
124+
useHotKey('f', this.focusInput, { ctrl: true, stop: true, prevent: true, caseSensitive: true })
98125
},
99126
100127
methods: {

src/composables/useHotKey/index.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { onKeyStroke } from '@vueuse/core'
66

77
const disableKeyboardShortcuts = window.OCP?.Accessibility?.disableKeyboardShortcuts?.()
88
const isMac = /mac|ipad|iphone|darwin/i.test(navigator.userAgent)
9+
const derivedKeysRegex = /^[a-zA-Z0-9]$/
10+
const nonAsciiPrintableRegex = /^[^\x20-\x7F]$/
911

1012
export interface UseHotKeyOptions {
1113
/** Make key filter case sensitive */
@@ -124,16 +126,32 @@ export function useHotKey(
124126

125127
/**
126128
* Validates event key to expected key
127-
* FIXME should support any languages / key codes
128129
*
129130
* @param event keyboard event
130131
* @param key expected key
131132
* @return whether it satisfies expected value or not
132133
*/
133134
const validateKeyEvent = (event: KeyboardEvent, key: string): boolean => {
135+
// If key exactly matches event.key, valid with any caseSensitive option. Do not perform further checks
136+
if (event.key === key) {
137+
return true
138+
}
139+
140+
// If key and event.key are in different cases, invalid with caseSensitive = true. Do not perform further checks
134141
if (options.caseSensitive) {
135-
return event.key === key
142+
const isKeyInLowerCase = key === key.toLowerCase()
143+
const isEventKeyInLowerCase = event.key === event.key.toLowerCase()
144+
if (isKeyInLowerCase !== isEventKeyInLowerCase) {
145+
return false
146+
}
136147
}
148+
149+
// If received event.key is not a printable ASCII character code (character code 32-127),
150+
// try to derive it from event.code and match with expected key
151+
if (derivedKeysRegex.test(key) && nonAsciiPrintableRegex.test(event.key)) {
152+
return event.code.replace(/^(?:Key|Digit|Numpad)/, '') === key.toUpperCase()
153+
}
154+
137155
return event.key.toLowerCase() === key.toLowerCase()
138156
}
139157

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { afterEach, describe, expect, it, vi } from 'vitest'
7+
import { useHotKey } from '../../../src/composables/useHotKey/index.ts'
8+
9+
describe('useHotKey', () => {
10+
const mockCallback = vi.fn()
11+
12+
function triggerKeyDown(eventPayload, target = document.body) {
13+
target.dispatchEvent(new KeyboardEvent('keydown', eventPayload))
14+
}
15+
16+
function triggerKeyUp(eventPayload, target = document.body) {
17+
target.dispatchEvent(new KeyboardEvent('keyup', eventPayload))
18+
}
19+
20+
afterEach(() => {
21+
mockCallback.mockReset()
22+
})
23+
24+
it('should register listener and invoke callback', () => {
25+
const stop = useHotKey(true, mockCallback)
26+
triggerKeyDown({ key: 'a', code: 'KeyA' })
27+
stop()
28+
29+
expect(mockCallback).toHaveBeenCalled()
30+
})
31+
32+
it('should not invoke callback, when on interactive elements', () => {
33+
const stop = useHotKey(true, mockCallback)
34+
const input = document.createElement('input')
35+
document.body.appendChild(input)
36+
triggerKeyDown({ key: 'a', code: 'KeyA' }, input)
37+
stop()
38+
39+
expect(mockCallback).not.toHaveBeenCalled()
40+
})
41+
42+
describe('options', () => {
43+
it('should accept array of keys and invoke callback for all of them', () => {
44+
const stop = useHotKey(['a', 'b'], mockCallback)
45+
triggerKeyDown({ key: 'a', code: 'KeyA' })
46+
triggerKeyDown({ key: 'b', code: 'KeyB' })
47+
stop()
48+
49+
expect(mockCallback).toHaveBeenCalledTimes(2)
50+
})
51+
52+
it('should accept filter function and invoke callback when passed', () => {
53+
const stop = useHotKey((event) => ['a', 'b'].includes(event.key), mockCallback)
54+
triggerKeyDown({ key: 'a', code: 'KeyA' })
55+
triggerKeyDown({ key: 'b', code: 'KeyB' })
56+
stop()
57+
58+
expect(mockCallback).toHaveBeenCalledTimes(2)
59+
})
60+
61+
it('should register only keydown listener and invoke callback', () => {
62+
const stop = useHotKey('a', mockCallback)
63+
triggerKeyDown({ key: 'a', code: 'KeyA' })
64+
triggerKeyUp({ key: 'a', code: 'KeyA' })
65+
stop()
66+
67+
expect(mockCallback).toHaveBeenCalledTimes(1)
68+
})
69+
70+
it('should register push listeners and invoke callback', () => {
71+
const stop = useHotKey('a', mockCallback, { push: true })
72+
triggerKeyDown({ key: 'a', code: 'KeyA' })
73+
triggerKeyUp({ key: 'a', code: 'KeyA' })
74+
stop()
75+
76+
expect(mockCallback).toHaveBeenCalledTimes(2)
77+
})
78+
79+
it('should register listener with Shift modifier and invoke callback', () => {
80+
const stop = useHotKey('a', mockCallback, { shift: true })
81+
triggerKeyDown({ key: 'a', code: 'KeyA', shiftKey: false })
82+
triggerKeyDown({ key: 'A', code: 'KeyA', shiftKey: true })
83+
stop()
84+
85+
expect(mockCallback).toHaveBeenCalledTimes(1)
86+
})
87+
88+
it('should register listener with Alt modifier and invoke callback', () => {
89+
const stop = useHotKey('a', mockCallback, { alt: true })
90+
triggerKeyDown({ key: 'a', code: 'KeyA', altKey: false })
91+
triggerKeyDown({ key: 'A', code: 'KeyA', altKey: true })
92+
stop()
93+
94+
expect(mockCallback).toHaveBeenCalledTimes(1)
95+
})
96+
97+
it('should register listener with Ctrl (Cmd on Mac) modifier and invoke callback', () => {
98+
const stop = useHotKey('a', mockCallback, { ctrl: true })
99+
triggerKeyDown({ key: 'a', code: 'KeyA', ctrlKey: false, metaKey: false })
100+
triggerKeyDown({ key: 'A', code: 'KeyA', ctrlKey: true, metaKey: true })
101+
stop()
102+
103+
expect(mockCallback).toHaveBeenCalledTimes(1)
104+
})
105+
})
106+
107+
describe('validation', () => {
108+
it.each([
109+
// Latin characters
110+
['a', { key: 'a', code: 'KeyA' }, {}],
111+
['a', { key: 'A', code: 'KeyA' }, {}],
112+
// Latin characters with different layout
113+
['a', { key: 'a', code: 'KeyQ' }, {}],
114+
// Non-Latin characters
115+
['a', { key: 'ф', code: 'KeyA' }, {}],
116+
['a', { key: 'Ф', code: 'KeyA' }, {}],
117+
['a', { key: 'ש', code: 'KeyA' }, {}],
118+
// Digits
119+
['1', { key: '1', code: 'Digit1' }, {}],
120+
['2', { key: '2', code: 'Numpad2' }, {}],
121+
// Non-Arabic numeral digits
122+
['1', { key: '일', code: 'Digit1' }, {}],
123+
['2', { key: '٢', code: 'Numpad2' }, {}],
124+
// With caseSensitive option
125+
['a', { key: 'a', code: 'KeyA' }, { caseSensitive: true }],
126+
['a', { key: 'ф', code: 'KeyA' }, { caseSensitive: true }],
127+
['a', { key: 'ש', code: 'KeyA' }, { caseSensitive: true }],
128+
])('should pass validation for %s => pressed %o', (key, eventKey, options) => {
129+
const stop = useHotKey(key, mockCallback, options)
130+
triggerKeyDown(eventKey)
131+
stop()
132+
133+
expect(mockCallback).toHaveBeenCalled()
134+
})
135+
136+
it.each([
137+
// Wrong key
138+
['a', { key: 'B', code: 'KeyB' }, {}],
139+
['a', { key: 'b', code: 'KeyB' }, { caseSensitive: true }],
140+
// Wrong case
141+
['a', { key: 'A', code: 'KeyA' }, { caseSensitive: true }],
142+
['a', { key: 'Ф', code: 'KeyA' }, { caseSensitive: true }],
143+
])('should not pass validation for %s => pressed %o', (key, eventKey, options) => {
144+
const stop = useHotKey(key, mockCallback, options)
145+
triggerKeyDown(eventKey)
146+
stop()
147+
148+
expect(mockCallback).not.toHaveBeenCalled()
149+
})
150+
})
151+
})

0 commit comments

Comments
 (0)