@pyreon/hotkeys 0.24.5 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +27 -1
- package/package.json +5 -7
- package/src/index.ts +0 -49
- package/src/manifest.ts +0 -119
- package/src/parse.ts +0 -91
- package/src/registry.ts +0 -176
- package/src/tests/hooks-coverage.test.ts +0 -120
- package/src/tests/hotkeys-coverage.test.ts +0 -192
- package/src/tests/hotkeys.test.ts +0 -521
- package/src/tests/manifest-snapshot.test.ts +0 -69
- package/src/types.ts +0 -50
- package/src/use-hotkey-scope.ts +0 -20
- package/src/use-hotkey.ts +0 -26
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
-
import {
|
|
3
|
-
_resetHotkeys,
|
|
4
|
-
disableScope,
|
|
5
|
-
enableScope,
|
|
6
|
-
formatCombo,
|
|
7
|
-
getRegisteredHotkeys,
|
|
8
|
-
matchesCombo,
|
|
9
|
-
parseShortcut,
|
|
10
|
-
registerHotkey,
|
|
11
|
-
} from '../index'
|
|
12
|
-
|
|
13
|
-
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
14
|
-
|
|
15
|
-
function fireKey(
|
|
16
|
-
key: string,
|
|
17
|
-
modifiers: Partial<{
|
|
18
|
-
ctrlKey: boolean
|
|
19
|
-
shiftKey: boolean
|
|
20
|
-
altKey: boolean
|
|
21
|
-
metaKey: boolean
|
|
22
|
-
}> = {},
|
|
23
|
-
target?: HTMLElement,
|
|
24
|
-
): KeyboardEvent {
|
|
25
|
-
const event = new KeyboardEvent('keydown', {
|
|
26
|
-
key,
|
|
27
|
-
ctrlKey: modifiers.ctrlKey ?? false,
|
|
28
|
-
shiftKey: modifiers.shiftKey ?? false,
|
|
29
|
-
altKey: modifiers.altKey ?? false,
|
|
30
|
-
metaKey: modifiers.metaKey ?? false,
|
|
31
|
-
bubbles: true,
|
|
32
|
-
cancelable: true,
|
|
33
|
-
})
|
|
34
|
-
;(target ?? window).dispatchEvent(event)
|
|
35
|
-
return event
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// ─── Additional coverage tests ─────────────────────────────────────────────
|
|
39
|
-
|
|
40
|
-
describe('parseShortcut — additional coverage', () => {
|
|
41
|
-
it('handles plus key via alias', () => {
|
|
42
|
-
const combo = parseShortcut('ctrl+plus')
|
|
43
|
-
expect(combo.ctrl).toBe(true)
|
|
44
|
-
expect(combo.key).toBe('+')
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('handles spacebar alias', () => {
|
|
48
|
-
expect(parseShortcut('spacebar').key).toBe(' ')
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
it('handles arrow key aliases', () => {
|
|
52
|
-
expect(parseShortcut('up').key).toBe('arrowup')
|
|
53
|
-
expect(parseShortcut('down').key).toBe('arrowdown')
|
|
54
|
-
expect(parseShortcut('left').key).toBe('arrowleft')
|
|
55
|
-
expect(parseShortcut('right').key).toBe('arrowright')
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
it('handles ins alias', () => {
|
|
59
|
-
expect(parseShortcut('ins').key).toBe('insert')
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
it('trims whitespace in parts', () => {
|
|
63
|
-
const combo = parseShortcut(' ctrl + shift + s ')
|
|
64
|
-
expect(combo.ctrl).toBe(true)
|
|
65
|
-
expect(combo.shift).toBe(true)
|
|
66
|
-
expect(combo.key).toBe('s')
|
|
67
|
-
})
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
describe('matchesCombo — additional coverage', () => {
|
|
71
|
-
it('does not match when alt differs', () => {
|
|
72
|
-
const combo = parseShortcut('alt+a')
|
|
73
|
-
const event = new KeyboardEvent('keydown', { key: 'a', altKey: false })
|
|
74
|
-
expect(matchesCombo(event, combo)).toBe(false)
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
it('does not match when meta differs', () => {
|
|
78
|
-
const combo = parseShortcut('meta+k')
|
|
79
|
-
const event = new KeyboardEvent('keydown', { key: 'k', metaKey: false })
|
|
80
|
-
expect(matchesCombo(event, combo)).toBe(false)
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
it('matches complex combo with all modifiers', () => {
|
|
84
|
-
const combo = parseShortcut('ctrl+shift+alt+meta+x')
|
|
85
|
-
const event = new KeyboardEvent('keydown', {
|
|
86
|
-
key: 'x',
|
|
87
|
-
ctrlKey: true,
|
|
88
|
-
shiftKey: true,
|
|
89
|
-
altKey: true,
|
|
90
|
-
metaKey: true,
|
|
91
|
-
})
|
|
92
|
-
expect(matchesCombo(event, combo)).toBe(true)
|
|
93
|
-
})
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
describe('formatCombo — additional coverage', () => {
|
|
97
|
-
it('formats alt modifier', () => {
|
|
98
|
-
const result = formatCombo(parseShortcut('alt+a'))
|
|
99
|
-
expect(result).toContain('Alt')
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
it('formats meta modifier on non-Mac', () => {
|
|
103
|
-
// happy-dom is non-Mac, so meta shows as "Meta"
|
|
104
|
-
const result = formatCombo(parseShortcut('meta+k'))
|
|
105
|
-
expect(result).toContain('Meta')
|
|
106
|
-
})
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
describe('registerHotkey — additional coverage', () => {
|
|
110
|
-
beforeEach(() => _resetHotkeys())
|
|
111
|
-
afterEach(() => _resetHotkeys())
|
|
112
|
-
|
|
113
|
-
it('stopPropagation option works', () => {
|
|
114
|
-
registerHotkey('ctrl+s', () => {}, { stopPropagation: true })
|
|
115
|
-
fireKey('s', { ctrlKey: true })
|
|
116
|
-
// Can't easily test stopPropagation on window, but the path is exercised
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
it('does not fire in select elements by default', () => {
|
|
120
|
-
let fired = false
|
|
121
|
-
registerHotkey('ctrl+s', () => {
|
|
122
|
-
fired = true
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
const select = document.createElement('select')
|
|
126
|
-
document.body.appendChild(select)
|
|
127
|
-
fireKey('s', { ctrlKey: true }, select)
|
|
128
|
-
select.remove()
|
|
129
|
-
|
|
130
|
-
expect(fired).toBe(false)
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
it('unregister is idempotent', () => {
|
|
134
|
-
const unsub = registerHotkey('ctrl+s', () => {})
|
|
135
|
-
unsub()
|
|
136
|
-
unsub() // double-call should not throw
|
|
137
|
-
expect(getRegisteredHotkeys()).toHaveLength(0)
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
it('getRegisteredHotkeys omits description when not provided', () => {
|
|
141
|
-
registerHotkey('ctrl+s', () => {})
|
|
142
|
-
const hotkeys = getRegisteredHotkeys()
|
|
143
|
-
expect(hotkeys[0]).toEqual({
|
|
144
|
-
shortcut: 'ctrl+s',
|
|
145
|
-
scope: 'global',
|
|
146
|
-
})
|
|
147
|
-
expect('description' in hotkeys[0]!).toBe(false)
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
it('handles null target in isInputFocused', () => {
|
|
151
|
-
let fired = false
|
|
152
|
-
registerHotkey('a', () => {
|
|
153
|
-
fired = true
|
|
154
|
-
})
|
|
155
|
-
// Dispatch on window (target could be window, not an element)
|
|
156
|
-
fireKey('a')
|
|
157
|
-
expect(fired).toBe(true)
|
|
158
|
-
})
|
|
159
|
-
|
|
160
|
-
it('scope restricts which hotkeys fire', () => {
|
|
161
|
-
let globalFired = false
|
|
162
|
-
let editorFired = false
|
|
163
|
-
|
|
164
|
-
registerHotkey('ctrl+s', () => {
|
|
165
|
-
globalFired = true
|
|
166
|
-
})
|
|
167
|
-
registerHotkey(
|
|
168
|
-
'ctrl+s',
|
|
169
|
-
() => {
|
|
170
|
-
editorFired = true
|
|
171
|
-
},
|
|
172
|
-
{ scope: 'editor' },
|
|
173
|
-
)
|
|
174
|
-
|
|
175
|
-
fireKey('s', { ctrlKey: true })
|
|
176
|
-
expect(globalFired).toBe(true)
|
|
177
|
-
expect(editorFired).toBe(false)
|
|
178
|
-
|
|
179
|
-
// Enable editor scope
|
|
180
|
-
enableScope('editor')
|
|
181
|
-
globalFired = false
|
|
182
|
-
fireKey('s', { ctrlKey: true })
|
|
183
|
-
expect(globalFired).toBe(true)
|
|
184
|
-
expect(editorFired).toBe(true)
|
|
185
|
-
|
|
186
|
-
// Disable editor scope
|
|
187
|
-
disableScope('editor')
|
|
188
|
-
editorFired = false
|
|
189
|
-
fireKey('s', { ctrlKey: true })
|
|
190
|
-
expect(editorFired).toBe(false)
|
|
191
|
-
})
|
|
192
|
-
})
|
|
@@ -1,521 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
-
import {
|
|
3
|
-
_resetHotkeys,
|
|
4
|
-
disableScope,
|
|
5
|
-
enableScope,
|
|
6
|
-
formatCombo,
|
|
7
|
-
getActiveScopes,
|
|
8
|
-
getRegisteredHotkeys,
|
|
9
|
-
matchesCombo,
|
|
10
|
-
parseShortcut,
|
|
11
|
-
registerHotkey,
|
|
12
|
-
} from '../index'
|
|
13
|
-
|
|
14
|
-
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
15
|
-
|
|
16
|
-
function fireKey(
|
|
17
|
-
key: string,
|
|
18
|
-
modifiers: Partial<{
|
|
19
|
-
ctrlKey: boolean
|
|
20
|
-
shiftKey: boolean
|
|
21
|
-
altKey: boolean
|
|
22
|
-
metaKey: boolean
|
|
23
|
-
}> = {},
|
|
24
|
-
target?: HTMLElement,
|
|
25
|
-
): KeyboardEvent {
|
|
26
|
-
const event = new KeyboardEvent('keydown', {
|
|
27
|
-
key,
|
|
28
|
-
ctrlKey: modifiers.ctrlKey ?? false,
|
|
29
|
-
shiftKey: modifiers.shiftKey ?? false,
|
|
30
|
-
altKey: modifiers.altKey ?? false,
|
|
31
|
-
metaKey: modifiers.metaKey ?? false,
|
|
32
|
-
bubbles: true,
|
|
33
|
-
cancelable: true,
|
|
34
|
-
})
|
|
35
|
-
;(target ?? window).dispatchEvent(event)
|
|
36
|
-
return event
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// ─── Tests ───────────────────────────────────────────────────────────────────
|
|
40
|
-
|
|
41
|
-
describe('parseShortcut', () => {
|
|
42
|
-
it('parses simple key', () => {
|
|
43
|
-
const combo = parseShortcut('a')
|
|
44
|
-
expect(combo).toEqual({
|
|
45
|
-
ctrl: false,
|
|
46
|
-
shift: false,
|
|
47
|
-
alt: false,
|
|
48
|
-
meta: false,
|
|
49
|
-
key: 'a',
|
|
50
|
-
})
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
it('parses ctrl+key', () => {
|
|
54
|
-
const combo = parseShortcut('ctrl+s')
|
|
55
|
-
expect(combo.ctrl).toBe(true)
|
|
56
|
-
expect(combo.key).toBe('s')
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it('parses multiple modifiers', () => {
|
|
60
|
-
const combo = parseShortcut('ctrl+shift+alt+k')
|
|
61
|
-
expect(combo.ctrl).toBe(true)
|
|
62
|
-
expect(combo.shift).toBe(true)
|
|
63
|
-
expect(combo.alt).toBe(true)
|
|
64
|
-
expect(combo.key).toBe('k')
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
it('parses meta/cmd/command as meta', () => {
|
|
68
|
-
expect(parseShortcut('meta+k').meta).toBe(true)
|
|
69
|
-
expect(parseShortcut('cmd+k').meta).toBe(true)
|
|
70
|
-
expect(parseShortcut('command+k').meta).toBe(true)
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it('handles aliases', () => {
|
|
74
|
-
expect(parseShortcut('esc').key).toBe('escape')
|
|
75
|
-
expect(parseShortcut('return').key).toBe('enter')
|
|
76
|
-
expect(parseShortcut('del').key).toBe('delete')
|
|
77
|
-
expect(parseShortcut('space').key).toBe(' ')
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('parses mod as ctrl on non-Mac', () => {
|
|
81
|
-
// happy-dom doesn't simulate Mac, so mod should resolve to ctrl
|
|
82
|
-
const combo = parseShortcut('mod+k')
|
|
83
|
-
expect(combo.ctrl || combo.meta).toBe(true)
|
|
84
|
-
expect(combo.key).toBe('k')
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
it('parses control as ctrl', () => {
|
|
88
|
-
expect(parseShortcut('control+s').ctrl).toBe(true)
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
it('is case-insensitive', () => {
|
|
92
|
-
const combo = parseShortcut('Ctrl+Shift+S')
|
|
93
|
-
expect(combo.ctrl).toBe(true)
|
|
94
|
-
expect(combo.shift).toBe(true)
|
|
95
|
-
expect(combo.key).toBe('s')
|
|
96
|
-
})
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
describe('matchesCombo', () => {
|
|
100
|
-
it('matches simple key', () => {
|
|
101
|
-
const combo = parseShortcut('a')
|
|
102
|
-
const event = new KeyboardEvent('keydown', { key: 'a' })
|
|
103
|
-
expect(matchesCombo(event, combo)).toBe(true)
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
it('matches with modifiers', () => {
|
|
107
|
-
const combo = parseShortcut('ctrl+s')
|
|
108
|
-
const event = new KeyboardEvent('keydown', { key: 's', ctrlKey: true })
|
|
109
|
-
expect(matchesCombo(event, combo)).toBe(true)
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
it('does not match when modifier is missing', () => {
|
|
113
|
-
const combo = parseShortcut('ctrl+s')
|
|
114
|
-
const event = new KeyboardEvent('keydown', { key: 's' })
|
|
115
|
-
expect(matchesCombo(event, combo)).toBe(false)
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
it('does not match when extra modifier is present', () => {
|
|
119
|
-
const combo = parseShortcut('ctrl+s')
|
|
120
|
-
const event = new KeyboardEvent('keydown', {
|
|
121
|
-
key: 's',
|
|
122
|
-
ctrlKey: true,
|
|
123
|
-
shiftKey: true,
|
|
124
|
-
})
|
|
125
|
-
expect(matchesCombo(event, combo)).toBe(false)
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
it('does not match wrong key', () => {
|
|
129
|
-
const combo = parseShortcut('ctrl+s')
|
|
130
|
-
const event = new KeyboardEvent('keydown', { key: 'a', ctrlKey: true })
|
|
131
|
-
expect(matchesCombo(event, combo)).toBe(false)
|
|
132
|
-
})
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
describe('formatCombo', () => {
|
|
136
|
-
it('formats simple key', () => {
|
|
137
|
-
expect(formatCombo(parseShortcut('a'))).toBe('A')
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
it('formats with modifiers', () => {
|
|
141
|
-
const result = formatCombo(parseShortcut('ctrl+shift+s'))
|
|
142
|
-
expect(result).toBe('Ctrl+Shift+S')
|
|
143
|
-
})
|
|
144
|
-
|
|
145
|
-
it('capitalizes special keys', () => {
|
|
146
|
-
expect(formatCombo(parseShortcut('escape'))).toBe('Escape')
|
|
147
|
-
expect(formatCombo(parseShortcut('enter'))).toBe('Enter')
|
|
148
|
-
})
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
describe('registerHotkey', () => {
|
|
152
|
-
beforeEach(() => {
|
|
153
|
-
_resetHotkeys()
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
afterEach(() => {
|
|
157
|
-
_resetHotkeys()
|
|
158
|
-
})
|
|
159
|
-
|
|
160
|
-
it('fires handler on matching keydown', () => {
|
|
161
|
-
let fired = false
|
|
162
|
-
registerHotkey('ctrl+s', () => {
|
|
163
|
-
fired = true
|
|
164
|
-
})
|
|
165
|
-
fireKey('s', { ctrlKey: true })
|
|
166
|
-
expect(fired).toBe(true)
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
it('does not fire on non-matching key', () => {
|
|
170
|
-
let fired = false
|
|
171
|
-
registerHotkey('ctrl+s', () => {
|
|
172
|
-
fired = true
|
|
173
|
-
})
|
|
174
|
-
fireKey('a', { ctrlKey: true })
|
|
175
|
-
expect(fired).toBe(false)
|
|
176
|
-
})
|
|
177
|
-
|
|
178
|
-
it('does not fire without required modifier', () => {
|
|
179
|
-
let fired = false
|
|
180
|
-
registerHotkey('ctrl+s', () => {
|
|
181
|
-
fired = true
|
|
182
|
-
})
|
|
183
|
-
fireKey('s')
|
|
184
|
-
expect(fired).toBe(false)
|
|
185
|
-
})
|
|
186
|
-
|
|
187
|
-
it('passes the event to the handler', () => {
|
|
188
|
-
let receivedEvent: KeyboardEvent | null = null
|
|
189
|
-
registerHotkey('ctrl+s', (e) => {
|
|
190
|
-
receivedEvent = e
|
|
191
|
-
})
|
|
192
|
-
fireKey('s', { ctrlKey: true })
|
|
193
|
-
expect(receivedEvent).not.toBeNull()
|
|
194
|
-
expect(receivedEvent!.key).toBe('s')
|
|
195
|
-
})
|
|
196
|
-
|
|
197
|
-
it('preventDefault is true by default', () => {
|
|
198
|
-
registerHotkey('ctrl+s', () => {
|
|
199
|
-
// handler
|
|
200
|
-
})
|
|
201
|
-
const event = fireKey('s', { ctrlKey: true })
|
|
202
|
-
expect(event.defaultPrevented).toBe(true)
|
|
203
|
-
})
|
|
204
|
-
|
|
205
|
-
it('preventDefault can be disabled', () => {
|
|
206
|
-
registerHotkey(
|
|
207
|
-
'ctrl+s',
|
|
208
|
-
() => {
|
|
209
|
-
// handler
|
|
210
|
-
},
|
|
211
|
-
{ preventDefault: false },
|
|
212
|
-
)
|
|
213
|
-
const event = fireKey('s', { ctrlKey: true })
|
|
214
|
-
expect(event.defaultPrevented).toBe(false)
|
|
215
|
-
})
|
|
216
|
-
|
|
217
|
-
it('unregister function removes the hotkey', () => {
|
|
218
|
-
let count = 0
|
|
219
|
-
const unregister = registerHotkey('ctrl+s', () => {
|
|
220
|
-
count++
|
|
221
|
-
})
|
|
222
|
-
fireKey('s', { ctrlKey: true })
|
|
223
|
-
expect(count).toBe(1)
|
|
224
|
-
|
|
225
|
-
unregister()
|
|
226
|
-
fireKey('s', { ctrlKey: true })
|
|
227
|
-
expect(count).toBe(1)
|
|
228
|
-
})
|
|
229
|
-
|
|
230
|
-
it('does not fire in input elements by default', () => {
|
|
231
|
-
let fired = false
|
|
232
|
-
registerHotkey('ctrl+s', () => {
|
|
233
|
-
fired = true
|
|
234
|
-
})
|
|
235
|
-
|
|
236
|
-
const input = document.createElement('input')
|
|
237
|
-
document.body.appendChild(input)
|
|
238
|
-
fireKey('s', { ctrlKey: true }, input)
|
|
239
|
-
input.remove()
|
|
240
|
-
|
|
241
|
-
expect(fired).toBe(false)
|
|
242
|
-
})
|
|
243
|
-
|
|
244
|
-
it('fires in input elements when enableOnInputs is true', () => {
|
|
245
|
-
let fired = false
|
|
246
|
-
registerHotkey(
|
|
247
|
-
'ctrl+s',
|
|
248
|
-
() => {
|
|
249
|
-
fired = true
|
|
250
|
-
},
|
|
251
|
-
{ enableOnInputs: true },
|
|
252
|
-
)
|
|
253
|
-
|
|
254
|
-
const input = document.createElement('input')
|
|
255
|
-
document.body.appendChild(input)
|
|
256
|
-
fireKey('s', { ctrlKey: true }, input)
|
|
257
|
-
input.remove()
|
|
258
|
-
|
|
259
|
-
expect(fired).toBe(true)
|
|
260
|
-
})
|
|
261
|
-
|
|
262
|
-
it('does not fire in textarea by default', () => {
|
|
263
|
-
let fired = false
|
|
264
|
-
registerHotkey('ctrl+s', () => {
|
|
265
|
-
fired = true
|
|
266
|
-
})
|
|
267
|
-
|
|
268
|
-
const textarea = document.createElement('textarea')
|
|
269
|
-
document.body.appendChild(textarea)
|
|
270
|
-
fireKey('s', { ctrlKey: true }, textarea)
|
|
271
|
-
textarea.remove()
|
|
272
|
-
|
|
273
|
-
expect(fired).toBe(false)
|
|
274
|
-
})
|
|
275
|
-
|
|
276
|
-
it('does not fire in contenteditable by default', () => {
|
|
277
|
-
let fired = false
|
|
278
|
-
registerHotkey('ctrl+s', () => {
|
|
279
|
-
fired = true
|
|
280
|
-
})
|
|
281
|
-
|
|
282
|
-
const div = document.createElement('div')
|
|
283
|
-
div.contentEditable = 'true'
|
|
284
|
-
document.body.appendChild(div)
|
|
285
|
-
fireKey('s', { ctrlKey: true }, div)
|
|
286
|
-
div.remove()
|
|
287
|
-
|
|
288
|
-
expect(fired).toBe(false)
|
|
289
|
-
})
|
|
290
|
-
|
|
291
|
-
it('enabled: false prevents firing', () => {
|
|
292
|
-
let fired = false
|
|
293
|
-
registerHotkey(
|
|
294
|
-
'ctrl+s',
|
|
295
|
-
() => {
|
|
296
|
-
fired = true
|
|
297
|
-
},
|
|
298
|
-
{ enabled: false },
|
|
299
|
-
)
|
|
300
|
-
fireKey('s', { ctrlKey: true })
|
|
301
|
-
expect(fired).toBe(false)
|
|
302
|
-
})
|
|
303
|
-
|
|
304
|
-
it('enabled as function controls firing dynamically', () => {
|
|
305
|
-
let enabled = true
|
|
306
|
-
let count = 0
|
|
307
|
-
registerHotkey(
|
|
308
|
-
'ctrl+s',
|
|
309
|
-
() => {
|
|
310
|
-
count++
|
|
311
|
-
},
|
|
312
|
-
{ enabled: () => enabled },
|
|
313
|
-
)
|
|
314
|
-
|
|
315
|
-
fireKey('s', { ctrlKey: true })
|
|
316
|
-
expect(count).toBe(1)
|
|
317
|
-
|
|
318
|
-
enabled = false
|
|
319
|
-
fireKey('s', { ctrlKey: true })
|
|
320
|
-
expect(count).toBe(1)
|
|
321
|
-
})
|
|
322
|
-
|
|
323
|
-
it('multiple hotkeys can be registered', () => {
|
|
324
|
-
let saveCount = 0
|
|
325
|
-
let undoCount = 0
|
|
326
|
-
registerHotkey('ctrl+s', () => saveCount++)
|
|
327
|
-
registerHotkey('ctrl+z', () => undoCount++)
|
|
328
|
-
|
|
329
|
-
fireKey('s', { ctrlKey: true })
|
|
330
|
-
fireKey('z', { ctrlKey: true })
|
|
331
|
-
|
|
332
|
-
expect(saveCount).toBe(1)
|
|
333
|
-
expect(undoCount).toBe(1)
|
|
334
|
-
})
|
|
335
|
-
})
|
|
336
|
-
|
|
337
|
-
describe('scopes', () => {
|
|
338
|
-
beforeEach(() => {
|
|
339
|
-
_resetHotkeys()
|
|
340
|
-
})
|
|
341
|
-
|
|
342
|
-
afterEach(() => {
|
|
343
|
-
_resetHotkeys()
|
|
344
|
-
})
|
|
345
|
-
|
|
346
|
-
it('global scope is active by default', () => {
|
|
347
|
-
const scopes = getActiveScopes()
|
|
348
|
-
expect(scopes.peek().has('global')).toBe(true)
|
|
349
|
-
})
|
|
350
|
-
|
|
351
|
-
it('global scope hotkeys fire by default', () => {
|
|
352
|
-
let fired = false
|
|
353
|
-
registerHotkey('ctrl+s', () => {
|
|
354
|
-
fired = true
|
|
355
|
-
})
|
|
356
|
-
fireKey('s', { ctrlKey: true })
|
|
357
|
-
expect(fired).toBe(true)
|
|
358
|
-
})
|
|
359
|
-
|
|
360
|
-
it('non-global scope hotkeys do not fire by default', () => {
|
|
361
|
-
let fired = false
|
|
362
|
-
registerHotkey(
|
|
363
|
-
'ctrl+s',
|
|
364
|
-
() => {
|
|
365
|
-
fired = true
|
|
366
|
-
},
|
|
367
|
-
{ scope: 'editor' },
|
|
368
|
-
)
|
|
369
|
-
fireKey('s', { ctrlKey: true })
|
|
370
|
-
expect(fired).toBe(false)
|
|
371
|
-
})
|
|
372
|
-
|
|
373
|
-
it('enableScope activates a scope', () => {
|
|
374
|
-
let fired = false
|
|
375
|
-
registerHotkey(
|
|
376
|
-
'ctrl+s',
|
|
377
|
-
() => {
|
|
378
|
-
fired = true
|
|
379
|
-
},
|
|
380
|
-
{ scope: 'editor' },
|
|
381
|
-
)
|
|
382
|
-
|
|
383
|
-
enableScope('editor')
|
|
384
|
-
fireKey('s', { ctrlKey: true })
|
|
385
|
-
expect(fired).toBe(true)
|
|
386
|
-
})
|
|
387
|
-
|
|
388
|
-
it('disableScope deactivates a scope', () => {
|
|
389
|
-
let count = 0
|
|
390
|
-
registerHotkey(
|
|
391
|
-
'ctrl+s',
|
|
392
|
-
() => {
|
|
393
|
-
count++
|
|
394
|
-
},
|
|
395
|
-
{ scope: 'editor' },
|
|
396
|
-
)
|
|
397
|
-
|
|
398
|
-
enableScope('editor')
|
|
399
|
-
fireKey('s', { ctrlKey: true })
|
|
400
|
-
expect(count).toBe(1)
|
|
401
|
-
|
|
402
|
-
disableScope('editor')
|
|
403
|
-
fireKey('s', { ctrlKey: true })
|
|
404
|
-
expect(count).toBe(1)
|
|
405
|
-
})
|
|
406
|
-
|
|
407
|
-
it('cannot disable global scope', () => {
|
|
408
|
-
disableScope('global')
|
|
409
|
-
expect(getActiveScopes().peek().has('global')).toBe(true)
|
|
410
|
-
})
|
|
411
|
-
|
|
412
|
-
it('enableScope is idempotent', () => {
|
|
413
|
-
enableScope('editor')
|
|
414
|
-
enableScope('editor')
|
|
415
|
-
expect(getActiveScopes().peek().size).toBe(2) // global + editor
|
|
416
|
-
})
|
|
417
|
-
|
|
418
|
-
it('disableScope for non-active scope is no-op', () => {
|
|
419
|
-
disableScope('nonexistent')
|
|
420
|
-
expect(getActiveScopes().peek().size).toBe(1)
|
|
421
|
-
})
|
|
422
|
-
})
|
|
423
|
-
|
|
424
|
-
describe('getRegisteredHotkeys', () => {
|
|
425
|
-
beforeEach(() => {
|
|
426
|
-
_resetHotkeys()
|
|
427
|
-
})
|
|
428
|
-
|
|
429
|
-
afterEach(() => {
|
|
430
|
-
_resetHotkeys()
|
|
431
|
-
})
|
|
432
|
-
|
|
433
|
-
it('returns all registered hotkeys', () => {
|
|
434
|
-
const noop = () => {}
|
|
435
|
-
registerHotkey('ctrl+s', noop, { description: 'Save' })
|
|
436
|
-
registerHotkey('ctrl+z', noop, { scope: 'editor', description: 'Undo' })
|
|
437
|
-
|
|
438
|
-
const hotkeys = getRegisteredHotkeys()
|
|
439
|
-
expect(hotkeys).toHaveLength(2)
|
|
440
|
-
expect(hotkeys[0]).toEqual({
|
|
441
|
-
shortcut: 'ctrl+s',
|
|
442
|
-
scope: 'global',
|
|
443
|
-
description: 'Save',
|
|
444
|
-
})
|
|
445
|
-
expect(hotkeys[1]).toEqual({
|
|
446
|
-
shortcut: 'ctrl+z',
|
|
447
|
-
scope: 'editor',
|
|
448
|
-
description: 'Undo',
|
|
449
|
-
})
|
|
450
|
-
})
|
|
451
|
-
|
|
452
|
-
it('reflects unregistered hotkeys', () => {
|
|
453
|
-
const noop = () => {}
|
|
454
|
-
const unsub = registerHotkey('ctrl+s', noop)
|
|
455
|
-
expect(getRegisteredHotkeys()).toHaveLength(1)
|
|
456
|
-
unsub()
|
|
457
|
-
expect(getRegisteredHotkeys()).toHaveLength(0)
|
|
458
|
-
})
|
|
459
|
-
})
|
|
460
|
-
|
|
461
|
-
describe('Event listener cleanup', () => {
|
|
462
|
-
beforeEach(() => {
|
|
463
|
-
_resetHotkeys()
|
|
464
|
-
})
|
|
465
|
-
|
|
466
|
-
afterEach(() => {
|
|
467
|
-
_resetHotkeys()
|
|
468
|
-
})
|
|
469
|
-
|
|
470
|
-
it('attaches listener on first registration', () => {
|
|
471
|
-
const addEventListenerSpy = vi.spyOn(window, 'addEventListener')
|
|
472
|
-
const noop = () => {}
|
|
473
|
-
|
|
474
|
-
registerHotkey('ctrl+s', noop)
|
|
475
|
-
|
|
476
|
-
expect(addEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
|
|
477
|
-
addEventListenerSpy.mockRestore()
|
|
478
|
-
})
|
|
479
|
-
|
|
480
|
-
it('detaches listener when all hotkeys are unregistered', () => {
|
|
481
|
-
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')
|
|
482
|
-
const noop = () => {}
|
|
483
|
-
|
|
484
|
-
const unsub1 = registerHotkey('ctrl+s', noop)
|
|
485
|
-
const unsub2 = registerHotkey('ctrl+z', noop)
|
|
486
|
-
|
|
487
|
-
// Both registered — listener still attached
|
|
488
|
-
unsub1()
|
|
489
|
-
expect(removeEventListenerSpy).not.toHaveBeenCalled()
|
|
490
|
-
|
|
491
|
-
// Last one unregistered — listener should detach
|
|
492
|
-
unsub2()
|
|
493
|
-
expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
|
|
494
|
-
|
|
495
|
-
removeEventListenerSpy.mockRestore()
|
|
496
|
-
})
|
|
497
|
-
|
|
498
|
-
it('reattaches listener when new hotkey registered after cleanup', () => {
|
|
499
|
-
const addEventListenerSpy = vi.spyOn(window, 'addEventListener')
|
|
500
|
-
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')
|
|
501
|
-
const noop = () => {}
|
|
502
|
-
|
|
503
|
-
const unsub1 = registerHotkey('ctrl+s', noop)
|
|
504
|
-
expect(addEventListenerSpy).toHaveBeenCalledTimes(1)
|
|
505
|
-
|
|
506
|
-
unsub1()
|
|
507
|
-
expect(removeEventListenerSpy).toHaveBeenCalledTimes(1)
|
|
508
|
-
|
|
509
|
-
// Register new hotkey — should reattach
|
|
510
|
-
registerHotkey('ctrl+z', noop)
|
|
511
|
-
expect(addEventListenerSpy).toHaveBeenCalledTimes(2)
|
|
512
|
-
|
|
513
|
-
addEventListenerSpy.mockRestore()
|
|
514
|
-
removeEventListenerSpy.mockRestore()
|
|
515
|
-
})
|
|
516
|
-
|
|
517
|
-
it('preserves vi for test environment', () => {
|
|
518
|
-
// This test just verifies vi is available (vitest spy helpers)
|
|
519
|
-
expect(typeof vi).toBe('object')
|
|
520
|
-
})
|
|
521
|
-
})
|