@remcostoeten/use-shortcut 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,7 +5,16 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [2.0.1] - 2026-03-11
9
+
10
+ ### Fixed
11
+
12
+ - Re-exported `useShortcutMap`, `registerShortcutMap`, `createShortcutGroup`, and `useShortcutGroup` from the public package entrypoint.
13
+ - Re-exported shortcut map and shortcut group types from the public package entrypoint.
14
+
15
+ ### Changed
16
+
17
+ - Updated the README to document the public shortcut map API.
9
18
 
10
19
  ## [2.0.0] - 2026-03-04
11
20
 
package/README.md CHANGED
@@ -10,6 +10,7 @@ WIP keyboard shortcut library for React with a chainable API.
10
10
  ## Implemented Features
11
11
 
12
12
  - Chainable shortcut builder: `$.mod.key("k").on(handler)`
13
+ - Bulk shortcut maps: `useShortcutMap()` and `registerShortcutMap()`
13
14
  - Modifier support: `ctrl`, `shift`, `alt`, `cmd`, `mod`
14
15
  - Sequence support: `$.key("g").then("d")`
15
16
  - Scope-aware shortcuts:
@@ -22,14 +23,51 @@ WIP keyboard shortcut library for React with a chainable API.
22
23
  - Global guard/filter support via `eventFilter`
23
24
  - React entry point:
24
25
  - `useShortcut`
26
+ - `useShortcutMap`
27
+ - `useShortcutGroup`
25
28
 
26
29
  ## API Intention (Consumer-Facing)
27
30
 
28
31
  - `useShortcut(options?)`
29
32
  - Main React hook. Use this for the chainable API (`$.mod.key("s").on(...)`).
33
+ - `useShortcutMap(shortcutMap, options?)`
34
+ - React-safe bulk registration for render paths where a declarative object is cleaner than multiple `.on()` calls.
35
+ - `registerShortcutMap(builder, shortcutMap)`
36
+ - Imperative bulk registration helper when you already have a `useShortcut()` builder.
30
37
 
31
38
  Internal helpers follow underscore naming (for example `_createShortcutBuilder`, `_canonicalizeParsed`) and are not re-exported from `src/index.ts`.
32
39
 
40
+ ## Shortcut Map Example
41
+
42
+ ```tsx
43
+ import { useShortcutMap } from "@remcostoeten/use-shortcut"
44
+
45
+ function App() {
46
+ useShortcutMap(
47
+ {
48
+ openPalette: {
49
+ keys: "mod+k",
50
+ handler: () => openPalette(),
51
+ options: { preventDefault: true },
52
+ },
53
+ closePalette: {
54
+ keys: "escape",
55
+ handler: () => closePalette(),
56
+ },
57
+ toggleSidebar: {
58
+ keys: "g then s",
59
+ handler: () => toggleSidebar(),
60
+ },
61
+ },
62
+ { ignoreInputs: false },
63
+ )
64
+
65
+ return <div>Shortcuts ready</div>
66
+ }
67
+ ```
68
+
69
+ If you already have a builder from `useShortcut()`, you can bulk register with `registerShortcutMap($, shortcutMap)` and unbind the returned handles on cleanup.
70
+
33
71
  ## Architecture Notes
34
72
 
35
73
  - Core runtime lives in `src/builder.ts`
package/dist/index.d.mts CHANGED
@@ -264,6 +264,26 @@ type UseShortcutOptions = {
264
264
  /** Global event filter; return false to skip all shortcuts for the event */
265
265
  eventFilter?: (event: KeyboardEvent) => boolean;
266
266
  };
