@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 +138 -76
- package/dist/index.d.ts +3 -1
- package/dist/useShortcuts.js +57 -8
- package/package.json +2 -2
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(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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(
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
|
305
|
-
|
|
|
306
|
-
| `scope`
|
|
307
|
-
| `scopeMode`
|
|
308
|
-
| `priority`
|
|
309
|
-
| `disabled`
|
|
310
|
-
| `preventDefault`
|
|
311
|
-
| `stopPropagation` | `boolean`
|
|
312
|
-
| `sequential`
|
|
313
|
-
| `sequenceDelay`
|
|
314
|
-
| `hold`
|
|
315
|
-
| `repeat`
|
|
316
|
-
| `ignoreInputs`
|
|
317
|
-
| `enableInInput`
|
|
318
|
-
| `data`
|
|
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
|
|
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;
|
package/dist/useShortcuts.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
}, [
|
|
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.
|
|
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.
|
|
55
|
+
"@keybindy/core": "2.0.2"
|
|
56
56
|
},
|
|
57
57
|
"publishConfig": {
|
|
58
58
|
"access": "public",
|