@keybindy/react 2.0.1 → 2.0.2

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
@@ -15,6 +15,7 @@
15
15
  ---
16
16
 
17
17
  Most React keyboard shortcut hooks fail in subtle, frustrating ways:
18
+
18
19
  1. **The "Blinking" Problem**: Every state change re-renders the component, causing the hook to unregister and re-register the hotkey. Rapid typing or animations cause micro-gaps where shortcuts are dropped.
19
20
  2. **Stale Closures**: Forgetting to update dependency arrays traps shortcuts with initial state values.
20
21
  3. **Modal Leaks**: Closing a dialog forgets to re-enable background shortcuts or corrupts the active scope.
@@ -62,17 +63,28 @@ function DocumentEditor() {
62
63
  const [content, setContent] = useState('');
63
64
 
64
65
  // ⚡️ Always accesses the latest `content` state without re-registering!
65
- useShortcut(['Ctrl', 'S'], (event) => {
66
- saveDocument(content);
67
- }, {
68
- preventDefault: true,
69
- ignoreInputs: true, // Won't trigger if user is typing in a textarea
70
- });
66
+ useShortcut(
67
+ ['Ctrl', 'S'],
68
+ event => {
69
+ saveDocument(content);
70
+ },
71
+ {
72
+ preventDefault: true,
73
+ ignoreInputs: true, // Won't trigger if user is typing in a textarea
74
+ }
75
+ );
71
76
 
72
77
  // Cross-platform Command/Ctrl + K
73
- useShortcut([['Meta', 'K'], ['Ctrl', 'K']], () => {
74
- openSearchPalette();
75
- }, { preventDefault: true });
78
+ useShortcut(
79
+ [
80
+ ['Meta', 'K'],
81
+ ['Ctrl', 'K'],
82
+ ],
83
+ () => {
84
+ openSearchPalette();
85
+ },
86
+ { preventDefault: true }
87
+ );
76
88
 
77
89
  return <textarea value={content} onChange={e => setContent(e.target.value)} />;
78
90
  }
@@ -88,31 +100,34 @@ Great for components with many hotkeys (e.g. video players, canvas apps, tables)
88
100
  import { useShortcuts } from '@keybindy/react';
89
101
 
90
102
  function VideoPlayer({ isPlaying, onPlayPause, onSeekForward, onSeekBackward }) {
91
- useShortcuts([
92
- {
93
- keys: ['Space'],
94
- handler: onPlayPause,
95
- options: { preventDefault: true },
96
- },
103
+ useShortcuts(
104
+ [
105
+ {
106
+ keys: ['Space'],
107
+ handler: onPlayPause,
108
+ options: { preventDefault: true },
109
+ },
110
+ {
111
+ keys: ['ArrowRight'],
112
+ handler: () => onSeekForward(5),
113
+ options: { preventDefault: true },
114
+ },
115
+ {
116
+ keys: ['ArrowLeft'],
117
+ handler: () => onSeekBackward(5),
118
+ options: { preventDefault: true },
119
+ },
120
+ {
121
+ // Push-to-talk / Hold action
122
+ keys: ['M'],
123
+ handler: (e, state) => setTemporaryMute(state === 'down'),
124
+ options: { hold: true },
125
+ },
126
+ ],
97
127
  {
98
- keys: ['ArrowRight'],
99
- handler: () => onSeekForward(5),
100
- options: { preventDefault: true },
101
- },
102
- {
103
- keys: ['ArrowLeft'],
104
- handler: () => onSeekBackward(5),
105
- options: { preventDefault: true },
106
- },
107
- {
108
- // Push-to-talk / Hold action
109
- keys: ['M'],
110
- handler: (e, state) => setTemporaryMute(state === 'down'),
111
- options: { hold: true },
112
- },
113
- ], {
114
- scope: 'video-player',
115
- });
128
+ scope: 'video-player',
129
+ }
130
+ );
116
131
 
