@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.
- package/LICENSE +21 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +221 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +198 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +130 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +52 -0
- package/src/index.ts +49 -0
- package/src/parse.ts +93 -0
- package/src/registry.ts +151 -0
- package/src/tests/hotkeys.test.ts +461 -0
- package/src/types.ts +54 -0
- package/src/use-hotkey-scope.ts +20 -0
- package/src/use-hotkey.ts +26 -0
|
@@ -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
|
+
}
|