Skip to content

Commit a1b94f1

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 c6d722e commit a1b94f1

File tree

3 files changed

+204
-5
lines changed

3 files changed

+204
-5
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 & 3 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,31 @@ 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 => {
134-
if (options.caseSensitive) {
135-
return event.key === key
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+
const isKeyInLowerCase = key === key.toLowerCase()
141+
const isEventKeyInLowerCase = event.key === event.key.toLowerCase()
142+
143+
// If key and event.key are in different cases, invalid with caseSensitive = true. Do not perform further checks
144+
if (options.caseSensitive && isKeyInLowerCase !== isEventKeyInLowerCase) {
145+
return false
136146
}
147+
148+
// If received event.key is not a printable ASCII character code (character code 32-127),
149+
// try to derive it from event.code and match with expected key
150+
if (derivedKeysRegex.test(key) && nonAsciiPrintableRegex.test(event.key)) {
151+
return event.code.replace(/(Key|Digit|Numpad)/, '') === key.toUpperCase()
152+
}
153+
137154
return event.key.toLowerCase() === key.toLowerCase()
138155
}
139156

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

0 commit comments

Comments
 (0)