@dhruvilshah191999/use-shortcut 0.2.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/LICENSE +21 -0
- package/README.md +160 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +132 -0
- package/dist/index.d.ts +132 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dhruvil Shah
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# @dhruvilshah191999/use-shortcut
|
|
2
|
+
|
|
3
|
+
> Tiny zero-dependency React hook for keyboard shortcuts. Cross-platform, TypeScript-first, ~1KB gzipped.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@dhruvilshah191999/use-shortcut)
|
|
6
|
+
[](https://bundlephobia.com/package/@dhruvilshah191999/use-shortcut)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @dhruvilshah191999/use-shortcut
|
|
13
|
+
pnpm add @dhruvilshah191999/use-shortcut
|
|
14
|
+
yarn add @dhruvilshah191999/use-shortcut
|
|
15
|
+
bun add @dhruvilshah191999/use-shortcut
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
import { useShortcut } from "@dhruvilshah191999/use-shortcut";
|
|
22
|
+
|
|
23
|
+
useShortcut("mod+k", () => setOpen(true));
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Why this hook?
|
|
27
|
+
|
|
28
|
+
- **Zero dependencies**
|
|
29
|
+
- **Tiny** (~1KB gzipped)
|
|
30
|
+
- **Cross-platform out of the box**: `mod` becomes Cmd on Mac, Ctrl elsewhere
|
|
31
|
+
- **Smart input field handling**: shortcuts don't fire while you're typing (except `Escape`)
|
|
32
|
+
- **TypeScript types built in**
|
|
33
|
+
- **Always-fresh callback**: no stale closures, no `useCallback` needed
|
|
34
|
+
|
|
35
|
+
## API
|
|
36
|
+
|
|
37
|
+
### `useShortcut(key, callback, options?)`
|
|
38
|
+
|
|
39
|
+
| Parameter | Type | Description |
|
|
40
|
+
| ---------- | ------------------------------------------ | --------------------------------------------------------------------- |
|
|
41
|
+
| `key` | `string \| string[]` | A combo like `"mod+k"`, or an array of combos that map to one action. |
|
|
42
|
+
| `callback` | `(e: KeyboardEvent) => void` | Called with the matching event. Always the latest reference. |
|
|
43
|
+
| `options` | `Options` | Optional. See below. |
|
|
44
|
+
|
|
45
|
+
Combo syntax is case-insensitive and whitespace-tolerant: `"Cmd+K"`, `"cmd+k"`, and `" cmd + k "` are equivalent. Supported modifier aliases: `mod`, `cmd`/`command`, `ctrl`/`control`, `alt`/`option`, `shift`, `meta`.
|
|
46
|
+
|
|
47
|
+
### Options
|
|
48
|
+
|
|
49
|
+
| Option | Type | Default | Description |
|
|
50
|
+
| ----------------- | ------------------------------------- | -------- | ------------------------------------------------------------------------------------ |
|
|
51
|
+
| `enabled` | `boolean` | `true` | Whether the shortcut is active. |
|
|
52
|
+
| `preventDefault` | `boolean` | `false` | Call `event.preventDefault()` on match. |
|
|
53
|
+
| `stopPropagation` | `boolean` | `false` | Call `event.stopPropagation()` on match. |
|
|
54
|
+
| `enableInInputs` | `boolean` | `false` | Fire even while typing in inputs/textarea/select/contenteditable. |
|
|
55
|
+
| `target` | `RefObject<HTMLElement> \| Window` | `window` | Where to attach the listener. Use a ref to scope a shortcut to one element. |
|
|
56
|
+
| `scope` | `string \| string[]` | — | Restrict the shortcut to named scope(s); fires only while one is active. See [Scopes](#scopes-optional). |
|
|
57
|
+
|
|
58
|
+
## Examples
|
|
59
|
+
|
|
60
|
+
### Basic
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
// Cmd+K (Mac) / Ctrl+K (Windows/Linux) to open search
|
|
64
|
+
useShortcut("mod+k", () => openSearch());
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Cross-platform
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
// `mod` resolves to Cmd on Mac and Ctrl everywhere else
|
|
71
|
+
useShortcut("mod+s", () => save());
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Multiple shortcuts for the same action
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
useShortcut(["mod+k", "/"], () => openSearch());
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Modal close on Escape
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
useShortcut("escape", () => setOpen(false), { enabled: isOpen });
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Prevent default browser behavior
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
// Stop the browser's native "Save page" dialog
|
|
90
|
+
useShortcut("mod+s", () => save(), { preventDefault: true });
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Scopes (optional)
|
|
94
|
+
|
|
95
|
+
By default every shortcut is **global** — it fires whenever its combo matches. If you need
|
|
96
|
+
the same key to do different things in different parts of the app (list navigation vs. an
|
|
97
|
+
open modal, say), opt into **scopes**: tag a shortcut with a `scope`, wrap the relevant
|
|
98
|
+
subtree in `<ShortcutProvider>`, and activate/deactivate scopes with `useShortcutScopes()`.
|
|
99
|
+
|
|
100
|
+
Shortcuts **without** a `scope` stay global and need no provider — scopes are purely additive.
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
import {
|
|
104
|
+
ShortcutProvider,
|
|
105
|
+
useShortcut,
|
|
106
|
+
useShortcutScopes,
|
|
107
|
+
} from "@dhruvilshah191999/use-shortcut";
|
|
108
|
+
|
|
109
|
+
function App() {
|
|
110
|
+
// `list` is active on first render; `modal` is off until we open the modal
|
|
111
|
+
return (
|
|
112
|
+
<ShortcutProvider initialScopes={["list"]}>
|
|
113
|
+
<List />
|
|
114
|
+
<Modal />
|
|
115
|
+
</ShortcutProvider>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function List() {
|
|
120
|
+
// fires only while the "list" scope is active
|
|
121
|
+
useShortcut("j", selectNext, { scope: "list" });
|
|
122
|
+
return /* ... */;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function Modal() {
|
|
126
|
+
const { activate, deactivate } = useShortcutScopes();
|
|
127
|
+
|
|
128
|
+
function open() {
|
|
129
|
+
activate("modal"); // modal shortcuts start firing
|
|
130
|
+
deactivate("list"); // list shortcuts pause
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// fires only while the "modal" scope is active
|
|
134
|
+
useShortcut("escape", close, { scope: "modal" });
|
|
135
|
+
return /* ... */;
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`useShortcutScopes()` returns `{ activeScopes, activate, deactivate, toggle, set }`. A
|
|
140
|
+
shortcut can belong to several scopes (`{ scope: ["a", "b"] }`) and fires if **any** of
|
|
141
|
+
them is active.
|
|
142
|
+
|
|
143
|
+
> If you use a `scope` without a `<ShortcutProvider>`, the shortcut degrades to global
|
|
144
|
+
> behavior and warns once in development.
|
|
145
|
+
|
|
146
|
+
## Caveats
|
|
147
|
+
|
|
148
|
+
- No sequence support (e.g., `g g`).
|
|
149
|
+
- Scopes are opt-in via `<ShortcutProvider>`; without one, every shortcut is global (you can
|
|
150
|
+
also narrow a single shortcut to an element with the `target` option).
|
|
151
|
+
- No priority/conflict resolution — multiple handlers for the same combo all fire. Use
|
|
152
|
+
scopes to gate which handlers are live.
|
|
153
|
+
|
|
154
|
+
## When NOT to use this
|
|
155
|
+
|
|
156
|
+
If you need key sequences (e.g., `g g`), a help-modal generator, or a key-recorder UI, use [react-hotkeys-hook](https://github.com/JohannesKlauss/react-hotkeys-hook) or [TanStack Hotkeys](https://tanstack.com/hotkeys).
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
"use strict";var C=Object.defineProperty;var I=Object.getOwnPropertyDescriptor;var N=Object.getOwnPropertyNames;var O=Object.prototype.hasOwnProperty;var R=(t,e)=>{for(var r in e)C(t,r,{get:e[r],enumerable:!0})},D=(t,e,r,s)=>{if(e&&typeof e=="object"||typeof e=="function")for(let u of N(e))!O.call(t,u)&&u!==r&&C(t,u,{get:()=>e[u],enumerable:!(s=I(e,u))||s.enumerable});return t};var F=t=>D(C({},"__esModule",{value:!0}),t);var W={};R(W,{ShortcutProvider:()=>T,isMac:()=>v,matchEvent:()=>h,parseKey:()=>y,useShortcut:()=>L,useShortcutScopes:()=>k});module.exports=F(W);var p=require("react");function h(t,e){return e.key===null||t.metaKey!==e.meta||t.ctrlKey!==e.ctrl||t.altKey!==e.alt||t.shiftKey!==e.shift?!1:t.key.toLowerCase()===e.key}function v(){if(typeof navigator>"u")return!1;let t=navigator.platform??"";if(t)return/mac|iphone|ipad|ipod/i.test(t);let e=navigator.userAgent??"";return/mac|iphone|ipad|ipod/i.test(e)}var _=process.env.NODE_ENV!=="production";function E(t){_&&console.warn(`[use-shortcut] Ignoring invalid shortcut: "${t}"`)}var H={ctrl:"ctrl",control:"ctrl",alt:"alt",option:"alt",opt:"alt",shift:"shift",meta:"meta",cmd:"meta",command:"meta",super:"meta",win:"meta"};function y(t){if(typeof t!="string")return null;let e=t.trim();if(e==="")return null;let r={meta:!1,ctrl:!1,alt:!1,shift:!1,key:null},s=v(),u=e.split("+").map(c=>c.trim().toLowerCase());for(let c of u){if(c==="")return E(t),null;if(c==="mod"){s?r.meta=!0:r.ctrl=!0;continue}let l=H[c];if(l){l==="meta"&&!s&&(c==="cmd"||c==="command")?r.ctrl=!0:r[l]=!0;continue}if(r.key!==null)return E(t),null;r.key=c}return r.key===null?(E(t),null):r}var o=require("react"),A=process.env.NODE_ENV!=="production",g=()=>{},j={activeScopes:new Set,hasProvider:!1,activate:g,deactivate:g,toggle:g,set:g},P=(0,o.createContext)(j);function b(){return(0,o.useContext)(P)}function T({initialScopes:t,children:e}){let[r,s]=(0,o.useState)(()=>new Set(t)),u=(0,o.useCallback)(a=>{s(i=>{if(i.has(a))return i;let n=new Set(i);return n.add(a),n})},[]),c=(0,o.useCallback)(a=>{s(i=>{if(!i.has(a))return i;let n=new Set(i);return n.delete(a),n})},[]),l=(0,o.useCallback)(a=>{s(i=>{let n=new Set(i);return n.has(a)?n.delete(a):n.add(a),n})},[]),S=(0,o.useCallback)(a=>{s(new Set(a))},[]),d=(0,o.useMemo)(()=>({activeScopes:r,hasProvider:!0,activate:u,deactivate:c,toggle:l,set:S}),[r,u,c,l,S]);return(0,o.createElement)(P.Provider,{value:d},e)}function k(){let t=(0,o.useContext)(P);return A&&!t.hasProvider&&console.warn("[use-shortcut] useShortcutScopes() called without a <ShortcutProvider>; scope controls are no-ops."),(0,o.useMemo)(()=>({activeScopes:Array.from(t.activeScopes),activate:t.activate,deactivate:t.deactivate,toggle:t.toggle,set:t.set}),[t])}var w=new Set;function U(t){if(!A)return;let e=Array.isArray(t)?t.join(","):t;w.has(e)||(w.add(e),console.warn(`[use-shortcut] Shortcut uses scope "${e}" but no <ShortcutProvider> is mounted; it will fire globally.`))}function V(t,e){return t===void 0?!0:e.hasProvider?(Array.isArray(t)?t:[t]).some(s=>e.activeScopes.has(s)):(U(t),!0)}function X(t){if(!t||!(t instanceof HTMLElement))return!1;if(t.isContentEditable)return!0;let e=t.tagName;if(e==="TEXTAREA"||e==="SELECT")return!0;if(e==="INPUT"){let r=t.type.toLowerCase();return r!=="checkbox"&&r!=="radio"}return!1}function $(t){return t===void 0?typeof window>"u"?null:window:"current"in t?t.current:t}function L(t,e,r={}){let s=b(),u=(0,p.useRef)(e),c=(0,p.useRef)(r),l=(0,p.useRef)(s);(0,p.useEffect)(()=>{u.current=e,c.current=r,l.current=s});let S=Array.isArray(t)?t.join("\0"):t,d=(0,p.useMemo)(()=>{let i=Array.isArray(t)?t:[t],n=[];for(let x of i){let f=y(x);f&&n.push(f)}return n},[S]),a=r.target;(0,p.useEffect)(()=>{if(d.length===0)return;let i=$(a);if(!i)return;let n=x=>{let f=x,m=c.current;if(m.enabled===!1||!V(m.scope,l.current))return;let M=f.key==="Escape";if(!(!m.enableInInputs&&!M&&X(f.target))){for(let K of d)if(h(f,K)){m.preventDefault&&f.preventDefault(),m.stopPropagation&&f.stopPropagation(),u.current(f);break}}};return i.addEventListener("keydown",n),()=>i.removeEventListener("keydown",n)},[d,a])}0&&(module.exports={ShortcutProvider,isMac,matchEvent,parseKey,useShortcut,useShortcutScopes});
|
|
2
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/use-shortcut.ts","../src/match-event.ts","../src/platform.ts","../src/parse-keys.ts","../src/scopes.tsx"],"sourcesContent":["export { useShortcut } from \"./use-shortcut\";\nexport {\n ShortcutProvider,\n type ShortcutProviderProps,\n useShortcutScopes,\n} from \"./scopes\";\nexport type { Options, ParsedCombo, ShortcutScopesApi } from \"./types\";\n\n// Lower-level helpers are exported for advanced use and testing. The hook is\n// the intended public API.\nexport { parseKey } from \"./parse-keys\";\nexport { matchEvent } from \"./match-event\";\nexport { isMac } from \"./platform\";\n","import { useEffect, useMemo, useRef } from \"react\";\nimport { matchEvent } from \"./match-event\";\nimport { parseKey } from \"./parse-keys\";\nimport {\n type ShortcutScopeContextValue,\n isScopeActive,\n useScopeContextValue,\n} from \"./scopes\";\nimport type { Options, ParsedCombo } from \"./types\";\n\n/** Element types where typing should suppress shortcuts (except Escape). */\nfunction isEditableTarget(target: EventTarget | null): boolean {\n if (!target || !(target instanceof HTMLElement)) return false;\n\n if (target.isContentEditable) return true;\n\n const tag = target.tagName;\n if (tag === \"TEXTAREA\" || tag === \"SELECT\") return true;\n\n if (tag === \"INPUT\") {\n // Checkboxes and radios aren't text entry, so shortcuts still apply.\n const type = (target as HTMLInputElement).type.toLowerCase();\n return type !== \"checkbox\" && type !== \"radio\";\n }\n\n return false;\n}\n\n/** Resolve the listener target: a ref's current element, a Window, or null. */\nfunction resolveTarget(target: Options[\"target\"]): Window | HTMLElement | null {\n if (target === undefined) {\n return typeof window === \"undefined\" ? null : window;\n }\n // A RefObject exposes `current`; a Window does not.\n if (\"current\" in target) return target.current;\n return target;\n}\n\n/**\n * Subscribe to a keyboard shortcut for the lifetime of the component.\n *\n * @param key A combo string (`\"mod+k\"`) or an array of them (`[\"cmd+k\", \"ctrl+k\"]`).\n * @param callback Invoked with the matching `KeyboardEvent`. Always the latest\n * reference — no need to wrap in `useCallback`.\n * @param options See {@link Options}.\n */\nexport function useShortcut(\n key: string | string[],\n callback: (event: KeyboardEvent) => void,\n options: Options = {},\n): void {\n // Scope state lives in context; reading it here re-renders on scope changes.\n const scopeCtx = useScopeContextValue();\n\n // Keep the freshest callback/options/scope-state without re-attaching the\n // listener (scope changes must not tear down and rebuild the keydown handler).\n const callbackRef = useRef(callback);\n const optionsRef = useRef(options);\n const scopeCtxRef = useRef<ShortcutScopeContextValue>(scopeCtx);\n useEffect(() => {\n callbackRef.current = callback;\n optionsRef.current = options;\n scopeCtxRef.current = scopeCtx;\n });\n\n // Normalize to a stable string so the parse effect only re-runs on change.\n const keyId = Array.isArray(key) ? key.join(\"\u0000\") : key;\n\n // `keyId` is the serialized form of `key`, so depending on it alone (rather\n // than the array identity, which changes every render) is intentional.\n // biome-ignore lint/correctness/useExhaustiveDependencies: keyId mirrors key\n const combos = useMemo<ParsedCombo[]>(() => {\n const list = Array.isArray(key) ? key : [key];\n const parsed: ParsedCombo[] = [];\n for (const k of list) {\n const combo = parseKey(k);\n if (combo) parsed.push(combo);\n }\n return parsed;\n }, [keyId]);\n\n const target = options.target;\n\n useEffect(() => {\n if (combos.length === 0) return;\n\n const el = resolveTarget(target);\n if (!el) return;\n\n const handler = (event: Event) => {\n const e = event as KeyboardEvent;\n const opts = optionsRef.current;\n\n if (opts.enabled === false) return;\n\n // Only fire if the shortcut's scope is active (unscoped = always).\n if (!isScopeActive(opts.scope, scopeCtxRef.current)) return;\n\n // Escape always fires, even while typing — standard \"close\" behavior.\n const isEscape = e.key === \"Escape\";\n if (!opts.enableInInputs && !isEscape && isEditableTarget(e.target)) {\n return;\n }\n\n for (const combo of combos) {\n if (matchEvent(e, combo)) {\n if (opts.preventDefault) e.preventDefault();\n if (opts.stopPropagation) e.stopPropagation();\n callbackRef.current(e);\n break; // fire once per event even if several combos in this hook match\n }\n }\n };\n\n el.addEventListener(\"keydown\", handler);\n return () => el.removeEventListener(\"keydown\", handler);\n }, [combos, target]);\n}\n","import type { ParsedCombo } from \"./types\";\n\n/**\n * Returns `true` if a `KeyboardEvent` exactly matches a parsed combo: every\n * modifier state must agree (no extra modifiers, none missing) and the key\n * must equal the combo's key (case-insensitive via `event.key`).\n *\n * A combo with `key: null` (modifier-only) never matches, so pressing just a\n * modifier does not fire.\n */\nexport function matchEvent(event: KeyboardEvent, combo: ParsedCombo): boolean {\n if (combo.key === null) return false;\n\n if (event.metaKey !== combo.meta) return false;\n if (event.ctrlKey !== combo.ctrl) return false;\n if (event.altKey !== combo.alt) return false;\n if (event.shiftKey !== combo.shift) return false;\n\n // `event.key` reflects the produced value (\"k\", \"Escape\", \"ArrowUp\"); the\n // combo key is already lower-cased, so compare case-insensitively.\n return event.key.toLowerCase() === combo.key;\n}\n","/**\n * Detect whether we're running on a Mac. Used to resolve `mod`/`cmd` to the\n * correct physical modifier (metaKey on Mac, ctrlKey elsewhere).\n *\n * Computed on every call rather than cached at module load so it stays correct\n * across SSR -> hydration and is trivially mockable in tests.\n */\nexport function isMac(): boolean {\n if (typeof navigator === \"undefined\") return false;\n\n // navigator.platform is deprecated but still the most reliable signal where\n // present; fall back to userAgent (covers \"Macintosh\", iPad/iPhone, etc).\n const platform = navigator.platform ?? \"\";\n if (platform) return /mac|iphone|ipad|ipod/i.test(platform);\n\n const ua = navigator.userAgent ?? \"\";\n return /mac|iphone|ipad|ipod/i.test(ua);\n}\n","import { isMac } from \"./platform\";\nimport type { ParsedCombo } from \"./types\";\n\nconst isDev = process.env.NODE_ENV !== \"production\";\n\nfunction warn(combo: string): void {\n if (isDev) {\n console.warn(`[use-shortcut] Ignoring invalid shortcut: \"${combo}\"`);\n }\n}\n\n// Normalize a single token to either a modifier flag or the literal key.\n// `mod` and `cmd`/`command` resolve per-platform; everything else is a key.\ntype ModifierFlag = \"meta\" | \"ctrl\" | \"alt\" | \"shift\";\n\nconst MODIFIER_ALIASES: Record<string, ModifierFlag> = {\n ctrl: \"ctrl\",\n control: \"ctrl\",\n alt: \"alt\",\n option: \"alt\",\n opt: \"alt\",\n shift: \"shift\",\n meta: \"meta\",\n cmd: \"meta\",\n command: \"meta\",\n super: \"meta\",\n win: \"meta\",\n};\n\n/**\n * Parse a combo string like `\"cmd+k\"` or `\" Ctrl + Shift + P \"` into a\n * normalized {@link ParsedCombo}. Case-insensitive, whitespace-tolerant.\n *\n * Returns `null` (and warns in development) for empty or invalid combos such as\n * `\"\"`, `\"cmd++\"`, or `\"+++\"`. A modifier-only combo also yields `key: null`,\n * which {@link matchEvent} treats as a non-match.\n */\nexport function parseKey(combo: string): ParsedCombo | null {\n if (typeof combo !== \"string\") return null;\n\n const trimmed = combo.trim();\n if (trimmed === \"\") return null;\n\n const result: ParsedCombo = {\n meta: false,\n ctrl: false,\n alt: false,\n shift: false,\n key: null,\n };\n\n const mac = isMac();\n const tokens = trimmed.split(\"+\").map((t) => t.trim().toLowerCase());\n\n for (const token of tokens) {\n // An empty token means a stray/double `+` (e.g. \"cmd++\", \"+++\").\n if (token === \"\") {\n warn(combo);\n return null;\n }\n\n // `mod` is the cross-platform modifier: Cmd on Mac, Ctrl elsewhere.\n if (token === \"mod\") {\n if (mac) result.meta = true;\n else result.ctrl = true;\n continue;\n }\n\n const modifier = MODIFIER_ALIASES[token];\n if (modifier) {\n // `cmd`/`command` map to ctrl on non-Mac; everything else is literal.\n if (\n modifier === \"meta\" &&\n !mac &&\n (token === \"cmd\" || token === \"command\")\n ) {\n result.ctrl = true;\n } else {\n result[modifier] = true;\n }\n continue;\n }\n\n // A non-modifier token is the key. Only one key per combo is allowed.\n if (result.key !== null) {\n warn(combo);\n return null;\n }\n result.key = token;\n }\n\n // A combo must include exactly one non-modifier key.\n if (result.key === null) {\n warn(combo);\n return null;\n }\n\n return result;\n}\n","import {\n type ReactNode,\n createContext,\n createElement,\n useCallback,\n useContext,\n useMemo,\n useState,\n} from \"react\";\nimport type { ShortcutScopesApi } from \"./types\";\n\nconst isDev = process.env.NODE_ENV !== \"production\";\n\n/** Internal context value consumed by `useShortcut` and `useShortcutScopes`. */\nexport type ShortcutScopeContextValue = {\n /** Currently active scope names. */\n activeScopes: Set<string>;\n /** Whether a `<ShortcutProvider>` is mounted above this point. */\n hasProvider: boolean;\n activate: (name: string) => void;\n deactivate: (name: string) => void;\n toggle: (name: string) => void;\n set: (names: string[]) => void;\n};\n\n// Default value used when no <ShortcutProvider> is mounted. `hasProvider: false`\n// lets useShortcut degrade scoped shortcuts to global behavior (and warn in dev).\nconst noop = () => {};\nconst DEFAULT_CONTEXT: ShortcutScopeContextValue = {\n activeScopes: new Set(),\n hasProvider: false,\n activate: noop,\n deactivate: noop,\n toggle: noop,\n set: noop,\n};\n\nconst ScopeContext = createContext<ShortcutScopeContextValue>(DEFAULT_CONTEXT);\n\n/** Read the raw scope context. Internal — used by {@link useShortcut}. */\nexport function useScopeContextValue(): ShortcutScopeContextValue {\n return useContext(ScopeContext);\n}\n\nexport type ShortcutProviderProps = {\n /** Scopes active on first render. Default: none. */\n initialScopes?: string[];\n children?: ReactNode;\n};\n\n/**\n * Provides scope state to descendant `useShortcut` calls. Wrap your app (or a\n * subtree) in this to enable `{ scope }`-gated shortcuts. Unscoped shortcuts do\n * not need a provider — they always fire.\n */\nexport function ShortcutProvider({\n initialScopes,\n children,\n}: ShortcutProviderProps) {\n const [activeScopes, setActiveScopes] = useState<Set<string>>(\n () => new Set(initialScopes),\n );\n\n const activate = useCallback((name: string) => {\n setActiveScopes((prev) => {\n if (prev.has(name)) return prev;\n const next = new Set(prev);\n next.add(name);\n return next;\n });\n }, []);\n\n const deactivate = useCallback((name: string) => {\n setActiveScopes((prev) => {\n if (!prev.has(name)) return prev;\n const next = new Set(prev);\n next.delete(name);\n return next;\n });\n }, []);\n\n const toggle = useCallback((name: string) => {\n setActiveScopes((prev) => {\n const next = new Set(prev);\n if (next.has(name)) next.delete(name);\n else next.add(name);\n return next;\n });\n }, []);\n\n const set = useCallback((names: string[]) => {\n setActiveScopes(new Set(names));\n }, []);\n\n const value = useMemo<ShortcutScopeContextValue>(\n () => ({\n activeScopes,\n hasProvider: true,\n activate,\n deactivate,\n toggle,\n set,\n }),\n [activeScopes, activate, deactivate, toggle, set],\n );\n\n return createElement(ScopeContext.Provider, { value }, children);\n}\n\n/**\n * Control which scopes are active. Returns the active scope names plus\n * `activate`/`deactivate`/`toggle`/`set`. Must be used under a\n * {@link ShortcutProvider}.\n */\nexport function useShortcutScopes(): ShortcutScopesApi {\n const ctx = useContext(ScopeContext);\n if (isDev && !ctx.hasProvider) {\n console.warn(\n \"[use-shortcut] useShortcutScopes() called without a <ShortcutProvider>; scope controls are no-ops.\",\n );\n }\n return useMemo<ShortcutScopesApi>(\n () => ({\n activeScopes: Array.from(ctx.activeScopes),\n activate: ctx.activate,\n deactivate: ctx.deactivate,\n toggle: ctx.toggle,\n set: ctx.set,\n }),\n [ctx],\n );\n}\n\n// Warn at most once per scope name when used without a provider, so a scoped\n// shortcut that degrades to global doesn't spam the console on every keypress.\nconst warnedScopes = new Set<string>();\n\nfunction warnOnce(scope: string | string[]): void {\n if (!isDev) return;\n const id = Array.isArray(scope) ? scope.join(\",\") : scope;\n if (warnedScopes.has(id)) return;\n warnedScopes.add(id);\n console.warn(\n `[use-shortcut] Shortcut uses scope \"${id}\" but no <ShortcutProvider> is mounted; it will fire globally.`,\n );\n}\n\n/**\n * Whether a shortcut's `scope` is currently active.\n * - No scope -> always active (global, the default behavior).\n * - No provider -> fires anyway (degrades to global) + warns once in dev.\n * - Provider present -> active only if one of the scopes is in `activeScopes`.\n */\nexport function isScopeActive(\n scope: string | string[] | undefined,\n ctx: ShortcutScopeContextValue,\n): boolean {\n if (scope === undefined) return true;\n\n if (!ctx.hasProvider) {\n warnOnce(scope);\n return true;\n }\n\n const scopes = Array.isArray(scope) ? scope : [scope];\n return scopes.some((s) => ctx.activeScopes.has(s));\n}\n"],"mappings":"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","names":["index_exports","__export","ShortcutProvider","isMac","matchEvent","parseKey","useShortcut","useShortcutScopes","__toCommonJS","import_react","matchEvent","event","combo","isMac","platform","ua","isDev","warn","combo","MODIFIER_ALIASES","parseKey","trimmed","result","mac","isMac","tokens","t","token","modifier","import_react","isDev","noop","DEFAULT_CONTEXT","ScopeContext","useScopeContextValue","ShortcutProvider","initialScopes","children","activeScopes","setActiveScopes","activate","name","prev","next","deactivate","toggle","set","names","value","useShortcutScopes","ctx","warnedScopes","warnOnce","scope","id","isScopeActive","isEditableTarget","target","tag","type","resolveTarget","useShortcut","key","callback","options","scopeCtx","useScopeContextValue","callbackRef","optionsRef","scopeCtxRef","keyId","combos","list","parsed","k","combo","parseKey","el","handler","event","e","opts","isScopeActive","isEscape","matchEvent"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { RefObject, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Options for {@link useShortcut}. All fields are optional.
|
|
6
|
+
*/
|
|
7
|
+
type Options = {
|
|
8
|
+
/** Whether the shortcut is active. Default: `true`. */
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
/** Call `event.preventDefault()` when the combo matches. Default: `false`. */
|
|
11
|
+
preventDefault?: boolean;
|
|
12
|
+
/** Call `event.stopPropagation()` when the combo matches. Default: `false`. */
|
|
13
|
+
stopPropagation?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Fire even when the user is typing in an input/textarea/select/contenteditable.
|
|
16
|
+
* Default: `false` (the handler is skipped in those fields, except for `Escape`).
|
|
17
|
+
*/
|
|
18
|
+
enableInInputs?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Where to attach the `keydown` listener. Default: `window`.
|
|
21
|
+
* Pass a React ref to scope the shortcut to a specific element.
|
|
22
|
+
*/
|
|
23
|
+
target?: RefObject<HTMLElement | null> | Window;
|
|
24
|
+
/**
|
|
25
|
+
* Restrict the shortcut to one or more named scopes. The shortcut fires only
|
|
26
|
+
* while a matching scope is active (see `ShortcutProvider` / `useShortcutScopes`).
|
|
27
|
+
* Omit for a global shortcut that always fires. Default: `undefined` (global).
|
|
28
|
+
*
|
|
29
|
+
* If a scope is used without a `<ShortcutProvider>`, the shortcut degrades to
|
|
30
|
+
* global behavior and warns once in development.
|
|
31
|
+
*/
|
|
32
|
+
scope?: string | string[];
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Return value of {@link useShortcut}'s companion hook `useShortcutScopes`.
|
|
36
|
+
* Controls which scopes are currently active.
|
|
37
|
+
*/
|
|
38
|
+
type ShortcutScopesApi = {
|
|
39
|
+
/** The currently active scope names. */
|
|
40
|
+
activeScopes: string[];
|
|
41
|
+
/** Activate a scope (shortcuts in it start firing). */
|
|
42
|
+
activate: (name: string) => void;
|
|
43
|
+
/** Deactivate a scope. */
|
|
44
|
+
deactivate: (name: string) => void;
|
|
45
|
+
/** Toggle a scope on/off. */
|
|
46
|
+
toggle: (name: string) => void;
|
|
47
|
+
/** Replace the active scope set entirely. */
|
|
48
|
+
set: (names: string[]) => void;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* A parsed keyboard combo. Modifiers are normalized booleans and `key` is the
|
|
52
|
+
* lower-cased non-modifier key (or `null` for a modifier-only combo, which never fires).
|
|
53
|
+
*/
|
|
54
|
+
type ParsedCombo = {
|
|
55
|
+
/** metaKey on Mac, ctrlKey elsewhere — i.e. the resolved `mod`/`cmd`. */
|
|
56
|
+
meta: boolean;
|
|
57
|
+
ctrl: boolean;
|
|
58
|
+
alt: boolean;
|
|
59
|
+
shift: boolean;
|
|
60
|
+
/** The non-modifier key, lower-cased (e.g. `"k"`, `"escape"`), or null. */
|
|
61
|
+
key: string | null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Subscribe to a keyboard shortcut for the lifetime of the component.
|
|
66
|
+
*
|
|
67
|
+
* @param key A combo string (`"mod+k"`) or an array of them (`["cmd+k", "ctrl+k"]`).
|
|
68
|
+
* @param callback Invoked with the matching `KeyboardEvent`. Always the latest
|
|
69
|
+
* reference — no need to wrap in `useCallback`.
|
|
70
|
+
* @param options See {@link Options}.
|
|
71
|
+
*/
|
|
72
|
+
declare function useShortcut(key: string | string[], callback: (event: KeyboardEvent) => void, options?: Options): void;
|
|
73
|
+
|
|
74
|
+
/** Internal context value consumed by `useShortcut` and `useShortcutScopes`. */
|
|
75
|
+
type ShortcutScopeContextValue = {
|
|
76
|
+
/** Currently active scope names. */
|
|
77
|
+
activeScopes: Set<string>;
|
|
78
|
+
/** Whether a `<ShortcutProvider>` is mounted above this point. */
|
|
79
|
+
hasProvider: boolean;
|
|
80
|
+
activate: (name: string) => void;
|
|
81
|
+
deactivate: (name: string) => void;
|
|
82
|
+
toggle: (name: string) => void;
|
|
83
|
+
set: (names: string[]) => void;
|
|
84
|
+
};
|
|
85
|
+
type ShortcutProviderProps = {
|
|
86
|
+
/** Scopes active on first render. Default: none. */
|
|
87
|
+
initialScopes?: string[];
|
|
88
|
+
children?: ReactNode;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Provides scope state to descendant `useShortcut` calls. Wrap your app (or a
|
|
92
|
+
* subtree) in this to enable `{ scope }`-gated shortcuts. Unscoped shortcuts do
|
|
93
|
+
* not need a provider — they always fire.
|
|
94
|
+
*/
|
|
95
|
+
declare function ShortcutProvider({ initialScopes, children, }: ShortcutProviderProps): react.FunctionComponentElement<react.ProviderProps<ShortcutScopeContextValue>>;
|
|
96
|
+
/**
|
|
97
|
+
* Control which scopes are active. Returns the active scope names plus
|
|
98
|
+
* `activate`/`deactivate`/`toggle`/`set`. Must be used under a
|
|
99
|
+
* {@link ShortcutProvider}.
|
|
100
|
+
*/
|
|
101
|
+
declare function useShortcutScopes(): ShortcutScopesApi;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Parse a combo string like `"cmd+k"` or `" Ctrl + Shift + P "` into a
|
|
105
|
+
* normalized {@link ParsedCombo}. Case-insensitive, whitespace-tolerant.
|
|
106
|
+
*
|
|
107
|
+
* Returns `null` (and warns in development) for empty or invalid combos such as
|
|
108
|
+
* `""`, `"cmd++"`, or `"+++"`. A modifier-only combo also yields `key: null`,
|
|
109
|
+
* which {@link matchEvent} treats as a non-match.
|
|
110
|
+
*/
|
|
111
|
+
declare function parseKey(combo: string): ParsedCombo | null;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Returns `true` if a `KeyboardEvent` exactly matches a parsed combo: every
|
|
115
|
+
* modifier state must agree (no extra modifiers, none missing) and the key
|
|
116
|
+
* must equal the combo's key (case-insensitive via `event.key`).
|
|
117
|
+
*
|
|
118
|
+
* A combo with `key: null` (modifier-only) never matches, so pressing just a
|
|
119
|
+
* modifier does not fire.
|
|
120
|
+
*/
|
|
121
|
+
declare function matchEvent(event: KeyboardEvent, combo: ParsedCombo): boolean;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Detect whether we're running on a Mac. Used to resolve `mod`/`cmd` to the
|
|
125
|
+
* correct physical modifier (metaKey on Mac, ctrlKey elsewhere).
|
|
126
|
+
*
|
|
127
|
+
* Computed on every call rather than cached at module load so it stays correct
|
|
128
|
+
* across SSR -> hydration and is trivially mockable in tests.
|
|
129
|
+
*/
|
|
130
|
+
declare function isMac(): boolean;
|
|
131
|
+
|
|
132
|
+
export { type Options, type ParsedCombo, ShortcutProvider, type ShortcutProviderProps, type ShortcutScopesApi, isMac, matchEvent, parseKey, useShortcut, useShortcutScopes };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { RefObject, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Options for {@link useShortcut}. All fields are optional.
|
|
6
|
+
*/
|
|
7
|
+
type Options = {
|
|
8
|
+
/** Whether the shortcut is active. Default: `true`. */
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
/** Call `event.preventDefault()` when the combo matches. Default: `false`. */
|
|
11
|
+
preventDefault?: boolean;
|
|
12
|
+
/** Call `event.stopPropagation()` when the combo matches. Default: `false`. */
|
|
13
|
+
stopPropagation?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Fire even when the user is typing in an input/textarea/select/contenteditable.
|
|
16
|
+
* Default: `false` (the handler is skipped in those fields, except for `Escape`).
|
|
17
|
+
*/
|
|
18
|
+
enableInInputs?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Where to attach the `keydown` listener. Default: `window`.
|
|
21
|
+
* Pass a React ref to scope the shortcut to a specific element.
|
|
22
|
+
*/
|
|
23
|
+
target?: RefObject<HTMLElement | null> | Window;
|
|
24
|
+
/**
|
|
25
|
+
* Restrict the shortcut to one or more named scopes. The shortcut fires only
|
|
26
|
+
* while a matching scope is active (see `ShortcutProvider` / `useShortcutScopes`).
|
|
27
|
+
* Omit for a global shortcut that always fires. Default: `undefined` (global).
|
|
28
|
+
*
|
|
29
|
+
* If a scope is used without a `<ShortcutProvider>`, the shortcut degrades to
|
|
30
|
+
* global behavior and warns once in development.
|
|
31
|
+
*/
|
|
32
|
+
scope?: string | string[];
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Return value of {@link useShortcut}'s companion hook `useShortcutScopes`.
|
|
36
|
+
* Controls which scopes are currently active.
|
|
37
|
+
*/
|
|
38
|
+
type ShortcutScopesApi = {
|
|
39
|
+
/** The currently active scope names. */
|
|
40
|
+
activeScopes: string[];
|
|
41
|
+
/** Activate a scope (shortcuts in it start firing). */
|
|
42
|
+
activate: (name: string) => void;
|
|
43
|
+
/** Deactivate a scope. */
|
|
44
|
+
deactivate: (name: string) => void;
|
|
45
|
+
/** Toggle a scope on/off. */
|
|
46
|
+
toggle: (name: string) => void;
|
|
47
|
+
/** Replace the active scope set entirely. */
|
|
48
|
+
set: (names: string[]) => void;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* A parsed keyboard combo. Modifiers are normalized booleans and `key` is the
|
|
52
|
+
* lower-cased non-modifier key (or `null` for a modifier-only combo, which never fires).
|
|
53
|
+
*/
|
|
54
|
+
type ParsedCombo = {
|
|
55
|
+
/** metaKey on Mac, ctrlKey elsewhere — i.e. the resolved `mod`/`cmd`. */
|
|
56
|
+
meta: boolean;
|
|
57
|
+
ctrl: boolean;
|
|
58
|
+
alt: boolean;
|
|
59
|
+
shift: boolean;
|
|
60
|
+
/** The non-modifier key, lower-cased (e.g. `"k"`, `"escape"`), or null. */
|
|
61
|
+
key: string | null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Subscribe to a keyboard shortcut for the lifetime of the component.
|
|
66
|
+
*
|
|
67
|
+
* @param key A combo string (`"mod+k"`) or an array of them (`["cmd+k", "ctrl+k"]`).
|
|
68
|
+
* @param callback Invoked with the matching `KeyboardEvent`. Always the latest
|
|
69
|
+
* reference — no need to wrap in `useCallback`.
|
|
70
|
+
* @param options See {@link Options}.
|
|
71
|
+
*/
|
|
72
|
+
declare function useShortcut(key: string | string[], callback: (event: KeyboardEvent) => void, options?: Options): void;
|
|
73
|
+
|
|
74
|
+
/** Internal context value consumed by `useShortcut` and `useShortcutScopes`. */
|
|
75
|
+
type ShortcutScopeContextValue = {
|
|
76
|
+
/** Currently active scope names. */
|
|
77
|
+
activeScopes: Set<string>;
|
|
78
|
+
/** Whether a `<ShortcutProvider>` is mounted above this point. */
|
|
79
|
+
hasProvider: boolean;
|
|
80
|
+
activate: (name: string) => void;
|
|
81
|
+
deactivate: (name: string) => void;
|
|
82
|
+
toggle: (name: string) => void;
|
|
83
|
+
set: (names: string[]) => void;
|
|
84
|
+
};
|
|
85
|
+
type ShortcutProviderProps = {
|
|
86
|
+
/** Scopes active on first render. Default: none. */
|
|
87
|
+
initialScopes?: string[];
|
|
88
|
+
children?: ReactNode;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Provides scope state to descendant `useShortcut` calls. Wrap your app (or a
|
|
92
|
+
* subtree) in this to enable `{ scope }`-gated shortcuts. Unscoped shortcuts do
|
|
93
|
+
* not need a provider — they always fire.
|
|
94
|
+
*/
|
|
95
|
+
declare function ShortcutProvider({ initialScopes, children, }: ShortcutProviderProps): react.FunctionComponentElement<react.ProviderProps<ShortcutScopeContextValue>>;
|
|
96
|
+
/**
|
|
97
|
+
* Control which scopes are active. Returns the active scope names plus
|
|
98
|
+
* `activate`/`deactivate`/`toggle`/`set`. Must be used under a
|
|
99
|
+
* {@link ShortcutProvider}.
|
|
100
|
+
*/
|
|
101
|
+
declare function useShortcutScopes(): ShortcutScopesApi;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Parse a combo string like `"cmd+k"` or `" Ctrl + Shift + P "` into a
|
|
105
|
+
* normalized {@link ParsedCombo}. Case-insensitive, whitespace-tolerant.
|
|
106
|
+
*
|
|
107
|
+
* Returns `null` (and warns in development) for empty or invalid combos such as
|
|
108
|
+
* `""`, `"cmd++"`, or `"+++"`. A modifier-only combo also yields `key: null`,
|
|
109
|
+
* which {@link matchEvent} treats as a non-match.
|
|
110
|
+
*/
|
|
111
|
+
declare function parseKey(combo: string): ParsedCombo | null;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Returns `true` if a `KeyboardEvent` exactly matches a parsed combo: every
|
|
115
|
+
* modifier state must agree (no extra modifiers, none missing) and the key
|
|
116
|
+
* must equal the combo's key (case-insensitive via `event.key`).
|
|
117
|
+
*
|
|
118
|
+
* A combo with `key: null` (modifier-only) never matches, so pressing just a
|
|
119
|
+
* modifier does not fire.
|
|
120
|
+
*/
|
|
121
|
+
declare function matchEvent(event: KeyboardEvent, combo: ParsedCombo): boolean;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Detect whether we're running on a Mac. Used to resolve `mod`/`cmd` to the
|
|
125
|
+
* correct physical modifier (metaKey on Mac, ctrlKey elsewhere).
|
|
126
|
+
*
|
|
127
|
+
* Computed on every call rather than cached at module load so it stays correct
|
|
128
|
+
* across SSR -> hydration and is trivially mockable in tests.
|
|
129
|
+
*/
|
|
130
|
+
declare function isMac(): boolean;
|
|
131
|
+
|
|
132
|
+
export { type Options, type ParsedCombo, ShortcutProvider, type ShortcutProviderProps, type ShortcutScopesApi, isMac, matchEvent, parseKey, useShortcut, useShortcutScopes };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{useEffect as V,useMemo as j,useRef as E}from"react";function v(t,e){return e.key===null||t.metaKey!==e.meta||t.ctrlKey!==e.ctrl||t.altKey!==e.alt||t.shiftKey!==e.shift?!1:t.key.toLowerCase()===e.key}function y(){if(typeof navigator>"u")return!1;let t=navigator.platform??"";if(t)return/mac|iphone|ipad|ipod/i.test(t);let e=navigator.userAgent??"";return/mac|iphone|ipad|ipod/i.test(e)}var K=process.env.NODE_ENV!=="production";function g(t){K&&console.warn(`[use-shortcut] Ignoring invalid shortcut: "${t}"`)}var I={ctrl:"ctrl",control:"ctrl",alt:"alt",option:"alt",opt:"alt",shift:"shift",meta:"meta",cmd:"meta",command:"meta",super:"meta",win:"meta"};function x(t){if(typeof t!="string")return null;let e=t.trim();if(e==="")return null;let r={meta:!1,ctrl:!1,alt:!1,shift:!1,key:null},c=y(),l=e.split("+").map(s=>s.trim().toLowerCase());for(let s of l){if(s==="")return g(t),null;if(s==="mod"){c?r.meta=!0:r.ctrl=!0;continue}let a=I[s];if(a){a==="meta"&&!c&&(s==="cmd"||s==="command")?r.ctrl=!0:r[a]=!0;continue}if(r.key!==null)return g(t),null;r.key=s}return r.key===null?(g(t),null):r}import{createContext as N,createElement as O,useCallback as m,useContext as w,useMemo as A,useState as R}from"react";var b=process.env.NODE_ENV!=="production",S=()=>{},D={activeScopes:new Set,hasProvider:!1,activate:S,deactivate:S,toggle:S,set:S},C=N(D);function T(){return w(C)}function F({initialScopes:t,children:e}){let[r,c]=R(()=>new Set(t)),l=m(i=>{c(n=>{if(n.has(i))return n;let o=new Set(n);return o.add(i),o})},[]),s=m(i=>{c(n=>{if(!n.has(i))return n;let o=new Set(n);return o.delete(i),o})},[]),a=m(i=>{c(n=>{let o=new Set(n);return o.has(i)?o.delete(i):o.add(i),o})},[]),d=m(i=>{c(new Set(i))},[]),f=A(()=>({activeScopes:r,hasProvider:!0,activate:l,deactivate:s,toggle:a,set:d}),[r,l,s,a,d]);return O(C.Provider,{value:f},e)}function _(){let t=w(C);return b&&!t.hasProvider&&console.warn("[use-shortcut] useShortcutScopes() called without a <ShortcutProvider>; scope controls are no-ops."),A(()=>({activeScopes:Array.from(t.activeScopes),activate:t.activate,deactivate:t.deactivate,toggle:t.toggle,set:t.set}),[t])}var P=new Set;function H(t){if(!b)return;let e=Array.isArray(t)?t.join(","):t;P.has(e)||(P.add(e),console.warn(`[use-shortcut] Shortcut uses scope "${e}" but no <ShortcutProvider> is mounted; it will fire globally.`))}function k(t,e){return t===void 0?!0:e.hasProvider?(Array.isArray(t)?t:[t]).some(c=>e.activeScopes.has(c)):(H(t),!0)}function U(t){if(!t||!(t instanceof HTMLElement))return!1;if(t.isContentEditable)return!0;let e=t.tagName;if(e==="TEXTAREA"||e==="SELECT")return!0;if(e==="INPUT"){let r=t.type.toLowerCase();return r!=="checkbox"&&r!=="radio"}return!1}function X(t){return t===void 0?typeof window>"u"?null:window:"current"in t?t.current:t}function $(t,e,r={}){let c=T(),l=E(e),s=E(r),a=E(c);V(()=>{l.current=e,s.current=r,a.current=c});let d=Array.isArray(t)?t.join("\0"):t,f=j(()=>{let n=Array.isArray(t)?t:[t],o=[];for(let h of n){let u=x(h);u&&o.push(u)}return o},[d]),i=r.target;V(()=>{if(f.length===0)return;let n=X(i);if(!n)return;let o=h=>{let u=h,p=s.current;if(p.enabled===!1||!k(p.scope,a.current))return;let L=u.key==="Escape";if(!(!p.enableInInputs&&!L&&U(u.target))){for(let M of f)if(v(u,M)){p.preventDefault&&u.preventDefault(),p.stopPropagation&&u.stopPropagation(),l.current(u);break}}};return n.addEventListener("keydown",o),()=>n.removeEventListener("keydown",o)},[f,i])}export{F as ShortcutProvider,y as isMac,v as matchEvent,x as parseKey,$ as useShortcut,_ as useShortcutScopes};
|
|
2
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/use-shortcut.ts","../src/match-event.ts","../src/platform.ts","../src/parse-keys.ts","../src/scopes.tsx"],"sourcesContent":["import { useEffect, useMemo, useRef } from \"react\";\nimport { matchEvent } from \"./match-event\";\nimport { parseKey } from \"./parse-keys\";\nimport {\n type ShortcutScopeContextValue,\n isScopeActive,\n useScopeContextValue,\n} from \"./scopes\";\nimport type { Options, ParsedCombo } from \"./types\";\n\n/** Element types where typing should suppress shortcuts (except Escape). */\nfunction isEditableTarget(target: EventTarget | null): boolean {\n if (!target || !(target instanceof HTMLElement)) return false;\n\n if (target.isContentEditable) return true;\n\n const tag = target.tagName;\n if (tag === \"TEXTAREA\" || tag === \"SELECT\") return true;\n\n if (tag === \"INPUT\") {\n // Checkboxes and radios aren't text entry, so shortcuts still apply.\n const type = (target as HTMLInputElement).type.toLowerCase();\n return type !== \"checkbox\" && type !== \"radio\";\n }\n\n return false;\n}\n\n/** Resolve the listener target: a ref's current element, a Window, or null. */\nfunction resolveTarget(target: Options[\"target\"]): Window | HTMLElement | null {\n if (target === undefined) {\n return typeof window === \"undefined\" ? null : window;\n }\n // A RefObject exposes `current`; a Window does not.\n if (\"current\" in target) return target.current;\n return target;\n}\n\n/**\n * Subscribe to a keyboard shortcut for the lifetime of the component.\n *\n * @param key A combo string (`\"mod+k\"`) or an array of them (`[\"cmd+k\", \"ctrl+k\"]`).\n * @param callback Invoked with the matching `KeyboardEvent`. Always the latest\n * reference — no need to wrap in `useCallback`.\n * @param options See {@link Options}.\n */\nexport function useShortcut(\n key: string | string[],\n callback: (event: KeyboardEvent) => void,\n options: Options = {},\n): void {\n // Scope state lives in context; reading it here re-renders on scope changes.\n const scopeCtx = useScopeContextValue();\n\n // Keep the freshest callback/options/scope-state without re-attaching the\n // listener (scope changes must not tear down and rebuild the keydown handler).\n const callbackRef = useRef(callback);\n const optionsRef = useRef(options);\n const scopeCtxRef = useRef<ShortcutScopeContextValue>(scopeCtx);\n useEffect(() => {\n callbackRef.current = callback;\n optionsRef.current = options;\n scopeCtxRef.current = scopeCtx;\n });\n\n // Normalize to a stable string so the parse effect only re-runs on change.\n const keyId = Array.isArray(key) ? key.join(\"\u0000\") : key;\n\n // `keyId` is the serialized form of `key`, so depending on it alone (rather\n // than the array identity, which changes every render) is intentional.\n // biome-ignore lint/correctness/useExhaustiveDependencies: keyId mirrors key\n const combos = useMemo<ParsedCombo[]>(() => {\n const list = Array.isArray(key) ? key : [key];\n const parsed: ParsedCombo[] = [];\n for (const k of list) {\n const combo = parseKey(k);\n if (combo) parsed.push(combo);\n }\n return parsed;\n }, [keyId]);\n\n const target = options.target;\n\n useEffect(() => {\n if (combos.length === 0) return;\n\n const el = resolveTarget(target);\n if (!el) return;\n\n const handler = (event: Event) => {\n const e = event as KeyboardEvent;\n const opts = optionsRef.current;\n\n if (opts.enabled === false) return;\n\n // Only fire if the shortcut's scope is active (unscoped = always).\n if (!isScopeActive(opts.scope, scopeCtxRef.current)) return;\n\n // Escape always fires, even while typing — standard \"close\" behavior.\n const isEscape = e.key === \"Escape\";\n if (!opts.enableInInputs && !isEscape && isEditableTarget(e.target)) {\n return;\n }\n\n for (const combo of combos) {\n if (matchEvent(e, combo)) {\n if (opts.preventDefault) e.preventDefault();\n if (opts.stopPropagation) e.stopPropagation();\n callbackRef.current(e);\n break; // fire once per event even if several combos in this hook match\n }\n }\n };\n\n el.addEventListener(\"keydown\", handler);\n return () => el.removeEventListener(\"keydown\", handler);\n }, [combos, target]);\n}\n","import type { ParsedCombo } from \"./types\";\n\n/**\n * Returns `true` if a `KeyboardEvent` exactly matches a parsed combo: every\n * modifier state must agree (no extra modifiers, none missing) and the key\n * must equal the combo's key (case-insensitive via `event.key`).\n *\n * A combo with `key: null` (modifier-only) never matches, so pressing just a\n * modifier does not fire.\n */\nexport function matchEvent(event: KeyboardEvent, combo: ParsedCombo): boolean {\n if (combo.key === null) return false;\n\n if (event.metaKey !== combo.meta) return false;\n if (event.ctrlKey !== combo.ctrl) return false;\n if (event.altKey !== combo.alt) return false;\n if (event.shiftKey !== combo.shift) return false;\n\n // `event.key` reflects the produced value (\"k\", \"Escape\", \"ArrowUp\"); the\n // combo key is already lower-cased, so compare case-insensitively.\n return event.key.toLowerCase() === combo.key;\n}\n","/**\n * Detect whether we're running on a Mac. Used to resolve `mod`/`cmd` to the\n * correct physical modifier (metaKey on Mac, ctrlKey elsewhere).\n *\n * Computed on every call rather than cached at module load so it stays correct\n * across SSR -> hydration and is trivially mockable in tests.\n */\nexport function isMac(): boolean {\n if (typeof navigator === \"undefined\") return false;\n\n // navigator.platform is deprecated but still the most reliable signal where\n // present; fall back to userAgent (covers \"Macintosh\", iPad/iPhone, etc).\n const platform = navigator.platform ?? \"\";\n if (platform) return /mac|iphone|ipad|ipod/i.test(platform);\n\n const ua = navigator.userAgent ?? \"\";\n return /mac|iphone|ipad|ipod/i.test(ua);\n}\n","import { isMac } from \"./platform\";\nimport type { ParsedCombo } from \"./types\";\n\nconst isDev = process.env.NODE_ENV !== \"production\";\n\nfunction warn(combo: string): void {\n if (isDev) {\n console.warn(`[use-shortcut] Ignoring invalid shortcut: \"${combo}\"`);\n }\n}\n\n// Normalize a single token to either a modifier flag or the literal key.\n// `mod` and `cmd`/`command` resolve per-platform; everything else is a key.\ntype ModifierFlag = \"meta\" | \"ctrl\" | \"alt\" | \"shift\";\n\nconst MODIFIER_ALIASES: Record<string, ModifierFlag> = {\n ctrl: \"ctrl\",\n control: \"ctrl\",\n alt: \"alt\",\n option: \"alt\",\n opt: \"alt\",\n shift: \"shift\",\n meta: \"meta\",\n cmd: \"meta\",\n command: \"meta\",\n super: \"meta\",\n win: \"meta\",\n};\n\n/**\n * Parse a combo string like `\"cmd+k\"` or `\" Ctrl + Shift + P \"` into a\n * normalized {@link ParsedCombo}. Case-insensitive, whitespace-tolerant.\n *\n * Returns `null` (and warns in development) for empty or invalid combos such as\n * `\"\"`, `\"cmd++\"`, or `\"+++\"`. A modifier-only combo also yields `key: null`,\n * which {@link matchEvent} treats as a non-match.\n */\nexport function parseKey(combo: string): ParsedCombo | null {\n if (typeof combo !== \"string\") return null;\n\n const trimmed = combo.trim();\n if (trimmed === \"\") return null;\n\n const result: ParsedCombo = {\n meta: false,\n ctrl: false,\n alt: false,\n shift: false,\n key: null,\n };\n\n const mac = isMac();\n const tokens = trimmed.split(\"+\").map((t) => t.trim().toLowerCase());\n\n for (const token of tokens) {\n // An empty token means a stray/double `+` (e.g. \"cmd++\", \"+++\").\n if (token === \"\") {\n warn(combo);\n return null;\n }\n\n // `mod` is the cross-platform modifier: Cmd on Mac, Ctrl elsewhere.\n if (token === \"mod\") {\n if (mac) result.meta = true;\n else result.ctrl = true;\n continue;\n }\n\n const modifier = MODIFIER_ALIASES[token];\n if (modifier) {\n // `cmd`/`command` map to ctrl on non-Mac; everything else is literal.\n if (\n modifier === \"meta\" &&\n !mac &&\n (token === \"cmd\" || token === \"command\")\n ) {\n result.ctrl = true;\n } else {\n result[modifier] = true;\n }\n continue;\n }\n\n // A non-modifier token is the key. Only one key per combo is allowed.\n if (result.key !== null) {\n warn(combo);\n return null;\n }\n result.key = token;\n }\n\n // A combo must include exactly one non-modifier key.\n if (result.key === null) {\n warn(combo);\n return null;\n }\n\n return result;\n}\n","import {\n type ReactNode,\n createContext,\n createElement,\n useCallback,\n useContext,\n useMemo,\n useState,\n} from \"react\";\nimport type { ShortcutScopesApi } from \"./types\";\n\nconst isDev = process.env.NODE_ENV !== \"production\";\n\n/** Internal context value consumed by `useShortcut` and `useShortcutScopes`. */\nexport type ShortcutScopeContextValue = {\n /** Currently active scope names. */\n activeScopes: Set<string>;\n /** Whether a `<ShortcutProvider>` is mounted above this point. */\n hasProvider: boolean;\n activate: (name: string) => void;\n deactivate: (name: string) => void;\n toggle: (name: string) => void;\n set: (names: string[]) => void;\n};\n\n// Default value used when no <ShortcutProvider> is mounted. `hasProvider: false`\n// lets useShortcut degrade scoped shortcuts to global behavior (and warn in dev).\nconst noop = () => {};\nconst DEFAULT_CONTEXT: ShortcutScopeContextValue = {\n activeScopes: new Set(),\n hasProvider: false,\n activate: noop,\n deactivate: noop,\n toggle: noop,\n set: noop,\n};\n\nconst ScopeContext = createContext<ShortcutScopeContextValue>(DEFAULT_CONTEXT);\n\n/** Read the raw scope context. Internal — used by {@link useShortcut}. */\nexport function useScopeContextValue(): ShortcutScopeContextValue {\n return useContext(ScopeContext);\n}\n\nexport type ShortcutProviderProps = {\n /** Scopes active on first render. Default: none. */\n initialScopes?: string[];\n children?: ReactNode;\n};\n\n/**\n * Provides scope state to descendant `useShortcut` calls. Wrap your app (or a\n * subtree) in this to enable `{ scope }`-gated shortcuts. Unscoped shortcuts do\n * not need a provider — they always fire.\n */\nexport function ShortcutProvider({\n initialScopes,\n children,\n}: ShortcutProviderProps) {\n const [activeScopes, setActiveScopes] = useState<Set<string>>(\n () => new Set(initialScopes),\n );\n\n const activate = useCallback((name: string) => {\n setActiveScopes((prev) => {\n if (prev.has(name)) return prev;\n const next = new Set(prev);\n next.add(name);\n return next;\n });\n }, []);\n\n const deactivate = useCallback((name: string) => {\n setActiveScopes((prev) => {\n if (!prev.has(name)) return prev;\n const next = new Set(prev);\n next.delete(name);\n return next;\n });\n }, []);\n\n const toggle = useCallback((name: string) => {\n setActiveScopes((prev) => {\n const next = new Set(prev);\n if (next.has(name)) next.delete(name);\n else next.add(name);\n return next;\n });\n }, []);\n\n const set = useCallback((names: string[]) => {\n setActiveScopes(new Set(names));\n }, []);\n\n const value = useMemo<ShortcutScopeContextValue>(\n () => ({\n activeScopes,\n hasProvider: true,\n activate,\n deactivate,\n toggle,\n set,\n }),\n [activeScopes, activate, deactivate, toggle, set],\n );\n\n return createElement(ScopeContext.Provider, { value }, children);\n}\n\n/**\n * Control which scopes are active. Returns the active scope names plus\n * `activate`/`deactivate`/`toggle`/`set`. Must be used under a\n * {@link ShortcutProvider}.\n */\nexport function useShortcutScopes(): ShortcutScopesApi {\n const ctx = useContext(ScopeContext);\n if (isDev && !ctx.hasProvider) {\n console.warn(\n \"[use-shortcut] useShortcutScopes() called without a <ShortcutProvider>; scope controls are no-ops.\",\n );\n }\n return useMemo<ShortcutScopesApi>(\n () => ({\n activeScopes: Array.from(ctx.activeScopes),\n activate: ctx.activate,\n deactivate: ctx.deactivate,\n toggle: ctx.toggle,\n set: ctx.set,\n }),\n [ctx],\n );\n}\n\n// Warn at most once per scope name when used without a provider, so a scoped\n// shortcut that degrades to global doesn't spam the console on every keypress.\nconst warnedScopes = new Set<string>();\n\nfunction warnOnce(scope: string | string[]): void {\n if (!isDev) return;\n const id = Array.isArray(scope) ? scope.join(\",\") : scope;\n if (warnedScopes.has(id)) return;\n warnedScopes.add(id);\n console.warn(\n `[use-shortcut] Shortcut uses scope \"${id}\" but no <ShortcutProvider> is mounted; it will fire globally.`,\n );\n}\n\n/**\n * Whether a shortcut's `scope` is currently active.\n * - No scope -> always active (global, the default behavior).\n * - No provider -> fires anyway (degrades to global) + warns once in dev.\n * - Provider present -> active only if one of the scopes is in `activeScopes`.\n */\nexport function isScopeActive(\n scope: string | string[] | undefined,\n ctx: ShortcutScopeContextValue,\n): boolean {\n if (scope === undefined) return true;\n\n if (!ctx.hasProvider) {\n warnOnce(scope);\n return true;\n }\n\n const scopes = Array.isArray(scope) ? scope : [scope];\n return scopes.some((s) => ctx.activeScopes.has(s));\n}\n"],"mappings":"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","names":["useEffect","useMemo","useRef","matchEvent","event","combo","isMac","platform","ua","isDev","warn","combo","MODIFIER_ALIASES","parseKey","trimmed","result","mac","isMac","tokens","t","token","modifier","createContext","createElement","useCallback","useContext","useMemo","useState","isDev","noop","DEFAULT_CONTEXT","ScopeContext","useScopeContextValue","ShortcutProvider","initialScopes","children","activeScopes","setActiveScopes","activate","name","prev","next","deactivate","toggle","set","names","value","useShortcutScopes","ctx","warnedScopes","warnOnce","scope","id","isScopeActive","s","isEditableTarget","target","tag","type","resolveTarget","useShortcut","key","callback","options","scopeCtx","useScopeContextValue","callbackRef","useRef","optionsRef","scopeCtxRef","useEffect","keyId","combos","useMemo","list","parsed","k","combo","parseKey","el","handler","event","e","opts","isScopeActive","isEscape","matchEvent"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dhruvilshah191999/use-shortcut",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Tiny zero-dependency React hook for keyboard shortcuts. Cross-platform, TypeScript-first.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Dhruvil Shah",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"react",
|
|
9
|
+
"hook",
|
|
10
|
+
"keyboard",
|
|
11
|
+
"shortcut",
|
|
12
|
+
"hotkey",
|
|
13
|
+
"keybinding",
|
|
14
|
+
"typescript"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"main": "./dist/index.cjs",
|
|
19
|
+
"module": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js",
|
|
25
|
+
"require": "./dist/index.cjs"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"dev": "tsup --watch",
|
|
33
|
+
"build": "tsup",
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"test:watch": "vitest",
|
|
36
|
+
"lint": "biome check .",
|
|
37
|
+
"format": "biome format --write .",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"prepublishOnly": "pnpm run build"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"react": ">=18.0.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@biomejs/biome": "^1.9.4",
|
|
46
|
+
"@testing-library/react": "^16.1.0",
|
|
47
|
+
"@types/node": "^20.17.0",
|
|
48
|
+
"@types/react": "^18.3.12",
|
|
49
|
+
"@types/react-dom": "^18.3.1",
|
|
50
|
+
"jsdom": "^25.0.1",
|
|
51
|
+
"react": "^18.3.1",
|
|
52
|
+
"react-dom": "^18.3.1",
|
|
53
|
+
"tsup": "^8.3.5",
|
|
54
|
+
"typescript": "^5.7.2",
|
|
55
|
+
"vitest": "^2.1.8"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=20"
|
|
59
|
+
},
|
|
60
|
+
"packageManager": "pnpm@9.15.0"
|
|
61
|
+
}
|