@pyreon/hotkeys 0.24.5 → 0.24.6

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.
@@ -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
- })