@remcostoeten/use-shortcut 2.0.0 → 2.1.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/README.md CHANGED
@@ -10,6 +10,7 @@ WIP keyboard shortcut library for React with a chainable API.
10
10
  ## Implemented Features
11
11
 
12
12
  - Chainable shortcut builder: `$.mod.key("k").on(handler)`
13
+ - Bulk shortcut maps: `useShortcutMap()` and `registerShortcutMap()`
13
14
  - Modifier support: `ctrl`, `shift`, `alt`, `cmd`, `mod`
14
15
  - Sequence support: `$.key("g").then("d")`
15
16
  - Scope-aware shortcuts:
@@ -17,19 +18,83 @@ WIP keyboard shortcut library for React with a chainable API.
17
18
  - Runtime controls: `setScopes`, `enableScope`, `disableScope`, `getScopes`, `isScopeActive`
18
19
  - Exception predicates/presets with `.except(...)`
19
20
  - Recording mode: `$.record({ timeoutMs })`
21
+ - Structured debug stream: `$.onDebug(...)` for every keypress
22
+ - Per-shortcut attempt inspection: `result.onAttempt((matched, event, details) => ...)`
20
23
  - Conflict detection (`exact`, `sequence-prefix`)
21
24
  - Priority ordering and `stopOnMatch`
22
25
  - Global guard/filter support via `eventFilter`
23
26
  - React entry point:
24
27
  - `useShortcut`
28
+ - `useShortcutMap`
29
+ - `useShortcutGroup`
25
30
 
26
31
  ## API Intention (Consumer-Facing)
27
32
 
28
33
  - `useShortcut(options?)`
29
34
  - Main React hook. Use this for the chainable API (`$.mod.key("s").on(...)`).
35
+ - `useShortcutMap(shortcutMap, options?)`
36
+ - React-safe bulk registration for render paths where a declarative object is cleaner than multiple `.on()` calls.
37
+ - `registerShortcutMap(builder, shortcutMap)`
38
+ - Imperative bulk registration helper when you already have a `useShortcut()` builder.
30
39
 
31
40
  Internal helpers follow underscore naming (for example `_createShortcutBuilder`, `_canonicalizeParsed`) and are not re-exported from `src/index.ts`.
32
41
 
42
+ ## Shortcut Map Example
43
+
44
+ ```tsx
45
+ import { useShortcutMap } from "@remcostoeten/use-shortcut"
46
+
47
+ function App() {
48
+ useShortcutMap(
49
+ {
50
+ openPalette: {
51
+ keys: "mod+k",
52
+ handler: () => openPalette(),
53
+ options: { preventDefault: true },
54
+ },
55
+ closePalette: {
56
+ keys: "escape",
57
+ handler: () => closePalette(),
58
+ },
59
+ toggleSidebar: {
60
+ keys: "g then s",
61
+ handler: () => toggleSidebar(),
62
+ },
63
+ },
64
+ { ignoreInputs: false },
65
+ )
66
+
67
+ return <div>Shortcuts ready</div>
68
+ }
69
+ ```
70
+
71
+ If you already have a builder from `useShortcut()`, you can bulk register with `registerShortcutMap($, shortcutMap)` and unbind the returned handles on cleanup.
72
+
73
+ ## Debug Example
74
+
75
+ ```tsx
76
+ const $ = useShortcut({
77
+ debug: {
78
+ console: true,
79
+ includeCode: true,
80
+ includeLocation: true,
81
+ includeKeyCode: true,
82
+ },
83
+ })
84
+
85
+ const unsubscribeDebug = $.onDebug((event) => {
86
+ console.log("key", event.input.combo, event.attempts)
87
+ })
88
+
89
+ const result = $.shift.key("e").then("e").on(runProbe, {
90
+ description: "sequence probe",
91
+ })
92
+
93
+ const unsubscribeAttempt = result.onAttempt?.((matched, _event, details) => {
94
+ console.log(matched ? "matched" : details?.status, details?.steps)
95
+ })
96
+ ```
97
+
33
98
  ## Architecture Notes