267
+ /** Single shortcut-map entry used by `registerShortcutMap` and `useShortcutMap`. */
268
+ type ShortcutMapEntry = {
269
+ keys: string | string[];
270
+ handler: ShortcutHandler;
271
+ options?: HandlerOptions;
272
+ };
273
+ /** Bulk registration shape mapping action ids to key+handler definitions. */
274
+ type ShortcutMap = Record<string, ShortcutMapEntry>;
275
+ /** Return type for map registrations, keyed by the same ids as the source map. */
276
+ type ShortcutMapResult<T extends ShortcutMap = ShortcutMap> = {
277
+ [K in keyof T]: ShortcutResult;
278
+ };
279
+ /** Imperative grouping controller for binding/unbinding many shortcut registrations together. */
280
+ type ShortcutGroup = {
281
+ add: (...results: ShortcutResult[]) => void;
282
+ addMany: (results: ShortcutResult[] | Record<string, ShortcutResult>) => void;
283
+ unbindAll: () => void;
284
+ clear: () => void;
285
+ getResults: () => ShortcutResult[];
286
+ };
267
287
 
268
288
  /**
269
289
  * Parse a shortcut string into its components
@@ -317,6 +337,23 @@ declare function matchesAnyShortcut(event: KeyboardEvent, parsedShortcuts: Parse
317
337
  */
318
338
  declare function formatShortcut(shortcut: string, platform?: PlatformType): string;
319
339
 
340
+ /**
341
+ * Registers an object-based shortcut map in one call and returns per-action handles.
342
+ *
343
+ * @param builder - Builder returned by `useShortcut()`
344
+ * @param shortcutMap - Record of action ids to key bindings, handlers, and options
345
+ * @returns A result map with one `ShortcutResult` per shortcut id
346
+ *
347
+ * @example
348
+ * ```ts
349
+ * const $ = useShortcut()
350
+ * const results = registerShortcutMap($, {
351
+ * save: { keys: "mod+s", handler: onSave },
352
+ * nav: { keys: ["g", "d"], handler: onGoDashboard },
353
+ * })
354
+ * ```
355
+ */
356
+ declare function registerShortcutMap<T extends ShortcutMap>(builder: ShortcutBuilder, shortcutMap: T): ShortcutMapResult<T>;
320
357
  /**
321
358
  * React hook for registering chainable keyboard shortcuts
322
359
  *
@@ -333,5 +370,46 @@ declare function formatShortcut(shortcut: string, platform?: PlatformType): stri
333
370
  * ```
334
371
  */
335
372
  declare function useShortcut(options?: UseShortcutOptions): ShortcutBuilder;
373
+ /**
374
+ * React hook that registers a shortcut map and automatically unbinds on cleanup.
375
+ *
376
+ * @param shortcutMap - Record of action ids to key bindings, handlers, and options
377
+ * @param options - Same options as `useShortcut()`
378
+ * @returns A map of `ShortcutResult` keyed by your shortcut ids
379
+ *
380
+ * @example
381
+ * ```ts
382
+ * const mapResults = useShortcutMap({
383
+ * save: { keys: "mod+s", handler: onSave },
384
+ * close: { keys: "escape", handler: onClose },
385
+ * })
386
+ * ```
387
+ */
388
+ declare function useShortcutMap<T extends ShortcutMap>(shortcutMap: T, options?: UseShortcutOptions): ShortcutMapResult<T>;
389
+ /**
390
+ * Creates an imperative group controller for many shortcut registrations.
391
+ *
392
+ * @returns A `ShortcutGroup` that can add and unbind multiple shortcuts together
393
+ *
394
+ * @example
395
+ * ```ts
396
+ * const group = createShortcutGroup()
397
+ * group.add($.mod.key("s").on(onSave))
398
+ * group.add($.key("escape").on(onClose))
399
+ * group.unbindAll()
400
+ * ```
401
+ */
402
+ declare function createShortcutGroup(): ShortcutGroup;
403
+ /**
404
+ * React hook that returns a stable `ShortcutGroup` instance.
405
+ *
406
+ * @returns A memoized `ShortcutGroup` tied to the component lifecycle
407
+ *
408
+ * @example
409
+ * ```ts
410
+ * const group = useShortcutGroup()
411
+ * ```
412
+ */
413
+ declare function useShortcutGroup(): ShortcutGroup;
336
414
 
