@pyreon/hotkeys 0.12.7 → 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.7",
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.7",
46
- "@pyreon/reactivity": "^0.12.7",
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.7",
51
- "@pyreon/reactivity": "^0.12.7"
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
+ })