@pyreon/hotkeys 0.6.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.
@@ -0,0 +1,461 @@
1
+ import { afterEach, beforeEach, describe, expect, it } 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
+ // biome-ignore lint/suspicious/noEmptyBlockStatements: intentional no-op handlers for registry test
435
+ const noop = () => {}
436
+ registerHotkey('ctrl+s', noop, { description: 'Save' })
437
+ registerHotkey('ctrl+z', noop, { scope: 'editor', description: 'Undo' })
438
+
439
+ const hotkeys = getRegisteredHotkeys()
440
+ expect(hotkeys).toHaveLength(2)
441
+ expect(hotkeys[0]).toEqual({
442
+ shortcut: 'ctrl+s',
443
+ scope: 'global',
444
+ description: 'Save',
445
+ })
446
+ expect(hotkeys[1]).toEqual({
447
+ shortcut: 'ctrl+z',
448
+ scope: 'editor',
449
+ description: 'Undo',
450
+ })
451
+ })
452
+
453
+ it('reflects unregistered hotkeys', () => {
454
+ // biome-ignore lint/suspicious/noEmptyBlockStatements: intentional no-op handler
455
+ const noop = () => {}
456
+ const unsub = registerHotkey('ctrl+s', noop)
457
+ expect(getRegisteredHotkeys()).toHaveLength(1)
458
+ unsub()
459
+ expect(getRegisteredHotkeys()).toHaveLength(0)
460
+ })
461
+ })
package/src/types.ts ADDED
@@ -0,0 +1,54 @@
1
+ // ─── Hotkey Types ────────────────────────────────────────────────────────────
2
+
3
+ /**
4
+ * A parsed key combination.
5
+ * Example: 'ctrl+shift+s' → { ctrl: true, shift: true, alt: false, meta: false, key: 's' }
6
+ */
7
+ export interface KeyCombo {
8
+ ctrl: boolean
9
+ shift: boolean
10
+ alt: boolean
11
+ meta: boolean
12
+ key: string
13
+ }
14
+
15
+ /**
16
+ * Options for registering a hotkey.
17
+ */
18
+ export interface HotkeyOptions {
19
+ /** Scope for the hotkey — only active when this scope is active. Default: 'global' */
20
+ scope?: string
21
+ /** Whether to prevent default browser behavior — default: true */
22
+ preventDefault?: boolean
23
+ /** Whether to stop event propagation — default: false */
24
+ stopPropagation?: boolean
25
+ /** Whether the hotkey fires when an input/textarea/contenteditable is focused — default: false */
26
+ enableOnInputs?: boolean
27
+ /** Description of what this hotkey does — useful for help dialogs */
28
+ description?: string
29
+ /** Whether the hotkey is enabled — default: true */
30
+ enabled?: boolean | (() => boolean)
31
+ }
32
+
33
+ /**
34
+ * A registered hotkey entry.
35
+ */
36
+ export interface HotkeyEntry {
37
+ /** The original shortcut string (e.g. 'ctrl+s') */
38
+ shortcut: string
39
+ /** Parsed key combination */
40
+ combo: KeyCombo
41
+ /** The callback to invoke */
42
+ handler: (event: KeyboardEvent) => void
43
+ /** Options */
44
+ options: Required<
45
+ Pick<
46
+ HotkeyOptions,
47
+ | 'scope'
48
+ | 'preventDefault'
49
+ | 'stopPropagation'
50
+ | 'enableOnInputs'
51
+ | 'enabled'
52
+ >
53
+ > & { description?: string }
54
+ }
@@ -0,0 +1,20 @@
1
+ import { onUnmount } from '@pyreon/core'
2
+ import { disableScope, enableScope } from './registry'
3
+
4
+ /**
5
+ * Activate a hotkey scope for the lifetime of a component.
6
+ * When the component unmounts, the scope is deactivated.
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * function Modal() {
11
+ * useHotkeyScope('modal')
12
+ * useHotkey('escape', () => closeModal(), { scope: 'modal' })
13
+ * // ...
14
+ * }
15
+ * ```
16
+ */
17
+ export function useHotkeyScope(scope: string): void {
18
+ enableScope(scope)
19
+ onUnmount(() => disableScope(scope))
20
+ }
@@ -0,0 +1,26 @@
1
+ import { onUnmount } from '@pyreon/core'
2
+ import { registerHotkey } from './registry'
3
+ import type { HotkeyOptions } from './types'
4
+
5
+ /**
6
+ * Register a keyboard shortcut scoped to a component's lifecycle.
7
+ * Automatically unregisters when the component unmounts.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * function Editor() {
12
+ * useHotkey('ctrl+s', () => save(), { description: 'Save document' })
13
+ * useHotkey('ctrl+z', () => undo())
14
+ * useHotkey('ctrl+shift+z', () => redo())
15
+ * // ...
16
+ * }
17
+ * ```
18
+ */
19
+ export function useHotkey(
20
+ shortcut: string,
21
+ handler: (event: KeyboardEvent) => void,
22
+ options?: HotkeyOptions,
23
+ ): void {
24
+ const unregister = registerHotkey(shortcut, handler, options)
25
+ onUnmount(unregister)
26
+ }