337
- export { type ActionKey, type AlphaKey, type ExceptPredicate, type ExceptPreset, type FunctionKey, type HandlerOptions, type KeyChain, ModifierAliases, type ModifierChain, ModifierDisplayOrder, ModifierDisplaySymbols, type ModifierFlags, ModifierKey, type ModifierName, type ModifierState, type NavigationKey, type NumericKey, type ParsedShortcut, Platform, type ShortcutBuilder, type ShortcutConflict, type ShortcutHandler, type ShortcutRecordingOptions, type ShortcutResult, type ShortcutScope, type SpecialKey, SpecialKeyMap, type SymbolKey, type UseShortcutOptions, detectPlatform, formatShortcut, matchesAnyShortcut, matchesShortcut, parseShortcut, parseShortcuts, useShortcut };
415
+ export { type ActionKey, type AlphaKey, type ExceptPredicate, type ExceptPreset, type FunctionKey, type HandlerOptions, type KeyChain, ModifierAliases, type ModifierChain, ModifierDisplayOrder, ModifierDisplaySymbols, type ModifierFlags, ModifierKey, type ModifierName, type ModifierState, type NavigationKey, type NumericKey, type ParsedShortcut, Platform, type ShortcutBuilder, type ShortcutConflict, type ShortcutGroup, type ShortcutHandler, type ShortcutMap, type ShortcutMapEntry, type ShortcutMapResult, type ShortcutRecordingOptions, type ShortcutResult, type ShortcutScope, type SpecialKey, SpecialKeyMap, type SymbolKey, type UseShortcutOptions, createShortcutGroup, detectPlatform, formatShortcut, matchesAnyShortcut, matchesShortcut, parseShortcut, parseShortcuts, registerShortcutMap, useShortcut, useShortcutGroup, useShortcutMap };
package/dist/index.d.ts CHANGED
@@ -264,6 +264,26 @@ type UseShortcutOptions = {
264
264
  /** Global event filter; return false to skip all shortcuts for the event */
265
265
  eventFilter?: (event: KeyboardEvent) => boolean;
266
266
  };
267
+ /** Single shortcut-map entry used by `registerShortcutMap` and `useShortcutMap`. */
268
+ type ShortcutMapEntry = {
269
+ keys: string | string[];
270
+ handler: ShortcutHandler;
271
+ options?: HandlerOptions;
272
+ };
273
+ /** Bulk registration shape mapping action ids to key+handler definitions. */
274
+ type ShortcutMap = Record<string, ShortcutMapEntry>;
275
+ /** Return type for map registrations, keyed by the same ids as the source map. */
276
+ type ShortcutMapResult<T extends ShortcutMap = ShortcutMap> = {
277
+ [K in keyof T]: ShortcutResult;
278
+ };
279
+ /** Imperative grouping controller for binding/unbinding many shortcut registrations together. */
280
+ type ShortcutGroup = {
281
+ add: (...results: ShortcutResult[]) => void;
282
+ addMany: (results: ShortcutResult[] | Record<string, ShortcutResult>) => void;
283
+ unbindAll: () => void;
284
+ clear: () => void;
285
+ getResults: () => ShortcutResult[];
286
+ };
267
287
 
268
288
  /**
269
289
  * Parse a shortcut string into its components
@@ -317,6 +337,23 @@ declare function matchesAnyShortcut(event: KeyboardEvent, parsedShortcuts: Parse
317
337
  */
318
338
  declare function formatShortcut(shortcut: string, platform?: PlatformType): string;
319
339
 
