@keybindy/react 1.1.12 → 2.0.1

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
@@ -1,26 +1,46 @@
1
1
  # @keybindy/react
2
2
 
3
- `@keybindy/react` is the official React integration for the [Keybindy](https://www.npmjs.com/package/@keybindy/core) keyboard shortcut system. Built on top of `@keybindy/core`, this package brings powerful and scoped keyboard bindings to your React applications — with components and hooks tailored to React’s architecture.
3
+ <p align="center">
4
+ <strong>Modern, rock-solid React hooks and components for keyboard shortcuts.</strong><br />
5
+ <em>Zero lifecycle blinking. Zero stale closures. 100% headless.</em>
6
+ </p>
7
+
8
+ <p align="center">
9
+ <a href="https://www.npmjs.com/package/@keybindy/react"><img src="https://img.shields.io/npm/v/@keybindy/react.svg?style=flat&colorA=18181B&colorB=3B82F6" alt="npm version" /></a>
10
+ <a href="https://bundlephobia.com/package/@keybindy/react"><img src="https://img.shields.io/bundlephobia/minzip/@keybindy/react?style=flat&colorA=18181B&colorB=10B981" alt="minzipped size" /></a>
11
+ <a href="https://github.com/keybindyjs/keybindy/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg?style=flat&colorA=18181B&colorB=6366F1" alt="MIT License" /></a>
12
+ <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-Ready-blue?style=flat&colorA=18181B&colorB=3178C6" alt="TypeScript" /></a>
13
+ </p>
4
14
 
5
- [![npm version](https://badge.fury.io/js/@keybindy%2Freact.svg)](https://www.npmjs.com/package/@keybindy/react)
6
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
15
+ ---
16
+
17
+ Most React keyboard shortcut hooks fail in subtle, frustrating ways:
18
+ 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
+ 2. **Stale Closures**: Forgetting to update dependency arrays traps shortcuts with initial state values.
20
+ 3. **Modal Leaks**: Closing a dialog forgets to re-enable background shortcuts or corrupts the active scope.
21
+
22
+ **`@keybindy/react`** completely eliminates these bugs with a **stable ref architecture**. Handlers always execute with the freshest state without ever re-registering the listener.
7
23
 
8
24
  ---
9
25
 
10
- ## 🧠 What is @keybindy/react?
26
+ ## Why `@keybindy/react`?
11
27
 
12
- While `@keybindy/core` gives you the underlying logic to register and manage shortcuts in any JavaScript environment, **@keybindy/react** wraps it in a React-friendly API with scoped context, declarative components, and hooks for full control.
28
+ - ⚡️ **Zero Lifecycle Blinking** Listeners register once on mount and unregister on unmount. No keystrokes are ever dropped during state updates.
29
+ - 🔄 **Always Fresh State** — Access your component's latest state and props inside callbacks without stale closures.
30
+ - 🎯 **Modal Isolation & Cascading Tools** — Effortlessly trap shortcuts in modals or cascade layered hotkeys.
31
+ - 🛡 **Hooks & Components** — Use `useShortcut`, `useShortcuts`, `useShortcutManager`, or `<Keybindy />` JSX.
32
+ - ⚡️ **SSR & Next.js App Router Ready** — Client-safe initialization that never crashes during server rendering.
13
33
 
14
34
  ---
15
35
 
16
- ## Installation
36
+ ## 📦 Installation
17
37
 
18
38
  ```bash
19
39
  # npm
20
40
  npm install @keybindy/react
21
41
 
22
- # yarn
23
- yarn add @keybindy/react
42
+ # pnpm
43
+ pnpm add @keybindy/react
24
44
 
25
45
  # bun
26
46
  bun add @keybindy/react
@@ -28,162 +48,277 @@ bun add @keybindy/react
28
48
 
29
49
  ---
30
50
 
31
- ## Usage
51
+ ## 🚀 Quick Start
32
52
 
33
- #### `<Keybindy />` component
53
+ ### 1. The `useShortcut` Hook (Single Shortcut)
34
54
 
35
- The core declarative component. Register all your scoped or global shortcuts with ease.
55
+ The cleanest, most ergonomic way to bind hotkeys in any functional component:
36
56
 
37
- | Prop | Type | Default | Description |
38
- | ----------------- | -------------------- | ----------- | -------------------------------------------------------------- |
39
- | `logs` | `boolean` | `false` | Whether to enable debug logs in the console. |
40
- | `onShortcutFired` | `fn(info: Shortcut)` | `undefined` | Optional callback to handle shortcut firing events. |
41
- | `disabled` | `boolean` | `false` | Whether to disable all shortcuts within the component's scope. |
42
- | `scope` | `string` | `global` | The scope to apply the shortcuts to. |
43
- | `shortcuts` | `Shortcut[]` | `[]` | Array of shortcut objects to register. |
44
- | `children` | `React.ReactNode` | `undefined` | The content to render inside the component. |
57
+ ```tsx
58
+ import { useState } from 'react';
59
+ import { useShortcut } from '@keybindy/react';
45
60
 
46
- ##### Example
61
+ function DocumentEditor() {
62
+ const [content, setContent] = useState('');
47
63
 
48
- ```ts
49
- import { Keybindy } from '@keybindy/react';
64
+ // ⚡️ 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
+ });
71
+
72
+ // Cross-platform Command/Ctrl + K
73
+ useShortcut([['Meta', 'K'], ['Ctrl', 'K']], () => {
74
+ openSearchPalette();
75
+ }, { preventDefault: true });
76
+
77
+ return <textarea value={content} onChange={e => setContent(e.target.value)} />;
78
+ }
79
+ ```
50
80
 
51
- <Keybindy
52
- scope="global"
53
- shortcuts={[
81
+ ---
82
+
83
+ ### 2. The `useShortcuts` Hook (Multiple Shortcuts)
84
+
85
+ Great for components with many hotkeys (e.g. video players, canvas apps, tables):
86
+
87
+ ```tsx
88
+ import { useShortcuts } from '@keybindy/react';
89
+
90
+ function VideoPlayer({ isPlaying, onPlayPause, onSeekForward, onSeekBackward }) {
91
+ useShortcuts([
54
92
  {
55
- keys: ['A'],
56
- handler: () => console.log('A pressed'),
57
- options: {
58
- preventDefault: true,
59
- },
93
+ keys: ['Space'],
94
+ handler: onPlayPause,
95
+ options: { preventDefault: true },
60
96
  },
61
97
  {
62
- keys: ['O', 'P'],
63
- handler: () => setIsOpen(true),
64
- options: {
65
- sequenceDelay: 1000,
66
- sequential: true,
67
- preventDefault: true,
68
- },
98
+ keys: ['ArrowRight'],
99
+ handler: () => onSeekForward(5),
100
+ options: { preventDefault: true },
69
101
  },
70
102
  {
71
- keys: ['R'],
72
- handler: () => window.open('https://react.dev', '_blank'),
73
- options: {
74
- preventDefault: true,
75
- },
103
+ keys: ['ArrowLeft'],
104
+ handler: () => onSeekBackward(5),
105
+ options: { preventDefault: true },
76
106
  },
77
- ]}
78
- />;
79
- ```
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
+ });
80
116
 
81
- #### `<ShortcutLabel />` component
117
+ return <div>{/* Player UI */}</div>;
118
+ }
119
+ ```
82
120
 
83
- A lightweight UI component to render visually styled shortcut hints.
121
+ ---
84
122
 
85
- | Prop | Type | Default | Description |
86
- | ----------- | ---------------------------------------------- | ----------- | ---------------------------------------------- |
87
- | `keys` | `Keys` | `[]` | The key combination(s) to display. |
88
- | `renderKey` | `fn(key: string, index: number, keys: Keys[])` | `undefined` | Custom renderer for each key badge or segment. |
123
+ ### 3. The `<Keybindy />` Component (Declarative JSX)
89
124
 
90
- ##### Example
125
+ If you prefer wrapping views declaratively, `<Keybindy />` provides the same rock-solid behavior in JSX:
91
126
 
92
- ```ts
93
- import { ShortcutLabel } from '@keybindy/react';
127
+ ```tsx
128
+ import { Keybindy } from '@keybindy/react';
94
129
 
95
- <ShortcutLabel
96
- keys={['ctrl', 'alt', 'delete']}
97
- renderKey={(key, i, all) => (
98
- <>
99
- <span style={{ color: '#00eaff' }}>{key.toUpperCase()}</span>
100
- {i < all.length - 1 && <span style={{ opacity: 0.5 }}> + </span>}
101
- </>
102
- )}
103
- />;
130
+ function App() {
131
+ return (
132
+ <Keybindy
133
+ scope="global"
134
+ shortcuts={[
135
+ {
136
+ keys: ['Ctrl', 'S'],
137
+ handler: () => console.log('Saved!'),
138
+ options: { preventDefault: true },
139
+ },
140
+ ]}
141
+ >
142
+ <MainLayout />
143
+ </Keybindy>
144
+ );
145
+ }
104
146
  ```
105
147
 
106
- #### `useKeybindy` Hook
107
-
108
- A powerful hook that gives you full control over the shortcut system via the ShortcutManager under the hood. Best for dynamic or advanced use cases.
109
-
110
- ##### Available methods
111
-
112
- | Method | Description |
113
- | ------------------------------------------------------------------------ | -------------------------------------------- |
114
- | [`register()`](https://github.com/keybindy/core#register) | Register a shortcut |
115
- | [`unregister()`](https://github.com/keybindy/core#unregister) | Unregister a shortcut |
116
- | [`enable()`](https://github.com/keybindy/core#enable--disable--toggle) | Enable a specific shortcut |
117
- | [`disable()`](https://github.com/keybindy/core#enable--disable--toggle) | Disable a specific shortcut |
118
- | [`toggle()`](https://github.com/keybindy/core#enable--disable--toggle) | Toggle a shortcut on/off |
119
- | [`enableAll()`](https://github.com/keybindy/core#enableall--disableall) | Enable all shortcuts (global or scoped) |
120
- | [`disableAll()`](https://github.com/keybindy/core#enableall--disableall) | Disable all shortcuts (global or scoped) |
121
- | [`setScope()`](https://github.com/keybindy/core#setactivescope) | Set the active scope |
122
- | [`resetScope()`](https://github.com/keybindy/core#resetScope) | Reset to default scope |
123
- | [`getScopes()`](https://github.com/keybindy/core#getScopes) | Get all defined scopes |
124
- | [`getActiveScope()`](https://github.com/keybindy/core#getActiveScope) | Get the current active scope |
125
- | [`popScope()`](https://github.com/keybindy/core#popScope) | Remove the top scope from the scope stack |
126
- | [`pushScope()`](https://github.com/keybindy/core#pushScope) | Push a new scope onto the scope stack |
127
- | [`getCheatSheet()`](https://github.com/keybindy/core#getCheatsheet) | Retrieve all shortcuts (optionally by scope) |
128
- | [`onTyping()`](https://github.com/keybindy/core#onTyping) | Listen to every key press |
129
- | [`destroy()`](https://github.com/keybindy/core#destroy) | Tear down the current manager instance |
130
- | [`clear()`](https://github.com/keybindy/core#clear) | Unregister all shortcuts |
131
- | [`getScopeInfo()`](https://github.com/keybindy/core#getScopeInfo) | Retrieve metadata about a specific scope |
132
- | [`isScopeActive()`](https://github.com/keybindy/core#isScopeActive) | Check if a scope is currently active |
133
-
134
- > _All methods mirror `@keybindy/core` with a React-friendly API._
135
-
136
- ##### Example
137
-
138
- ```ts
139
- import { useKeybindy } from '@keybindy/react';
140
-
141
- const { register, unregister, setScope, getCheatSheet } = useKeybindy();
142
-
143
- React.useEffect(() => {
144
- register(
145
- ['ctrl', 'k'],
146
- () => {
147
- console.log('Shortcut fired!');
148
+ ---
149
+
150
+ ## 🎯 Scoping: Modals vs. Layered Tools
151
+
152
+ ### A. Modal Isolation (`default` mode)
153
+ 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
+
155
+ ```tsx
156
+ function DeleteConfirmationModal({ isOpen, onClose, onDelete }) {
157
+ // Opening this modal automatically deactivates global shortcuts
158
+ useShortcuts([
159
+ {
160
+ keys: ['Enter'],
161
+ handler: onDelete,
148
162
  },
149
163
  {
150
- scope: 'editor',
151
- preventDefault: true,
152
- }
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
+ });
172
+
173
+ if (!isOpen) return null;
174
+ return <div className="modal">Are you sure?</div>;
175
+ }
176
+ ```
177
+
178
+ ---
179
+
180
+ ### B. Layered Tools with Priority (`cascade` mode)
181
+ 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
+
183
+ ```tsx
184
+ // 1. Root Canvas (in cascade mode)
185
+ function CanvasApp() {
186
+ return (
187
+ <Keybindy scopeMode="cascade" scope="canvas" shortcuts={[
188
+ { keys: ['Space'], handler: panCanvas, options: { hold: true } },
189
+ { keys: ['V'], handler: selectTool },
190
+ ]}>
191
+ <Toolbox />
192
+ <TextLayerEditor />
193
+ </Keybindy>
153
194
  );
195
+ }
154
196
 
155
- return () => {
156
- unregister(['ctrl', 'k'], 'editor');
157
- };
158
- }, []);
197
+ // 2. Focused Text Layer (higher priority weight)
198
+ function TextLayerEditor() {
199
+ useShortcuts([
200
+ {
201
+ keys: ['V'], // Overrides global 'V' tool while text editor is focused
202
+ handler: pastePlainText,
203
+ }
204
+ ], {
205
+ scope: 'text-editor',
206
+ priority: 100, // Higher priority wins colliding keys
207
+ });
208
+ }
209
+
210
+ // 3. Isolated Modal inside a cascading app
211
+ function SettingsModal({ isOpen, onClose }) {
212
+ // 💡 Want to trap shortcuts in a specific modal and block parent cascading?
213
+ // Pass scopeMode="default" to isolate this child from parent shortcuts!
214
+ useShortcuts([
215
+ { keys: ['Esc'], handler: onClose, options: { enableInInput: true } }
216
+ ], {
217
+ scope: 'settings-modal',
218
+ scopeMode: 'default', // Traps shortcuts: parent canvas keys won't fire
219
+ disabled: !isOpen,
220
+ });
221
+
222
+ if (!isOpen) return null;
223
+ return <div className="modal">Settings</div>;
224
+ }
159
225
  ```
160
226
 
227
+ > [!TIP]
228
+ > **Isolating a Child from Parent Cascading**: If your parent container is in `scopeMode="cascade"`, but you want a specific modal or dialog to **trap and block** all parent hotkeys, simply pass `scopeMode="default"` to that child `<Keybindy />` or `useShortcuts` / `useShortcut`. When the modal closes or unmounts, the parent's `cascade` mode is automatically restored.
229
+
161
230
  ---
162
231
 
163
- ## Reference
232
+ ## 🛡 Guard & Interceptor Hooks
233
+
234
+ Hook into shortcut lifecycles cleanly from any component:
235
+
236
+ ```tsx
237
+ import { useBeforeShortcut, useAfterShortcut } from '@keybindy/react';
238
+
239
+ function GlobalHotkeysManager() {
240
+ // 🛑 Guard: Block all shortcuts while an async mutation is pending
241
+ useBeforeShortcut((shortcut, event) => {
242
+ if (isSaving) {
243
+ console.warn('Action blocked: Save in progress.');
244
+ return false; // Returning false cancels the shortcut
245
+ }
246
+ });
164
247
 
165
- If you're looking for more detailed implementation logic and architecture, check out the [@keybindy/core](https://www.npmjs.com/package/@keybindy/core) documentation for an in-depth look at how shortcut handling works under the hood.
248
+ // 📊 Interceptor: Log analytics after shortcuts execute
249
+ useAfterShortcut((shortcut, event) => {
250
+ analytics.track('Shortcut Fired', { keys: shortcut.keys });
251
+ });
252
+ }
253
+ ```
166
254
 
167
255
  ---
168
256
 
169
- ## 🧩 Want More?
257
+ ## 🛠 Programmatic Manager: `useShortcutManager`
258
+
259
+ When you need direct programmatic control over scopes, priorities, or cheat sheets:
260
+
261
+ ```tsx
262
+ import { useShortcutManager } from '@keybindy/react';
170
263
 
171
- This package is part of the Keybindy Ecosystem:
264
+ function ShortcutsHelpModal() {
265
+ const { getCheatSheet, setScope, getActiveScope } = useShortcutManager();
172
266
 
173
- | Package | Description |
174
- | -------------------------------------------------------------- | -------------------------------------------------------------------------------- |
175
- | [`@keybindy/core`](https://npmjs.com/package/@keybindy/core) | The core JavaScript library. Framework-agnostic, fully typed, and tree-shakable. |
176
- | [`@keybindy/react`](https://npmjs.com/package/@keybindy/react) | React bindings with hooks and components for easy integration. |
177
- | _Coming Soon_ | Stay tuned! |
267
+ const allShortcuts = getCheatSheet();
268
+
269
+ return (
270
+ <dialog>
271
+ <h2>Keyboard Shortcuts</h2>
272
+ {allShortcuts.map((s, i) => (
273
+ <div key={i}>
274
+ <kbd>{s.keys.join(' + ')}</kbd>
275
+ <span>{s.data?.description}</span>
276
+ </div>
277
+ ))}
278
+ </dialog>
279
+ );
280
+ }
281
+ ```
282
+ *(Note: `useKeybindy` is retained as an exact alias to `useShortcutManager`)*.
178
283
 
179
284
  ---
180
285
 
181
- ## Contributing
286
+ ## 📝 Input Handling
182
287
 
183
- PRs, issues, and ideas are welcome! See [CONTRIBUTING.md](./CONTRIBUTING.md) for details.
288
+ Prevent shortcuts from firing while users type in `<input>`, `<textarea>`, `<select>`, or `contenteditable` elements:
184
289
 
185
- If you're adding a new framework integration (like Vue or Svelte), feel free to open a draft PR — we'd love to collaborate.
290
+ ```tsx
291
+ // Ignore inputs by default for this shortcut
292
+ useShortcut(['D'], deleteItem, { ignoreInputs: true });
293
+
294
+ // Allow this shortcut even while typing in an input
295
+ useShortcut(['Esc'], clearSearch, { enableInInput: true });
296
+ ```
297
+
298
+ ---
299
+
300
+ ## 📖 Hook Options Reference
301
+
302
+ ### `useShortcut(keys, handler, options?)`
303
+
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. |
186
319
 
187
320
  ---
188
321
 
189
- > _Might be new in the shortcut game, but Keybindy’s here to change the frame — fast, flexible, and ready to claim. 🎯_
322
+ ## 📄 License
323
+
324
+ MIT © [Keybindy Contributors](https://github.com/keybindyjs/keybindy)
package/dist/Keybindy.js CHANGED
@@ -1,65 +1,9 @@
1
1
  import { jsx, Fragment } from 'react/jsx-runtime';
2
2
  import React from 'react';
3
- import { useKeybindy } from './useKeybindy.js';
3
+ import { useShortcuts } from './useShortcuts.js';
4
4
 
5
- const KeybindyComponent = ({ scope = 'global', shortcuts: shortcutsProp = [], children, disabled, onShortcutFired, logs = false, }) => {
6
- const { register, unregister, manager, pushScope, popScope, getScopes, setScope } = useKeybindy({
7
- onShortcutFired,
8
- logs,
9
- });
10
- const shortcuts = React.useMemo(() => (typeof shortcutsProp === 'function' ? shortcutsProp() : shortcutsProp) || [], [shortcutsProp]);
11
- // Memoize a stable representation of shortcuts, excluding the handler.
12
- // This prevents the effect from re-running unnecessarily.
13
- const stableShortcuts = React.useMemo(() => {
14
- return shortcuts.map(({ keys, options }) => ({ keys, options }));
15
- }, [JSON.stringify(shortcuts.map(s => ({ keys: s.keys, options: s.options })))]);
16
- // Use a ref to store the latest handlers, preventing re-renders from causing issues.
17
- const handlersRef = React.useRef({});
18
- React.useEffect(() => {
19
- handlersRef.current = shortcuts.reduce((acc, { keys, handler }) => {
20
- const key = JSON.stringify(keys);
21
- acc[key] = handler;
22
- return acc;
23
- }, {});
24
- });
25
- React.useEffect(() => {
26
- if (!manager) {
27
- return;
28
- }
29
- if (!getScopes()?.includes(scope)) {
30
- pushScope(scope);
31
- }
32
- setScope(scope);
33
- // Register shortcuts using the stable definitions.
34
- stableShortcuts.forEach(({ keys, options }) => {
35
- const stableHandler = (event, state) => {
36
- const key = JSON.stringify(keys);
37
- const currentHandler = handlersRef.current[key];
38
- if (currentHandler) {
39
- currentHandler(event, state);
40
- }
41
- };
42
- register(keys, stableHandler, { ...options, scope });
43
- });
44
- if (disabled) {
45
- manager.disableAll(scope);
46
- }
47
- else {
48
- manager.enableAll(scope);
49
- }
50
- return () => {
51
- // Unregister using the same stable definitions.
52
- stableShortcuts.forEach(({ keys }) => {
53
- if (Array.isArray(keys[0])) {
54
- keys.forEach(key => unregister(key, scope));
55
- }
56
- else {
57
- unregister(keys, scope);
58
- }
59
- });
60
- popScope();
61
- };
62
- }, [scope, manager, disabled, stableShortcuts]);
5
+ const KeybindyComponent = ({ children, shortcuts = [], ...options }) => {
6
+ useShortcuts(shortcuts, options);
63
7
  return jsx(Fragment, { children: children });
64
8
  };
65
9
  const Keybindy = React.memo(KeybindyComponent);
package/dist/index.d.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import React from 'react';
2
- import ShortcutManager, { Keys, ShortcutHandler, ShortcutOptions, Shortcut } from '@keybindy/core';
3
- export { Keys, Shortcut, ShortcutOptions } from '@keybindy/core';
4
- import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import ShortcutManager, { ShortcutBinding, ShortcutHandler, ShortcutOptions, ScopeMode, BeforeEachHook, AfterEachHook, Shortcut, Keys, ScopePriorityInput, HookOptions } from '@keybindy/core';
3
+ export { AfterEachHook, BeforeEachHook, HookOptions, Key, Keys, ScopeMode, ScopePriorityInput, ScopePriorityRecord, Shortcut, ShortcutBinding, ShortcutHandler, ShortcutManagerOptions, ShortcutOptions } from '@keybindy/core';
5
4
 
6
5
  /**
7
6
  * Represents a shortcut definition for the `<Keybindy />` component.
@@ -11,7 +10,7 @@ type KeybindyShortcut = {
11
10
  * The key combination(s) to listen for.
12
11
  * Can be a single array of keys or an array of key combinations.
13
12
  */
14
- keys: Keys[] | Keys[][];
13
+ keys: ShortcutBinding;
15
14
  /**
16
15
  * Callback function to invoke when the shortcut is triggered.
17
16
  */
@@ -23,24 +22,44 @@ type KeybindyShortcut = {
23
22
  };
24
23
 
25
24
  /**
26
- * Props for the `<Keybindy />` component.
25
+ * Options for `useShortcuts` hook.
27
26
  */
28
- type KeybindyProps = {
27
+ type UseShortcutsOptions = {
29
28
  /**
30
29
  * The scope under which the shortcuts should be active.
31
- * This allows managing different contexts for shortcuts.
30
+ * Defaults to `'global'`.
32
31
  */
33
32
  scope?: 'global' | string;
34
33
  /**
35
- * An array of shortcut definitions or a function that returns an array of shortcuts.
36
- * Using a function can be useful for memoizing shortcuts or defining them conditionally.
34
+ * Scope management mode:
35
+ * - `'default'`: Only shortcuts in the single active scope are enabled.
36
+ * - `'cascade'`: Shortcuts across all active scopes are enabled, with common shortcut collisions resolved by scope priority or stack order.
37
37
  */
38
- shortcuts?: KeybindyShortcut[] | (() => KeybindyShortcut[]);
38
+ scopeMode?: ScopeMode;
39
39
  /**
40
- * Whether the shortcuts should be disabled for this scope.
40
+ * Whether all shortcuts in this scope should be disabled.
41
41
  * Defaults to `false`.
42
42
  */
43
43
  disabled?: boolean;
44
+ /**
45
+ * Numeric priority weight for this scope in cascade mode.
46
+ * Higher numbers take precedence over lower numbers (e.g. 100 > 10 > 0).
47
+ */
48
+ priority?: number;
49
+ /**
50
+ * Whether to ignore shortcuts when typing inside an input, textarea, select, or contenteditable.
51
+ * Can be overridden per shortcut via `enableInInput: true` or `ignoreInputs: false`.
52
+ */
53
+ ignoreInputs?: boolean;
54
+ /**
55
+ * Guard hook that runs before any shortcut in this scope executes.
56
+ * Return `false` to cancel/abort execution.
57
+ */
58
+ beforeEach?: BeforeEachHook;
59
+ /**
60
+ * Interceptor hook that runs after any shortcut in this scope successfully executes.
61
+ */
62
+ afterEach?: AfterEachHook;
44
63
  /**
45
64
  * Callback function that will be called when a shortcut is fired.
46
65
  * Receives the fired shortcut info as an argument.
@@ -50,81 +69,88 @@ type KeybindyProps = {
50
69
  * Whether to enable debug logs in the console.
51
70
  */
52
71
  logs?: boolean;
72
+ };
73
+ /**
74
+ * Options for single `useShortcut` hook.
75
+ */
76
+ type UseShortcutOptions = Omit<ShortcutOptions, 'scope'> & {
53
77
  /**
54
- * The content that will be rendered inside the Shortcut component.
78
+ * The scope under which the shortcut should be active.
79
+ * Defaults to `'global'`.
55
80
  */
56
- children?: React.ReactNode;
57
- };
58
- declare const Keybindy: React.NamedExoticComponent<KeybindyProps>;
59
-
60
- type AllowedKeys = Omit<Keys, 'Ctrl (Left)' | 'Ctrl (Right)' | 'Shift (Left)' | 'Shift (Right)' | 'Alt (Left)' | 'Alt (Right)' | 'Meta (Left)' | 'Meta (Right)'>;
61
- interface ShortcutLabelProps extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> {
81
+ scope?: 'global' | string;
82
+ /**
83
+ * Scope management mode: `'default'` | `'cascade'`.
84
+ */
85
+ scopeMode?: ScopeMode;
86
+ /**
87
+ * Whether the shortcut is disabled.
88
+ */
89
+ disabled?: boolean;
62
90
  /**
63
- * Array of keys to display. Can be a single binding `['Ctrl', 'S']` or multiple bindings `[['Ctrl', 'S'], ['Cmd', 'S']]`.
91
+ * Numeric priority weight for this scope in cascade mode.
64
92
  */
65
- keys: AllowedKeys[] | AllowedKeys[][];
93
+ priority?: number;
66
94
  /**
67
- * Custom render function for full control over how your keys are rendered.
68
- * This function receives the entire `keys` array (either `string[]` or `string[][]`)
69
- * and should return the ReactNode to be displayed.
95
+ * Whether to ignore shortcut when typing inside an input/textarea.
70
96
  */
71
- render?: (keys: AllowedKeys[] | AllowedKeys[][]) => React.ReactNode;
72
- }
97
+ ignoreInputs?: boolean;
98
+ /**
99
+ * Guard hook that runs before this shortcut executes. Return `false` to abort.
100
+ */
101
+ beforeEach?: BeforeEachHook;
102
+ /**
103
+ * Interceptor hook that runs after this shortcut executes.
104
+ */
105
+ afterEach?: AfterEachHook;
106
+ /**
107
+ * Whether to enable debug logs in the console.
108
+ */
109
+ logs?: boolean;
110
+ };
73
111
  /**
74
- * ... (rest of the description) ...
75
- *
76
- * @example
77
- * // Default usage
78
- * <ShortcutLabel keys={['ctrl', 's']} />
79
- *
80
- * @example
81
- * // With multiple bindings
82
- * <ShortcutLabel keys={[['ctrl', 's'], ['meta', 's']]} />
112
+ * React hook to register multiple keyboard shortcuts declaratively.
113
+ * Safe from stale closures and re-registration flickering/blinking.
83
114
  *
84
- * @example
85
- * // With custom render prop
86
- * <ShortcutLabel
87
- * keys={['ctrl', 'shift', 'a']}
88
- * render={(keys) => {
89
- * // 'keys' here will be ['ctrl', 'shift', 'a']
90
- * return keys.map((key) => (
91
- * <span key={key} style={{ color: '#00eaff' }}>
92
- * {key.toUpperCase()}
93
- * </span>
94
- * ));
95
- * }}
96
- * />
115
+ * @param shortcutsProp - An array of shortcut definitions or a function returning shortcuts.
116
+ * @param options - Scope, priority, mode, and lifecycle configuration.
117
+ */
118
+ declare const useShortcuts: (shortcutsProp?: KeybindyShortcut[] | (() => KeybindyShortcut[]), options?: UseShortcutsOptions) => void;
119
+ /**
120
+ * React hook to register a single keyboard shortcut.
121
+ * Safe from stale closures and re-registration flickering/blinking.
97
122
  *
98
123
  * @example
99
- * // With custom render prop for multiple bindings
100
- * <ShortcutLabel
101
- * keys={[['ctrl', 's'], ['meta', 's']]}
102
- * render={(bindings) => {
103
- * // 'bindings' here will be [['ctrl', 's'], ['meta', 's']]
104
- * return bindings.map((binding, bindingIndex) => (
105
- * <React.Fragment key={bindingIndex}>
106
- * {binding.map((key, keyIndex) => (
107
- * <span key={keyIndex} style={{ fontWeight: 'bold' }}>
108
- * {key}
109
- * </span>
110
- * ))}
111
- * {bindingIndex < bindings.length - 1 && ' or '}
112
- * </React.Fragment>
113
- * ));
114
- * }}
115
- * />
124
+ * ```tsx
125
+ * useShortcut(['Ctrl', 'S'], (e) => {
126
+ * save(currentData);
127
+ * }, { preventDefault: true, scope: 'editor' });
128
+ * ```
116
129
  *
117
- * @param {ShortcutLabelProps} props - Props for the ShortcutLabel component
118
- * @param {string[] | string[][]} props.keys - The list of keys to display.
119
- * @param {Function} [props.render] - Optional custom render function for full control over how your keys are rendered.
120
- *
121
- * @returns {JSX.Element} Rendered shortcut label
130
+ * @param keys - Key combination (e.g. `['Ctrl', 'S']` or `[['Ctrl', 'K'], ['Meta', 'K']]`).
131
+ * @param handler - Callback to execute when shortcut is triggered. Always accesses fresh state without re-registering.
132
+ * @param options - Configuration options for scope, preventDefault, hold, repeat, etc.
133
+ */
134
+ declare const useShortcut: (keys: ShortcutBinding, handler: ShortcutHandler, options?: UseShortcutOptions) => void;
135
+
136
+ /**
137
+ * Props for the `<Keybindy />` component.
122
138
  */
123
- declare const ShortcutLabel: ({ keys, render, style, ...props }: ShortcutLabelProps) => react_jsx_runtime.JSX.Element;
139
+ type KeybindyProps = UseShortcutsOptions & {
140
+ /**
141
+ * An array of shortcut definitions or a function that returns an array of shortcuts.
142
+ */
143
+ shortcuts?: KeybindyShortcut[] | (() => KeybindyShortcut[]);
144
+ /**
145
+ * Child elements to render within the Keybindy context.
146
+ */
147
+ children?: React.ReactNode;
148
+ };
149
+ declare const Keybindy: React.NamedExoticComponent<KeybindyProps>;
124
150
 
125
- type UseKeybindyReturn = {
126
- register: (keys: Keys[] | Keys[][], handler: ShortcutHandler, options?: ShortcutOptions) => void;
127
- unregister: (keys: Keys[], scope?: string) => void;
151
+ type UseShortcutManagerReturn = {
152
+ register: (keys: ShortcutBinding, handler: ShortcutHandler, options?: ShortcutOptions) => void;
153
+ unregister: (keys: ShortcutBinding, scope?: string) => void;
128
154
  enable: (keys: Keys[], scope?: string) => void;
129
155
  disable: (keys: Keys[], scope?: string) => void;
130
156
  toggle: (keys: Keys[], scope?: string) => void;
@@ -138,11 +164,19 @@ type UseKeybindyReturn = {
138
164
  destroy: () => void;
139
165
  getScopeInfo: (scope?: string) => any;
140
166
  getActiveScope: () => string | undefined;
141
- popScope: () => void;
167
+ popScope: (scope?: string) => void;
142
168
  pushScope: (scope: string) => void;
143
169
  resetScope: () => void;
144
170
  getScopes: () => string[] | undefined;
145
171
  isScopeActive: (scope: string) => boolean | undefined;
172
+ setScopeMode: (mode: ScopeMode) => void;
173
+ getScopeMode: () => ScopeMode | undefined;
174
+ setScopePriority: ((scope: string, priority: number) => void) & ((input: ScopePriorityInput) => void);
175
+ getScopePriority: (scope?: string) => number | Record<string, number> | null | undefined;
176
+ getScopePriorityValue: (scope: string) => number | undefined;
177
+ getSortedScopes: () => string[] | undefined;
178
+ clearScopePriority: () => void;
179
+ removeScopePriority: (scope: string) => void;
146
180
  onTyping: (callback: (payload: {
147
181
  key: string;
148
182
  event: KeyboardEvent;
@@ -150,22 +184,53 @@ type UseKeybindyReturn = {
150
184
  enableAll: (scope?: string) => void;
151
185
  clear: () => void;
152
186
  disableAll: (scope?: string) => void;
187
+ beforeEach: (hook: BeforeEachHook, options?: HookOptions) => () => void;
188
+ afterEach: (hook: AfterEachHook, options?: HookOptions) => () => void;
153
189
  manager: ShortcutManager | null;
154
190
  };
191
+ type UseKeybindyReturn = UseShortcutManagerReturn;
155
192
  /**
156
- * React hook to manage keyboard shortcuts using a shared instance of `ShortcutManager`.
157
- * This hook is safe for server-side rendering (SSR) and will only initialize the manager on the client.
193
+ * Low-level programmatic hook to manage keyboard shortcuts and the `ShortcutManager` instance.
194
+ * Safe for server-side rendering (SSR) - manager initializes on client.
158
195
  *
159
196
  * @param {Object} config - Configuration object.
160
197
  * @param {boolean} [config.logs=false] - Whether to enable debug logs in the console.
198
+ * @param {boolean} [config.ignoreInputs=false] - Global default to ignore shortcuts when typing in inputs/textareas.
161
199
  * @param {(info: Shortcut) => void} [config.onShortcutFired] - Callback for when a shortcut is fired.
200
+ * @param {ScopeMode} [config.scopeMode] - Initial scope mode ('default' | 'cascade').
162
201
  *
163
- * @returns {Object} Object containing shortcut management methods and the manager instance (null on server).
202
+ * @returns {UseShortcutManagerReturn} Object containing programmatic methods and manager instance.
203
+ */
204
+ declare const useShortcutManager: ({ logs, ignoreInputs, onShortcutFired, scopeMode, }?: {
205
+ logs?: boolean;
206
+ ignoreInputs?: boolean;
207
+ onShortcutFired?: (info: Shortcut) => void;
208
+ scopeMode?: ScopeMode;
209
+ }) => UseShortcutManagerReturn;
210
+ /**
211
+ * @deprecated Use `useShortcutManager` for programmatic manager access or `useShortcut` / `useShortcuts` for component shortcuts.
164
212
  */
165
- declare const useKeybindy: ({ logs, onShortcutFired, }?: {
213
+ declare const useKeybindy: ({ logs, ignoreInputs, onShortcutFired, scopeMode, }?: {
166
214
  logs?: boolean;
215
+ ignoreInputs?: boolean;
167
216
  onShortcutFired?: (info: Shortcut) => void;
168
- }) => UseKeybindyReturn;
217
+ scopeMode?: ScopeMode;
218
+ }) => UseShortcutManagerReturn;
219
+ /**
220
+ * React hook to register a guard hook before shortcuts execute.
221
+ * Returning `false` will cancel/abort the shortcut execution.
222
+ *
223
+ * @param hook - Guard function to run before matching shortcuts.
224
+ * @param options - Optional filter options (scope, keys).
225
+ */
226
+ declare const useBeforeShortcut: (hook: BeforeEachHook, options?: HookOptions) => void;
227
+ /**
228
+ * React hook to register an interceptor hook after shortcuts successfully execute.
229
+ *
230
+ * @param hook - Interceptor function to run after matching shortcuts.
231
+ * @param options - Optional filter options (scope, keys).
232
+ */
233
+ declare const useAfterShortcut: (hook: AfterEachHook, options?: HookOptions) => void;
169
234
 
170
- export { Keybindy, ShortcutLabel, useKeybindy };
171
- export type { KeybindyShortcut };
235
+ export { Keybindy, useAfterShortcut, useBeforeShortcut, useKeybindy, useShortcut, useShortcutManager, useShortcuts };
236
+ export type { KeybindyProps, KeybindyShortcut, UseKeybindyReturn, UseShortcutManagerReturn, UseShortcutOptions, UseShortcutsOptions };
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  export { Keybindy } from './Keybindy.js';
2
- export { ShortcutLabel } from './ShortcutLabel.js';
3
- export { useKeybindy } from './useKeybindy.js';
2
+ export { useShortcut, useShortcuts } from './useShortcuts.js';
3
+ export { useAfterShortcut, useBeforeShortcut, useKeybindy, useShortcutManager } from './useKeybindy.js';
@@ -12,20 +12,27 @@ const getSharedInstance = (options) => {
12
12
  return sharedInstance;
13
13
  };
14
14
  /**
15
- * React hook to manage keyboard shortcuts using a shared instance of `ShortcutManager`.
16
- * This hook is safe for server-side rendering (SSR) and will only initialize the manager on the client.
15
+ * Low-level programmatic hook to manage keyboard shortcuts and the `ShortcutManager` instance.
16
+ * Safe for server-side rendering (SSR) - manager initializes on client.
17
17
  *
18
18
  * @param {Object} config - Configuration object.
19
19
  * @param {boolean} [config.logs=false] - Whether to enable debug logs in the console.
20
+ * @param {boolean} [config.ignoreInputs=false] - Global default to ignore shortcuts when typing in inputs/textareas.
20
21
  * @param {(info: Shortcut) => void} [config.onShortcutFired] - Callback for when a shortcut is fired.
22
+ * @param {ScopeMode} [config.scopeMode] - Initial scope mode ('default' | 'cascade').
21
23
  *
22
- * @returns {Object} Object containing shortcut management methods and the manager instance (null on server).
24
+ * @returns {UseShortcutManagerReturn} Object containing programmatic methods and manager instance.
23
25
  */
24
- const useKeybindy = ({ logs = false, onShortcutFired, } = {}) => {
26
+ const useShortcutManager = ({ logs = false, ignoreInputs = false, onShortcutFired, scopeMode, } = {}) => {
25
27
  const [manager, setManager] = React.useState(null);
26
28
  React.useEffect(() => {
27
29
  if (!manager) {
28
- const instance = getSharedInstance({ onShortcutFired, silent: !logs });
30
+ const instance = getSharedInstance({
31
+ onShortcutFired,
32
+ silent: !logs,
33
+ ignoreInputs,
34
+ scopeMode,
35
+ });
29
36
  setManager(instance);
30
37
  }
31
38
  }, []);
@@ -116,11 +123,45 @@ const useKeybindy = ({ logs = false, onShortcutFired, } = {}) => {
116
123
  const isScopeActive = React.useCallback((scope) => {
117
124
  return manager?.isScopeActive(scope);
118
125
  }, [manager]);
126
+ const setScopeMode = React.useCallback((mode) => {
127
+ manager?.setScopeMode(mode);
128
+ log('Scope mode set to:', mode);
129
+ }, [manager]);
130
+ const getScopeMode = React.useCallback(() => {
131
+ return manager?.getScopeMode();
132
+ }, [manager]);
133
+ const setScopePriority = React.useCallback((scopeOrInput, priority) => {
134
+ if (typeof scopeOrInput === 'string' && typeof priority === 'number') {
135
+ manager?.setScopePriority(scopeOrInput, priority);
136
+ log(`Scope "${scopeOrInput}" priority set to:`, priority);
137
+ }
138
+ else {
139
+ manager?.setScopePriority(scopeOrInput);
140
+ log('Scope priority set');
141
+ }
142
+ }, [manager]);
143
+ const getScopePriority = React.useCallback((scope) => {
144
+ return manager?.getScopePriority(scope);
145
+ }, [manager]);
146
+ const getScopePriorityValue = React.useCallback((scope) => {
147
+ return manager?.getScopePriorityValue(scope);
148
+ }, [manager]);
149
+ const getSortedScopes = React.useCallback(() => {
150
+ return manager?.getSortedScopes();
151
+ }, [manager]);
152
+ const clearScopePriority = React.useCallback(() => {
153
+ manager?.clearScopePriority();
154
+ log('Cleared scope priority');
155
+ }, [manager]);
156
+ const removeScopePriority = React.useCallback((scope) => {
157
+ manager?.removeScopePriority(scope);
158
+ log(`Removed scope "${scope}" from priority`);
159
+ }, [manager]);
119
160
  const onTyping = React.useCallback((callback) => {
120
161
  manager?.onTyping(callback);
121
162
  }, [manager]);
122
- const popScope = React.useCallback(() => {
123
- manager?.popScope();
163
+ const popScope = React.useCallback((scope) => {
164
+ manager?.popScope(scope);
124
165
  log('Popped scope, active scope is:', manager?.getActiveScope());
125
166
  }, [manager]);
126
167
  const pushScope = React.useCallback((scope) => {
@@ -130,6 +171,12 @@ const useKeybindy = ({ logs = false, onShortcutFired, } = {}) => {
130
171
  const getScopeInfo = React.useCallback((scope) => {
131
172
  return manager?.getScopesInfo(scope);
132
173
  }, [manager]);
174
+ const beforeEach = React.useCallback((hook, options) => {
175
+ return manager?.beforeEach(hook, options) ?? (() => { });
176
+ }, [manager]);
177
+ const afterEach = React.useCallback((hook, options) => {
178
+ return manager?.afterEach(hook, options) ?? (() => { });
179
+ }, [manager]);
133
180
  const destroy = () => {
134
181
  manager?.destroy();
135
182
  };
@@ -152,12 +199,69 @@ const useKeybindy = ({ logs = false, onShortcutFired, } = {}) => {
152
199
  resetScope,
153
200
  getScopes,
154
201
  isScopeActive,
202
+ setScopeMode,
203
+ getScopeMode,
204
+ setScopePriority,
205
+ getScopePriority,
206
+ getScopePriorityValue,
207
+ getSortedScopes,
208
+ clearScopePriority,
209
+ removeScopePriority,
155
210
  onTyping,
156
211
  enableAll,
157
212
  clear,
158
213
  disableAll,
214
+ beforeEach,
215
+ afterEach,
159
216
  manager,
160
217
  };
161
218
  };
219
+ /**
220
+ * @deprecated Use `useShortcutManager` for programmatic manager access or `useShortcut` / `useShortcuts` for component shortcuts.
221
+ */
222
+ const useKeybindy = useShortcutManager;
223
+ /**
224
+ * React hook to register a guard hook before shortcuts execute.
225
+ * Returning `false` will cancel/abort the shortcut execution.
226
+ *
227
+ * @param hook - Guard function to run before matching shortcuts.
228
+ * @param options - Optional filter options (scope, keys).
229
+ */
230
+ const useBeforeShortcut = (hook, options) => {
231
+ const hookRef = React.useRef(hook);
232
+ hookRef.current = hook;
233
+ React.useEffect(() => {
234
+ const instance = getSharedInstance();
235
+ if (!instance)
236
+ return;
237
+ const unregister = instance.beforeEach((shortcut, event) => {
238
+ return hookRef.current(shortcut, event);
239
+ }, options);
240
+ return () => {
241
+ unregister();
242
+ };
243
+ }, [options?.scope, JSON.stringify(options?.keys)]);
244
+ };
245
+ /**
246
+ * React hook to register an interceptor hook after shortcuts successfully execute.
247
+ *
248
+ * @param hook - Interceptor function to run after matching shortcuts.
249
+ * @param options - Optional filter options (scope, keys).
250
+ */
251
+ const useAfterShortcut = (hook, options) => {
252
+ const hookRef = React.useRef(hook);
253
+ hookRef.current = hook;
254
+ React.useEffect(() => {
255
+ const instance = getSharedInstance();
256
+ if (!instance)
257
+ return;
258
+ const unregister = instance.afterEach((shortcut, event) => {
259
+ hookRef.current(shortcut, event);
260
+ }, options);
261
+ return () => {
262
+ unregister();
263
+ };
264
+ }, [options?.scope, JSON.stringify(options?.keys)]);
265
+ };
162
266
 
163
- export { useKeybindy };
267
+ export { useAfterShortcut, useBeforeShortcut, useKeybindy, useShortcutManager };
@@ -0,0 +1,151 @@
1
+ import React from 'react';
2
+ import { useShortcutManager } from './useKeybindy.js';
3
+
4
+ /**
5
+ * React hook to register multiple keyboard shortcuts declaratively.
6
+ * Safe from stale closures and re-registration flickering/blinking.
7
+ *
8
+ * @param shortcutsProp - An array of shortcut definitions or a function returning shortcuts.
9
+ * @param options - Scope, priority, mode, and lifecycle configuration.
10
+ */
11
+ const useShortcuts = (shortcutsProp = [], options = {}) => {
12
+ const { scope = 'global', scopeMode, disabled, priority, ignoreInputs, beforeEach, afterEach, onShortcutFired, logs = false, } = options;
13
+ const { register, unregister, manager, pushScope, popScope, getScopes, setScope, setScopeMode, setScopePriority, removeScopePriority, } = useShortcutManager({
14
+ onShortcutFired,
15
+ logs,
16
+ });
17
+ const beforeEachRef = React.useRef(beforeEach);
18
+ beforeEachRef.current = beforeEach;
19
+ const afterEachRef = React.useRef(afterEach);
20
+ afterEachRef.current = afterEach;
21
+ // Resolve shortcuts from prop, whether array or function
22
+ const shortcuts = typeof shortcutsProp === 'function' ? shortcutsProp() : shortcutsProp;
23
+ // Keep a ref of current handlers so our stable handler closures never go stale.
24
+ const handlersRef = React.useRef({});
25
+ handlersRef.current = {};
26
+ shortcuts.forEach(({ keys, handler }) => {
27
+ handlersRef.current[JSON.stringify(keys)] = handler;
28
+ });
29
+ // Create stable shortcuts definitions containing only serializable fields (keys, options).
30
+ const stableShortcuts = React.useMemo(() => {
31
+ return shortcuts.map(({ keys, options: opt }) => ({ keys, options: opt }));
32
+ }, [JSON.stringify(shortcuts.map(s => ({ keys: s.keys, options: s.options })))]);
33
+ React.useEffect(() => {
34
+ if (!manager)
35
+ return;
36
+ let prevScopeMode;
37
+ if (scopeMode) {
38
+ prevScopeMode = manager.getScopeMode();
39
+ setScopeMode(scopeMode);
40
+ }
41
+ if (typeof priority === 'number') {
42
+ setScopePriority(scope, priority);
43
+ }
44
+ else {
45
+ if (!getScopes()?.includes(scope)) {
46
+ pushScope(scope);
47
+ }
48
+ setScope(scope);
49
+ }
50
+ let unregisterBefore;
51
+ let unregisterAfter;
52
+ const hookKeys = stableShortcuts.flatMap(s => Array.isArray(s.keys[0]) ? s.keys : [s.keys]);
53
+ if (beforeEach) {
54
+ unregisterBefore = manager.beforeEach((shortcut, event) => {
55
+ return beforeEachRef.current ? beforeEachRef.current(shortcut, event) : undefined;
56
+ }, { scope, keys: hookKeys.length > 0 ? hookKeys : undefined });
57
+ }
58
+ if (afterEach) {
59
+ unregisterAfter = manager.afterEach((shortcut, event) => {
60
+ if (afterEachRef.current)
61
+ afterEachRef.current(shortcut, event);
62
+ }, { scope, keys: hookKeys.length > 0 ? hookKeys : undefined });
63
+ }
64
+ // Register shortcuts using the stable definitions.
65
+ stableShortcuts.forEach(({ keys, options: opt }) => {
66
+ const stableHandler = (event, state) => {
67
+ const key = JSON.stringify(keys);
68
+ const currentHandler = handlersRef.current[key];
69
+ if (currentHandler) {
70
+ currentHandler(event, state);
71
+ }
72
+ };
73
+ register(keys, stableHandler, {
74
+ ...opt,
75
+ scope,
76
+ ignoreInputs: opt?.ignoreInputs ?? ignoreInputs,
77
+ });
78
+ });
79
+ if (disabled) {
80
+ manager.disableAll(scope);
81
+ }
82
+ else {
83
+ manager.enableAll(scope);
84
+ }
85
+ return () => {
86
+ if (unregisterBefore)
87
+ unregisterBefore();
88
+ if (unregisterAfter)
89
+ unregisterAfter();
90
+ // Unregister using the same stable definitions.
91
+ stableShortcuts.forEach(({ keys }) => {
92
+ unregister(keys, scope);
93
+ });
94
+ if (typeof priority === 'number') {
95
+ removeScopePriority(scope);
96
+ }
97
+ if (scope !== 'global') {
98
+ const remaining = manager.getCheatSheet(scope);
99
+ if (!remaining || remaining.length === 0) {
100
+ popScope(scope);
101
+ }
102
+ }
103
+ if (scopeMode && prevScopeMode !== undefined) {
104
+ setScopeMode(prevScopeMode);
105
+ }
106
+ };
107
+ }, [scope, manager, disabled, priority, scopeMode, Boolean(beforeEach), Boolean(afterEach), stableShortcuts]);
108
+ };
109
+ /**
110
+ * React hook to register a single keyboard shortcut.
111
+ * Safe from stale closures and re-registration flickering/blinking.
112
+ *
113
+ * @example
114
+ * ```tsx
115
+ * useShortcut(['Ctrl', 'S'], (e) => {
116
+ * save(currentData);
117
+ * }, { preventDefault: true, scope: 'editor' });
118
+ * ```
119
+ *
120
+ * @param keys - Key combination (e.g. `['Ctrl', 'S']` or `[['Ctrl', 'K'], ['Meta', 'K']]`).
121
+ * @param handler - Callback to execute when shortcut is triggered. Always accesses fresh state without re-registering.
122
+ * @param options - Configuration options for scope, preventDefault, hold, repeat, etc.
123
+ */
124
+ const useShortcut = (keys, handler, options) => {
125
+ const { scope = 'global', scopeMode, disabled, priority, ignoreInputs, beforeEach, afterEach, logs, ...shortcutOptions } = options || {};
126
+ const handlerRef = React.useRef(handler);
127
+ handlerRef.current = handler;
128
+ const shortcutsGetter = React.useCallback(() => [
129
+ {
130
+ keys,
131
+ handler: ((e, state) => {
132
+ if (handlerRef.current) {
133
+ handlerRef.current(e, state);
134
+ }
135
+ }),
136
+ options: shortcutOptions,
137
+ }
138
+ ], [JSON.stringify(keys), JSON.stringify(shortcutOptions)]);
139
+ useShortcuts(shortcutsGetter, {
140
+ scope,
141
+ scopeMode,
142
+ disabled,
143
+ priority,
144
+ ignoreInputs,
145
+ beforeEach,
146
+ afterEach,
147
+ logs,
148
+ });
149
+ };
150
+
151
+ export { useShortcut, useShortcuts };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keybindy/react",
3
- "version": "1.1.12",
3
+ "version": "2.0.1",
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",
@@ -22,6 +22,7 @@
22
22
  "types": "dist/index.d.ts",
23
23
  "exports": {
24
24
  ".": {
25
+ "types": "./dist/index.d.ts",
25
26
  "require": "./dist/index.js",
26
27
  "import": "./dist/index.js"
27
28
  }
@@ -51,7 +52,11 @@
51
52
  },
52
53
  "dependencies": {
53
54
  "react": "^19.1.0",
54
- "@keybindy/core": "1.1.7"
55
+ "@keybindy/core": "2.0.1"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public",
59
+ "provenance": true
55
60
  },
56
61
  "scripts": {
57
62
  "format": "prettier --write \"src/**/*.{ts,tsx}\"",
@@ -1,94 +0,0 @@
1
- import { jsx, jsxs } from 'react/jsx-runtime';
2
- import React from 'react';
3
-
4
- /**
5
- * ... (rest of the description) ...
6
- *
7
- * @example
8
- * // Default usage
9
- * <ShortcutLabel keys={['ctrl', 's']} />
10
- *
11
- * @example
12
- * // With multiple bindings
13
- * <ShortcutLabel keys={[['ctrl', 's'], ['meta', 's']]} />
14
- *
15
- * @example
16
- * // With custom render prop
17
- * <ShortcutLabel
18
- * keys={['ctrl', 'shift', 'a']}
19
- * render={(keys) => {
20
- * // 'keys' here will be ['ctrl', 'shift', 'a']
21
- * return keys.map((key) => (
22
- * <span key={key} style={{ color: '#00eaff' }}>
23
- * {key.toUpperCase()}
24
- * </span>
25
- * ));
26
- * }}
27
- * />
28
- *
29
- * @example
30
- * // With custom render prop for multiple bindings
31
- * <ShortcutLabel
32
- * keys={[['ctrl', 's'], ['meta', 's']]}
33
- * render={(bindings) => {
34
- * // 'bindings' here will be [['ctrl', 's'], ['meta', 's']]
35
- * return bindings.map((binding, bindingIndex) => (
36
- * <React.Fragment key={bindingIndex}>
37
- * {binding.map((key, keyIndex) => (
38
- * <span key={keyIndex} style={{ fontWeight: 'bold' }}>
39
- * {key}
40
- * </span>
41
- * ))}
42
- * {bindingIndex < bindings.length - 1 && ' or '}
43
- * </React.Fragment>
44
- * ));
45
- * }}
46
- * />
47
- *
48
- * @param {ShortcutLabelProps} props - Props for the ShortcutLabel component
49
- * @param {string[] | string[][]} props.keys - The list of keys to display.
50
- * @param {Function} [props.render] - Optional custom render function for full control over how your keys are rendered.
51
- *
52
- * @returns {JSX.Element} Rendered shortcut label
53
- */
54
- const ShortcutLabel = ({ keys, render, style, ...props }) => {
55
- const isMac = typeof navigator !== 'undefined' && /Mac/.test(navigator.userAgent);
56
- const defaultRenderKey = (key) => {
57
- switch (key.toLowerCase()) {
58
- case 'meta':
59
- return isMac ? '⌘' : 'Ctrl';
60
- case 'ctrl':
61
- return 'Ctrl';
62
- case 'shift':
63
- return '⇧';
64
- case 'alt':
65
- return isMac ? '⌥' : 'Alt';
66
- case 'enter':
67
- return '↵';
68
- default:
69
- return key.toUpperCase();
70
- }
71
- };
72
- const renderBinding = (binding) => {
73
- if (render) {
74
- return render(binding);
75
- }
76
- return binding.map(key => defaultRenderKey(key)).join(' + ');
77
- };
78
- const isNestedArray = Array.isArray(keys[0]);
79
- return (jsx("kbd", { style: {
80
- fontFamily: 'monospace',
81
- padding: '2.5px 5px',
82
- border: '1px solid #cccccc2f',
83
- backgroundColor: '#2e2e2e',
84
- borderRadius: '4px',
85
- userSelect: 'none',
86
- ...style,
87
- }, ...props, children: isNestedArray
88
- ? render
89
- ? renderBinding(keys)
90
- : keys.map((binding, index) => (jsxs(React.Fragment, { children: [renderBinding(binding), index < keys.length - 1 && ' / '] }, index)))
91
- : renderBinding(keys) }));
92
- };
93
-
94
- export { ShortcutLabel };