@inklu/keys 1.1.0 → 1.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/README.md CHANGED
@@ -73,6 +73,17 @@ function Dialog({ isOpen }) {
73
73
  }
74
74
  ```
75
75
 
76
+ `exclusive` is a barrier: it silences the whole app beneath it. To claim only a few keys, drop it and give those commands a `scope` instead — see below.
77
+
78
+ ### When two commands share a key
79
+
80
+ Exactly one command runs per keypress. The winner is:
81
+
82
+ 1. The command in a named scope, over a global one. `"*"` counts as global here — it means "survives barriers", not "belongs to a region of the UI".
83
+ 2. Failing that, the one registered later. Registration order tracks mount order, so the most recently opened surface claims the key.
84
+
85
+ A winner whose `when()` currently returns `false` steps aside rather than swallowing the keypress. This is how `Escape` closes the dialog when one is open and the panel otherwise, without making the dialog `exclusive`. Two commands sharing a chord *in the same scope* can never separate, so that case warns in development.
86
+
76
87
  ## 4. Rebinding
77
88
 
78
89
  Overrides are layered over defaults.
@@ -84,6 +95,19 @@ commands.keymap.set("palette.open", null); // unbind
84
95
  commands.keymap.reset("palette.open"); // reset to default
85
96
  ```
86
97
 
98
+ To rebind a command you did not define — one a package shipped — pass `bindings` when creating the registry. An unknown id is a compile error, and `reset` returns here rather than to the definition's `keys`:
99
+
100
+ ```ts
101
+ createCommands(commands, {
102
+ bindings: {
103
+ "palette.open": "Mod+P", // replace the shipped default
104
+ "doc.publish": null, // ship it unbound
105
+ },
106
+ });
107
+ ```
108
+
109
+ These are defaults, applied before persisted overrides load, so a user's own rebinding still wins.
110
+
87
111
  In React, use the `useRebind` hook for a capture flow:
88
112
 
89
113
  ```tsx
@@ -97,6 +121,7 @@ const rebind = useRebind(commands, "palette.open");
97
121
  ## API Summary
98
122
 
99
123
  - `createCommands(definitions, options)` - creates the registry
124
+ - `options`: `{ bindings, platform, target, persist, display, onRun, onError }`
100
125
  - `useMountCommands(commands)` - starts listening
101
126
  - `useShortcut(commands, id)` - gets display tokens and aria for rendering
102
127
  - `useScope(commands, name, active, { exclusive })` - isolates command execution
@@ -143,7 +143,25 @@ interface CommandKeymap<Id extends string = string> {
143
143
  //#endregion
144
144
  //#region src/registry.d.ts
145
145
  /** Configuration options for a commands registry. */
146
- interface CommandsOptions {
146
+ interface CommandsOptions<Id extends string = string> {
147
+ /**
148
+ * Replaces the default `keys` a command was defined with, keyed by command
149
+ * id. Lets an app rebind commands it did not define — a package ships the
150
+ * behaviour, the app decides which key reaches it — and an unknown id is a
151
+ * compile error rather than a silent no-op. `null` ships the command with
152
+ * no default binding at all.
153
+ *
154
+ * These are defaults, applied before persisted overrides load, so a user's
155
+ * own rebinding still wins.
156
+ *
157
+ * @example
158
+ * ```ts
159
+ * createCommands(commands, {
160
+ * bindings: { "palette.open": "Mod+P", "doc.publish": null },
161
+ * });
162
+ * ```
163
+ */
164
+ bindings?: Partial<Record<Id, BindingInput | null>>;
147
165
  /** Platform for modifier resolution and display formatting. Defaults to auto-detection. */
148
166
  platform?: Platform;
149
167
  /** Target element for key events. Defaults to `document`. */
@@ -260,7 +278,7 @@ interface Commands<Id extends string = string> {
260
278
  * });
261
279
  * ```
262
280
  */