340
+ /**
341
+ * Registers an object-based shortcut map in one call and returns per-action handles.
342
+ *
343
+ * @param builder - Builder returned by `useShortcut()`
344
+ * @param shortcutMap - Record of action ids to key bindings, handlers, and options
345
+ * @returns A result map with one `ShortcutResult` per shortcut id
346
+ *
347
+ * @example
348
+ * ```ts
349
+ * const $ = useShortcut()
350
+ * const results = registerShortcutMap($, {
351
+ * save: { keys: "mod+s", handler: onSave },
352
+ * nav: { keys: ["g", "d"], handler: onGoDashboard },
353
+ * })
354
+ * ```
355
+ */
356
+ declare function registerShortcutMap<T extends ShortcutMap>(builder: ShortcutBuilder, shortcutMap: T): ShortcutMapResult<T>;
320
357
  /**
321
358
  * React hook for registering chainable keyboard shortcuts
322
359
  *
@@ -333,5 +370,46 @@ declare function formatShortcut(shortcut: string, platform?: PlatformType): stri
333
370
  * ```
334
371
  */
335
372
  declare function useShortcut(options?: UseShortcutOptions): ShortcutBuilder;
373
+ /**
374
+ * React hook that registers a shortcut map and automatically unbinds on cleanup.
375
+ *
376
+ * @param shortcutMap - Record of action ids to key bindings, handlers, and options
377
+ * @param options - Same options as `useShortcut()`
378
+ * @returns A map of `ShortcutResult` keyed by your shortcut ids
379
+ *
380
+ * @example
381
+ * ```ts
382
+ * const mapResults = useShortcutMap({
383
+ * save: { keys: "mod+s", handler: onSave },
384
+ * close: { keys: "escape", handler: onClose },
385
+ * })
386
+ * ```
387
+ */
388
+ declare function useShortcutMap<T extends ShortcutMap>(shortcutMap: T, options?: UseShortcutOptions): ShortcutMapResult<T>;
389
+ /**
390
+ * Creates an imperative group controller for many shortcut registrations.
391
+ *
392
+ * @returns A `ShortcutGroup` that can add and unbind multiple shortcuts together
393
+ *
394
+ * @example
395
+ * ```ts
396
+ * const group = createShortcutGroup()
397
+ * group.add($.mod.key("s").on(onSave))
398
+ * group.add($.key("escape").on(onClose))
399
+ * group.unbindAll()
400
+ * ```
401
+ */
402
+ declare function createShortcutGroup(): ShortcutGroup;
403
+ /**
404
+ * React hook that returns a stable `ShortcutGroup` instance.
405
+ *
406
+ * @returns A memoized `ShortcutGroup` tied to the component lifecycle
407
+ *
408
+ * @example
409
+ * ```ts
410
+ * const group = useShortcutGroup()
411
+ * ```
412
+ */
413
+ declare function useShortcutGroup(): ShortcutGroup;
336
414
 
337
- export { type ActionKey, type AlphaKey, type ExceptPredicate, type ExceptPreset, type FunctionKey, type HandlerOptions, type KeyChain, ModifierAliases, type ModifierChain, ModifierDisplayOrder, ModifierDisplaySymbols, type ModifierFlags, ModifierKey, type ModifierName, type ModifierState, type NavigationKey, type NumericKey, type ParsedShortcut, Platform, type ShortcutBuilder, type ShortcutConflict, type ShortcutHandler, type ShortcutRecordingOptions, type ShortcutResult, type ShortcutScope, type SpecialKey, SpecialKeyMap, type SymbolKey, type UseShortcutOptions, detectPlatform, formatShortcut, matchesAnyShortcut, matchesShortcut, parseShortcut, parseShortcuts, useShortcut };
415
+ export { type ActionKey, type AlphaKey, type ExceptPredicate, type ExceptPreset, type FunctionKey, type HandlerOptions, type KeyChain, ModifierAliases, type ModifierChain, ModifierDisplayOrder, ModifierDisplaySymbols, type ModifierFlags, ModifierKey, type ModifierName, type ModifierState, type NavigationKey, type NumericKey, type ParsedShortcut, Platform, type ShortcutBuilder, type ShortcutConflict, type ShortcutGroup, type ShortcutHandler, type ShortcutMap, type ShortcutMapEntry, type ShortcutMapResult, type ShortcutRecordingOptions, type ShortcutResult, type ShortcutScope, type SpecialKey, SpecialKeyMap, type SymbolKey, type UseShortcutOptions, createShortcutGroup, detectPlatform, formatShortcut, matchesAnyShortcut, matchesShortcut, parseShortcut, parseShortcuts, registerShortcutMap, useShortcut, useShortcutGroup, useShortcutMap };
package/dist/index.js CHANGED
@@ -750,6 +750,95 @@ function _createShortcutBuilder(options = {}) {
750
750
  }