117
132
  return <div>{/* Player UI */}</div>;
118
133
  }
@@ -147,28 +162,68 @@ function App() {
147
162
 
148
163
  ---
149
164
 
165
+ ## 🧬 Duplicate Component Instances (Dialogs, Pickers, Rows)
166
+
167
+ If several copies of the same component are mounted at once — multiple media pickers, hidden dialogs, list rows, desktop + mobile layouts — they would normally all register the same keys and silently overwrite each other. Only the last mounted instance would ever fire, usually with the wrong component's state.
168
+
169
+ Gate registration with `disabled` so only the instance that should listen is registered:
170
+
171
+ ```tsx
172
+ function MediaPicker({ isOpen, onClose }) {
173
+ const [selected, setSelected] = useState<Map<string, string>>(new Map());
174
+
175
+ useShortcuts(
176
+ [
177
+ {
178
+ keys: ['Enter'],
179
+ handler: () => {
180
+ onSelect([...selected.values()]);
181
+ onClose();
182
+ },
183
+ },
184
+ ],
185
+ {
186
+ disabled: !isOpen, // register only while this instance is open
187
+ }
188
+ );
189
+
190
+ if (!isOpen) return null;
191
+ return <div className="picker">{/* ... */}</div>;
192
+ }
193
+ ```
194
+
195
+ - **Disabled hooks register nothing** — a closed picker can never steal `Enter`, and it never claims the active scope.
196
+ - **No collisions** — an inactive instance does not overwrite the active one, and unmounting it never kills other bindings.
197
+ - In development, `@keybindy/react` also prints a warning when duplicate registrations are detected, pointing you to `disabled`.
198
+
199
+ ---
200
+
150
201
  ## 🎯 Scoping: Modals vs. Layered Tools
151
202
 
152
203
  ### A. Modal Isolation (`default` mode)
204
+
153
205
  When opening a modal or dialog, you want to **trap hotkeys** so background shortcuts cannot fire. When the modal unmounts, background shortcuts are automatically restored:
154
206
 
155
207
  ```tsx
156
208
  function DeleteConfirmationModal({ isOpen, onClose, onDelete }) {
157
209
  // Opening this modal automatically deactivates global shortcuts
158
- useShortcuts([
210
+ useShortcuts(
211
+ [
212
+ {
213
+ keys: ['Enter'],
214
+ handler: onDelete,
215
+ },
216
+ {
217
+ keys: ['Esc'],
218
+ handler: onClose,
219
+ options: { enableInInput: true }, // Escape works even inside modal inputs
220
+ },
221
+ ],
159
222
  {
160
- keys: ['Enter'],
161
- handler: onDelete,
162
- },
163
- {
164
- keys: ['Esc'],
165
- handler: onClose,
166
- options: { enableInInput: true }, // Escape works even inside modal inputs
167
- },
168
- ], {
169
- scope: 'delete-dialog',
170
- disabled: !isOpen,
171
- });
223
+ scope: 'delete-dialog',
224
+ disabled: !isOpen,
225
+ }
226
+ );
172
227
 
173
228
  if (!isOpen) return null;
174
229
  return <div className="modal">Are you sure?</div>;
@@ -178,16 +233,21 @@ function DeleteConfirmationModal({ isOpen, onClose, onDelete }) {
178
233
  ---
179
234
 
180
235
  ### B. Layered Tools with Priority (`cascade` mode)
236
+
181
237
  In Figma / Photoshop style apps, global canvas shortcuts (like `Space` to pan or `Z` to zoom) should continue working while editing in a sub-tool, but sub-tool shortcuts should override colliding keys:
182
238
 
183
239
  ```tsx
184
240
  // 1. Root Canvas (in cascade mode)
185
241
  function CanvasApp() {
186
242
  return (
187
- <Keybindy scopeMode="cascade" scope="canvas" shortcuts={[
188
- { keys: ['Space'], handler: panCanvas, options: { hold: true } },
189
- { keys: ['V'], handler: selectTool },
190
- ]}>
243
+ <Keybindy
244
+ scopeMode="cascade"
245
+ scope="canvas"
246
+ shortcuts={[
247
+ { keys: ['Space'], handler: panCanvas, options: { hold: true } },
248
+ { keys: ['V'], handler: selectTool },
249
+ ]}
250
+ >
191
251
  <Toolbox />
192
252
  <TextLayerEditor />
193
253
  </Keybindy>
@@ -196,24 +256,25 @@ function CanvasApp() {
196
256
 
197
257
  // 2. Focused Text Layer (higher priority weight)
198
258
  function TextLayerEditor() {
199
- useShortcuts([
259
+ useShortcuts(
260
+ [
261
+ {
262
+ keys: ['V'], // Overrides global 'V' tool while text editor is focused
263
+ handler: pastePlainText,
264
+ },
265
+ ],
200
266
  {
201
- keys: ['V'], // Overrides global 'V' tool while text editor is focused
202
- handler: pastePlainText,
267
+ scope: 'text-editor',
268
+ priority: 100, // Higher priority wins colliding keys
203
269
  }
204
- ], {
205
- scope: 'text-editor',
206
- priority: 100, // Higher priority wins colliding keys
207
- });
270
+ );
208
271
  }
209
272
 
210
273
  // 3. Isolated Modal inside a cascading app
211
274
  function SettingsModal({ isOpen, onClose }) {
212
275
  // 💡 Want to trap shortcuts in a specific modal and block parent cascading?
213
276
  // Pass scopeMode="default" to isolate this child from parent shortcuts!
214
- useShortcuts([
215
- { keys: ['Esc'], handler: onClose, options: { enableInInput: true } }
216
- ], {
277
+ useShortcuts([{ keys: ['Esc'], handler: onClose, options: { enableInInput: true } }], {
217
278
  scope: 'settings-modal',
218
279
  scopeMode: 'default', // Traps shortcuts: parent canvas keys won't fire
219
280
  disabled: !isOpen,
@@ -279,7 +340,8 @@ function ShortcutsHelpModal() {
279
340
  );
280
341
  }
281
342
  ```
282
- *(Note: `useKeybindy` is retained as an exact alias to `useShortcutManager`)*.
343
+
344
+ _(Note: `useKeybindy` is retained as an exact alias to `useShortcutManager`)_.
283
345
 
284
346
  ---
285
347
 
@@ -301,21 +363,21 @@ useShortcut(['Esc'], clearSearch, { enableInInput: true });
301
363
 
302
364
  ### `useShortcut(keys, handler, options?)`
303
365
 
304
- | Option | Type | Default | Description |
305
- | :--- | :--- | :--- | :--- |
306
- | `scope` | `string` | `'global'` | Scope context for the shortcut. |
307
- | `scopeMode` | `'default' \| 'cascade'` | `'default'` | Scope resolution behavior. |
308
- | `priority` | `number` | `undefined` | Numeric priority weight for cascade mode (e.g. `100`). |
309
- | `disabled` | `boolean` | `false` | Disable the shortcut without unmounting. |
310
- | `preventDefault` | `boolean` | `false` | Calls `event.preventDefault()`. |
311
- | `stopPropagation` | `boolean` | `false` | Calls `event.stopPropagation()`. |
312
- | `sequential` | `boolean` | `false` | Treat keys as a sequence (e.g. `['G', 'D']`). |
313
- | `sequenceDelay` | `number` | `1000` | Max milliseconds between sequential keys. |
314
- | `hold` | `boolean` | `false` | Triggers handler with `state: 'down' \| 'up'`. |
315
- | `repeat` | `boolean` | `false` | Allow continuous firing when holding key. |
316
- | `ignoreInputs` | `boolean` | `false` | Ignore shortcut when typing in inputs/textareas. |
317
- | `enableInInput` | `boolean` | `false` | Explicitly enable shortcut while typing in inputs. |
318
- | `data` | `object` | `{}` | Custom metadata for cheat sheets. |
366
+ | Option | Type | Default | Description |
367
+ | :---------------- | :----------------------- | :---------- | :----------------------------------------------------------------- |
368
+ | `scope` | `string` | `'global'` | Scope context for the shortcut. |
369
+ | `scopeMode` | `'default' \| 'cascade'` | `'default'` | Scope resolution behavior. |
370
+ | `priority` | `number` | `undefined` | Numeric priority weight for cascade mode (e.g. `100`). |
371
+ | `disabled` | `boolean` | `false` | Unregister the shortcut entirely without unmounting the component. |
372
+ | `preventDefault` | `boolean` | `false` | Calls `event.preventDefault()`. |
373
+ | `stopPropagation` | `boolean` | `false` | Calls `event.stopPropagation()`. |
374
+ | `sequential` | `boolean` | `false` | Treat keys as a sequence (e.g. `['G', 'D']`). |
375
+ | `sequenceDelay` | `number` | `1000` | Max milliseconds between sequential keys. |
376
+ | `hold` | `boolean` | `false` | Triggers handler with `state: 'down' \| 'up'`. |
377
+ | `repeat` | `boolean` | `false` | Allow continuous firing when holding key. |
378
+ | `ignoreInputs` | `boolean` | `false` | Ignore shortcut when typing in inputs/textareas. |
379
+ | `enableInInput` | `boolean` | `false` | Explicitly enable shortcut while typing in inputs. |
380
+ | `data` | `object` | `{}` | Custom metadata for cheat sheets. |
319
381
 
320
382
  ---
321
383
 
package/dist/index.d.ts CHANGED
@@ -37,7 +37,9 @@ type UseShortcutsOptions = {
37
37
  */
38
38
  scopeMode?: ScopeMode;
39
39
  /**
40
- * Whether all shortcuts in this scope should be disabled.
40
+ * Whether all shortcuts in this hook call should be disabled.
41
+ * A disabled hook registers nothing at all and never claims the active scope,
42
+ * so `disabled: !isOpen` is the recommended way to gate dialogs and pickers.
41
43
  * Defaults to `false`.
42
44
  */
43
45
  disabled?: boolean;
@@ -1,6 +1,45 @@
1
1
  import React from 'react';
2
2
  import { useShortcutManager } from './useKeybindy.js';
3
3
 
4
+ /**
5
+ * Whether the current build is a production bundle.
6
+ * Written so bundlers (Next.js, Vite, webpack) can statically replace `process.env.NODE_ENV`,
7
+ * while plain browser environments without a `process` shim simply fall back to `false`.
8
+ */
9
+ const isProductionBuild = (() => {
10
+ try {
11
+ return process.env.NODE_ENV === 'production';
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ })();
17
+ /**
18
+ * Tracks how many mounted hooks currently register the same key combo in the same scope,
19
+ * so duplicates (which silently overwrite each other) can be reported in development.
20
+ */
21
+ const activeRegistrations = new Map();
22
+ const duplicateWarnings = new Set();
23
+ const trackDuplicate = (scope, keys) => {
24
+ const signature = `${scope}::${JSON.stringify(keys)}`;
25
+ const count = (activeRegistrations.get(signature) ?? 0) + 1;
26
+ activeRegistrations.set(signature, count);
27
+ if (count > 1 && !duplicateWarnings.has(signature) && !isProductionBuild) {
28
+ duplicateWarnings.add(signature);
29
+ console.warn(`[Keybindy] Duplicate shortcut detected: ${JSON.stringify(keys)} is registered by ${count} component instances in scope "${scope}". Only the most recent instance will fire. ` +
30
+ `If these are parallel instances of the same component (dialogs, pickers), pass { disabled: !isOpen } so only the active instance registers.`);
31
+ }
32
+ return () => {
33
+ const remaining = (activeRegistrations.get(signature) ?? 1) - 1;
34
+ if (remaining <= 0) {
35
+ activeRegistrations.delete(signature);
36
+ duplicateWarnings.delete(signature);
37
+ }
38
+ else {
39
+ activeRegistrations.set(signature, remaining);
40
+ }
41
+ };
42
+ };
4
43
  /**
5
44
  * React hook to register multiple keyboard shortcuts declaratively.
6
45
  * Safe from stale closures and re-registration flickering/blinking.
@@ -33,6 +72,10 @@ const useShortcuts = (shortcutsProp = [], options = {}) => {
33
72
  React.useEffect(() => {
34
73
  if (!manager)
35
74
  return;
75
+ // Disabled hooks register nothing and never claim the active scope, so
76
+ // parallel component instances (closed dialogs, hidden pickers) never collide.
77
+ if (disabled)
78
+ return;
36
79
  let prevScopeMode;
37
80
  if (scopeMode) {
38
81
  prevScopeMode = manager.getScopeMode();
@@ -61,6 +104,7 @@ const useShortcuts = (shortcutsProp = [], options = {}) => {
61
104
  afterEachRef.current(shortcut, event);
62
105
  }, { scope, keys: hookKeys.length > 0 ? hookKeys : undefined });
63
106
  }
107
+ const stopTracking = stableShortcuts.map(({ keys }) => trackDuplicate(scope, keys));
64
108
  // Register shortcuts using the stable definitions.
65
109
  stableShortcuts.forEach(({ keys, options: opt }) => {
66
110
  const stableHandler = (event, state) => {
@@ -76,12 +120,7 @@ const useShortcuts = (shortcutsProp = [], options = {}) => {
76
120
  ignoreInputs: opt?.ignoreInputs ?? ignoreInputs,
77
121
  });
78
122
  });
79
- if (disabled) {
80
- manager.disableAll(scope);
81
- }
82
- else {
83
- manager.enableAll(scope);
84
- }
123
+ manager.enableAll(scope);
85
124
  return () => {
86
125
  if (unregisterBefore)
87
126
  unregisterBefore();
@@ -103,8 +142,18 @@ const useShortcuts = (shortcutsProp = [], options = {}) => {
103
142
  if (scopeMode && prevScopeMode !== undefined) {
104
143
  setScopeMode(prevScopeMode);
105
144
  }
145
+ stopTracking.forEach(stop => stop());
106
146
  };
107
- }, [scope, manager, disabled, priority, scopeMode, Boolean(beforeEach), Boolean(afterEach), stableShortcuts]);
147
+ }, [
148
+ scope,
149
+ manager,
150
+ disabled,
151
+ priority,
152
+ scopeMode,
153
+ Boolean(beforeEach),
154
+ Boolean(afterEach),
155
+ stableShortcuts,
156
+ ]);
108
157
  };
109
158
  /**
110
159
  * React hook to register a single keyboard shortcut.
@@ -134,7 +183,7 @@ const useShortcut = (keys, handler, options) => {
134
183
  }
135
184
  }),
136
185
  options: shortcutOptions,
137
- }
186
+ },
138
187
  ], [JSON.stringify(keys), JSON.stringify(shortcutOptions)]);
139
188
  useShortcuts(shortcutsGetter, {
140
189
  scope,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keybindy/react",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "Keybindy for React: Simple, scoped keyboard shortcuts that require little setup. designed to smoothly blend in with your React applications, allowing for robust keybinding functionality without the overhead.",
5
5
  "author": {
6
6
  "name": "PRASSamin",
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "react": "^19.1.0",
55
- "@keybindy/core": "2.0.1"
55
+ "@keybindy/core": "2.0.2"
56
56
  },
57
57
  "publishConfig": {
58
58
  "access": "public",