@pyreon/hotkeys 0.12.15 → 0.13.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.
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
5386
5386
  </script>
5387
5387
  <script>
5388
5388
  /*<!--*/
5389
- const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"b111ad13-1","name":"parse.ts"},{"uid":"b111ad13-3","name":"registry.ts"},{"uid":"b111ad13-5","name":"use-hotkey.ts"},{"uid":"b111ad13-7","name":"use-hotkey-scope.ts"},{"uid":"b111ad13-9","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"b111ad13-1":{"renderedLength":1941,"gzipLength":838,"brotliLength":0,"metaUid":"b111ad13-0"},"b111ad13-3":{"renderedLength":2918,"gzipLength":1080,"brotliLength":0,"metaUid":"b111ad13-2"},"b111ad13-5":{"renderedLength":496,"gzipLength":295,"brotliLength":0,"metaUid":"b111ad13-4"},"b111ad13-7":{"renderedLength":421,"gzipLength":264,"brotliLength":0,"metaUid":"b111ad13-6"},"b111ad13-9":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"b111ad13-8"}},"nodeMetas":{"b111ad13-0":{"id":"/src/parse.ts","moduleParts":{"index.js":"b111ad13-1"},"imported":[],"importedBy":[{"uid":"b111ad13-8"},{"uid":"b111ad13-2"}]},"b111ad13-2":{"id":"/src/registry.ts","moduleParts":{"index.js":"b111ad13-3"},"imported":[{"uid":"b111ad13-11"},{"uid":"b111ad13-0"}],"importedBy":[{"uid":"b111ad13-8"},{"uid":"b111ad13-4"},{"uid":"b111ad13-6"}]},"b111ad13-4":{"id":"/src/use-hotkey.ts","moduleParts":{"index.js":"b111ad13-5"},"imported":[{"uid":"b111ad13-10"},{"uid":"b111ad13-2"}],"importedBy":[{"uid":"b111ad13-8"}]},"b111ad13-6":{"id":"/src/use-hotkey-scope.ts","moduleParts":{"index.js":"b111ad13-7"},"imported":[{"uid":"b111ad13-10"},{"uid":"b111ad13-2"}],"importedBy":[{"uid":"b111ad13-8"}]},"b111ad13-8":{"id":"/src/index.ts","moduleParts":{"index.js":"b111ad13-9"},"imported":[{"uid":"b111ad13-4"},{"uid":"b111ad13-6"},{"uid":"b111ad13-2"},{"uid":"b111ad13-0"}],"importedBy":[],"isEntry":true},"b111ad13-10":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"b111ad13-4"},{"uid":"b111ad13-6"}]},"b111ad13-11":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"b111ad13-2"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5389
+ const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"7a21fee1-1","name":"parse.ts"},{"uid":"7a21fee1-3","name":"registry.ts"},{"uid":"7a21fee1-5","name":"use-hotkey.ts"},{"uid":"7a21fee1-7","name":"use-hotkey-scope.ts"},{"uid":"7a21fee1-9","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"7a21fee1-1":{"renderedLength":1941,"gzipLength":838,"brotliLength":0,"metaUid":"7a21fee1-0"},"7a21fee1-3":{"renderedLength":3232,"gzipLength":1141,"brotliLength":0,"metaUid":"7a21fee1-2"},"7a21fee1-5":{"renderedLength":496,"gzipLength":295,"brotliLength":0,"metaUid":"7a21fee1-4"},"7a21fee1-7":{"renderedLength":421,"gzipLength":264,"brotliLength":0,"metaUid":"7a21fee1-6"},"7a21fee1-9":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"7a21fee1-8"}},"nodeMetas":{"7a21fee1-0":{"id":"/src/parse.ts","moduleParts":{"index.js":"7a21fee1-1"},"imported":[],"importedBy":[{"uid":"7a21fee1-8"},{"uid":"7a21fee1-2"}]},"7a21fee1-2":{"id":"/src/registry.ts","moduleParts":{"index.js":"7a21fee1-3"},"imported":[{"uid":"7a21fee1-11"},{"uid":"7a21fee1-0"}],"importedBy":[{"uid":"7a21fee1-8"},{"uid":"7a21fee1-4"},{"uid":"7a21fee1-6"}]},"7a21fee1-4":{"id":"/src/use-hotkey.ts","moduleParts":{"index.js":"7a21fee1-5"},"imported":[{"uid":"7a21fee1-10"},{"uid":"7a21fee1-2"}],"importedBy":[{"uid":"7a21fee1-8"}]},"7a21fee1-6":{"id":"/src/use-hotkey-scope.ts","moduleParts":{"index.js":"7a21fee1-7"},"imported":[{"uid":"7a21fee1-10"},{"uid":"7a21fee1-2"}],"importedBy":[{"uid":"7a21fee1-8"}]},"7a21fee1-8":{"id":"/src/index.ts","moduleParts":{"index.js":"7a21fee1-9"},"imported":[{"uid":"7a21fee1-4"},{"uid":"7a21fee1-6"},{"uid":"7a21fee1-2"},{"uid":"7a21fee1-0"}],"importedBy":[],"isEntry":true},"7a21fee1-10":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"7a21fee1-4"},{"uid":"7a21fee1-6"}]},"7a21fee1-11":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"7a21fee1-2"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5390
5390
 
5391
5391
  const run = () => {
5392
5392
  const width = window.innerWidth;
package/lib/index.js CHANGED
@@ -75,6 +75,7 @@ function isMac() {
75
75
  const entries = [];
76
76
  const activeScopes = signal(new Set(["global"]));
77
77
  let listenerAttached = false;
78
+ let keydownHandler = null;
78
79
  const INPUT_TAGS = new Set([
79
80
  "INPUT",
80
81
  "TEXTAREA",
@@ -91,7 +92,7 @@ function attachListener() {
91
92
  if (listenerAttached) return;
92
93
  if (typeof window === "undefined") return;
93
94
  listenerAttached = true;
94
- window.addEventListener("keydown", (event) => {
95
+ keydownHandler = (event) => {
95
96
  const scopes = activeScopes.peek();
96
97
  for (const entry of entries) {
97
98
  if (!scopes.has(entry.options.scope)) continue;
@@ -102,7 +103,14 @@ function attachListener() {
102
103
  if (entry.options.stopPropagation) event.stopPropagation();
103
104
  entry.handler(event);
104
105
  }
105
- });
106
+ };
107
+ window.addEventListener("keydown", keydownHandler);
108
+ }
109
+ function detachListener() {
110
+ if (!listenerAttached || !keydownHandler) return;
111
+ window.removeEventListener("keydown", keydownHandler);
112
+ listenerAttached = false;
113
+ keydownHandler = null;
106
114
  }
107
115
  /**
108
116
  * Register a keyboard shortcut. Returns an unregister function.
@@ -132,6 +140,7 @@ function registerHotkey(shortcut, handler, options) {
132
140
  return () => {
133
141
  const idx = entries.indexOf(entry);
134
142
  if (idx !== -1) entries.splice(idx, 1);
143
+ if (entries.length === 0) detachListener();
135
144
  };
136
145
  }
137
146
  /**
@@ -174,6 +183,7 @@ function getRegisteredHotkeys() {
174
183
  function _resetHotkeys() {
175
184
  entries.length = 0;
176
185
  activeScopes.set(new Set(["global"]));
186
+ detachListener();
177
187
  }
178
188
 
179
189
  //#endregion
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/parse.ts","../src/registry.ts","../src/use-hotkey.ts","../src/use-hotkey-scope.ts"],"sourcesContent":["import type { KeyCombo } from './types'\n\n// ─── Key aliases ─────────────────────────────────────────────────────────────\n\nconst KEY_ALIASES: Record<string, string> = {\n esc: 'escape',\n return: 'enter',\n del: 'delete',\n ins: 'insert',\n space: ' ',\n spacebar: ' ',\n up: 'arrowup',\n down: 'arrowdown',\n left: 'arrowleft',\n right: 'arrowright',\n plus: '+',\n}\n\n/**\n * Parse a shortcut string like 'ctrl+shift+s' into a KeyCombo.\n * Supports aliases (esc, del, space, etc.) and mod (ctrl on Windows/Linux, meta on Mac).\n */\nexport function parseShortcut(shortcut: string): KeyCombo {\n const parts = shortcut.toLowerCase().trim().split('+')\n const combo: KeyCombo = {\n ctrl: false,\n shift: false,\n alt: false,\n meta: false,\n key: '',\n }\n\n for (const part of parts) {\n const p = part.trim()\n if (p === 'ctrl' || p === 'control') {\n combo.ctrl = true\n } else if (p === 'shift') {\n combo.shift = true\n } else if (p === 'alt') {\n combo.alt = true\n } else if (p === 'meta' || p === 'cmd' || p === 'command') {\n combo.meta = true\n } else if (p === 'mod') {\n // mod = meta on Mac, ctrl elsewhere\n if (isMac()) {\n combo.meta = true\n } else {\n combo.ctrl = true\n }\n } else {\n combo.key = KEY_ALIASES[p] ?? p\n }\n }\n\n return combo\n}\n\n/**\n * Check if a KeyboardEvent matches a KeyCombo.\n */\nexport function matchesCombo(event: KeyboardEvent, combo: KeyCombo): boolean {\n if (event.ctrlKey !== combo.ctrl) return false\n if (event.shiftKey !== combo.shift) return false\n if (event.altKey !== combo.alt) return false\n if (event.metaKey !== combo.meta) return false\n\n const eventKey = event.key.toLowerCase()\n return eventKey === combo.key\n}\n\n/**\n * Format a KeyCombo back to a human-readable string.\n */\nexport function formatCombo(combo: KeyCombo): string {\n const parts: string[] = []\n if (combo.ctrl) parts.push('Ctrl')\n if (combo.shift) parts.push('Shift')\n if (combo.alt) parts.push('Alt')\n if (combo.meta) parts.push(isMac() ? '⌘' : 'Meta')\n parts.push(combo.key.length === 1 ? combo.key.toUpperCase() : capitalize(combo.key))\n return parts.join('+')\n}\n\nfunction capitalize(s: string): string {\n return s.charAt(0).toUpperCase() + s.slice(1)\n}\n\nfunction isMac(): boolean {\n if (typeof navigator === 'undefined') return false\n return /mac|iphone|ipad|ipod/i.test(navigator.userAgent)\n}\n","import type { Signal } from '@pyreon/reactivity'\nimport { signal } from '@pyreon/reactivity'\nimport { matchesCombo, parseShortcut } from './parse'\nimport type { HotkeyEntry, HotkeyOptions } from './types'\n\n// ─── State ───────────────────────────────────────────────────────────────────\n\nconst entries: HotkeyEntry[] = []\nconst activeScopes = signal<Set<string>>(new Set(['global']))\nlet listenerAttached = false\n\n// ─── Input detection ─────────────────────────────────────────────────────────\n\nconst INPUT_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT'])\n\nfunction isInputFocused(event: KeyboardEvent): boolean {\n const target = event.target as HTMLElement | null\n if (!target) return false\n if (INPUT_TAGS.has(target.tagName)) return true\n if (target.isContentEditable) return true\n return false\n}\n\n// ─── Global listener ─────────────────────────────────────────────────────────\n\nfunction attachListener(): void {\n if (listenerAttached) return\n if (typeof window === 'undefined') return\n listenerAttached = true\n\n window.addEventListener('keydown', (event) => {\n const scopes = activeScopes.peek()\n\n for (const entry of entries) {\n // Check scope\n if (!scopes.has(entry.options.scope)) continue\n\n // Check enabled\n const enabled =\n typeof entry.options.enabled === 'function'\n ? entry.options.enabled()\n : entry.options.enabled\n if (!enabled) continue\n\n // Check input focus\n if (!entry.options.enableOnInputs && isInputFocused(event)) continue\n\n // Check key match\n if (!matchesCombo(event, entry.combo)) continue\n\n // Match found\n if (entry.options.preventDefault) event.preventDefault()\n if (entry.options.stopPropagation) event.stopPropagation()\n entry.handler(event)\n }\n })\n}\n\n// ─── Registration ────────────────────────────────────────────────────────────\n\n/**\n * Register a keyboard shortcut. Returns an unregister function.\n *\n * @example\n * ```ts\n * const unregister = registerHotkey('ctrl+s', (e) => save(), { description: 'Save' })\n * // later: unregister()\n * ```\n */\nexport function registerHotkey(\n shortcut: string,\n handler: (event: KeyboardEvent) => void,\n options?: HotkeyOptions,\n): () => void {\n attachListener()\n\n const entry: HotkeyEntry = {\n shortcut,\n combo: parseShortcut(shortcut),\n handler,\n options: {\n scope: options?.scope ?? 'global',\n preventDefault: options?.preventDefault !== false,\n stopPropagation: options?.stopPropagation === true,\n enableOnInputs: options?.enableOnInputs === true,\n enabled: options?.enabled ?? true,\n ...(options?.description != null ? { description: options.description } : {}),\n },\n }\n\n entries.push(entry)\n\n return () => {\n const idx = entries.indexOf(entry)\n if (idx !== -1) entries.splice(idx, 1)\n }\n}\n\n// ─── Scope management ────────────────────────────────────────────────────────\n\n/**\n * Activate a hotkey scope. 'global' is always active.\n */\nexport function enableScope(scope: string): void {\n const current = activeScopes.peek()\n if (current.has(scope)) return\n const next = new Set(current)\n next.add(scope)\n activeScopes.set(next)\n}\n\n/**\n * Deactivate a hotkey scope. Cannot deactivate 'global'.\n */\nexport function disableScope(scope: string): void {\n if (scope === 'global') return\n const current = activeScopes.peek()\n if (!current.has(scope)) return\n const next = new Set(current)\n next.delete(scope)\n activeScopes.set(next)\n}\n\n/**\n * Get the currently active scopes as a reactive signal.\n */\nexport function getActiveScopes(): Signal<Set<string>> {\n return activeScopes\n}\n\n/**\n * Get all registered hotkeys (for building help dialogs).\n */\nexport function getRegisteredHotkeys(): ReadonlyArray<{\n shortcut: string\n scope: string\n description?: string\n}> {\n return entries.map((e) => ({\n shortcut: e.shortcut,\n scope: e.options.scope,\n ...(e.options.description != null ? { description: e.options.description } : {}),\n }))\n}\n\n// ─── Reset (for testing) ────────────────────────────────────────────────────\n\nexport function _resetHotkeys(): void {\n entries.length = 0\n activeScopes.set(new Set(['global']))\n}\n","import { onUnmount } from '@pyreon/core'\nimport { registerHotkey } from './registry'\nimport type { HotkeyOptions } from './types'\n\n/**\n * Register a keyboard shortcut scoped to a component's lifecycle.\n * Automatically unregisters when the component unmounts.\n *\n * @example\n * ```ts\n * function Editor() {\n * useHotkey('ctrl+s', () => save(), { description: 'Save document' })\n * useHotkey('ctrl+z', () => undo())\n * useHotkey('ctrl+shift+z', () => redo())\n * // ...\n * }\n * ```\n */\nexport function useHotkey(\n shortcut: string,\n handler: (event: KeyboardEvent) => void,\n options?: HotkeyOptions,\n): void {\n const unregister = registerHotkey(shortcut, handler, options)\n onUnmount(unregister)\n}\n","import { onUnmount } from '@pyreon/core'\nimport { disableScope, enableScope } from './registry'\n\n/**\n * Activate a hotkey scope for the lifetime of a component.\n * When the component unmounts, the scope is deactivated.\n *\n * @example\n * ```tsx\n * function Modal() {\n * useHotkeyScope('modal')\n * useHotkey('escape', () => closeModal(), { scope: 'modal' })\n * // ...\n * }\n * ```\n */\nexport function useHotkeyScope(scope: string): void {\n enableScope(scope)\n onUnmount(() => disableScope(scope))\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/parse.ts","../src/registry.ts","../src/use-hotkey.ts","../src/use-hotkey-scope.ts"],"sourcesContent":["import type { KeyCombo } from './types'\n\n// ─── Key aliases ─────────────────────────────────────────────────────────────\n\nconst KEY_ALIASES: Record<string, string> = {\n esc: 'escape',\n return: 'enter',\n del: 'delete',\n ins: 'insert',\n space: ' ',\n spacebar: ' ',\n up: 'arrowup',\n down: 'arrowdown',\n left: 'arrowleft',\n right: 'arrowright',\n plus: '+',\n}\n\n/**\n * Parse a shortcut string like 'ctrl+shift+s' into a KeyCombo.\n * Supports aliases (esc, del, space, etc.) and mod (ctrl on Windows/Linux, meta on Mac).\n */\nexport function parseShortcut(shortcut: string): KeyCombo {\n const parts = shortcut.toLowerCase().trim().split('+')\n const combo: KeyCombo = {\n ctrl: false,\n shift: false,\n alt: false,\n meta: false,\n key: '',\n }\n\n for (const part of parts) {\n const p = part.trim()\n if (p === 'ctrl' || p === 'control') {\n combo.ctrl = true\n } else if (p === 'shift') {\n combo.shift = true\n } else if (p === 'alt') {\n combo.alt = true\n } else if (p === 'meta' || p === 'cmd' || p === 'command') {\n combo.meta = true\n } else if (p === 'mod') {\n // mod = meta on Mac, ctrl elsewhere\n if (isMac()) {\n combo.meta = true\n } else {\n combo.ctrl = true\n }\n } else {\n combo.key = KEY_ALIASES[p] ?? p\n }\n }\n\n return combo\n}\n\n/**\n * Check if a KeyboardEvent matches a KeyCombo.\n */\nexport function matchesCombo(event: KeyboardEvent, combo: KeyCombo): boolean {\n if (event.ctrlKey !== combo.ctrl) return false\n if (event.shiftKey !== combo.shift) return false\n if (event.altKey !== combo.alt) return false\n if (event.metaKey !== combo.meta) return false\n\n const eventKey = event.key.toLowerCase()\n return eventKey === combo.key\n}\n\n/**\n * Format a KeyCombo back to a human-readable string.\n */\nexport function formatCombo(combo: KeyCombo): string {\n const parts: string[] = []\n if (combo.ctrl) parts.push('Ctrl')\n if (combo.shift) parts.push('Shift')\n if (combo.alt) parts.push('Alt')\n if (combo.meta) parts.push(isMac() ? '⌘' : 'Meta')\n parts.push(combo.key.length === 1 ? combo.key.toUpperCase() : capitalize(combo.key))\n return parts.join('+')\n}\n\nfunction capitalize(s: string): string {\n return s.charAt(0).toUpperCase() + s.slice(1)\n}\n\nfunction isMac(): boolean {\n if (typeof navigator === 'undefined') return false\n return /mac|iphone|ipad|ipod/i.test(navigator.userAgent)\n}\n","import type { Signal } from '@pyreon/reactivity'\nimport { signal } from '@pyreon/reactivity'\nimport { matchesCombo, parseShortcut } from './parse'\nimport type { HotkeyEntry, HotkeyOptions } from './types'\n\n// ─── State ───────────────────────────────────────────────────────────────────\n\nconst entries: HotkeyEntry[] = []\nconst activeScopes = signal<Set<string>>(new Set(['global']))\nlet listenerAttached = false\nlet keydownHandler: ((event: KeyboardEvent) => void) | null = null\n\n// ─── Input detection ─────────────────────────────────────────────────────────\n\nconst INPUT_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT'])\n\nfunction isInputFocused(event: KeyboardEvent): boolean {\n const target = event.target as HTMLElement | null\n if (!target) return false\n if (INPUT_TAGS.has(target.tagName)) return true\n if (target.isContentEditable) return true\n return false\n}\n\n// ─── Global listener ─────────────────────────────────────────────────────────\n\nfunction attachListener(): void {\n if (listenerAttached) return\n if (typeof window === 'undefined') return\n listenerAttached = true\n\n keydownHandler = (event) => {\n const scopes = activeScopes.peek()\n\n for (const entry of entries) {\n // Check scope\n if (!scopes.has(entry.options.scope)) continue\n\n // Check enabled\n const enabled =\n typeof entry.options.enabled === 'function'\n ? entry.options.enabled()\n : entry.options.enabled\n if (!enabled) continue\n\n // Check input focus\n if (!entry.options.enableOnInputs && isInputFocused(event)) continue\n\n // Check key match\n if (!matchesCombo(event, entry.combo)) continue\n\n // Match found\n if (entry.options.preventDefault) event.preventDefault()\n if (entry.options.stopPropagation) event.stopPropagation()\n entry.handler(event)\n }\n }\n\n window.addEventListener('keydown', keydownHandler)\n}\n\nfunction detachListener(): void {\n if (!listenerAttached || !keydownHandler) return\n window.removeEventListener('keydown', keydownHandler)\n listenerAttached = false\n keydownHandler = null\n}\n\n// ─── Registration ────────────────────────────────────────────────────────────\n\n/**\n * Register a keyboard shortcut. Returns an unregister function.\n *\n * @example\n * ```ts\n * const unregister = registerHotkey('ctrl+s', (e) => save(), { description: 'Save' })\n * // later: unregister()\n * ```\n */\nexport function registerHotkey(\n shortcut: string,\n handler: (event: KeyboardEvent) => void,\n options?: HotkeyOptions,\n): () => void {\n attachListener()\n\n const entry: HotkeyEntry = {\n shortcut,\n combo: parseShortcut(shortcut),\n handler,\n options: {\n scope: options?.scope ?? 'global',\n preventDefault: options?.preventDefault !== false,\n stopPropagation: options?.stopPropagation === true,\n enableOnInputs: options?.enableOnInputs === true,\n enabled: options?.enabled ?? true,\n ...(options?.description != null ? { description: options.description } : {}),\n },\n }\n\n entries.push(entry)\n\n return () => {\n const idx = entries.indexOf(entry)\n if (idx !== -1) entries.splice(idx, 1)\n\n // Detach listener if no more entries\n if (entries.length === 0) {\n detachListener()\n }\n }\n}\n\n// ─── Scope management ────────────────────────────────────────────────────────\n\n/**\n * Activate a hotkey scope. 'global' is always active.\n */\nexport function enableScope(scope: string): void {\n const current = activeScopes.peek()\n if (current.has(scope)) return\n const next = new Set(current)\n next.add(scope)\n activeScopes.set(next)\n}\n\n/**\n * Deactivate a hotkey scope. Cannot deactivate 'global'.\n */\nexport function disableScope(scope: string): void {\n if (scope === 'global') return\n const current = activeScopes.peek()\n if (!current.has(scope)) return\n const next = new Set(current)\n next.delete(scope)\n activeScopes.set(next)\n}\n\n/**\n * Get the currently active scopes as a reactive signal.\n */\nexport function getActiveScopes(): Signal<Set<string>> {\n return activeScopes\n}\n\n/**\n * Get all registered hotkeys (for building help dialogs).\n */\nexport function getRegisteredHotkeys(): ReadonlyArray<{\n shortcut: string\n scope: string\n description?: string\n}> {\n return entries.map((e) => ({\n shortcut: e.shortcut,\n scope: e.options.scope,\n ...(e.options.description != null ? { description: e.options.description } : {}),\n }))\n}\n\n// ─── Reset (for testing) ────────────────────────────────────────────────\n\nexport function _resetHotkeys(): void {\n entries.length = 0\n activeScopes.set(new Set(['global']))\n detachListener()\n}\n","import { onUnmount } from '@pyreon/core'\nimport { registerHotkey } from './registry'\nimport type { HotkeyOptions } from './types'\n\n/**\n * Register a keyboard shortcut scoped to a component's lifecycle.\n * Automatically unregisters when the component unmounts.\n *\n * @example\n * ```ts\n * function Editor() {\n * useHotkey('ctrl+s', () => save(), { description: 'Save document' })\n * useHotkey('ctrl+z', () => undo())\n * useHotkey('ctrl+shift+z', () => redo())\n * // ...\n * }\n * ```\n */\nexport function useHotkey(\n shortcut: string,\n handler: (event: KeyboardEvent) => void,\n options?: HotkeyOptions,\n): void {\n const unregister = registerHotkey(shortcut, handler, options)\n onUnmount(unregister)\n}\n","import { onUnmount } from '@pyreon/core'\nimport { disableScope, enableScope } from './registry'\n\n/**\n * Activate a hotkey scope for the lifetime of a component.\n * When the component unmounts, the scope is deactivated.\n *\n * @example\n * ```tsx\n * function Modal() {\n * useHotkeyScope('modal')\n * useHotkey('escape', () => closeModal(), { scope: 'modal' })\n * // ...\n * }\n * ```\n */\nexport function useHotkeyScope(scope: string): void {\n enableScope(scope)\n onUnmount(() => disableScope(scope))\n}\n"],"mappings":";;;;AAIA,MAAM,cAAsC;CAC1C,KAAK;CACL,QAAQ;CACR,KAAK;CACL,KAAK;CACL,OAAO;CACP,UAAU;CACV,IAAI;CACJ,MAAM;CACN,MAAM;CACN,OAAO;CACP,MAAM;CACP;;;;;AAMD,SAAgB,cAAc,UAA4B;CACxD,MAAM,QAAQ,SAAS,aAAa,CAAC,MAAM,CAAC,MAAM,IAAI;CACtD,MAAM,QAAkB;EACtB,MAAM;EACN,OAAO;EACP,KAAK;EACL,MAAM;EACN,KAAK;EACN;AAED,MAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,IAAI,KAAK,MAAM;AACrB,MAAI,MAAM,UAAU,MAAM,UACxB,OAAM,OAAO;WACJ,MAAM,QACf,OAAM,QAAQ;WACL,MAAM,MACf,OAAM,MAAM;WACH,MAAM,UAAU,MAAM,SAAS,MAAM,UAC9C,OAAM,OAAO;WACJ,MAAM,MAEf,KAAI,OAAO,CACT,OAAM,OAAO;MAEb,OAAM,OAAO;MAGf,OAAM,MAAM,YAAY,MAAM;;AAIlC,QAAO;;;;;AAMT,SAAgB,aAAa,OAAsB,OAA0B;AAC3E,KAAI,MAAM,YAAY,MAAM,KAAM,QAAO;AACzC,KAAI,MAAM,aAAa,MAAM,MAAO,QAAO;AAC3C,KAAI,MAAM,WAAW,MAAM,IAAK,QAAO;AACvC,KAAI,MAAM,YAAY,MAAM,KAAM,QAAO;AAGzC,QADiB,MAAM,IAAI,aAAa,KACpB,MAAM;;;;;AAM5B,SAAgB,YAAY,OAAyB;CACnD,MAAM,QAAkB,EAAE;AAC1B,KAAI,MAAM,KAAM,OAAM,KAAK,OAAO;AAClC,KAAI,MAAM,MAAO,OAAM,KAAK,QAAQ;AACpC,KAAI,MAAM,IAAK,OAAM,KAAK,MAAM;AAChC,KAAI,MAAM,KAAM,OAAM,KAAK,OAAO,GAAG,MAAM,OAAO;AAClD,OAAM,KAAK,MAAM,IAAI,WAAW,IAAI,MAAM,IAAI,aAAa,GAAG,WAAW,MAAM,IAAI,CAAC;AACpF,QAAO,MAAM,KAAK,IAAI;;AAGxB,SAAS,WAAW,GAAmB;AACrC,QAAO,EAAE,OAAO,EAAE,CAAC,aAAa,GAAG,EAAE,MAAM,EAAE;;AAG/C,SAAS,QAAiB;AACxB,KAAI,OAAO,cAAc,YAAa,QAAO;AAC7C,QAAO,wBAAwB,KAAK,UAAU,UAAU;;;;;AClF1D,MAAM,UAAyB,EAAE;AACjC,MAAM,eAAe,OAAoB,IAAI,IAAI,CAAC,SAAS,CAAC,CAAC;AAC7D,IAAI,mBAAmB;AACvB,IAAI,iBAA0D;AAI9D,MAAM,aAAa,IAAI,IAAI;CAAC;CAAS;CAAY;CAAS,CAAC;AAE3D,SAAS,eAAe,OAA+B;CACrD,MAAM,SAAS,MAAM;AACrB,KAAI,CAAC,OAAQ,QAAO;AACpB,KAAI,WAAW,IAAI,OAAO,QAAQ,CAAE,QAAO;AAC3C,KAAI,OAAO,kBAAmB,QAAO;AACrC,QAAO;;AAKT,SAAS,iBAAuB;AAC9B,KAAI,iBAAkB;AACtB,KAAI,OAAO,WAAW,YAAa;AACnC,oBAAmB;AAEnB,mBAAkB,UAAU;EAC1B,MAAM,SAAS,aAAa,MAAM;AAElC,OAAK,MAAM,SAAS,SAAS;AAE3B,OAAI,CAAC,OAAO,IAAI,MAAM,QAAQ,MAAM,CAAE;AAOtC,OAAI,EAHF,OAAO,MAAM,QAAQ,YAAY,aAC7B,MAAM,QAAQ,SAAS,GACvB,MAAM,QAAQ,SACN;AAGd,OAAI,CAAC,MAAM,QAAQ,kBAAkB,eAAe,MAAM,CAAE;AAG5D,OAAI,CAAC,aAAa,OAAO,MAAM,MAAM,CAAE;AAGvC,OAAI,MAAM,QAAQ,eAAgB,OAAM,gBAAgB;AACxD,OAAI,MAAM,QAAQ,gBAAiB,OAAM,iBAAiB;AAC1D,SAAM,QAAQ,MAAM;;;AAIxB,QAAO,iBAAiB,WAAW,eAAe;;AAGpD,SAAS,iBAAuB;AAC9B,KAAI,CAAC,oBAAoB,CAAC,eAAgB;AAC1C,QAAO,oBAAoB,WAAW,eAAe;AACrD,oBAAmB;AACnB,kBAAiB;;;;;;;;;;;AAcnB,SAAgB,eACd,UACA,SACA,SACY;AACZ,iBAAgB;CAEhB,MAAM,QAAqB;EACzB;EACA,OAAO,cAAc,SAAS;EAC9B;EACA,SAAS;GACP,OAAO,SAAS,SAAS;GACzB,gBAAgB,SAAS,mBAAmB;GAC5C,iBAAiB,SAAS,oBAAoB;GAC9C,gBAAgB,SAAS,mBAAmB;GAC5C,SAAS,SAAS,WAAW;GAC7B,GAAI,SAAS,eAAe,OAAO,EAAE,aAAa,QAAQ,aAAa,GAAG,EAAE;GAC7E;EACF;AAED,SAAQ,KAAK,MAAM;AAEnB,cAAa;EACX,MAAM,MAAM,QAAQ,QAAQ,MAAM;AAClC,MAAI,QAAQ,GAAI,SAAQ,OAAO,KAAK,EAAE;AAGtC,MAAI,QAAQ,WAAW,EACrB,iBAAgB;;;;;;AAUtB,SAAgB,YAAY,OAAqB;CAC/C,MAAM,UAAU,aAAa,MAAM;AACnC,KAAI,QAAQ,IAAI,MAAM,CAAE;CACxB,MAAM,OAAO,IAAI,IAAI,QAAQ;AAC7B,MAAK,IAAI,MAAM;AACf,cAAa,IAAI,KAAK;;;;;AAMxB,SAAgB,aAAa,OAAqB;AAChD,KAAI,UAAU,SAAU;CACxB,MAAM,UAAU,aAAa,MAAM;AACnC,KAAI,CAAC,QAAQ,IAAI,MAAM,CAAE;CACzB,MAAM,OAAO,IAAI,IAAI,QAAQ;AAC7B,MAAK,OAAO,MAAM;AAClB,cAAa,IAAI,KAAK;;;;;AAMxB,SAAgB,kBAAuC;AACrD,QAAO;;;;;AAMT,SAAgB,uBAIb;AACD,QAAO,QAAQ,KAAK,OAAO;EACzB,UAAU,EAAE;EACZ,OAAO,EAAE,QAAQ;EACjB,GAAI,EAAE,QAAQ,eAAe,OAAO,EAAE,aAAa,EAAE,QAAQ,aAAa,GAAG,EAAE;EAChF,EAAE;;AAKL,SAAgB,gBAAsB;AACpC,SAAQ,SAAS;AACjB,cAAa,IAAI,IAAI,IAAI,CAAC,SAAS,CAAC,CAAC;AACrC,iBAAgB;;;;;;;;;;;;;;;;;;;ACnJlB,SAAgB,UACd,UACA,SACA,SACM;AAEN,WADmB,eAAe,UAAU,SAAS,QAAQ,CACxC;;;;;;;;;;;;;;;;;;ACRvB,SAAgB,eAAe,OAAqB;AAClD,aAAY,MAAM;AAClB,iBAAgB,aAAa,MAAM,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"index2.d.ts","names":[],"sources":["../../../src/types.ts","../../../src/use-hotkey.ts","../../../src/use-hotkey-scope.ts","../../../src/registry.ts","../../../src/parse.ts"],"mappings":";;;;;;AAMA;UAAiB,QAAA;EACf,IAAA;EACA,KAAA;EACA,GAAA;EACA,IAAA;EACA,GAAA;AAAA;;;;UAMe,aAAA;EAAa;EAE5B,KAAA;EAF4B;EAI5B,cAAA;EAAA;EAEA,eAAA;EAEA;EAAA,cAAA;EAIA;EAFA,WAAA;EAEO;EAAP,OAAA;AAAA;;;;UAMe,WAAA;EASb;EAPF,QAAA;EAMiB;EAJjB,KAAA,EAAO,QAAA;EAFP;EAIA,OAAA,GAAU,KAAA,EAAO,aAAA;EAFV;EAIP,OAAA,EAAS,QAAA,CACP,IAAA,CACE,aAAA;IAGE,WAAA;EAAA;AAAA;;;;;AA1CR;;;;;;;;;;;AAWA;iBCCgB,SAAA,CACd,QAAA,UACA,OAAA,GAAU,KAAA,EAAO,aAAA,WACjB,OAAA,GAAU,aAAA;;;;;;ADfZ;;;;;;;;;;iBEUgB,cAAA,CAAe,KAAA;;;;AFV/B;;;;;;;;iBG+DgB,cAAA,CACd,QAAA,UACA,OAAA,GAAU,KAAA,EAAO,aAAA,WACjB,OAAA,GAAU,aAAA;;;AHvDZ;iBGsFgB,WAAA,CAAY,KAAA;;;;iBAWZ,YAAA,CAAa,KAAA;;;;iBAYb,eAAA,CAAA,GAAmB,MAAA,CAAO,GAAA;;;AH3F1C;iBGkGgB,oBAAA,CAAA,GAAwB,aAAA;EACtC,QAAA;EACA,KAAA;EACA,WAAA;AAAA;AAAA,iBAWc,aAAA,CAAA;;;;;AH7IhB;;iBIgBgB,aAAA,CAAc,QAAA,WAAmB,QAAA;;;;iBAsCjC,YAAA,CAAa,KAAA,EAAO,aAAA,EAAe,KAAA,EAAO,QAAA;;;;iBAa1C,WAAA,CAAY,KAAA,EAAO,QAAA"}
1
+ {"version":3,"file":"index2.d.ts","names":[],"sources":["../../../src/types.ts","../../../src/use-hotkey.ts","../../../src/use-hotkey-scope.ts","../../../src/registry.ts","../../../src/parse.ts"],"mappings":";;;;;;AAMA;UAAiB,QAAA;EACf,IAAA;EACA,KAAA;EACA,GAAA;EACA,IAAA;EACA,GAAA;AAAA;;;;UAMe,aAAA;EAAa;EAE5B,KAAA;EAF4B;EAI5B,cAAA;EAAA;EAEA,eAAA;EAEA;EAAA,cAAA;EAIA;EAFA,WAAA;EAEO;EAAP,OAAA;AAAA;;;;UAMe,WAAA;EASb;EAPF,QAAA;EAMiB;EAJjB,KAAA,EAAO,QAAA;EAFP;EAIA,OAAA,GAAU,KAAA,EAAO,aAAA;EAFV;EAIP,OAAA,EAAS,QAAA,CACP,IAAA,CACE,aAAA;IAGE,WAAA;EAAA;AAAA;;;;;AA1CR;;;;;;;;;;;AAWA;iBCCgB,SAAA,CACd,QAAA,UACA,OAAA,GAAU,KAAA,EAAO,aAAA,WACjB,OAAA,GAAU,aAAA;;;;;;ADfZ;;;;;;;;;;iBEUgB,cAAA,CAAe,KAAA;;;;AFV/B;;;;;;;;iBGyEgB,cAAA,CACd,QAAA,UACA,OAAA,GAAU,KAAA,EAAO,aAAA,WACjB,OAAA,GAAU,aAAA;;;AHjEZ;iBGqGgB,WAAA,CAAY,KAAA;;;;iBAWZ,YAAA,CAAa,KAAA;;;;iBAYb,eAAA,CAAA,GAAmB,MAAA,CAAO,GAAA;;;AH1G1C;iBGiHgB,oBAAA,CAAA,GAAwB,aAAA;EACtC,QAAA;EACA,KAAA;EACA,WAAA;AAAA;AAAA,iBAWc,aAAA,CAAA;;;;;AH5JhB;;iBIgBgB,aAAA,CAAc,QAAA,WAAmB,QAAA;;;;iBAsCjC,YAAA,CAAa,KAAA,EAAO,aAAA,EAAe,KAAA,EAAO,QAAA;;;;iBAa1C,WAAA,CAAY,KAAA,EAAO,QAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/hotkeys",
3
- "version": "0.12.15",
3
+ "version": "0.13.0",
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,13 @@
42
42
  },
43
43
  "devDependencies": {
44
44
  "@happy-dom/global-registrator": "^20.8.9",
45
- "@pyreon/core": "^0.12.15",
46
- "@pyreon/reactivity": "^0.12.15",
45
+ "@pyreon/core": "^0.13.0",
46
+ "@pyreon/manifest": "0.13.0",
47
+ "@pyreon/reactivity": "^0.13.0",
47
48
  "@vitus-labs/tools-lint": "^1.15.5"
48
49
  },
49
50
  "peerDependencies": {
50
- "@pyreon/core": "^0.12.15",
51
- "@pyreon/reactivity": "^0.12.15"
51
+ "@pyreon/core": "^0.13.0",
52
+ "@pyreon/reactivity": "^0.13.0"
52
53
  }
53
54
  }
@@ -0,0 +1,119 @@
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/registry.ts CHANGED
@@ -8,6 +8,7 @@ import type { HotkeyEntry, HotkeyOptions } from './types'
8
8
  const entries: HotkeyEntry[] = []
9
9
  const activeScopes = signal<Set<string>>(new Set(['global']))
10
10
  let listenerAttached = false
11
+ let keydownHandler: ((event: KeyboardEvent) => void) | null = null
11
12
 
12
13
  // ─── Input detection ─────────────────────────────────────────────────────────
13
14
 
@@ -28,7 +29,7 @@ function attachListener(): void {
28
29
  if (typeof window === 'undefined') return
29
30
  listenerAttached = true
30
31
 
31
- window.addEventListener('keydown', (event) => {
32
+ keydownHandler = (event) => {
32
33
  const scopes = activeScopes.peek()
33
34
 
34
35
  for (const entry of entries) {
@@ -53,7 +54,16 @@ function attachListener(): void {
53
54
  if (entry.options.stopPropagation) event.stopPropagation()
54
55
  entry.handler(event)
55
56
  }
56
- })
57
+ }
58
+
59
+ window.addEventListener('keydown', keydownHandler)
60
+ }
61
+
62
+ function detachListener(): void {
63
+ if (!listenerAttached || !keydownHandler) return
64
+ window.removeEventListener('keydown', keydownHandler)
65
+ listenerAttached = false
66
+ keydownHandler = null
57
67
  }
58
68
 
59
69
  // ─── Registration ────────────────────────────────────────────────────────────
@@ -93,6 +103,11 @@ export function registerHotkey(
93
103
  return () => {
94
104
  const idx = entries.indexOf(entry)
95
105
  if (idx !== -1) entries.splice(idx, 1)
106
+
107
+ // Detach listener if no more entries
108
+ if (entries.length === 0) {
109
+ detachListener()
110
+ }
96
111
  }
97
112
  }
98
113
 
@@ -143,9 +158,10 @@ export function getRegisteredHotkeys(): ReadonlyArray<{
143
158
  }))
144
159
  }
145
160
 
146
- // ─── Reset (for testing) ────────────────────────────────────────────────────
161
+ // ─── Reset (for testing) ────────────────────────────────────────────────
147
162
 
148
163
  export function _resetHotkeys(): void {
149
164
  entries.length = 0
150
165
  activeScopes.set(new Set(['global']))
166
+ detachListener()
151
167
  }
@@ -1,4 +1,4 @@
1
- import { afterEach, beforeEach, describe, expect, it } from 'vitest'
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
2
  import {
3
3
  _resetHotkeys,
4
4
  disableScope,
@@ -457,3 +457,65 @@ describe('getRegisteredHotkeys', () => {
457
457
  expect(getRegisteredHotkeys()).toHaveLength(0)
458
458
  })
459
459
  })
460
+
461
+ describe('Event listener cleanup', () => {
462
+ beforeEach(() => {
463
+ _resetHotkeys()
464
+ })
465
+
466
+ afterEach(() => {
467
+ _resetHotkeys()
468
+ })
469
+
470
+ it('attaches listener on first registration', () => {
471
+ const addEventListenerSpy = vi.spyOn(window, 'addEventListener')
472
+ const noop = () => {}
473
+
474
+ registerHotkey('ctrl+s', noop)
475
+
476
+ expect(addEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
477
+ addEventListenerSpy.mockRestore()
478
+ })
479
+
480
+ it('detaches listener when all hotkeys are unregistered', () => {
481
+ const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')
482
+ const noop = () => {}
483
+
484
+ const unsub1 = registerHotkey('ctrl+s', noop)
485
+ const unsub2 = registerHotkey('ctrl+z', noop)
486
+
487
+ // Both registered — listener still attached
488
+ unsub1()
489
+ expect(removeEventListenerSpy).not.toHaveBeenCalled()
490
+
491
+ // Last one unregistered — listener should detach
492
+ unsub2()
493
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
494
+
495
+ removeEventListenerSpy.mockRestore()
496
+ })
497
+
498
+ it('reattaches listener when new hotkey registered after cleanup', () => {
499
+ const addEventListenerSpy = vi.spyOn(window, 'addEventListener')
500
+ const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')
501
+ const noop = () => {}
502
+
503
+ const unsub1 = registerHotkey('ctrl+s', noop)
504
+ expect(addEventListenerSpy).toHaveBeenCalledTimes(1)
505
+
506
+ unsub1()
507
+ expect(removeEventListenerSpy).toHaveBeenCalledTimes(1)
508
+
509
+ // Register new hotkey — should reattach
510
+ registerHotkey('ctrl+z', noop)
511
+ expect(addEventListenerSpy).toHaveBeenCalledTimes(2)
512
+
513
+ addEventListenerSpy.mockRestore()
514
+ removeEventListenerSpy.mockRestore()
515
+ })
516
+
517
+ it('preserves vi for test environment', () => {
518
+ // This test just verifies vi is available (vitest spy helpers)
519
+ expect(typeof vi).toBe('object')
520
+ })
521
+ })
@@ -0,0 +1,69 @@
1
+ import {
2
+ renderApiReferenceEntries,
3
+ renderLlmsFullSection,
4
+ renderLlmsTxtLine,
5
+ } from '@pyreon/manifest'
6
+ import manifest from '../manifest'
7
+
8
+ describe('gen-docs — hotkeys snapshot', () => {
9
+ it('renders to llms.txt bullet', () => {
10
+ expect(renderLlmsTxtLine(manifest)).toMatchInlineSnapshot(`"- @pyreon/hotkeys — Keyboard shortcut management — scope-aware, modifier keys, conflict detection. 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."`)
11
+ })
12
+
13
+ it('renders to llms-full.txt section', () => {
14
+ expect(renderLlmsFullSection(manifest)).toMatchInlineSnapshot(`
15
+ "## @pyreon/hotkeys — Keyboard Shortcuts
16
+
17
+ 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.
18
+
19
+ \`\`\`typescript
20
+ import { useHotkey, useHotkeyScope, registerHotkey, getRegisteredHotkeys, enableScope, disableScope } from '@pyreon/hotkeys'
21
+
22
+ // Global shortcut — auto-unregisters on unmount
23
+ useHotkey('mod+s', (e) => {
24
+ e.preventDefault() // prevent browser save dialog
25
+ save()
26
+ }, { description: 'Save document' })
27
+
28
+ // Platform-aware: mod = ⌘ on Mac, Ctrl on Windows/Linux
29
+ useHotkey('mod+k', () => openCommandPalette())
30
+
31
+ // Multi-key combo
32
+ useHotkey('ctrl+shift+p', () => openPreferences())
33
+
34
+ // Scoped shortcuts — only active when scope is enabled
35
+ useHotkeyScope('editor') // activates 'editor' scope for this component's lifetime
36
+
37
+ useHotkey('ctrl+z', () => undo(), { scope: 'editor', description: 'Undo' })
38
+ useHotkey('ctrl+shift+z', () => redo(), { scope: 'editor', description: 'Redo' })
39
+ useHotkey('ctrl+d', () => duplicateLine(), { scope: 'editor' })
40
+
41
+ // Imperative API — for non-component contexts (stores, middleware)
42
+ const unregister = registerHotkey('ctrl+q', () => quit(), { scope: 'global' })
43
+ // unregister() when done
44
+
45
+ // Introspection
46
+ const hotkeys = getRegisteredHotkeys() // all registered shortcuts
47
+ enableScope('modal') // programmatically enable a scope
48
+ disableScope('editor') // programmatically disable a scope
49
+
50
+ // Shortcuts can filter input elements — by default, shortcuts
51
+ // don't fire when focused on <input>, <textarea>, <select>.
52
+ // Override with:
53
+ useHotkey('escape', () => closeModal(), { enableOnFormElements: true })
54
+ \`\`\`
55
+
56
+ > **Note**: 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.
57
+ >
58
+ > **mod alias**: \`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.
59
+ >
60
+ > **Scopes**: 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.
61
+ "
62
+ `)
63
+ })
64
+
65
+ it('renders to MCP api-reference entries', () => {
66
+ const record = renderApiReferenceEntries(manifest)
67
+ expect(Object.keys(record).length).toBe(3)
68
+ })
69
+ })