751
751
 
752
752
  // src/hook.ts
753
+ function _areShortcutMapKeysEqual(a, b) {
754
+ if (Array.isArray(a) && Array.isArray(b)) {
755
+ if (a.length !== b.length) return false;
756
+ for (let i = 0; i < a.length; i += 1) {
757
+ if (a[i] !== b[i]) return false;
758
+ }
759
+ return true;
760
+ }
761
+ if (!Array.isArray(a) && !Array.isArray(b)) {
762
+ return a === b;
763
+ }
764
+ return false;
765
+ }
766
+ function _areShortcutMapsEquivalent(a, b) {
767
+ const aKeys = Object.keys(a);
768
+ const bKeys = Object.keys(b);
769
+ if (aKeys.length !== bKeys.length) return false;
770
+ for (const key of aKeys) {
771
+ const aEntry = a[key];
772
+ const bEntry = b[key];
773
+ if (!bEntry) return false;
774
+ if (!_areShortcutMapKeysEqual(aEntry.keys, bEntry.keys)) return false;
775
+ if (aEntry.handler !== bEntry.handler) return false;
776
+ if (aEntry.options !== bEntry.options) return false;
777
+ }
778
+ return true;
779
+ }
780
+ function _normalizeShortcutMapKeys(keys) {
781
+ if (Array.isArray(keys)) {
782
+ return keys.map((key) => key.trim()).filter(Boolean);
783
+ }
784
+ const trimmed = keys.trim();
785
+ if (!trimmed) return [];
786
+ if (trimmed.includes(" then ")) {
787
+ return trimmed.split(/\s+then\s+/i).map((key) => key.trim()).filter(Boolean);
788
+ }
789
+ if (trimmed.includes(" ") && !trimmed.includes("+")) {
790
+ return trimmed.split(/\s+/).map((key) => key.trim()).filter(Boolean);
791
+ }
792
+ return [trimmed];
793
+ }
794
+ function _applyStep(builder, step) {
795
+ const tokens = step.toLowerCase().split("+").map((token) => token.trim()).filter(Boolean);
796
+ if (tokens.length === 0) {
797
+ throw new Error("[useShortcutMap] Invalid step: empty shortcut step");
798
+ }
799
+ const key = tokens.pop();
800
+ let chain = builder;
801
+ for (const token of tokens) {
802
+ if (token === "ctrl" || token === "control") {
803
+ chain = chain.ctrl;
804
+ continue;
805
+ }
806
+ if (token === "shift") {
807
+ chain = chain.shift;
808
+ continue;
809
+ }
810
+ if (token === "alt" || token === "option") {
811
+ chain = chain.alt;
812
+ continue;
813
+ }
814
+ if (token === "cmd" || token === "command" || token === "meta") {
815
+ chain = chain.cmd;
816
+ continue;
817
+ }
818
+ if (token === "mod") {
819
+ chain = chain.mod;
820
+ continue;
821
+ }
822
+ throw new Error(`[useShortcutMap] Unsupported modifier token "${token}" in step "${step}"`);
823
+ }
824
+ return chain.key(key);
825
+ }
826
+ function registerShortcutMap(builder, shortcutMap) {
827
+ const results = {};
828
+ for (const id of Object.keys(shortcutMap)) {
829
+ const entry = shortcutMap[id];
830
+ const steps = _normalizeShortcutMapKeys(entry.keys);
831
+ if (steps.length === 0) {
832
+ throw new Error(`[useShortcutMap] Shortcut "${String(id)}" has no key steps`);
833
+ }
834
+ let chain = _applyStep(builder, steps[0]);
835
+ for (const step of steps.slice(1)) {
836
+ chain = chain.then(step);
837
+ }
838
+ results[id] = chain.on(entry.handler, entry.options);
839
+ }
840
+ return results;
841
+ }
753
842
  function useShortcut(options = {}) {
754
843
  const optionsRef = react.useRef(options);
755
844
  optionsRef.current = options;
@@ -777,6 +866,64 @@ function useShortcut(options = {}) {
777
866
  }, [registry]);