263
- declare function createCommands<const T extends { [K in keyof T]: CommandDefinition & Record<Exclude<keyof T[K], keyof CommandDefinition>, never>; }>(definitions: T, options?: CommandsOptions): Commands<Extract<keyof T, string>>;
281
+ declare function createCommands<const T extends { [K in keyof T]: CommandDefinition & Record<Exclude<keyof T[K], keyof CommandDefinition>, never>; }>(definitions: T, options?: CommandsOptions<Extract<keyof T, string>>): Commands<Extract<keyof T, string>>;
264
282
  /**
265
283
  * Extracts command ID union from a Commands registry type.
266
284
  *
@@ -1,2 +1,2 @@
1
- import { n as observe, t as ObserveOptions } from "./attributes-lCPeoWII.js";
1
+ import { n as observe, t as ObserveOptions } from "./attributes-DbxlLdrb.js";
2
2
  export { ObserveOptions, observe };
@@ -1,4 +1,4 @@
1
- import { M as SequenceBinding, T as Conflict, a as Commands, b as Chord, v as Binding } from "./attributes-lCPeoWII.js";
1
+ import { M as SequenceBinding, T as Conflict, a as Commands, b as Chord, v as Binding } from "./attributes-DbxlLdrb.js";
2
2
  import { Hotkey } from "@tanstack/hotkeys";
3
3
  //#region src/bindings.d.ts
4
4
  /**
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- import { A as Platform, C as CommandDefinition, D as HotkeySequence, E as Hotkey, M as SequenceBinding, N as SerializedBinding, O as KeymapPersistence, S as CommandContext, T as Conflict, _ as formatBinding, a as Commands, b as Chord, c as PendingSequence, d as createCommands, f as CommandKeymap, g as bindingTokens, h as ariaKeyShortcuts, i as CommandId, j as ScopeOptions, k as KeymapStorage, l as RunBlockReason, m as DisplayOptions, n as observe, o as CommandsOptions, p as SerializedKeymap, r as CommandHandle, s as ConflictOptions, t as ObserveOptions, u as RunOptions, v as Binding, w as CommandSource, x as Command, y as BindingInput } from "./attributes-lCPeoWII.js";
2
- import { a as Rebinder, c as seq, i as RebindState, l as validateBinding, n as RebindMode, o as createRebinder, r as RebindOptions, s as BindingValidation, t as ALWAYS_SCOPE } from "./index-WDNLK0dX.js";
1
+ import { A as Platform, C as CommandDefinition, D as HotkeySequence, E as Hotkey, M as SequenceBinding, N as SerializedBinding, O as KeymapPersistence, S as CommandContext, T as Conflict, _ as formatBinding, a as Commands, b as Chord, c as PendingSequence, d as createCommands, f as CommandKeymap, g as bindingTokens, h as ariaKeyShortcuts, i as CommandId, j as ScopeOptions, k as KeymapStorage, l as RunBlockReason, m as DisplayOptions, n as observe, o as CommandsOptions, p as SerializedKeymap, r as CommandHandle, s as ConflictOptions, t as ObserveOptions, u as RunOptions, v as Binding, w as CommandSource, x as Command, y as BindingInput } from "./attributes-DbxlLdrb.js";
2
+ import { a as Rebinder, c as seq, i as RebindState, l as validateBinding, n as RebindMode, o as createRebinder, r as RebindOptions, s as BindingValidation, t as ALWAYS_SCOPE } from "./index-D4UoKAQU.js";
3
3
  export { ALWAYS_SCOPE, type Binding, type BindingInput, type BindingValidation, type Chord, type Command, type CommandContext, type CommandDefinition, type CommandHandle, type CommandId, type CommandKeymap, type CommandSource, type Commands, type CommandsOptions, type Conflict, type ConflictOptions, type DisplayOptions, type Hotkey, type HotkeySequence, type KeymapPersistence, type KeymapStorage, type ObserveOptions, type PendingSequence, type Platform, type RebindMode, type RebindOptions, type RebindState, type Rebinder, type RunBlockReason, type RunOptions, type ScopeOptions, type SequenceBinding, type SerializedBinding, type SerializedKeymap, ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, formatBinding, observe, seq, validateBinding };
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  import { observe } from "./attributes.js";
2
- import { a as bindingTokens, c as seq, i as ariaKeyShortcuts, n as ALWAYS_SCOPE, o as formatBinding, r as createRebinder, t as createCommands, u as validateBinding } from "./src-B305icXC.js";
2
+ import { a as bindingTokens, c as seq, i as ariaKeyShortcuts, n as ALWAYS_SCOPE, o as formatBinding, r as createRebinder, t as createCommands, u as validateBinding } from "./src-DyFUgmho.js";
3
3
  export { ALWAYS_SCOPE, ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, formatBinding, observe, seq, validateBinding };
package/dist/react.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { A as Platform, C as CommandDefinition, D as HotkeySequence, E as Hotkey, M as SequenceBinding, N as SerializedBinding, O as KeymapPersistence, S as CommandContext, T as Conflict, _ as formatBinding, a as Commands, b as Chord, c as PendingSequence, d as createCommands, f as CommandKeymap, g as bindingTokens, h as ariaKeyShortcuts, i as CommandId, j as ScopeOptions, k as KeymapStorage, l as RunBlockReason, m as DisplayOptions, n as observe, o as CommandsOptions, p as SerializedKeymap, r as CommandHandle, s as ConflictOptions, t as ObserveOptions, u as RunOptions, v as Binding, w as CommandSource, x as Command, y as BindingInput } from "./attributes-lCPeoWII.js";
2
- import { a as Rebinder, c as seq, i as RebindState, l as validateBinding, n as RebindMode, o as createRebinder, r as RebindOptions, s as BindingValidation, t as ALWAYS_SCOPE } from "./index-WDNLK0dX.js";
1
+ import { A as Platform, C as CommandDefinition, D as HotkeySequence, E as Hotkey, M as SequenceBinding, N as SerializedBinding, O as KeymapPersistence, S as CommandContext, T as Conflict, _ as formatBinding, a as Commands, b as Chord, c as PendingSequence, d as createCommands, f as CommandKeymap, g as bindingTokens, h as ariaKeyShortcuts, i as CommandId, j as ScopeOptions, k as KeymapStorage, l as RunBlockReason, m as DisplayOptions, n as observe, o as CommandsOptions, p as SerializedKeymap, r as CommandHandle, s as ConflictOptions, t as ObserveOptions, u as RunOptions, v as Binding, w as CommandSource, x as Command, y as BindingInput } from "./attributes-DbxlLdrb.js";
2
+ import { a as Rebinder, c as seq, i as RebindState, l as validateBinding, n as RebindMode, o as createRebinder, r as RebindOptions, s as BindingValidation, t as ALWAYS_SCOPE } from "./index-D4UoKAQU.js";
3
3
  //#region src/react/hooks.d.ts
4
4
  /**
5
5
  * Mounts a commands registry for the lifecycle of the component.
package/dist/react.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { observe } from "./attributes.js";
3
- import { a as bindingTokens, c as seq, i as ariaKeyShortcuts, l as toBindings, n as ALWAYS_SCOPE, o as formatBinding, r as createRebinder, s as bindingSignature, t as createCommands, u as validateBinding } from "./src-B305icXC.js";
3
+ import { a as bindingTokens, c as seq, i as ariaKeyShortcuts, l as toBindings, n as ALWAYS_SCOPE, o as formatBinding, r as createRebinder, s as bindingSignature, t as createCommands, u as validateBinding } from "./src-DyFUgmho.js";
4
4
  import { getKeyStateTracker } from "@tanstack/hotkeys";
5
5
  import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
6
6
  //#region src/react/hooks.ts
@@ -340,7 +340,7 @@ var Binder = class {
340
340
  this.#bound.clear();
341
341
  }
342
342
  #register(id, binding, signature, definition, enabled) {
343
- const callback = (event) => this.#handle(id, binding, event);
343
+ const callback = (event) => this.#handle(id, binding, signature, event);
344
344
  const options = {
345
345
  preventDefault: false,
346
346
  stopPropagation: false,
@@ -370,8 +370,18 @@ var Binder = class {
370
370
  return null;
371
371
  }
372
372
  }
373
- #handle(id, binding, event) {
373
+ /**
374
+ * One keypress can reach several registrations (see `conflictBehavior`
375
+ * above), so every candidate asks the registry whether it won before doing
376
+ * anything observable. Arbitration comes before `preventDefault` on
377
+ * purpose: a losing command must leave the event exactly as it found it,
378
+ * or a command declaring `preventDefault: false` would still have the
379
+ * browser's default action cancelled by whichever rival happened to be
380
+ * registered alongside it.
381
+ */
382
+ #handle(id, binding, signature, event) {
374
383
  if (!this.#host.canRun(id)) return;
384
+ if (!this.#host.claims(id, signature)) return;
375
385
  const definition = this.#host.getDefinition(id);
376
386
  if (!definition) return;
377
387
  if (definition.preventDefault !== false) event.preventDefault();
@@ -394,7 +404,7 @@ var Keymap = class {
394
404
  constructor(persistence) {
395
405
  this.#persistence = persistence;
396
406
  }
397
- /** Sets a command's default bindings. */
407
+ /** Sets a command's default bindings. `null` leaves the command unbound. */
398
408
  setDefault(loose, input) {
399
409
  const id = loose;
400
410
  const bindings = toBindings(input);
@@ -573,6 +583,7 @@ var CommandRegistry = class {
573
583
  #pendingSubscription = null;
574
584
  #snapshot = null;
575
585
  #index = null;
586
+ #ownerIndex = null;
576
587
  #mounts = 0;
577
588
  #hydrated = false;
578
589
  #barrier = false;
@@ -584,6 +595,7 @@ var CommandRegistry = class {
584
595
  getBindings: (id) => this.#keymap.get(id),
585
596
  canRun: (id) => this.canRun(id),
586
597
  isReachable: (id) => this.#isReachable(id),
598
+ claims: (id, signature) => this.#claims(id, signature),
587
599
  platform: () => this.#platform(),
588
600
  execute: (id, context) => {
589
601
  this.#execute(id, context);
@@ -808,7 +820,9 @@ var CommandRegistry = class {
808
820
  const definition = this.#definition(id);
809
821
  if (!definition) return;
810
822
  this.#enabled.set(id, definition.enabled !== false);
811
- this.#keymap.setDefault(id, definition.keys);
823
+ const overrides = this.#options.bindings;
824
+ const keys = overrides && Object.hasOwn(overrides, id) ? overrides[id] : definition.keys;
825
+ this.#keymap.setDefault(id, keys);
812
826
  }
813
827
  #drop(id) {
814
828
  this.#stacks.delete(id);
@@ -822,6 +836,57 @@ var CommandRegistry = class {
822
836
  if (!(this.#enabled.get(id) ?? false)) return false;
823
837
  return this.#scopes.isActive(this.#definition(id)?.scope);
824
838
  }
839
+ /**
840
+ * Decides which of several commands sharing one binding gets to run.
841
+ *
842
+ * Without this every registration for the pressed chord fires, so two
843
+ * commands bound to `Mod+S` in overlapping scopes both ran and the only
844
+ * defence was a dev-time console warning. Exclusive scopes could not fix
845
+ * it either: a barrier shadows *every* global command, not just the one
846
+ * colliding, so "Esc closes the dialog, and the panel otherwise" forced
847
+ * the whole dialog to become exclusive.
848
+ *
849
+ * Ranking, highest first:
850
+ *
851
+ * 1. A command in a named scope beats a global one. `ALWAYS_SCOPE` is
852
+ * global for this purpose — it means "survives barriers", not
853
+ * "belongs to some region of the UI".
854
+ * 2. Later registration beats earlier. Registration order tracks mount
855
+ * order, so the most recently opened surface claims the key — the
856
+ * same intuition that makes `add()` stack definitions per id.
857
+ *
858
+ * Guards are evaluated live rather than baked into the ranking, so a
859
+ * winner whose `when()` currently returns false steps aside instead of
860
+ * swallowing the keypress.
861
+ */
862
+ #claims(id, signature) {
863
+ const candidates = this.#owners().get(signature);
864
+ if (!candidates || candidates.length < 2) return true;
865
+ for (const candidate of candidates) {
866
+ if (candidate !== id && !this.canRun(candidate)) continue;
867
+ return candidate === id;
868
+ }
869
+ return false;
870
+ }
871
+ /** Binding signature → command ids that share it, ranked by `#claims`. */
872
+ #owners() {
873
+ if (this.#ownerIndex) return this.#ownerIndex;
874
+ const platform = this.#platform();
875
+ const index = /* @__PURE__ */ new Map();
876
+ for (const id of this.#order) for (const binding of this.#keymap.get(id)) {
877
+ const signature = bindingSignature(binding, platform);
878
+ const ids = index.get(signature);
879
+ if (ids) ids.unshift(id);
880
+ else index.set(signature, [id]);
881
+ }
882
+ for (const ids of index.values()) if (ids.length > 1) ids.sort((a, b) => this.#scopeRank(b) - this.#scopeRank(a));
883
+ this.#ownerIndex = index;
884
+ return index;
885
+ }
886
+ #scopeRank(id) {
887
+ const scope = this.#definition(id)?.scope;
888
+ return scope === void 0 || scope === "*" ? 0 : 1;
889
+ }
825
890
  #execute(id, context) {
826
891
  const definition = this.#definition(id);
827
892
  if (!definition) return;
@@ -894,6 +959,7 @@ var CommandRegistry = class {
894
959
  #invalidate() {
895
960
  this.#snapshot = null;
896
961
  this.#index = null;
962
+ this.#ownerIndex = null;
897
963
  for (const listener of this.#listeners) listener();
898
964
  }
899
965
  #warnAboutConflicts() {
@@ -908,10 +974,10 @@ var CommandRegistry = class {
908
974
  seen.set(key, id);
909
975
  continue;
910
976
  }
911
- collisions.push(`${formatBinding(binding, this.#displayOptions())} — "${owner}" and "${id}"`);
977
+ collisions.push(`${formatBinding(binding, this.#displayOptions())} — "${id}" shadows "${owner}"`);
912
978
  }
913
979
  }
914
- if (collisions.length > 0) console.warn(`[@inklu/keys] ${collisions.length} conflicting binding(s):\n ${collisions.join("\n ")}`);
980
+ if (collisions.length > 0) console.warn(`[@inklu/keys] ${collisions.length} unreachable binding(s) — same chord, same scope:\n ${collisions.join("\n ")}`);
915
981
  }
916
982
  #warnAboutBindings(id) {
917
983
  for (const binding of this.#keymap.get(id)) {
@@ -921,9 +987,17 @@ var CommandRegistry = class {
921
987
  console.warn(`[@inklu/keys] "${id}" has an unrecognized binding ${describeBinding(binding)}, which resolves to "${resolved}". ${errors.join("; ")}.`);
922
988
  }
923
989
  }
990
+ /** Catches ids in `options.bindings` that TypeScript could not — plain JS, or a runtime-only command. */
991
+ #warnAboutUnknownOverrides() {
992
+ const overrides = this.#options.bindings;
993
+ if (!overrides) return;
994
+ const unknown = Object.keys(overrides).filter((id) => !this.#stacks.has(id));
995
+ if (unknown.length > 0) console.warn(`[@inklu/keys] bindings override ${unknown.map((id) => `"${id}"`).join(", ")}, which ${unknown.length === 1 ? "is not a registered command" : "are not registered commands"}.`);
996
+ }
924
997
  #audit() {
925
998
  if (!isDevelopment()) return;
926
999
  for (const id of this.#order) this.#warnAboutBindings(id);
1000
+ this.#warnAboutUnknownOverrides();
927
1001
  this.#warnAboutConflicts();
928
1002
  }
929
1003
  #auditAdded(id) {
@@ -933,7 +1007,10 @@ var CommandRegistry = class {
933
1007
  for (const conflict of this.conflicts(this.#keymap.get(id), {
934
1008
  ignore: id,
935
1009
  scope
936
- })) console.warn(`[@inklu/keys] "${id}" binds ${conflict.display}, which "${conflict.id}" already uses.`);
1010
+ })) {
1011
+ const winner = this.#scopeRank(conflict.id) > this.#scopeRank(id) ? conflict.id : id;
1012
+ console.warn(`[@inklu/keys] "${id}" binds ${conflict.display}, which "${conflict.id}" already uses. While both are runnable the key goes to "${winner}".`);
1013
+ }
937
1014
  }
938
1015
  };
939
1016
  function describeBinding(binding) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inklu/keys",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "A command system for the web — typed commands, portable keybindings, scopes, sequences, conflict detection, rebinding, and platform-aware display.",
5
5
  "keywords": [
6
6
  "keyboard",