34
99
 
35
100
  - Core runtime lives in `src/builder.ts`
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import{existsSync as h,mkdirSync as f,readFileSync as w,writeFileSync as x}from"fs";import{join as l,dirname as B}from"path";import{fileURLToPath as I}from"url";function m(t){return{"scopes.ts":`/** App-defined scope catalog used by the scaffolded provider. */
2
+ import{existsSync as h,mkdirSync as f,readFileSync as A,writeFileSync as x}from"fs";import{join as l,dirname as B}from"path";import{fileURLToPath as I}from"url";function m(t){return{"scopes.ts":`/** App-defined scope catalog used by the scaffolded provider. */
3
3
  export const shortcutScopes = ["global", "navigation", "editor", "modal"] as const
4
4
 
5
5
  export type ShortcutScope = (typeof shortcutScopes)[number]
@@ -82,7 +82,7 @@ export type ShortcutContextValue = {
82
82
  actions: ShortcutActions
83
83
  meta: ShortcutMeta
84
84
  }
85
- `,"runtime.ts":`import type { ShortcutMap } from "@remcostoeten/use-shortcut"
85
+ `,"runtime.ts":`import type { HandlerOptions, ShortcutMap } from "@remcostoeten/use-shortcut"
86
86
  import { shortcutRegistry, type ShortcutActionId, type ShortcutBindings } from "./registry"
87
87
  import type { ShortcutHandlers } from "./types"
88
88
 
@@ -120,11 +120,15 @@ export function buildShortcutMap(bindings: ShortcutBindings, handlers: ShortcutH
120
120
  continue
121
121
  }
122
122
 
123
+ const definitionOptions: Partial<HandlerOptions> = ("options" in definition && definition.options)
124
+ ? definition.options
125
+ : {}
126
+
123
127
  map[actionId] = {
124
128
  keys: bindings[actionId],
125
129
  handler,
126
130
  options: {
127
- ...definition.options,
131
+ ...definitionOptions,
128
132
  scopes: definition.scopes,
129
133
  description: definition.description,
130
134
  },
@@ -431,6 +435,16 @@ It follows a scalable architecture with a strict split between:
431
435
  3. Optionally expose a user-configurable key in your settings UI through \`useShortcutManager().actions.setBinding\`.
432
436
  4. Activate scopes from feature boundaries (for example editor route enters \`editor\` scope).
433
437
 
438
+ ## Wiring A React App
439
+
440
+ Scaffold the shortcut files into a React app shell:
441
+
442
+ \`\`\`bash
443
+ npx @remcostoeten/use-shortcut scaffold --framework react --target apps/shortcut-playground/src
444
+ \`\`\`
445
+
446
+ Then wire those generated files into your app root by wrapping your app in \`<ShortcutProvider handlers={...} />\`.
447
+
434
448
  ${t==="next"?"## Next.js Integration\n\n1. Create a client provider wrapper at `app/shortcut-provider.tsx` and render `<ShortcutProvider />` there.\n2. Render that provider inside `app/layout.tsx` around your app shell.\n3. Keep page/server components pure; shortcut handlers stay in client components.\n":"## React Integration\n\n1. Wrap your app root (for example in `main.tsx`) with `<ShortcutProvider />`.\n2. Keep handlers in a top-level client component and pass them to the provider via `handlers`.\n3. Use `useShortcutManager()` inside feature components to toggle scopes and bindings.\n"}
435
449
  ## Rules For Scale
436
450
 
@@ -438,16 +452,16 @@ ${t==="next"?"## Next.js Integration\n\n1. Create a client provider wrapper at `
438
452
  - Use scopes instead of conditionals in handlers.
439
453
  - Persist only key bindings, not executable handlers.
440
454
  - Treat \`registry.ts\` as your architectural contract.
441
- `}}var k=I(import.meta.url),_=B(k),e={reset:"\x1B[0m",green:"\x1B[32m",cyan:"\x1B[36m",yellow:"\x1B[33m",dim:"\x1B[2m",red:"\x1B[31m"};function r(t,o=e.reset){console.log(`${o}${t}${e.reset}`)}function O(){return l(_,"..","src")}function R(t){return l(process.cwd(),t,"use-shortcut")}function C(t,o){return l(process.cwd(),t,o)}function p(t,o,a){let s=t.indexOf(o);if(s===-1)return a;let n=t[s+1];return!n||n.startsWith("--")?a:n}function g(t,o){return t.includes(o)}var P=["index.ts","hook.ts","builder.ts","types.ts","parser.ts","constants.ts","formatter.ts","runtime/types.ts","runtime/binding.ts","runtime/conflicts.ts","runtime/debug.ts","runtime/guards.ts","runtime/keys.ts","runtime/listener.ts","runtime/recording.ts"];function E(t="hooks",o=!1){let a=O(),s=R(t);if(r(`
455
+ `}}var k=I(import.meta.url),O=B(k),e={reset:"\x1B[0m",green:"\x1B[32m",cyan:"\x1B[36m",yellow:"\x1B[33m",dim:"\x1B[2m",red:"\x1B[31m"};function r(t,o=e.reset){console.log(`${o}${t}${e.reset}`)}function _(){return l(O,"..","src")}function R(t){return l(process.cwd(),t,"use-shortcut")}function C(t,o){return l(process.cwd(),t,o)}function p(t,o,a){let s=t.indexOf(o);if(s===-1)return a;let n=t[s+1];return!n||n.startsWith("--")?a:n}function g(t,o){return t.includes(o)}var P=["index.ts","hook.ts","builder.ts","types.ts","parser.ts","constants.ts","formatter.ts","runtime/types.ts","runtime/binding.ts","runtime/conflicts.ts","runtime/debug.ts","runtime/guards.ts","runtime/keys.ts","runtime/listener.ts","runtime/recording.ts"];function E(t="hooks",o=!1){let a=_(),s=R(t);if(r(`
442
456
  use-shortcut CLI
443
457
  `,e.cyan),h(s)&&!o){r(`Directory already exists: ${s}`,e.yellow),r(`Use --force to overwrite existing files
444
- `,e.dim);return}f(s,{recursive:!0});let n=0;for(let i of P){let c=l(a,i),d=l(s,i);if(f(B(d),{recursive:!0}),!h(c)){r(`Source file not found: ${i}`,e.yellow);continue}let u=w(c,"utf-8");x(d,u),n+=1,r(` wrote ${i}`,e.green)}r(`
458
+ `,e.dim);return}f(s,{recursive:!0});let n=0;for(let i of P){let c=l(a,i),d=l(s,i);if(f(B(d),{recursive:!0}),!h(c)){r(`Source file not found: ${i}`,e.yellow);continue}let u=A(c,"utf-8");x(d,u),n+=1,r(` wrote ${i}`,e.green)}r(`
445
459
  Copied ${n} files to:`,e.green),r(` ${s}
446
460
  `,e.dim),r("Usage:",e.cyan),r(` import { useShortcut } from "@/${t}/use-shortcut"`,e.dim),r(" const $ = useShortcut()",e.dim),r(` $.mod.key("k").on(() => console.log("Search"))
447
461
  `,e.dim)}function y(t,o="src",a="shortcuts",s=!1){let n=C(o,a),i=m(t);r(`
448
462
  use-shortcut CLI
449
463
  `,e.cyan),r(`Scaffolding ${t} architecture in ${n}
450
- `,e.dim),f(n,{recursive:!0});let c=0,d=0;for(let[u,A]of Object.entries(i)){let S=l(n,u);if(h(S)&&!s){d+=1,r(` skipped ${u} (already exists)`,e.yellow);continue}x(S,A),c+=1,r(` wrote ${u}`,e.green)}r("",e.reset),r(`Architecture scaffold complete: ${c} written, ${d} skipped.`,e.green),r(`Location: ${n}
464
+ `,e.dim),f(n,{recursive:!0});let c=0,d=0;for(let[u,w]of Object.entries(i)){let S=l(n,u);if(h(S)&&!s){d+=1,r(` skipped ${u} (already exists)`,e.yellow);continue}x(S,w),c+=1,r(` wrote ${u}`,e.green)}r("",e.reset),r(`Architecture scaffold complete: ${c} written, ${d} skipped.`,e.green),r(`Location: ${n}
451
465
  `,e.dim),r("Next steps:",e.cyan),r(` 1. Open ${l(o,a,"registry.ts")} and define your action catalog`,e.dim),r(" 2. Wire app handlers into <ShortcutProvider handlers={...} />",e.dim),r(" 3. Toggle scopes from feature boundaries via useShortcutManager()",e.dim),r(` 4. Optionally expose setBinding/resetBinding in your settings UI
452
466
  `,e.dim)}function b(){r(`
453
467
  use-shortcut CLI
package/dist/index.d.ts CHANGED
@@ -101,6 +101,57 @@ type ShortcutConflict = {
101
101
  existingCombo: string;
102
102
  reason: "exact" | "sequence-prefix";
103
103
  };
104
+ type ShortcutAttemptStatus = "matched" | "partial" | "wrong-order" | "mismatch";
105
+ type ShortcutDebugTokenStatus = "match" | "wrong-order" | "mismatch";
106
+ type ShortcutDebugToken = {
107
+ token: string;
108
+ kind: "modifier" | "key";
109
+ status: ShortcutDebugTokenStatus;
110
+ };
111
+ type ShortcutDebugStep = {
112
+ index: number;
113
+ expected: string;
114
+ actual?: string;
115
+ status: "match" | "partial" | "pending" | "wrong-order" | "mismatch";
116
+ tokens: ShortcutDebugToken[];
117
+ };
118
+ type ShortcutDebugInput = {
119
+ key: string;
120
+ code: string;
121
+ location: number;
122
+ repeat: boolean;
123
+ keyCode?: number;
124
+ which?: number;
125
+ combo: string;
126
+ modifiers: ModifierState;
127
+ };
128
+ type ShortcutAttemptDebugEvent = {
129
+ combo: string;
130
+ display: string;
131
+ description?: string;
132
+ status: ShortcutAttemptStatus;
133
+ matched: boolean;
134
+ progress: number;
135
+ expectedSteps: string[];
136
+ actualSteps: string[];
137
+ stepIndex: number;
138
+ input: ShortcutDebugInput;
139
+ steps: ShortcutDebugStep[];
140
+ };
141
+ type ShortcutDebugEvent = {
142
+ input: ShortcutDebugInput;
143
+ attempts: ShortcutAttemptDebugEvent[];
144
+ };
145
+ type ShortcutDebugOptions = {
146
+ /** Log shortcut attempts to the console (default: true) */
147
+ console?: boolean;
148
+ /** Include `KeyboardEvent.code` in console output */
149
+ includeCode?: boolean;
150
+ /** Include `KeyboardEvent.location` in console output */
151
+ includeLocation?: boolean;
152
+ /** Include deprecated numeric key metadata in console output when available */
153
+ includeKeyCode?: boolean;
154
+ };
104
155
  /**
105
156
  * Options for shortcut handler registration
106
157
  */
@@ -146,7 +197,7 @@ type ShortcutResult = {
146
197
  /** Temporarily disable the shortcut */
147
198
  disable: () => void;
148
199
  /** Subscribe to shortcut attempt events (useful for visual feedback) */
149
- onAttempt?: (callback: (matched: boolean, event: KeyboardEvent) => void) => () => void;
200
+ onAttempt?: (callback: (matched: boolean, event: KeyboardEvent, details?: ShortcutAttemptDebugEvent) => void) => () => void;
150
201
  };
151
202
  /**
152
203
  * Chainable modifier builder with type-safe exhaustion
@@ -234,6 +285,8 @@ type ShortcutBuilder = ModifierChain<EmptyModifiers> & {
234
285
  getScopes: () => string[];
235
286
  /** Check if a scope is active */
236
287
  isScopeActive: (scope: string) => boolean;
288
+ /** Subscribe to every keyboard input evaluated by this shortcut registry */
289
+ onDebug: (callback: (event: ShortcutDebugEvent) => void) => () => void;
237
290
  /** Record the next key combo */
238
291
  record: (options?: ShortcutRecordingOptions) => Promise<string>;
239
292
  };
@@ -241,8 +294,8 @@ type ShortcutBuilder = ModifierChain<EmptyModifiers> & {
241
294
  * Options for the `useShortcut` hook
242
295
  */
243
296
  type UseShortcutOptions = {
244
- /** Enable debug logging to console */
245
- debug?: boolean;
297
+ /** Enable debug logging to console or configure structured debug output */
298
+ debug?: boolean | ShortcutDebugOptions;
246
299
  /** Global delay for all handlers in milliseconds */
247
300
  delay?: number;
248
301
  /** Skip shortcuts when focused on input elements (default: `true`) */
@@ -264,6 +317,26 @@ type UseShortcutOptions = {
264
317
  /** Global event filter; return false to skip all shortcuts for the event */
265
318
  eventFilter?: (event: KeyboardEvent) => boolean;
266
319
  };
320
+ /** Single shortcut-map entry used by `registerShortcutMap` and `useShortcutMap`. */
321
+ type ShortcutMapEntry = {
322
+ keys: string | string[];
323
+ handler: ShortcutHandler;
324
+ options?: HandlerOptions;
325
+ };
326
+ /** Bulk registration shape mapping action ids to key+handler definitions. */
327
+ type ShortcutMap = Record<string, ShortcutMapEntry>;
328
+ /** Return type for map registrations, keyed by the same ids as the source map. */
329
+ type ShortcutMapResult<T extends ShortcutMap = ShortcutMap> = {
330
+ [K in keyof T]: ShortcutResult;
331
+ };
332
+ /** Imperative grouping controller for binding/unbinding many shortcut registrations together. */
333
+ type ShortcutGroup = {
334
+ add: (...results: ShortcutResult[]) => void;
335
+ addMany: (results: ShortcutResult[] | Record<string, ShortcutResult>) => void;
336
+ unbindAll: () => void;
337
+ clear: () => void;
338
+ getResults: () => ShortcutResult[];
339
+ };
267
340
 
268
341
  /**
269
342
  * Parse a shortcut string into its components
@@ -317,6 +390,23 @@ declare function matchesAnyShortcut(event: KeyboardEvent, parsedShortcuts: Parse
317
390
  */
318
391
  declare function formatShortcut(shortcut: string, platform?: PlatformType): string;
319
392
 
393
+ /**
394
+ * Registers an object-based shortcut map in one call and returns per-action handles.
395
+ *
396
+ * @param builder - Builder returned by `useShortcut()`
397
+ * @param shortcutMap - Record of action ids to key bindings, handlers, and options
398
+ * @returns A result map with one `ShortcutResult` per shortcut id
399
+ *
400
+ * @example
401
+ * ```ts
402
+ * const $ = useShortcut()
403
+ * const results = registerShortcutMap($, {
404
+ * save: { keys: "mod+s", handler: onSave },
405
+ * nav: { keys: ["g", "d"], handler: onGoDashboard },
406
+ * })
407
+ * ```
408
+ */
409
+ declare function registerShortcutMap<T extends ShortcutMap>(builder: ShortcutBuilder, shortcutMap: T): ShortcutMapResult<T>;
320
410
  /**
321
411
  * React hook for registering chainable keyboard shortcuts
322
412
  *
@@ -333,5 +423,46 @@ declare function formatShortcut(shortcut: string, platform?: PlatformType): stri
333
423
  * ```
334
424
  */
335
425
  declare function useShortcut(options?: UseShortcutOptions): ShortcutBuilder;
426
+ /**
427
+ * React hook that registers a shortcut map and automatically unbinds on cleanup.
428
+ *
429
+ * @param shortcutMap - Record of action ids to key bindings, handlers, and options
430
+ * @param options - Same options as `useShortcut()`
431
+ * @returns A map of `ShortcutResult` keyed by your shortcut ids
432
+ *
433
+ * @example
434
+ * ```ts
435
+ * const mapResults = useShortcutMap({
436
+ * save: { keys: "mod+s", handler: onSave },
437
+ * close: { keys: "escape", handler: onClose },
438
+ * })
439
+ * ```
440
+ */
441
+ declare function useShortcutMap<T extends ShortcutMap>(shortcutMap: T, options?: UseShortcutOptions): ShortcutMapResult<T>;
442
+ /**
443
+ * Creates an imperative group controller for many shortcut registrations.
444
+ *
445
+ * @returns A `ShortcutGroup` that can add and unbind multiple shortcuts together
446
+ *
447
+ * @example
448
+ * ```ts
449
+ * const group = createShortcutGroup()
450
+ * group.add($.mod.key("s").on(onSave))
451
+ * group.add($.key("escape").on(onClose))
452
+ * group.unbindAll()
453
+ * ```
454
+ */
455
+ declare function createShortcutGroup(): ShortcutGroup;
456
+ /**
457
+ * React hook that returns a stable `ShortcutGroup` instance.
458
+ *
459
+ * @returns A memoized `ShortcutGroup` tied to the component lifecycle
460
+ *
461
+ * @example
462
+ * ```ts
463
+ * const group = useShortcutGroup()
464
+ * ```
465
+ */
466
+ declare function useShortcutGroup(): ShortcutGroup;
336
467
 
337
- export { type ActionKey, type AlphaKey, type ExceptPredicate, type ExceptPreset, type FunctionKey, type HandlerOptions, type KeyChain, ModifierAliases, type ModifierChain, ModifierDisplayOrder, ModifierDisplaySymbols, type ModifierFlags, ModifierKey, type ModifierName, type ModifierState, type NavigationKey, type NumericKey, type ParsedShortcut, Platform, type ShortcutBuilder, type ShortcutConflict, type ShortcutHandler, type ShortcutRecordingOptions, type ShortcutResult, type ShortcutScope, type SpecialKey, SpecialKeyMap, type SymbolKey, type UseShortcutOptions, detectPlatform, formatShortcut, matchesAnyShortcut, matchesShortcut, parseShortcut, parseShortcuts, useShortcut };
468
+ export { type ActionKey, type AlphaKey, type ExceptPredicate, type ExceptPreset, type FunctionKey, type HandlerOptions, type KeyChain, ModifierAliases, type ModifierChain, ModifierDisplayOrder, ModifierDisplaySymbols, type ModifierFlags, ModifierKey, type ModifierName, type ModifierState, type NavigationKey, type NumericKey, type ParsedShortcut, Platform, type ShortcutAttemptDebugEvent, type ShortcutAttemptStatus, type ShortcutBuilder, type ShortcutConflict, type ShortcutDebugEvent, type ShortcutDebugInput, type ShortcutDebugOptions, type ShortcutDebugStep, type ShortcutDebugToken, type ShortcutDebugTokenStatus, type ShortcutGroup, type ShortcutHandler, type ShortcutMap, type ShortcutMapEntry, type ShortcutMapResult, type ShortcutRecordingOptions, type ShortcutResult, type ShortcutScope, type SpecialKey, SpecialKeyMap, type SymbolKey, type UseShortcutOptions, createShortcutGroup, detectPlatform, formatShortcut, matchesAnyShortcut, matchesShortcut, parseShortcut, parseShortcuts, registerShortcutMap, useShortcut, useShortcutGroup, useShortcutMap };