@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/hotkeys",
3
- "version": "0.24.5",
3
+ "version": "0.24.6",
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": {
@@ -15,7 +15,6 @@
15
15
  "files": [
16
16
  "lib",
17
17
  "!lib/**/*.map",
18
- "src",
19
18
  "README.md",
20
19
  "LICENSE"
21
20
  ],
@@ -26,7 +25,6 @@
26
25
  "types": "./lib/types/index.d.ts",
27
26
  "exports": {
28
27
  ".": {
29
- "bun": "./src/index.ts",
30
28
  "import": "./lib/index.js",
31
29
  "types": "./lib/types/index.d.ts"
32
30
  }
@@ -43,13 +41,13 @@
43
41
  },
44
42
  "devDependencies": {
45
43
  "@happy-dom/global-registrator": "^20.8.9",
46
- "@pyreon/core": "^0.24.5",
44
+ "@pyreon/core": "^0.24.6",
47
45
  "@pyreon/manifest": "0.13.1",
48
- "@pyreon/reactivity": "^0.24.5",
46
+ "@pyreon/reactivity": "^0.24.6",
49
47
  "@vitus-labs/tools-lint": "^2.4.0"
50
48
  },
51
49
  "dependencies": {
52
- "@pyreon/core": "^0.24.5",
53
- "@pyreon/reactivity": "^0.24.5"
50
+ "@pyreon/core": "^0.24.6",
51
+ "@pyreon/reactivity": "^0.24.6"
54
52
  }
55
53
  }
