@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 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
+ [![npm version](https://img.shields.io/npm/v/@dhruvilshah191999/use-shortcut.svg)](https://www.npmjs.com/package/@dhruvilshah191999/use-shortcut)
6
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/@dhruvilshah191999/use-shortcut)](https://bundlephobia.com/package/@dhruvilshah191999/use-shortcut)
7
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./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":"yaAAA,IAAAA,EAAA,GAAAC,EAAAD,EAAA,sBAAAE,EAAA,UAAAC,EAAA,eAAAC,EAAA,aAAAC,EAAA,gBAAAC,EAAA,sBAAAC,IAAA,eAAAC,EAAAR,GCAA,IAAAS,EAA2C,iBCUpC,SAASC,EAAWC,EAAsBC,EAA6B,CAM5E,OALIA,EAAM,MAAQ,MAEdD,EAAM,UAAYC,EAAM,MACxBD,EAAM,UAAYC,EAAM,MACxBD,EAAM,SAAWC,EAAM,KACvBD,EAAM,WAAaC,EAAM,MAAc,GAIpCD,EAAM,IAAI,YAAY,IAAMC,EAAM,GAC3C,CCdO,SAASC,GAAiB,CAC/B,GAAI,OAAO,UAAc,IAAa,MAAO,GAI7C,IAAMC,EAAW,UAAU,UAAY,GACvC,GAAIA,EAAU,MAAO,wBAAwB,KAAKA,CAAQ,EAE1D,IAAMC,EAAK,UAAU,WAAa,GAClC,MAAO,wBAAwB,KAAKA,CAAE,CACxC,CCdA,IAAMC,EAAQ,QAAQ,IAAI,WAAa,aAEvC,SAASC,EAAKC,EAAqB,CAC7BF,GACF,QAAQ,KAAK,8CAA8CE,CAAK,GAAG,CAEvE,CAMA,IAAMC,EAAiD,CACrD,KAAM,OACN,QAAS,OACT,IAAK,MACL,OAAQ,MACR,IAAK,MACL,MAAO,QACP,KAAM,OACN,IAAK,OACL,QAAS,OACT,MAAO,OACP,IAAK,MACP,EAUO,SAASC,EAASF,EAAmC,CAC1D,GAAI,OAAOA,GAAU,SAAU,OAAO,KAEtC,IAAMG,EAAUH,EAAM,KAAK,EAC3B,GAAIG,IAAY,GAAI,OAAO,KAE3B,IAAMC,EAAsB,CAC1B,KAAM,GACN,KAAM,GACN,IAAK,GACL,MAAO,GACP,IAAK,IACP,EAEMC,EAAMC,EAAM,EACZC,EAASJ,EAAQ,MAAM,GAAG,EAAE,IAAKK,GAAMA,EAAE,KAAK,EAAE,YAAY,CAAC,EAEnE,QAAWC,KAASF,EAAQ,CAE1B,GAAIE,IAAU,GACZ,OAAAV,EAAKC,CAAK,EACH,KAIT,GAAIS,IAAU,MAAO,CACfJ,EAAKD,EAAO,KAAO,GAClBA,EAAO,KAAO,GACnB,QACF,CAEA,IAAMM,EAAWT,EAAiBQ,CAAK,EACvC,GAAIC,EAAU,CAGVA,IAAa,QACb,CAACL,IACAI,IAAU,OAASA,IAAU,WAE9BL,EAAO,KAAO,GAEdA,EAAOM,CAAQ,EAAI,GAErB,QACF,CAGA,GAAIN,EAAO,MAAQ,KACjB,OAAAL,EAAKC,CAAK,EACH,KAETI,EAAO,IAAMK,CACf,CAGA,OAAIL,EAAO,MAAQ,MACjBL,EAAKC,CAAK,EACH,MAGFI,CACT,CClGA,IAAAO,EAQO,iBAGDC,EAAQ,QAAQ,IAAI,WAAa,aAgBjCC,EAAO,IAAM,CAAC,EACdC,EAA6C,CACjD,aAAc,IAAI,IAClB,YAAa,GACb,SAAUD,EACV,WAAYA,EACZ,OAAQA,EACR,IAAKA,CACP,EAEME,KAAe,iBAAyCD,CAAe,EAGtE,SAASE,GAAkD,CAChE,SAAO,cAAWD,CAAY,CAChC,CAaO,SAASE,EAAiB,CAC/B,cAAAC,EACA,SAAAC,CACF,EAA0B,CACxB,GAAM,CAACC,EAAcC,CAAe,KAAI,YACtC,IAAM,IAAI,IAAIH,CAAa,CAC7B,EAEMI,KAAW,eAAaC,GAAiB,CAC7CF,EAAiBG,GAAS,CACxB,GAAIA,EAAK,IAAID,CAAI,EAAG,OAAOC,EAC3B,IAAMC,EAAO,IAAI,IAAID,CAAI,EACzB,OAAAC,EAAK,IAAIF,CAAI,EACNE,CACT,CAAC,CACH,EAAG,CAAC,CAAC,EAECC,KAAa,eAAaH,GAAiB,CAC/CF,EAAiBG,GAAS,CACxB,GAAI,CAACA,EAAK,IAAID,CAAI,EAAG,OAAOC,EAC5B,IAAMC,EAAO,IAAI,IAAID,CAAI,EACzB,OAAAC,EAAK,OAAOF,CAAI,EACTE,CACT,CAAC,CACH,EAAG,CAAC,CAAC,EAECE,KAAS,eAAaJ,GAAiB,CAC3CF,EAAiBG,GAAS,CACxB,IAAMC,EAAO,IAAI,IAAID,CAAI,EACzB,OAAIC,EAAK,IAAIF,CAAI,EAAGE,EAAK,OAAOF,CAAI,EAC/BE,EAAK,IAAIF,CAAI,EACXE,CACT,CAAC,CACH,EAAG,CAAC,CAAC,EAECG,KAAM,eAAaC,GAAoB,CAC3CR,EAAgB,IAAI,IAAIQ,CAAK,CAAC,CAChC,EAAG,CAAC,CAAC,EAECC,KAAQ,WACZ,KAAO,CACL,aAAAV,EACA,YAAa,GACb,SAAAE,EACA,WAAAI,EACA,OAAAC,EACA,IAAAC,CACF,GACA,CAACR,EAAcE,EAAUI,EAAYC,EAAQC,CAAG,CAClD,EAEA,SAAO,iBAAcb,EAAa,SAAU,CAAE,MAAAe,CAAM,EAAGX,CAAQ,CACjE,CAOO,SAASY,GAAuC,CACrD,IAAMC,KAAM,cAAWjB,CAAY,EACnC,OAAIH,GAAS,CAACoB,EAAI,aAChB,QAAQ,KACN,oGACF,KAEK,WACL,KAAO,CACL,aAAc,MAAM,KAAKA,EAAI,YAAY,EACzC,SAAUA,EAAI,SACd,WAAYA,EAAI,WAChB,OAAQA,EAAI,OACZ,IAAKA,EAAI,GACX,GACA,CAACA,CAAG,CACN,CACF,CAIA,IAAMC,EAAe,IAAI,IAEzB,SAASC,EAASC,EAAgC,CAChD,GAAI,CAACvB,EAAO,OACZ,IAAMwB,EAAK,MAAM,QAAQD,CAAK,EAAIA,EAAM,KAAK,GAAG,EAAIA,EAChDF,EAAa,IAAIG,CAAE,IACvBH,EAAa,IAAIG,CAAE,EACnB,QAAQ,KACN,uCAAuCA,CAAE,gEAC3C,EACF,CAQO,SAASC,EACdF,EACAH,EACS,CACT,OAAIG,IAAU,OAAkB,GAE3BH,EAAI,aAKM,MAAM,QAAQG,CAAK,EAAIA,EAAQ,CAACA,CAAK,GACtC,KAAM,GAAMH,EAAI,aAAa,IAAI,CAAC,CAAC,GAL/CE,EAASC,CAAK,EACP,GAKX,CJ3JA,SAASG,EAAiBC,EAAqC,CAC7D,GAAI,CAACA,GAAU,EAAEA,aAAkB,aAAc,MAAO,GAExD,GAAIA,EAAO,kBAAmB,MAAO,GAErC,IAAMC,EAAMD,EAAO,QACnB,GAAIC,IAAQ,YAAcA,IAAQ,SAAU,MAAO,GAEnD,GAAIA,IAAQ,QAAS,CAEnB,IAAMC,EAAQF,EAA4B,KAAK,YAAY,EAC3D,OAAOE,IAAS,YAAcA,IAAS,OACzC,CAEA,MAAO,EACT,CAGA,SAASC,EAAcH,EAAwD,CAC7E,OAAIA,IAAW,OACN,OAAO,OAAW,IAAc,KAAO,OAG5C,YAAaA,EAAeA,EAAO,QAChCA,CACT,CAUO,SAASI,EACdC,EACAC,EACAC,EAAmB,CAAC,EACd,CAEN,IAAMC,EAAWC,EAAqB,EAIhCC,KAAc,UAAOJ,CAAQ,EAC7BK,KAAa,UAAOJ,CAAO,EAC3BK,KAAc,UAAkCJ,CAAQ,KAC9D,aAAU,IAAM,CACdE,EAAY,QAAUJ,EACtBK,EAAW,QAAUJ,EACrBK,EAAY,QAAUJ,CACxB,CAAC,EAGD,IAAMK,EAAQ,MAAM,QAAQR,CAAG,EAAIA,EAAI,KAAK,IAAG,EAAIA,EAK7CS,KAAS,WAAuB,IAAM,CAC1C,IAAMC,EAAO,MAAM,QAAQV,CAAG,EAAIA,EAAM,CAACA,CAAG,EACtCW,EAAwB,CAAC,EAC/B,QAAWC,KAAKF,EAAM,CACpB,IAAMG,EAAQC,EAASF,CAAC,EACpBC,GAAOF,EAAO,KAAKE,CAAK,CAC9B,CACA,OAAOF,CACT,EAAG,CAACH,CAAK,CAAC,EAEJb,EAASO,EAAQ,UAEvB,aAAU,IAAM,CACd,GAAIO,EAAO,SAAW,EAAG,OAEzB,IAAMM,EAAKjB,EAAcH,CAAM,EAC/B,GAAI,CAACoB,EAAI,OAET,IAAMC,EAAWC,GAAiB,CAChC,IAAMC,EAAID,EACJE,EAAOb,EAAW,QAKxB,GAHIa,EAAK,UAAY,IAGjB,CAACC,EAAcD,EAAK,MAAOZ,EAAY,OAAO,EAAG,OAGrD,IAAMc,EAAWH,EAAE,MAAQ,SAC3B,GAAI,GAACC,EAAK,gBAAkB,CAACE,GAAY3B,EAAiBwB,EAAE,MAAM,IAIlE,QAAWL,KAASJ,EAClB,GAAIa,EAAWJ,EAAGL,CAAK,EAAG,CACpBM,EAAK,gBAAgBD,EAAE,eAAe,EACtCC,EAAK,iBAAiBD,EAAE,gBAAgB,EAC5Cb,EAAY,QAAQa,CAAC,EACrB,KACF,EAEJ,EAEA,OAAAH,EAAG,iBAAiB,UAAWC,CAAO,EAC/B,IAAMD,EAAG,oBAAoB,UAAWC,CAAO,CACxD,EAAG,CAACP,EAAQd,CAAM,CAAC,CACrB","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"]}
@@ -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 };
@@ -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
+ }