@lacspace/hotkeys 1.0.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,51 @@
1
+ Lacspace Free Licence
2
+ Version 1.0, August 2026
3
+
4
+ Copyright (c) 2026 Lacspace
5
+
6
+ PREAMBLE
7
+
8
+ This software is published by Lacspace under the Lacspace Free Licence — a free,
9
+ permissive licence that lets you use this software for any purpose, including in
10
+ commercial products and services, at no cost. It grants the same freedoms as
11
+ common permissive open-source licences; the only condition is that this notice
12
+ travels with the software. The canonical, always-current text of this licence is
13
+ maintained at https://lacspace.com/licenses/lacspace-free-1.0
14
+
15
+ GRANT OF RIGHTS
16
+
17
+ Permission is hereby granted, free of charge, to any person or organisation
18
+ obtaining a copy of this software and its associated documentation and data files
19
+ (the "Software"), to deal in the Software without restriction, including without
20
+ limitation the rights to use, copy, modify, merge, publish, distribute,
21
+ sublicense, and/or sell copies of the Software, and to permit persons to whom the
22
+ Software is furnished to do so, subject to the conditions below. These rights are
23
+ granted for any purpose, personal or commercial, and are perpetual, worldwide,
24
+ non-exclusive, and royalty-free.
25
+
26
+ CONDITIONS
27
+
28
+ The above copyright notice, this permission notice, and the name of this licence
29
+ ("Lacspace Free Licence") shall be included in all copies or substantial portions
30
+ of the Software.
31
+
32
+ TRADEMARKS
33
+
34
+ This licence does not grant permission to use the trade names, trademarks, service
35
+ marks, logos, or product names of Lacspace, except as required to reproduce the
36
+ notice above or to describe the origin of the Software in a truthful manner.
37
+
38
+ DISCLAIMER OF WARRANTY AND LIMITATION OF LIABILITY
39
+
40
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
41
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
42
+ FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
43
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN
44
+ AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION
45
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
46
+
47
+ ---
48
+
49
+ The Lacspace Free Licence is a source-available, permissive licence and is not (as
50
+ of this version) an OSI-approved licence. In substance it grants the same freedoms
51
+ as the MIT Licence. Learn more at https://lacspace.com/licenses
package/README.md ADDED
@@ -0,0 +1,173 @@
1
+ # @lacspace/hotkeys
2
+
3
+ **Ergonomic keyboard shortcuts for React** — combos (`mod+k`), key sequences (`g then d`), scopes, and pretty display formatting (`⌘K`). SSR-safe, respects form fields, zero-dependency, fully typed.
4
+
5
+ - **`mod` does the right thing** — Cmd on macOS, Ctrl everywhere else.
6
+ - **Sequences** — Gmail-style `g then d` chords with a rolling timeout.
7
+ - **Scopes** — enable/disable groups of shortcuts without unmounting anything.
8
+ - **Display helper** — render `⌘⇧K` / `Ctrl+Shift+K` from a single string.
9
+ - **SSR-safe** — listeners attach only in effects; every `window`/`navigator` access is guarded.
10
+ - **Respects form fields** — ignores typing in inputs/textareas/`contentEditable` by default.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm i @lacspace/hotkeys
16
+ ```
17
+
18
+ React `>=18` is a peer dependency. This is a hooks-only library — no `"use client"` shipped; add the directive in your own component files when using the App Router.
19
+
20
+ ## Usage
21
+
22
+ ### 1. A basic `mod+k` command palette
23
+
24
+ ```tsx
25
+ import { useState } from "react";
26
+ import { useHotkeys, formatHotkey } from "@lacspace/hotkeys";
27
+
28
+ function App() {
29
+ const [open, setOpen] = useState(false);
30
+
31
+ // ⌘K on mac, Ctrl+K elsewhere. preventDefault is on by default.
32
+ useHotkeys("mod+k", () => setOpen((v) => !v));
33
+
34
+ return (
35
+ <>
36
+ <button onClick={() => setOpen(true)}>
37
+ Search <kbd>{formatHotkey("mod+k")}</kbd>
38
+ </button>
39
+ {open && <CommandPalette onClose={() => setOpen(false)} />}
40
+ </>
41
+ );
42
+ }
43
+ ```
44
+
45
+ ### 2. A `g then d` sequence
46
+
47
+ ```tsx
48
+ import { useHotkeys } from "@lacspace/hotkeys";
49
+ import { useRouter } from "next/navigation";
50
+
51
+ function Shortcuts() {
52
+ const router = useRouter();
53
+
54
+ // Press "g", then "d" within ~1 second.
55
+ useHotkeys("g then d", () => router.push("/dashboard"));
56
+ useHotkeys("g then s", () => router.push("/settings"));
57
+
58
+ // Arrays work too — any combo fires the handler.
59
+ useHotkeys(["?", "shift+/"], () => openHelp());
60
+
61
+ return null;
62
+ }
63
+ ```
64
+
65
+ ### 3. Rendering the hint with `formatHotkey`
66
+
67
+ ```tsx
68
+ import { formatHotkey } from "@lacspace/hotkeys";
69
+
70
+ // Auto-detects the platform:
71
+ formatHotkey("mod+shift+k"); // "⌘⇧K" on mac, "Ctrl+Shift+K" elsewhere
72
+
73
+ // Force a platform (useful for docs / screenshots):
74
+ formatHotkey("ctrl+shift+k", { mac: false }); // "Ctrl+Shift+K"
75
+ formatHotkey("mod+enter", { mac: true }); // "⌘↵"
76
+ ```
77
+
78
+ ### 4. Scopes
79
+
80
+ Bind shortcuts to a named scope; they only fire while that scope is active. No provider required.
81
+
82
+ ```tsx
83
+ import {
84
+ useHotkeys,
85
+ useHotkeysScopes,
86
+ enableScope,
87
+ disableScope,
88
+ } from "@lacspace/hotkeys";
89
+
90
+ function Editor() {
91
+ // Only fires while the "editor" scope is active.
92
+ useHotkeys("mod+b", () => toggleBold(), { scopes: "editor" });
93
+ useHotkeys("mod+i", () => toggleItalic(), { scopes: "editor" });
94
+
95
+ return (
96
+ <div
97
+ onFocus={() => enableScope("editor")}
98
+ onBlur={() => disableScope("editor")}
99
+ >
100
+
101
+ </div>
102
+ );
103
+ }
104
+
105
+ function ScopeIndicator() {
106
+ const { activeScopes, toggleScope } = useHotkeysScopes();
107
+ return (
108
+ <button onClick={() => toggleScope("editor")}>
109
+ Editor shortcuts: {activeScopes.includes("editor") ? "on" : "off"}
110
+ </button>
111
+ );
112
+ }
113
+ ```
114
+
115
+ You can also scope to a specific element via `target`:
116
+
117
+ ```tsx
118
+ const boxRef = useRef<HTMLDivElement>(null);
119
+ useHotkeys("escape", () => close(), { target: boxRef, enableOnFormTags: true });
120
+ ```
121
+
122
+ ## API
123
+
124
+ ### `useHotkeys(keys, handler, options?, deps?)`
125
+
126
+ Binds one or more shortcuts for a component's lifetime.
127
+
128
+ - `keys: string | string[]` — a combo (`"mod+k"`), a sequence (`"g then d"` / `"g d"`), or an array of them.
129
+ - `handler: (event: KeyboardEvent, combo: string) => void` — receives the event and the matched combo string. Kept in a ref, so `deps` are optional.
130
+ - `options?: HotkeyOptions`
131
+ - `enabled?` (default `true`)
132
+ - `preventDefault?` (default `true`)
133
+ - `enableOnFormTags?` (default `false`) — when `false`, events from `input`/`textarea`/`select`/`contentEditable` are ignored.
134
+ - `eventType?: "keydown" | "keyup"` (default `"keydown"`)
135
+ - `target?: Window | HTMLElement | RefObject<HTMLElement | null>` (default `window`)
136
+ - `scopes?: string | string[]` — fire only when at least one is active.
137
+ - `deps?: unknown[]` — rarely needed thanks to the latest-ref handler.
138
+
139
+ ### `parseHotkey(str): ParsedHotkey`
140
+
141
+ Parses `"mod+shift+k"` → `{ key, mod, ctrl, alt, shift, meta }`. Modifiers: `mod`, `ctrl`/`control`, `alt`/`option`, `shift`, `meta`/`cmd`/`command`/`win`. Key aliases: `esc`→`escape`, `space`→`" "`, `up`/`down`/`left`/`right`→`arrow*`, etc.
142
+
143
+ ### `matchesHotkey(event, combo): boolean`
144
+
145
+ Returns `true` when a `KeyboardEvent` satisfies a combo. `mod` → `metaKey` on mac, `ctrlKey` otherwise. Modifiers must match exactly.
146
+
147
+ ### `formatHotkey(combo, opts?): string`
148
+
149
+ Pretty display: mac → `"⌘⇧K"`, non-mac → `"Ctrl+Shift+K"`. Auto-detects platform unless `opts.mac` is set.
150
+
151
+ ### `isMac(): boolean`
152
+
153
+ SSR-safe platform check (returns `false` on the server).
154
+
155
+ ### Scopes
156
+
157
+ `enableScope(name)`, `disableScope(name)`, `toggleScope(name)`, `isScopeActive(name)`, and `useHotkeysScopes()` → `{ activeScopes, enableScope, disableScope, toggleScope }`. Backed by `useSyncExternalStore` — no provider, concurrent-safe, SSR-safe.
158
+
159
+ ## Why it's tiny
160
+
161
+ No dependencies. No context providers. Combos and sequences are matched by comparing native `KeyboardEvent` fields against parsed strings — no synthetic key state to maintain. Scopes are a single module-level `Set` exposed through `useSyncExternalStore`. Handlers live in refs, so re-renders never re-bind listeners, and there is nothing to tree-shake away that you did not import.
162
+
163
+ ## Licensing
164
+
165
+ Free under the **[Lacspace Free Licence](https://lacspace.com/licenses/lacspace-free-1.0)** — MIT-equivalent freedoms. Use it in personal and commercial projects at no cost; just keep the notice. See the **[Lacspace Licence Centre](https://lacspace.com/licenses)**.
166
+
167
+ ---
168
+
169
+ **Part of the Lacspace ecosystem — zero-dependency, isomorphic TypeScript packages.**
170
+
171
+ [All packages ↗](https://lacspace.com/packages) · [npm org ↗](https://www.npmjs.com/org/lacspace) · [Licence Centre ↗](https://lacspace.com/licenses) · [GitHub ↗](https://github.com/lacspace/npm-packages)
172
+
173
+ <div align="center"><sub>Built with care by <a href="https://lacspace.com">Lacspace</a> · Lacspace Free Licence · <a href="https://github.com/lacspace/npm-packages">source</a></sub></div>
package/dist/index.cjs ADDED
@@ -0,0 +1,292 @@
1
+ 'use strict';
2
+
3
+ var react = require('react');
4
+
5
+ // src/index.ts
6
+ function isMac() {
7
+ if (typeof navigator === "undefined") return false;
8
+ const uaData = navigator.userAgentData;
9
+ if (uaData && typeof uaData.platform === "string" && uaData.platform) {
10
+ return /mac/i.test(uaData.platform);
11
+ }
12
+ const platform = navigator.platform || "";
13
+ if (platform) return /mac|iphone|ipad|ipod/i.test(platform);
14
+ return /mac|iphone|ipad|ipod/i.test(navigator.userAgent || "");
15
+ }
16
+ var MOD_TOKENS = {
17
+ mod: "mod",
18
+ ctrl: "ctrl",
19
+ control: "ctrl",
20
+ alt: "alt",
21
+ option: "alt",
22
+ opt: "alt",
23
+ shift: "shift",
24
+ meta: "meta",
25
+ cmd: "meta",
26
+ command: "meta",
27
+ win: "meta",
28
+ super: "meta"
29
+ };
30
+ var KEY_ALIASES = {
31
+ esc: "escape",
32
+ space: " ",
33
+ spacebar: " ",
34
+ up: "arrowup",
35
+ down: "arrowdown",
36
+ left: "arrowleft",
37
+ right: "arrowright",
38
+ return: "enter",
39
+ del: "delete",
40
+ ins: "insert",
41
+ pgup: "pageup",
42
+ pgdn: "pagedown"
43
+ };
44
+ function normalizeKey(raw) {
45
+ const k = raw.toLowerCase();
46
+ const aliased = KEY_ALIASES[k];
47
+ if (aliased !== void 0) return aliased;
48
+ return k;
49
+ }
50
+ function parseHotkey(str) {
51
+ const parsed = {
52
+ key: "",
53
+ mod: false,
54
+ ctrl: false,
55
+ alt: false,
56
+ shift: false,
57
+ meta: false
58
+ };
59
+ const tokens = str.split("+");
60
+ for (const token of tokens) {
61
+ const t = token.trim().toLowerCase();
62
+ if (t === "") continue;
63
+ const modKey = MOD_TOKENS[t];
64
+ if (modKey) {
65
+ parsed[modKey] = true;
66
+ } else {
67
+ parsed.key = normalizeKey(token.trim());
68
+ }
69
+ }
70
+ return parsed;
71
+ }
72
+ function matchesParsed(event, parsed, mac) {
73
+ const wantMeta = parsed.meta || mac && parsed.mod;
74
+ const wantCtrl = parsed.ctrl || !mac && parsed.mod;
75
+ if (event.metaKey !== wantMeta) return false;
76
+ if (event.ctrlKey !== wantCtrl) return false;
77
+ if (event.altKey !== parsed.alt) return false;
78
+ if (event.shiftKey !== parsed.shift) return false;
79
+ return event.key.toLowerCase() === parsed.key;
80
+ }
81
+ function matchesHotkey(event, combo) {
82
+ return matchesParsed(event, parseHotkey(combo), isMac());
83
+ }
84
+ function formatKeyLabel(key, mac) {
85
+ if (!key) return "";
86
+ switch (key) {
87
+ case " ":
88
+ return "Space";
89
+ case "escape":
90
+ return "Esc";
91
+ case "enter":
92
+ return mac ? "\u21B5" : "Enter";
93
+ case "arrowup":
94
+ return mac ? "\u2191" : "Up";
95
+ case "arrowdown":
96
+ return mac ? "\u2193" : "Down";
97
+ case "arrowleft":
98
+ return mac ? "\u2190" : "Left";
99
+ case "arrowright":
100
+ return mac ? "\u2192" : "Right";
101
+ case "backspace":
102
+ return mac ? "\u232B" : "Backspace";
103
+ case "delete":
104
+ return mac ? "\u2326" : "Del";
105
+ case "tab":
106
+ return mac ? "\u21E5" : "Tab";
107
+ }
108
+ if (key.length === 1) return key.toUpperCase();
109
+ return key.charAt(0).toUpperCase() + key.slice(1);
110
+ }
111
+ function formatHotkey(combo, opts = {}) {
112
+ const mac = opts.mac ?? isMac();
113
+ const p = parseHotkey(combo);
114
+ const parts = [];
115
+ if (mac) {
116
+ if (p.meta || p.mod) parts.push("\u2318");
117
+ if (p.ctrl) parts.push("\u2303");
118
+ if (p.alt) parts.push("\u2325");
119
+ if (p.shift) parts.push("\u21E7");
120
+ parts.push(formatKeyLabel(p.key, true));
121
+ return parts.join("");
122
+ }
123
+ if (p.ctrl || p.mod) parts.push("Ctrl");
124
+ if (p.alt) parts.push("Alt");
125
+ if (p.shift) parts.push("Shift");
126
+ if (p.meta) parts.push("Win");
127
+ parts.push(formatKeyLabel(p.key, false));
128
+ return parts.join("+");
129
+ }
130
+ var activeScopeSet = /* @__PURE__ */ new Set();
131
+ var scopeListeners = /* @__PURE__ */ new Set();
132
+ var EMPTY_SCOPES = Object.freeze([]);
133
+ var scopesSnapshot = [];
134
+ function refreshScopesSnapshot() {
135
+ scopesSnapshot = Array.from(activeScopeSet);
136
+ }
137
+ function emitScopeChange() {
138
+ for (const listener of scopeListeners) listener();
139
+ }
140
+ function enableScope(name) {
141
+ if (!activeScopeSet.has(name)) {
142
+ activeScopeSet.add(name);
143
+ refreshScopesSnapshot();
144
+ emitScopeChange();
145
+ }
146
+ }
147
+ function disableScope(name) {
148
+ if (activeScopeSet.delete(name)) {
149
+ refreshScopesSnapshot();
150
+ emitScopeChange();
151
+ }
152
+ }
153
+ function toggleScope(name) {
154
+ if (activeScopeSet.has(name)) disableScope(name);
155
+ else enableScope(name);
156
+ }
157
+ function isScopeActive(name) {
158
+ return activeScopeSet.has(name);
159
+ }
160
+ function subscribeScopes(callback) {
161
+ scopeListeners.add(callback);
162
+ return () => {
163
+ scopeListeners.delete(callback);
164
+ };
165
+ }
166
+ function getScopesSnapshot() {
167
+ return scopesSnapshot;
168
+ }
169
+ function getServerScopesSnapshot() {
170
+ return EMPTY_SCOPES;
171
+ }
172
+ function useHotkeysScopes() {
173
+ const active = react.useSyncExternalStore(
174
+ subscribeScopes,
175
+ getScopesSnapshot,
176
+ getServerScopesSnapshot
177
+ );
178
+ return { activeScopes: active, enableScope, disableScope, toggleScope };
179
+ }
180
+ var SEQUENCE_TIMEOUT = 1e3;
181
+ function isModifierKey(key) {
182
+ return key === "Control" || key === "Shift" || key === "Alt" || key === "Meta" || key === "OS" || key === "AltGraph";
183
+ }
184
+ function isFromFormField(target) {
185
+ if (typeof HTMLElement === "undefined") return false;
186
+ if (!(target instanceof HTMLElement)) return false;
187
+ const tag = target.tagName;
188
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
189
+ return target.isContentEditable;
190
+ }
191
+ function resolveTarget(target) {
192
+ if (typeof window === "undefined") return null;
193
+ if (!target) return window;
194
+ if (target === window) return window;
195
+ if (typeof HTMLElement !== "undefined" && target instanceof HTMLElement) {
196
+ return target;
197
+ }
198
+ return target.current;
199
+ }
200
+ function toEntries(keys) {
201
+ const list = Array.isArray(keys) ? keys : [keys];
202
+ return list.map((raw) => {
203
+ const steps = raw.replace(/\s+then\s+/gi, " ").trim().split(/\s+/).filter(Boolean).map(parseHotkey);
204
+ return { raw, steps, isSequence: steps.length > 1 };
205
+ });
206
+ }
207
+ function useHotkeys(keys, handler, options = {}, deps) {
208
+ const handlerRef = react.useRef(handler);
209
+ handlerRef.current = handler;
210
+ const optionsRef = react.useRef(options);
211
+ optionsRef.current = options;
212
+ const entriesRef = react.useRef([]);
213
+ entriesRef.current = toEntries(keys);
214
+ const progressRef = react.useRef(/* @__PURE__ */ new Map());
215
+ const eventType = options.eventType ?? "keydown";
216
+ const depList = deps ?? [];
217
+ react.useEffect(() => {
218
+ const el = resolveTarget(optionsRef.current.target);
219
+ if (!el) return;
220
+ const listener = (rawEvent) => {
221
+ const event = rawEvent;
222
+ const o = optionsRef.current;
223
+ if (o.enabled === false) return;
224
+ const enableOnFormTags = o.enableOnFormTags ?? false;
225
+ if (!enableOnFormTags && isFromFormField(event.target)) return;
226
+ const rawScopes = o.scopes;
227
+ const scopeArr = rawScopes == null ? [] : Array.isArray(rawScopes) ? rawScopes : [rawScopes];
228
+ if (scopeArr.length > 0 && !scopeArr.some((s) => activeScopeSet.has(s))) {
229
+ return;
230
+ }
231
+ const mac = isMac();
232
+ const preventDefault = o.preventDefault ?? true;
233
+ const lone = isModifierKey(event.key);
234
+ const progress = progressRef.current;
235
+ for (const entry of entriesRef.current) {
236
+ if (entry.isSequence) {
237
+ if (lone) continue;
238
+ let state = progress.get(entry.raw);
239
+ if (!state) {
240
+ state = { index: 0, time: 0 };
241
+ progress.set(entry.raw, state);
242
+ }
243
+ const now = Date.now();
244
+ if (state.index > 0 && now - state.time > SEQUENCE_TIMEOUT) {
245
+ state.index = 0;
246
+ }
247
+ const expected = entry.steps[state.index];
248
+ if (expected && matchesParsed(event, expected, mac)) {
249
+ state.index += 1;
250
+ state.time = now;
251
+ if (state.index >= entry.steps.length) {
252
+ state.index = 0;
253
+ if (preventDefault) event.preventDefault();
254
+ handlerRef.current(event, entry.raw);
255
+ }
256
+ } else {
257
+ const first = entry.steps[0];
258
+ if (first && matchesParsed(event, first, mac)) {
259
+ state.index = 1;
260
+ state.time = now;
261
+ } else {
262
+ state.index = 0;
263
+ }
264
+ }
265
+ } else {
266
+ const combo = entry.steps[0];
267
+ if (combo && matchesParsed(event, combo, mac)) {
268
+ if (preventDefault) event.preventDefault();
269
+ handlerRef.current(event, entry.raw);
270
+ }
271
+ }
272
+ }
273
+ };
274
+ el.addEventListener(eventType, listener);
275
+ return () => {
276
+ el.removeEventListener(eventType, listener);
277
+ };
278
+ }, [eventType, ...depList]);
279
+ }
280
+
281
+ exports.disableScope = disableScope;
282
+ exports.enableScope = enableScope;
283
+ exports.formatHotkey = formatHotkey;
284
+ exports.isMac = isMac;
285
+ exports.isScopeActive = isScopeActive;
286
+ exports.matchesHotkey = matchesHotkey;
287
+ exports.parseHotkey = parseHotkey;
288
+ exports.toggleScope = toggleScope;
289
+ exports.useHotkeys = useHotkeys;
290
+ exports.useHotkeysScopes = useHotkeysScopes;
291
+ //# sourceMappingURL=index.cjs.map
292
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"names":["useSyncExternalStore","useRef","useEffect"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @lacspace/hotkeys — ergonomic keyboard shortcuts for React.\n *\n * Combos (`mod+k`), key sequences (`g then d`), scopes, and pretty display\n * formatting (`⌘K`). SSR-safe, respects form fields, zero-dependency, fully typed.\n *\n * @packageDocumentation\n */\n\nimport { useEffect, useRef, useSyncExternalStore } from \"react\";\nimport type { RefObject } from \"react\";\n\n/* -------------------------------------------------------------------------- */\n/* Types */\n/* -------------------------------------------------------------------------- */\n\n/** A parsed hotkey combo: the resolved key plus each modifier requirement. */\nexport interface ParsedHotkey {\n /** Normalized key (e.g. `\"k\"`, `\"escape\"`, `\" \"`, `\"arrowup\"`). */\n key: string;\n /** `mod` — Cmd on mac, Ctrl elsewhere. */\n mod: boolean;\n /** Control key. */\n ctrl: boolean;\n /** Alt / Option key. */\n alt: boolean;\n /** Shift key. */\n shift: boolean;\n /** Meta / Cmd / Win key. */\n meta: boolean;\n}\n\n/** The handler invoked when a hotkey (or the final step of a sequence) fires. */\nexport type HotkeyHandler = (event: KeyboardEvent, combo: string) => void;\n\n/** Where to attach the key listener. */\nexport type HotkeyTarget =\n | Window\n | HTMLElement\n | RefObject<HTMLElement | null>;\n\n/** Options for {@link useHotkeys}. */\nexport interface HotkeyOptions {\n /** Master switch. When `false`, nothing fires. @default true */\n enabled?: boolean;\n /** Call `event.preventDefault()` when a hotkey matches. @default true */\n preventDefault?: boolean;\n /**\n * Allow firing while an `input` / `textarea` / `select` / `contentEditable`\n * element is the event source. @default false\n */\n enableOnFormTags?: boolean;\n /** Which key event to listen for. @default \"keydown\" */\n eventType?: \"keydown\" | \"keyup\";\n /** Where to bind the listener. @default window */\n target?: HotkeyTarget;\n /**\n * Scope name(s). The hotkey only fires when at least one is active\n * (see {@link enableScope}). Omit to always fire.\n */\n scopes?: string | string[];\n}\n\n/* -------------------------------------------------------------------------- */\n/* Platform detection */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Detects whether the current platform is a Mac (or iOS device).\n *\n * SSR-safe: always returns `false` when there is no `navigator`.\n *\n * @returns `true` on macOS / iOS, `false` otherwise (and on the server).\n *\n * @example\n * ```ts\n * const symbol = isMac() ? \"⌘\" : \"Ctrl\";\n * ```\n */\nexport function isMac(): boolean {\n if (typeof navigator === \"undefined\") return false;\n const uaData = (navigator as Navigator & {\n userAgentData?: { platform?: string };\n }).userAgentData;\n if (uaData && typeof uaData.platform === \"string\" && uaData.platform) {\n return /mac/i.test(uaData.platform);\n }\n const platform = navigator.platform || \"\";\n if (platform) return /mac|iphone|ipad|ipod/i.test(platform);\n return /mac|iphone|ipad|ipod/i.test(navigator.userAgent || \"\");\n}\n\n/* -------------------------------------------------------------------------- */\n/* Parsing */\n/* -------------------------------------------------------------------------- */\n\nconst MOD_TOKENS: Record<string, keyof Omit<ParsedHotkey, \"key\">> = {\n mod: \"mod\",\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 win: \"meta\",\n super: \"meta\",\n};\n\nconst KEY_ALIASES: Record<string, string> = {\n esc: \"escape\",\n space: \" \",\n spacebar: \" \",\n up: \"arrowup\",\n down: \"arrowdown\",\n left: \"arrowleft\",\n right: \"arrowright\",\n return: \"enter\",\n del: \"delete\",\n ins: \"insert\",\n pgup: \"pageup\",\n pgdn: \"pagedown\",\n};\n\n/** Normalize a raw key token into its canonical `event.key` (lowercased) form. */\nfunction normalizeKey(raw: string): string {\n const k = raw.toLowerCase();\n const aliased = KEY_ALIASES[k];\n if (aliased !== undefined) return aliased;\n return k;\n}\n\n/**\n * Parses a combo string like `\"mod+shift+k\"` into modifier flags and a key.\n *\n * Tokens split on `\"+\"`, case-insensitive. Modifiers: `mod` (Cmd on mac / Ctrl\n * elsewhere), `ctrl`/`control`, `alt`/`option`, `shift`, `meta`/`cmd`/`command`/`win`.\n * The remaining token is the key (`esc`→`escape`, `space`→`\" \"`, arrows→`arrowup`…,\n * single letters lowercased).\n *\n * @param str - The combo string, e.g. `\"mod+k\"` or `\"ctrl+shift+escape\"`.\n * @returns The parsed combo.\n *\n * @example\n * ```ts\n * parseHotkey(\"mod+shift+k\");\n * // { key: \"k\", mod: true, ctrl: false, alt: false, shift: true, meta: false }\n * ```\n */\nexport function parseHotkey(str: string): ParsedHotkey {\n const parsed: ParsedHotkey = {\n key: \"\",\n mod: false,\n ctrl: false,\n alt: false,\n shift: false,\n meta: false,\n };\n const tokens = str.split(\"+\");\n for (const token of tokens) {\n const t = token.trim().toLowerCase();\n if (t === \"\") continue;\n const modKey = MOD_TOKENS[t];\n if (modKey) {\n parsed[modKey] = true;\n } else {\n parsed.key = normalizeKey(token.trim());\n }\n }\n return parsed;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Matching */\n/* -------------------------------------------------------------------------- */\n\n/** Match a keyboard event against an already-parsed combo. */\nfunction matchesParsed(\n event: KeyboardEvent,\n parsed: ParsedHotkey,\n mac: boolean,\n): boolean {\n const wantMeta = parsed.meta || (mac && parsed.mod);\n const wantCtrl = parsed.ctrl || (!mac && parsed.mod);\n if (event.metaKey !== wantMeta) return false;\n if (event.ctrlKey !== wantCtrl) return false;\n if (event.altKey !== parsed.alt) return false;\n if (event.shiftKey !== parsed.shift) return false;\n return event.key.toLowerCase() === parsed.key;\n}\n\n/**\n * Returns `true` when a keyboard event satisfies a combo string.\n *\n * `mod` resolves to `metaKey` on mac and `ctrlKey` elsewhere. Modifiers must\n * match exactly (so `\"ctrl+k\"` does not fire when `Ctrl+Shift+K` is pressed).\n *\n * @param event - The keyboard event.\n * @param combo - The combo string, e.g. `\"mod+k\"`.\n * @returns Whether the event satisfies the combo.\n *\n * @example\n * ```ts\n * window.addEventListener(\"keydown\", (e) => {\n * if (matchesHotkey(e, \"mod+k\")) openPalette();\n * });\n * ```\n */\nexport function matchesHotkey(event: KeyboardEvent, combo: string): boolean {\n return matchesParsed(event, parseHotkey(combo), isMac());\n}\n\n/* -------------------------------------------------------------------------- */\n/* Display formatting */\n/* -------------------------------------------------------------------------- */\n\nfunction formatKeyLabel(key: string, mac: boolean): string {\n if (!key) return \"\";\n switch (key) {\n case \" \":\n return \"Space\";\n case \"escape\":\n return \"Esc\";\n case \"enter\":\n return mac ? \"↵\" : \"Enter\";\n case \"arrowup\":\n return mac ? \"↑\" : \"Up\";\n case \"arrowdown\":\n return mac ? \"↓\" : \"Down\";\n case \"arrowleft\":\n return mac ? \"←\" : \"Left\";\n case \"arrowright\":\n return mac ? \"→\" : \"Right\";\n case \"backspace\":\n return mac ? \"⌫\" : \"Backspace\";\n case \"delete\":\n return mac ? \"⌦\" : \"Del\";\n case \"tab\":\n return mac ? \"⇥\" : \"Tab\";\n default:\n break;\n }\n if (key.length === 1) return key.toUpperCase();\n return key.charAt(0).toUpperCase() + key.slice(1);\n}\n\n/**\n * Formats a combo for display, e.g. mac → `\"⌘⇧K\"`, non-mac → `\"Ctrl+Shift+K\"`.\n *\n * Auto-detects the platform when `opts.mac` is omitted.\n *\n * @param combo - The combo string, e.g. `\"mod+shift+k\"`.\n * @param opts - Optional overrides.\n * @param opts.mac - Force mac (`true`) or non-mac (`false`) rendering.\n * @returns A human-friendly label.\n *\n * @example\n * ```tsx\n * <kbd>{formatHotkey(\"mod+k\")}</kbd> // \"⌘K\" on mac, \"Ctrl+K\" elsewhere\n * formatHotkey(\"ctrl+shift+k\", { mac: false }); // \"Ctrl+Shift+K\"\n * ```\n */\nexport function formatHotkey(combo: string, opts: { mac?: boolean } = {}): string {\n const mac = opts.mac ?? isMac();\n const p = parseHotkey(combo);\n const parts: string[] = [];\n if (mac) {\n if (p.meta || p.mod) parts.push(\"⌘\");\n if (p.ctrl) parts.push(\"⌃\");\n if (p.alt) parts.push(\"⌥\");\n if (p.shift) parts.push(\"⇧\");\n parts.push(formatKeyLabel(p.key, true));\n return parts.join(\"\");\n }\n if (p.ctrl || p.mod) parts.push(\"Ctrl\");\n if (p.alt) parts.push(\"Alt\");\n if (p.shift) parts.push(\"Shift\");\n if (p.meta) parts.push(\"Win\");\n parts.push(formatKeyLabel(p.key, false));\n return parts.join(\"+\");\n}\n\n/* -------------------------------------------------------------------------- */\n/* Scopes (module-level, provider-free) */\n/* -------------------------------------------------------------------------- */\n\nconst activeScopeSet = new Set<string>();\nconst scopeListeners = new Set<() => void>();\nconst EMPTY_SCOPES: readonly string[] = Object.freeze([]);\nlet scopesSnapshot: string[] = [];\n\nfunction refreshScopesSnapshot(): void {\n scopesSnapshot = Array.from(activeScopeSet);\n}\n\nfunction emitScopeChange(): void {\n for (const listener of scopeListeners) listener();\n}\n\n/**\n * Activates a scope. Hotkeys bound to this scope will start firing.\n *\n * @param name - The scope name.\n *\n * @example\n * ```ts\n * enableScope(\"editor\"); // now editor hotkeys are live\n * ```\n */\nexport function enableScope(name: string): void {\n if (!activeScopeSet.has(name)) {\n activeScopeSet.add(name);\n refreshScopesSnapshot();\n emitScopeChange();\n }\n}\n\n/**\n * Deactivates a scope. Hotkeys bound only to this scope stop firing.\n *\n * @param name - The scope name.\n */\nexport function disableScope(name: string): void {\n if (activeScopeSet.delete(name)) {\n refreshScopesSnapshot();\n emitScopeChange();\n }\n}\n\n/**\n * Toggles a scope on or off.\n *\n * @param name - The scope name.\n */\nexport function toggleScope(name: string): void {\n if (activeScopeSet.has(name)) disableScope(name);\n else enableScope(name);\n}\n\n/** Returns whether a scope is currently active. */\nexport function isScopeActive(name: string): boolean {\n return activeScopeSet.has(name);\n}\n\nfunction subscribeScopes(callback: () => void): () => void {\n scopeListeners.add(callback);\n return () => {\n scopeListeners.delete(callback);\n };\n}\n\nfunction getScopesSnapshot(): string[] {\n return scopesSnapshot;\n}\n\nfunction getServerScopesSnapshot(): readonly string[] {\n return EMPTY_SCOPES;\n}\n\n/**\n * Subscribes to the set of active scopes and exposes controls.\n *\n * The component re-renders whenever scopes change (backed by\n * `useSyncExternalStore`, so it is concurrent-safe and SSR-safe).\n *\n * @returns `{ activeScopes, enableScope, disableScope, toggleScope }`.\n *\n * @example\n * ```tsx\n * function ScopeBadge() {\n * const { activeScopes, toggleScope } = useHotkeysScopes();\n * return (\n * <button onClick={() => toggleScope(\"editor\")}>\n * {activeScopes.includes(\"editor\") ? \"Editor on\" : \"Editor off\"}\n * </button>\n * );\n * }\n * ```\n */\nexport function useHotkeysScopes(): {\n activeScopes: readonly string[];\n enableScope: (name: string) => void;\n disableScope: (name: string) => void;\n toggleScope: (name: string) => void;\n} {\n const active = useSyncExternalStore(\n subscribeScopes,\n getScopesSnapshot,\n getServerScopesSnapshot,\n );\n return { activeScopes: active, enableScope, disableScope, toggleScope };\n}\n\n/* -------------------------------------------------------------------------- */\n/* useHotkeys */\n/* -------------------------------------------------------------------------- */\n\nconst SEQUENCE_TIMEOUT = 1000;\n\ninterface HotkeyEntry {\n raw: string;\n steps: ParsedHotkey[];\n isSequence: boolean;\n}\n\ninterface SequenceProgress {\n index: number;\n time: number;\n}\n\nfunction isModifierKey(key: string): boolean {\n return (\n key === \"Control\" ||\n key === \"Shift\" ||\n key === \"Alt\" ||\n key === \"Meta\" ||\n key === \"OS\" ||\n key === \"AltGraph\"\n );\n}\n\nfunction isFromFormField(target: EventTarget | null): boolean {\n if (typeof HTMLElement === \"undefined\") return false;\n if (!(target instanceof HTMLElement)) return false;\n const tag = target.tagName;\n if (tag === \"INPUT\" || tag === \"TEXTAREA\" || tag === \"SELECT\") return true;\n return target.isContentEditable;\n}\n\nfunction resolveTarget(target: HotkeyTarget | undefined): Window | HTMLElement | null {\n if (typeof window === \"undefined\") return null;\n if (!target) return window;\n if (target === window) return window;\n if (typeof HTMLElement !== \"undefined\" && target instanceof HTMLElement) {\n return target;\n }\n return (target as RefObject<HTMLElement | null>).current;\n}\n\nfunction toEntries(keys: string | string[]): HotkeyEntry[] {\n const list = Array.isArray(keys) ? keys : [keys];\n return list.map((raw) => {\n const steps = raw\n .replace(/\\s+then\\s+/gi, \" \")\n .trim()\n .split(/\\s+/)\n .filter(Boolean)\n .map(parseHotkey);\n return { raw, steps, isSequence: steps.length > 1 };\n });\n}\n\n/**\n * Binds keyboard shortcut(s) — combos and/or sequences — for the lifetime of a component.\n *\n * Supports:\n * - **Combos**: `\"mod+k\"`, `\"ctrl+shift+p\"`.\n * - **Multiple combos**: pass an array — any match fires the handler.\n * - **Sequences**: `\"g then d\"` or `\"g d\"` — press keys in order within ~1s.\n * - **Scopes**: only fire while a named scope is active (see {@link enableScope}).\n *\n * The handler is kept in a ref, so `deps` are optional — the latest closure is\n * always used without re-binding the listener.\n *\n * @param keys - A combo/sequence string, or an array of them.\n * @param handler - Called with the event and the matched combo string.\n * @param options - See {@link HotkeyOptions}.\n * @param deps - Optional dependency list (rarely needed thanks to the latest-ref).\n *\n * @example\n * ```tsx\n * // Open a command palette with ⌘K / Ctrl+K\n * useHotkeys(\"mod+k\", (e) => {\n * e.preventDefault();\n * setPaletteOpen(true);\n * });\n *\n * // Navigate with a sequence: press \"g\" then \"d\"\n * useHotkeys(\"g then d\", () => router.push(\"/dashboard\"));\n *\n * // Scoped: only while the \"editor\" scope is active\n * useHotkeys(\"mod+b\", toggleBold, { scopes: \"editor\" });\n * ```\n */\nexport function useHotkeys(\n keys: string | string[],\n handler: HotkeyHandler,\n options: HotkeyOptions = {},\n deps?: unknown[],\n): void {\n const handlerRef = useRef<HotkeyHandler>(handler);\n handlerRef.current = handler;\n\n const optionsRef = useRef<HotkeyOptions>(options);\n optionsRef.current = options;\n\n const entriesRef = useRef<HotkeyEntry[]>([]);\n entriesRef.current = toEntries(keys);\n\n const progressRef = useRef<Map<string, SequenceProgress>>(new Map());\n\n const eventType = options.eventType ?? \"keydown\";\n const depList = deps ?? [];\n\n useEffect(() => {\n const el = resolveTarget(optionsRef.current.target);\n if (!el) return;\n\n const listener = (rawEvent: Event) => {\n const event = rawEvent as KeyboardEvent;\n const o = optionsRef.current;\n\n if (o.enabled === false) return;\n\n const enableOnFormTags = o.enableOnFormTags ?? false;\n if (!enableOnFormTags && isFromFormField(event.target)) return;\n\n const rawScopes = o.scopes;\n const scopeArr =\n rawScopes == null ? [] : Array.isArray(rawScopes) ? rawScopes : [rawScopes];\n if (scopeArr.length > 0 && !scopeArr.some((s) => activeScopeSet.has(s))) {\n return;\n }\n\n const mac = isMac();\n const preventDefault = o.preventDefault ?? true;\n const lone = isModifierKey(event.key);\n const progress = progressRef.current;\n\n for (const entry of entriesRef.current) {\n if (entry.isSequence) {\n // Ignore lone modifier presses so they neither advance nor reset.\n if (lone) continue;\n\n let state = progress.get(entry.raw);\n if (!state) {\n state = { index: 0, time: 0 };\n progress.set(entry.raw, state);\n }\n\n const now = Date.now();\n if (state.index > 0 && now - state.time > SEQUENCE_TIMEOUT) {\n state.index = 0;\n }\n\n const expected = entry.steps[state.index];\n if (expected && matchesParsed(event, expected, mac)) {\n state.index += 1;\n state.time = now;\n if (state.index >= entry.steps.length) {\n state.index = 0;\n if (preventDefault) event.preventDefault();\n handlerRef.current(event, entry.raw);\n }\n } else {\n const first = entry.steps[0];\n if (first && matchesParsed(event, first, mac)) {\n state.index = 1;\n state.time = now;\n } else {\n state.index = 0;\n }\n }\n } else {\n const combo = entry.steps[0];\n if (combo && matchesParsed(event, combo, mac)) {\n if (preventDefault) event.preventDefault();\n handlerRef.current(event, entry.raw);\n }\n }\n }\n };\n\n el.addEventListener(eventType, listener as EventListener);\n return () => {\n el.removeEventListener(eventType, listener as EventListener);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [eventType, ...depList]);\n}\n"]}