@pyreon/hotkeys 0.12.8 → 0.12.9
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/hotkeys",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.9",
|
|
4
4
|
"description": "Reactive keyboard shortcut management for Pyreon — scope-aware, conflict detection",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/hotkeys#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -42,12 +42,12 @@
|
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@happy-dom/global-registrator": "^20.8.9",
|
|
45
|
-
"@pyreon/core": "^0.12.
|
|
46
|
-
"@pyreon/reactivity": "^0.12.
|
|
45
|
+
"@pyreon/core": "^0.12.9",
|
|
46
|
+
"@pyreon/reactivity": "^0.12.9",
|
|
47
47
|
"@vitus-labs/tools-lint": "^1.15.5"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@pyreon/core": "^0.12.
|
|
51
|
-
"@pyreon/reactivity": "^0.12.
|
|
50
|
+
"@pyreon/core": "^0.12.9",
|
|
51
|
+
"@pyreon/reactivity": "^0.12.9"
|
|
52
52
|
}
|
|
53
53
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
let unmountCallbacks: Array<() => void> = []
|
|
4
|
+
|
|
5
|
+
vi.mock('@pyreon/core', () => ({
|
|
6
|
+
onUnmount: (fn: () => void) => {
|
|
7
|
+
unmountCallbacks.push(fn)
|
|
8
|
+
},
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
_resetHotkeys,
|
|
13
|
+
getActiveScopes,
|
|
14
|
+
getRegisteredHotkeys,
|
|
15
|
+
} from '../registry'
|
|
16
|
+
import { useHotkey } from '../use-hotkey'
|
|
17
|
+
import { useHotkeyScope } from '../use-hotkey-scope'
|
|
18
|
+
|
|
19
|
+
function fireKey(
|
|
20
|
+
key: string,
|
|
21
|
+
modifiers: Partial<{
|
|
22
|
+
ctrlKey: boolean
|
|
23
|
+
shiftKey: boolean
|
|
24
|
+
altKey: boolean
|
|
25
|
+
metaKey: boolean
|
|
26
|
+
}> = {},
|
|
27
|
+
): void {
|
|
28
|
+
const event = new KeyboardEvent('keydown', {
|
|
29
|
+
key,
|
|
30
|
+
ctrlKey: modifiers.ctrlKey ?? false,
|
|
31
|
+
shiftKey: modifiers.shiftKey ?? false,
|
|
32
|
+
altKey: modifiers.altKey ?? false,
|
|
33
|
+
metaKey: modifiers.metaKey ?? false,
|
|
34
|
+
bubbles: true,
|
|
35
|
+
cancelable: true,
|
|
36
|
+
})
|
|
37
|
+
window.dispatchEvent(event)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('useHotkey', () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
_resetHotkeys()
|
|
43
|
+
unmountCallbacks = []
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
_resetHotkeys()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('registers a hotkey that fires on keydown', () => {
|
|
51
|
+
let fired = false
|
|
52
|
+
useHotkey('ctrl+s', () => {
|
|
53
|
+
fired = true
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
fireKey('s', { ctrlKey: true })
|
|
57
|
+
expect(fired).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('unregisters hotkey on unmount', () => {
|
|
61
|
+
let count = 0
|
|
62
|
+
useHotkey('ctrl+s', () => {
|
|
63
|
+
count++
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
fireKey('s', { ctrlKey: true })
|
|
67
|
+
expect(count).toBe(1)
|
|
68
|
+
|
|
69
|
+
// Simulate unmount
|
|
70
|
+
unmountCallbacks.forEach((fn) => fn())
|
|
71
|
+
|
|
72
|
+
fireKey('s', { ctrlKey: true })
|
|
73
|
+
expect(count).toBe(1) // should not fire after unmount
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('passes options to registerHotkey', () => {
|
|
77
|
+
useHotkey('ctrl+s', () => {}, { description: 'Save', scope: 'editor' })
|
|
78
|
+
const hotkeys = getRegisteredHotkeys()
|
|
79
|
+
expect(hotkeys).toHaveLength(1)
|
|
80
|
+
expect(hotkeys[0]!.description).toBe('Save')
|
|
81
|
+
expect(hotkeys[0]!.scope).toBe('editor')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('useHotkeyScope', () => {
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
_resetHotkeys()
|
|
88
|
+
unmountCallbacks = []
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
_resetHotkeys()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('enables a scope', () => {
|
|
96
|
+
useHotkeyScope('modal')
|
|
97
|
+
expect(getActiveScopes().peek().has('modal')).toBe(true)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('disables scope on unmount', () => {
|
|
101
|
+
useHotkeyScope('modal')
|
|
102
|
+
expect(getActiveScopes().peek().has('modal')).toBe(true)
|
|
103
|
+
|
|
104
|
+
// Simulate unmount
|
|
105
|
+
unmountCallbacks.forEach((fn) => fn())
|
|
106
|
+
|
|
107
|
+
expect(getActiveScopes().peek().has('modal')).toBe(false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('scoped hotkey fires when scope is active', () => {
|
|
111
|
+
let fired = false
|
|
112
|
+
useHotkeyScope('editor')
|
|
113
|
+
useHotkey('escape', () => {
|
|
114
|
+
fired = true
|
|
115
|
+
}, { scope: 'editor' })
|
|
116
|
+
|
|
117
|
+
fireKey('Escape')
|
|
118
|
+
expect(fired).toBe(true)
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -0,0 +1,192 @@
|
|
|
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
|
+
})
|