778
867
  return builder;
779
868
  }
869
+ function useShortcutMap(shortcutMap, options = {}) {
870
+ const $ = useShortcut(options);
871
+ const stableShortcutMapRef = react.useRef(shortcutMap);
872
+ if (!_areShortcutMapsEquivalent(stableShortcutMapRef.current, shortcutMap)) {
873
+ stableShortcutMapRef.current = shortcutMap;
874
+ }
875
+ const stableShortcutMap = stableShortcutMapRef.current;
876
+ const resultsRef = react.useRef({});
877
+ react.useEffect(() => {
878
+ const registrations = registerShortcutMap($, stableShortcutMap);
879
+ const results = resultsRef.current;
880
+ for (const key of Object.keys(results)) {
881
+ delete results[key];
882
+ }
883
+ Object.assign(results, registrations);
884
+ return () => {
885
+ for (const result of Object.values(registrations)) {
886
+ result.unbind();
887
+ }
888
+ for (const key of Object.keys(results)) {
889
+ delete results[key];
890
+ }
891
+ };
892
+ }, [$, stableShortcutMap]);
893
+ return resultsRef.current;
894
+ }
895
+ function createShortcutGroup() {
896
+ const results = [];
897
+ return {
898
+ add: (...entries) => {
899
+ results.push(...entries);
900
+ },
901
+ addMany: (entries) => {
902
+ if (Array.isArray(entries)) {
903
+ results.push(...entries);
904
+ return;
905
+ }
906
+ results.push(...Object.values(entries));
907
+ },
908
+ unbindAll: () => {
909
+ for (const entry of results) {
910
+ entry.unbind();
911
+ }
912
+ results.length = 0;
913
+ },
914
+ clear: () => {
915
+ results.length = 0;
916
+ },
917
+ getResults: () => [...results]
918
+ };
919
+ }
920
+ function useShortcutGroup() {
921
+ const groupRef = react.useRef(null);
922
+ if (!groupRef.current) {
923
+ groupRef.current = createShortcutGroup();
924
+ }
925
+ return groupRef.current;
926
+ }
780
927
 
781
928
  exports.ModifierAliases = ModifierAliases;
782
929
  exports.ModifierDisplayOrder = ModifierDisplayOrder;
@@ -784,12 +931,16 @@ exports.ModifierDisplaySymbols = ModifierDisplaySymbols;
784
931
  exports.ModifierKey = ModifierKey;
785
932
  exports.Platform = Platform;
786
933
  exports.SpecialKeyMap = SpecialKeyMap;
934
+ exports.createShortcutGroup = createShortcutGroup;
787
935
  exports.detectPlatform = detectPlatform;
788
936
  exports.formatShortcut = formatShortcut;
789
937
  exports.matchesAnyShortcut = matchesAnyShortcut;
790
938
  exports.matchesShortcut = matchesShortcut;
791
939
  exports.parseShortcut = parseShortcut;
792
940
  exports.parseShortcuts = parseShortcuts;
941
+ exports.registerShortcutMap = registerShortcutMap;
793
942
  exports.useShortcut = useShortcut;
943
+ exports.useShortcutGroup = useShortcutGroup;
944
+ exports.useShortcutMap = useShortcutMap;
794
945
  //# sourceMappingURL=index.js.map
795
946
  //# sourceMappingURL=index.js.map