package/src/index.ts DELETED
@@ -1,49 +0,0 @@
1
- /**
2
- * @pyreon/hotkeys — Reactive keyboard shortcut management for Pyreon.
3
- *
4
- * Register global or scoped keyboard shortcuts with automatic lifecycle
5
- * management. Supports modifier keys, aliases, input filtering, and
6
- * scope-based activation.
7
- *
8
- * @example
9
- * ```ts
10
- * import { useHotkey, useHotkeyScope } from '@pyreon/hotkeys'
11
- *
12
- * // Global shortcut
13
- * useHotkey('ctrl+s', () => save(), { description: 'Save' })
14
- *
15
- * // Scoped shortcut — only active when scope is enabled
16
- * useHotkeyScope('editor')
17
- * useHotkey('ctrl+z', () => undo(), { scope: 'editor' })
18
- *
19
- * // Platform-aware — mod = ⌘ on Mac, Ctrl elsewhere
20
- * useHotkey('mod+k', () => openCommandPalette())
21
- * ```
22
- */
23
-
24
- // ─── Hooks ───────────────────────────────────────────────────────────────────
25
-
26
- export { useHotkey } from './use-hotkey'
27
- export { useHotkeyScope } from './use-hotkey-scope'
28
-
29
- // ─── Imperative API ──────────────────────────────────────────────────────────
30
-
31
- export {
32
- disableScope,
33
- enableScope,
34
- getActiveScopes,
35
- getRegisteredHotkeys,
36
- registerHotkey,
37
- } from './registry'
38
-
39
- // ─── Utilities ───────────────────────────────────────────────────────────────
40
-
41
- export { formatCombo, matchesCombo, parseShortcut } from './parse'
42
-
43
- // ─── Types ───────────────────────────────────────────────────────────────────
44
-
45
- export type { HotkeyEntry, HotkeyOptions, KeyCombo } from './types'
46
-
47
- // ─── Testing ─────────────────────────────────────────────────────────────────
48
-
49
- export { _resetHotkeys } from './registry'
package/src/manifest.ts DELETED
@@ -1,119 +0,0 @@
1
- import { defineManifest } from '@pyreon/manifest'
2
-
3
- export default defineManifest({
4
- name: '@pyreon/hotkeys',
5
- title: 'Keyboard Shortcuts',
6
- tagline:
7
- 'Keyboard shortcut management — scope-aware, modifier keys, conflict detection',
8
- description:
9
- 'Reactive keyboard shortcut management for Pyreon. Register global or scoped shortcuts with automatic lifecycle management. Supports `mod` alias (Command on Mac, Ctrl elsewhere), multi-key combos, scope-based activation for context-aware shortcuts, and conflict detection. Component-scoped hooks auto-unregister on unmount. Imperative API available for non-component contexts.',
10
- category: 'universal',
11
- longExample: `import { useHotkey, useHotkeyScope, registerHotkey, getRegisteredHotkeys, enableScope, disableScope } from '@pyreon/hotkeys'
12
-
13
- // Global shortcut — auto-unregisters on unmount
14
- useHotkey('mod+s', (e) => {
15
- e.preventDefault() // prevent browser save dialog
16
- save()
17
- }, { description: 'Save document' })
18
-
19
- // Platform-aware: mod = ⌘ on Mac, Ctrl on Windows/Linux
20
- useHotkey('mod+k', () => openCommandPalette())
21
-
22
- // Multi-key combo
23
- useHotkey('ctrl+shift+p', () => openPreferences())
24
-
25
- // Scoped shortcuts — only active when scope is enabled
26
- useHotkeyScope('editor') // activates 'editor' scope for this component's lifetime
27
-
28
- useHotkey('ctrl+z', () => undo(), { scope: 'editor', description: 'Undo' })
29
- useHotkey('ctrl+shift+z', () => redo(), { scope: 'editor', description: 'Redo' })
30
- useHotkey('ctrl+d', () => duplicateLine(), { scope: 'editor' })
31
-
32
- // Imperative API — for non-component contexts (stores, middleware)
33
- const unregister = registerHotkey('ctrl+q', () => quit(), { scope: 'global' })
34
- // unregister() when done
35
-
36
- // Introspection
37
- const hotkeys = getRegisteredHotkeys() // all registered shortcuts
38
- enableScope('modal') // programmatically enable a scope
39
- disableScope('editor') // programmatically disable a scope
40
-
41
- // Shortcuts can filter input elements — by default, shortcuts
42
- // don't fire when focused on <input>, <textarea>, <select>.
43
- // Override with:
44
- useHotkey('escape', () => closeModal(), { enableOnFormElements: true })`,
45
- features: [
46
- 'useHotkey(shortcut, handler, options?) — component-scoped, auto-unregisters on unmount',
47
- 'useHotkeyScope(scope) — activate a scope for a component\'s lifetime',
48
- 'mod alias — Command on Mac, Ctrl elsewhere',
49
- 'Scope-based activation for context-aware shortcuts',
50
- 'Imperative API: registerHotkey, enableScope, disableScope, getRegisteredHotkeys',
51
- 'parseShortcut / matchesCombo / formatCombo utilities',
52
- ],
53
- api: [
54
- {
55
- name: 'useHotkey',
56
- kind: 'hook',
57
- signature:
58
- '(shortcut: string, handler: (e: KeyboardEvent) => void, options?: HotkeyOptions) => void',
59
- summary:
60
- 'Register a keyboard shortcut that auto-unregisters when the component unmounts. Shortcut format: `mod+s`, `ctrl+shift+p`, `escape`, etc. `mod` is Command on Mac, Ctrl elsewhere. By default, shortcuts don\'t fire when focused on form elements (input, textarea, select) — override with `enableOnFormElements: true`. Supports `scope` option for context-aware activation and `description` for introspection.',
61
- example: `useHotkey('mod+s', (e) => {
62
- e.preventDefault()
63
- save()
64
- }, { description: 'Save' })
65
-
66
- useHotkey('ctrl+z', () => undo(), { scope: 'editor' })
67
- useHotkey('escape', () => close(), { enableOnFormElements: true })`,
68
- mistakes: [
69
- 'Forgetting e.preventDefault() for browser-reserved shortcuts (mod+s, mod+p) — the browser dialog fires alongside your handler',
70
- 'Registering the same shortcut in overlapping scopes without priority — both handlers fire; use scope isolation to prevent conflicts',
71
- 'Using useHotkey outside a component body — the onUnmount cleanup requires an active component setup context',
72
- 'Not activating the scope — useHotkey with a scope option does nothing unless useHotkeyScope(scope) is called or enableScope(scope) is invoked',
73
- ],
74
- seeAlso: ['useHotkeyScope', 'registerHotkey'],
75
- },
76
- {
77
- name: 'useHotkeyScope',
78
- kind: 'hook',
79
- signature: '(scope: string) => void',
80
- summary:
81
- 'Activate a hotkey scope for the lifetime of the current component. When the component mounts, the scope is enabled; when it unmounts, the scope is disabled. Shortcuts registered with a matching `scope` option only fire when the scope is active. Multiple components can activate the same scope — it stays active until the last one unmounts.',
82
- example: `// In an editor component:
83
- useHotkeyScope('editor')
84
- useHotkey('ctrl+z', () => undo(), { scope: 'editor' })
85
-
86
- // In a modal component:
87
- useHotkeyScope('modal')
88
- useHotkey('escape', () => close(), { scope: 'modal' })`,
89
- mistakes: [
90
- 'Using useHotkeyScope outside a component body — the lifecycle hooks require an active setup context',
91
- 'Assuming scope deactivation is immediate on unmount — if another component also activated the scope, it stays active',
92
- ],
93
- seeAlso: ['useHotkey', 'enableScope', 'disableScope'],
94
- },
95
- {
96
- name: 'registerHotkey',
97
- kind: 'function',
98
- signature:
99
- '(shortcut: string, handler: (e: KeyboardEvent) => void, options?: HotkeyOptions) => () => void',
100
- summary:
101
- 'Imperative hotkey registration for non-component contexts (stores, global setup). Returns an unregister function. Unlike useHotkey, this does NOT auto-cleanup on unmount — caller is responsible for calling the returned unregister function.',
102
- example: `const unregister = registerHotkey('ctrl+q', () => quit(), { scope: 'global' })
103
- // Later:
104
- unregister()`,
105
- seeAlso: ['useHotkey'],
106
- },
107
- ],
108
- gotchas: [
109
- 'By default, shortcuts do NOT fire when focused on form elements (input, textarea, select). Pass `enableOnFormElements: true` in options to override. Escape is a common candidate for this override.',
110
- {
111
- label: 'mod alias',
112
- note: '`mod` maps to Command on macOS, Ctrl on Windows/Linux. Write `mod+s` instead of platform-specific `ctrl+s` / `cmd+s` for cross-platform shortcuts.',
113
- },
114
- {
115
- label: 'Scopes',
116
- note: 'Scoped shortcuts only fire when their scope is active. Activate with `useHotkeyScope(scope)` (component-scoped) or `enableScope(scope)` (imperative). Without activation, scoped handlers are silently dormant.',
117
- },
118
- ],
119
- })
package/src/parse.ts DELETED
@@ -1,91 +0,0 @@
1
- import type { KeyCombo } from './types'
2
-
3
- // ─── Key aliases ─────────────────────────────────────────────────────────────
4
-
5
- const KEY_ALIASES: Record<string, string> = {
6
- esc: 'escape',
7
- return: 'enter',
8
- del: 'delete',
9
- ins: 'insert',
10
- space: ' ',
11
- spacebar: ' ',
12
- up: 'arrowup',
13
- down: 'arrowdown',
14
- left: 'arrowleft',
15
- right: 'arrowright',
16
- plus: '+',
17
- }
18
-
19
- /**
20
- * Parse a shortcut string like 'ctrl+shift+s' into a KeyCombo.
21
- * Supports aliases (esc, del, space, etc.) and mod (ctrl on Windows/Linux, meta on Mac).
22
- */
23
- export function parseShortcut(shortcut: string): KeyCombo {
24
- const parts = shortcut.toLowerCase().trim().split('+')
25
- const combo: KeyCombo = {
26
- ctrl: false,
27
- shift: false,
28
- alt: false,
29
- meta: false,
30
- key: '',
31
- }
32
-
33
- for (const part of parts) {
34
- const p = part.trim()
35
- if (p === 'ctrl' || p === 'control') {
36
- combo.ctrl = true
37
- } else if (p === 'shift') {
38
- combo.shift = true
39
- } else if (p === 'alt') {
40
- combo.alt = true
41
- } else if (p === 'meta' || p === 'cmd' || p === 'command') {
42
- combo.meta = true
43
- } else if (p === 'mod') {
44
- // mod = meta on Mac, ctrl elsewhere
45
- if (isMac()) {
46
- combo.meta = true
47
- } else {
48
- combo.ctrl = true
49
- }
50
- } else {
51
- combo.key = KEY_ALIASES[p] ?? p
52
- }
53
- }
54
-
55
- return combo
56
- }
57
-
58
- /**
59
- * Check if a KeyboardEvent matches a KeyCombo.
60
- */
61
- export function matchesCombo(event: KeyboardEvent, combo: KeyCombo): boolean {
62
- if (event.ctrlKey !== combo.ctrl) return false
63
- if (event.shiftKey !== combo.shift) return false
64
- if (event.altKey !== combo.alt) return false
65
- if (event.metaKey !== combo.meta) return false
66
-
67
- const eventKey = event.key.toLowerCase()
68
- return eventKey === combo.key
69
- }
70
-
71
- /**
72
- * Format a KeyCombo back to a human-readable string.
73
- */
74
- export function formatCombo(combo: KeyCombo): string {
75
- const parts: string[] = []
76
- if (combo.ctrl) parts.push('Ctrl')
77
- if (combo.shift) parts.push('Shift')
78
- if (combo.alt) parts.push('Alt')
79
- if (combo.meta) parts.push(isMac() ? '⌘' : 'Meta')
80
- parts.push(combo.key.length === 1 ? combo.key.toUpperCase() : capitalize(combo.key))
81
- return parts.join('+')
82
- }
83
-
84
- function capitalize(s: string): string {
85
- return s.charAt(0).toUpperCase() + s.slice(1)
86
- }
87
-
88
- function isMac(): boolean {
89
- if (typeof navigator === 'undefined') return false
90
- return /mac|iphone|ipad|ipod/i.test(navigator.userAgent)
91
- }
package/src/registry.ts DELETED
@@ -1,176 +0,0 @@
1
- import type { Signal } from '@pyreon/reactivity'
2
- import { signal } from '@pyreon/reactivity'
3
- import { matchesCombo, parseShortcut } from './parse'
4
- import type { HotkeyEntry, HotkeyOptions } from './types'
5
-
6
- // ─── State ───────────────────────────────────────────────────────────────────
7
-
8
- // Linear array on purpose. Per-keystroke dispatch (line 35) iterates entries
9
- // in registration order — O(n) where n = registered hotkeys. For real apps
10
- // (single-digit to low-tens) the cost is sub-microsecond and well under the
11
- // 16ms frame budget. Switching to a Map<comboKey, entries[]> would help only
12
- // past ~5,000 hotkeys, which is unrealistic. The array also preserves
13
- // registration order for the rare case where two hotkeys match the same
14
- // combo on different scopes — first-registered wins. Don't replace with a
15
- // Map without confirming a real app actually hits the perf wall.
16
- const entries: HotkeyEntry[] = []
17
- const activeScopes = signal<Set<string>>(new Set(['global']))
18
- let listenerAttached = false
19
- let keydownHandler: ((event: KeyboardEvent) => void) | null = null
20
-
21
- // ─── Input detection ─────────────────────────────────────────────────────────
22
-
23
- const INPUT_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT'])
24
-
25
- function isInputFocused(event: KeyboardEvent): boolean {
26
- const target = event.target as HTMLElement | null
27
- if (!target) return false
28
- if (INPUT_TAGS.has(target.tagName)) return true
29
- if (target.isContentEditable) return true
30
- return false
31
- }
32
-
33
- // ─── Global listener ─────────────────────────────────────────────────────────
34
-
35
- function attachListener(): void {
36
- if (listenerAttached) return
37
- if (typeof window === 'undefined') return
38
- listenerAttached = true
39
-
40
- keydownHandler = (event) => {
41
- const scopes = activeScopes.peek()
42
-
43
- for (const entry of entries) {
44
- // Check scope
45
- if (!scopes.has(entry.options.scope)) continue
46
-
47
- // Check enabled
48
- const enabled =
49
- typeof entry.options.enabled === 'function'
50
- ? entry.options.enabled()
51
- : entry.options.enabled
52
- if (!enabled) continue
53
-
54
- // Check input focus
55
- if (!entry.options.enableOnInputs && isInputFocused(event)) continue
56
-
57
- // Check key match
58
- if (!matchesCombo(event, entry.combo)) continue
59
-
60
- // Match found
61
- if (entry.options.preventDefault) event.preventDefault()
62
- if (entry.options.stopPropagation) event.stopPropagation()
63
- entry.handler(event)
64
- }
65
- }
66
-
67
- window.addEventListener('keydown', keydownHandler)
68
- }
69
-
70
- function detachListener(): void {
71
- if (typeof window === 'undefined') return
72
- if (!listenerAttached || !keydownHandler) return
73
- window.removeEventListener('keydown', keydownHandler)
74
- listenerAttached = false
75
- keydownHandler = null
76
- }
77
-
78
- // ─── Registration ────────────────────────────────────────────────────────────
79
-
80
- /**
81
- * Register a keyboard shortcut. Returns an unregister function.
82
- *
83
- * @example
84
- * ```ts
85
- * const unregister = registerHotkey('ctrl+s', (e) => save(), { description: 'Save' })
86
- * // later: unregister()
87
- * ```
88
- */
89
- export function registerHotkey(
90
- shortcut: string,
91
- handler: (event: KeyboardEvent) => void,
92
- options?: HotkeyOptions,
93
- ): () => void {
94
- attachListener()
95
-
96
- const entry: HotkeyEntry = {
97
- shortcut,
98
- combo: parseShortcut(shortcut),
99
- handler,
100
- options: {
101
- scope: options?.scope ?? 'global',
102
- preventDefault: options?.preventDefault !== false,
103
- stopPropagation: options?.stopPropagation === true,
104
- enableOnInputs: options?.enableOnInputs === true,
105
- enabled: options?.enabled ?? true,
106
- ...(options?.description != null ? { description: options.description } : {}),
107
- },
108
- }
109
-
110
- entries.push(entry)
111
-
112
- return () => {
113
- const idx = entries.indexOf(entry)
114
- if (idx !== -1) entries.splice(idx, 1)
115
-
116
- // Detach listener if no more entries
117
- if (entries.length === 0) {
118
- detachListener()
119
- }
120
- }
121
- }
122
-
123
- // ─── Scope management ────────────────────────────────────────────────────────
124
-
125
- /**
126
- * Activate a hotkey scope. 'global' is always active.
127
- */
128
- export function enableScope(scope: string): void {
129
- const current = activeScopes.peek()
130
- if (current.has(scope)) return
131
- const next = new Set(current)
132
- next.add(scope)
133
- activeScopes.set(next)
134
- }
135
-
136
- /**
137
- * Deactivate a hotkey scope. Cannot deactivate 'global'.
138
- */
139
- export function disableScope(scope: string): void {
140
- if (scope === 'global') return
141
- const current = activeScopes.peek()
142
- if (!current.has(scope)) return
143
- const next = new Set(current)
144
- next.delete(scope)
145
- activeScopes.set(next)
146
- }
147
-
148
- /**
149
- * Get the currently active scopes as a reactive signal.
150
- */
151
- export function getActiveScopes(): Signal<Set<string>> {
152
- return activeScopes
153
- }
154
-
155
- /**
156
- * Get all registered hotkeys (for building help dialogs).
157
- */
158
- export function getRegisteredHotkeys(): ReadonlyArray<{
159
- shortcut: string
160
- scope: string
161
- description?: string
162
- }> {
163
- return entries.map((e) => ({
164
- shortcut: e.shortcut,
165
- scope: e.options.scope,
166
- ...(e.options.description != null ? { description: e.options.description } : {}),
167
- }))
168
- }
169
-
170
- // ─── Reset (for testing) ────────────────────────────────────────────────
171
-
172
- export function _resetHotkeys(): void {
173
- entries.length = 0
174
- activeScopes.set(new Set(['global']))
175
- detachListener()
176
- }
@@ -1,120 +0,0 @@
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
- })