modern-cmdk 1.5.0 → 1.6.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
@@ -119,7 +119,7 @@ function CommandPalette() {
119
119
  ```ts
120
120
  import { createCommandMachine, itemId } from 'modern-cmdk';
121
121
 
122
- using machine = createCommandMachine({
122
+ const machine = createCommandMachine({
123
123
  items: [
124
124
  { id: itemId('copy'), value: 'Copy', shortcut: 'Mod+C', onSelect: () => navigator.clipboard.writeText(getSelection()?.toString() ?? '') },
125
125
  { id: itemId('paste'), value: 'Paste', shortcut: 'Mod+V', onSelect: () => navigator.clipboard.readText().then((text) => console.log('Pasted:', text)) },
@@ -128,14 +128,30 @@ using machine = createCommandMachine({
128
128
  loop: true,
129
129
  });
130
130
 
131
+ const unsubscribe = machine.subscribe(() => {
132
+ console.log(machine.getState());
133
+ });
134
+
135
+ // send() is batched: the machine applies these three events on the next animation frame
136
+ // (a microtask where requestAnimationFrame is absent, such as Node), commits one
137
+ // stateChange for the batch (each SEARCH_CHANGE filters when it runs; registry events refilter once
138
+ // per batch; with `frecency` on, the frecency load can commit one more stateChange when it settles
139
+ // after the batch). getState() right after send() still returns the previous state.
140
+ // The sample is for a browser: the copy item's onSelect uses getSelection() and navigator.clipboard.
131
141
  machine.send({ type: 'SEARCH_CHANGE', query: 'cop' });
132
142
  machine.send({ type: 'NAVIGATE', direction: 'next' });
133
143
  machine.send({ type: 'ITEM_SELECT', id: itemId('copy') });
134
144
 
135
- machine.subscribe(() => {
136
- console.log(machine.getState());
137
- });
138
- // Automatic cleanup via `using` -- no manual dispose needed
145
+ // Teardown, when the palette goes away (not right after send(): dispose drops events still pending
146
+ // and removes the global shortcut listener):
147
+ export function teardown(): void {
148
+ unsubscribe();
149
+ machine[Symbol.dispose]();
150
+ }
151
+
152
+ // Do not write `using machine = ...` at module top level: it disposes the machine when the module
153
+ // finishes evaluating, before the batch runs. `await using machine = ...` inside an async scope that
154
+ // outlives the work also works, and additionally awaits a pending frecency save before it disposes.
139
155
  ```
140
156
 
141
157
  ## What is in it
@@ -144,10 +160,10 @@ machine.subscribe(() => {
144
160
  - **React 19 adapter** -- compound components over `useSyncExternalStore`, `useTransition` and `use()`; React Compiler compatible.
145
161
  - **Automatic virtualization** -- turns on when more than 100 items match and off again at 50 or fewer (fixed thresholds; force it with `<Command.List virtualize>` or `virtualize={false}`). The `virtualizeThreshold` option is accepted but not read yet.
146
162
  - **Fuzzy search and frecency** -- a built-in TypeScript scorer with match ranges, and frequency x recency ranking with memory or IndexedDB persistence. `modern-cmdk-search-wasm` is an experimental, unpublished workspace package that does not work yet.
147
- - **Keyboard shortcuts** -- a registry with a cross-platform `Mod` key and conflict detection.
163
+ - **Keyboard shortcuts** -- a registry with a cross-platform `Mod` key. Two items bound to one key are a conflict: the later binding wins the keydown (disposing it restores the earlier one), `getConflicts()` lists the keys with two or more bindings and `getBindings()` returns the winning binding of each key.
148
164
  - **Accessibility** -- WAI-ARIA combobox pattern, `aria-live` announcements, `forced-colors`, `prefers-contrast`, `prefers-reduced-motion`.
149
165
  - **Animation** -- the stylesheet defines the dialog enter and exit on opacity, scale and translate, which are compositor-only; the overlay fades opacity and also transitions `--command-overlay-opacity`, its background alpha, which repaints, and the list height and item background-color transitions are not compositor-only either. In 1.x neither the dialog nor the overlay animates on enter or exit, because their duration and easing tokens are declared only on `[data-command-root]`, which sits inside the dialog (DT-19, planned for 1.8.0).
150
- - **Extras** -- `CommandErrorBoundary`, `useCommandDevtools()` and telemetry middleware.
166
+ - **Extras** -- `CommandErrorBoundary`, `useCommandDevtools()` and `createTelemetryMiddleware()`, which returns hooks that your code calls itself (the machine and the React components have no telemetry option).
151
167
 
152
168
  Theming (CSS custom properties, `@layer command`) is covered in the [theming guide](https://abcrimson.github.io/modern-cmdk/guide/theming.html).
153
169
 
@@ -219,17 +219,26 @@ async function relativeToRoot(files, cwd, root) {
219
219
  }
220
220
  const key = (path) => process.platform === "win32" ? path.toLowerCase() : path;
221
221
  /**
222
- * Root-relative paths of the gitlinks (mode 160000: submodules) in the repository at `root`. One
223
- * level is enough: a submodule nested inside a submodule is not looked for.
222
+ * Root-relative paths (forward slashes) of the gitlinks (mode 160000: submodules) in the
223
+ * repository at `root`, and of the gitlinks inside each submodule that holds one of `rels` (the
224
+ * root-relative files being checked), at any depth: a submodule nested in a submodule is its own
225
+ * repository, and git refuses a path that lies inside it.
224
226
  */
225
- async function gitlinksOf(root, boundary) {
227
+ async function gitlinksOf(root, boundary, rels) {
226
228
  const { code, stdout, stderr } = await runGit([
227
229
  "ls-files",
228
230
  "-s",
229
231
  "-z"
230
232
  ], root, boundary);
231
233
  if (code !== 0) throw new Error(`git ls-files failed (exit ${code}): ${stderr.trim()}`);
232
- return stdout.split("\0").filter((entry) => entry.startsWith("160000 ")).map((entry) => entry.slice(entry.indexOf(" ") + 1));
234
+ const links = stdout.split("\0").filter((entry) => entry.startsWith("160000 ")).map((entry) => entry.slice(entry.indexOf(" ") + 1));
235
+ const found = [...links];
236
+ for (const link of links) {
237
+ const below = rels.filter((rel) => key(rel).startsWith(`${key(link)}/`)).map((rel) => rel.slice(link.length + 1));
238
+ if (below.length === 0) continue;
239
+ for (const nested of await gitlinksOf(join(root, link), boundary, below)) found.push(`${link}/${nested}`);
240
+ }
241
+ return found;
233
242
  }
234
243
  /** The paths of `rels` that `git check-ignore` run in `owner` reports as ignored. */
235
244
  async function ignoredIn(owner, rels, boundary) {
@@ -250,7 +259,7 @@ async function dropGitIgnored(files, cwd, boundary, warn) {
250
259
  if (repo.kind === "none") return files;
251
260
  const rels = await relativeToRoot(files, cwd, repo.root);
252
261
  if (!rels.some((rel) => rel !== void 0)) return files;
253
- const gitlinks = (await gitlinksOf(repo.root, boundary)).map(key);
262
+ const gitlinks = (await gitlinksOf(repo.root, boundary, rels.filter((rel) => rel !== void 0))).map(key);
254
263
  const groups = /* @__PURE__ */ new Map();
255
264
  rels.forEach((rel, index) => {
256
265
  if (rel === void 0) return;
@@ -36,9 +36,9 @@ const ATTRIBUTE_SELECTOR = /\[\s*([A-Za-z_][\w-]*)\s*(?:([~|^$*]?=)\s*("[^"]*"|'
36
36
  */
37
37
  const PARTIAL_ATTRIBUTE_SELECTOR = /\[\s*([A-Za-z_][\w-]*)(?=\s*[~|^$*]?=|\s*$)/dy;
38
38
  /** Tailwind presence form of the two state attributes: data-[selected]:, data-[disabled]/name:. */
39
- const TAILWIND_PRESENCE = /(?<!\w)data-\[(selected|disabled)\](?=[:/])/g;
39
+ const TAILWIND_PRESENCE = /(?<!\w)data-\[((?i:selected|disabled))\](?=[:/])/g;
40
40
  /** Tailwind value variants of the two state attributes: data-[selected=true]:, data-[disabled='false']/name:. */
41
- const TAILWIND_STATE = /(?<!\w)data-\[(selected|disabled)=(?:(["'])(true|false)\2|(true|false))\](?=[:/])/g;
41
+ const TAILWIND_STATE = /(?<!\w)data-\[((?i:selected|disabled))=(?:(["'])(true|false)\2|(true|false))\](?=[:/])/g;
42
42
  /** Whitespace that only pads a closing parenthesis, a comma or a combinator inside parentheses. */
43
43
  const ARGUMENT_PADDING = /\s*[),>+~]/y;
44
44
  /** Tailwind's `*:` and `**:` variants, which move a class to the element's children or descendants. */
@@ -63,6 +63,7 @@ function unquote(value) {
63
63
  * The text is cut into compounds (runs of simple selectors with no combinator, comma, brace or
64
64
  * whitespace at the top level). Inside any compound, a bracketed attribute whose name is in
65
65
  * ATTR_MAP gets its name replaced, operator and value kept; a name in UNMAPPED_ATTRS is reported.
66
+ * Attribute names match case-insensitively, as they do in an HTML document: [CMDK-ITEM] is [cmdk-item].
66
67
  * A compound that holds a cmdk attribute also has its data-selected / data-disabled value
67
68
  * attributes rewritten: ="true" to the presence attribute, ="false" to :not(presence); any other
68
69
  * form is reported. In a stylesheet, a compound starting with `&` counts as holding a cmdk
@@ -97,9 +98,9 @@ function selectorEdits(text, css, report, tailwind) {
97
98
  let brackets = 0;
98
99
  if (!css) {
99
100
  const whole = text.trim();
100
- const reason = UNMAPPED_ATTRS.get(whole);
101
+ const reason = UNMAPPED_ATTRS.get(whole.toLowerCase());
101
102
  if (reason !== void 0) report(text.indexOf(whole), `${whole} has no modern-cmdk equivalent: ${reason}; left unchanged`);
102
- const mapped = ATTR_MAP.get(whole);
103
+ const mapped = ATTR_MAP.get(whole.toLowerCase());
103
104
  if (mapped !== void 0) report(text.indexOf(whole), `${whole} is a cmdk attribute name outside a selector; modern-cmdk renders ${mapped}; left unchanged`);
104
105
  }
105
106
  const endCompound = (isArgument = false) => {
@@ -121,7 +122,7 @@ function selectorEdits(text, css, report, tailwind) {
121
122
  }
122
123
  const reason = UNMAPPED_ATTRS.get(a.name);
123
124
  if (reason !== void 0) {
124
- report(a.start, `${a.name} has no modern-cmdk equivalent: ${reason}; left unchanged`);
125
+ report(a.start, `${text.slice(a.nameStart, a.nameEnd)} has no modern-cmdk equivalent: ${reason}; left unchanged`);
125
126
  continue;
126
127
  }
127
128
  const presence = STATE_ATTRS.get(a.name);
@@ -237,7 +238,7 @@ function selectorEdits(text, css, report, tailwind) {
237
238
  attrs.push({
238
239
  start: i,
239
240
  end: ATTRIBUTE_SELECTOR.lastIndex,
240
- name: m[1],
241
+ name: m[1].toLowerCase(),
241
242
  nameStart: nameSpan[0],
242
243
  nameEnd: nameSpan[1],
243
244
  operator: m[2],
@@ -254,7 +255,7 @@ function selectorEdits(text, css, report, tailwind) {
254
255
  attrs.push({
255
256
  start: i,
256
257
  end: PARTIAL_ATTRIBUTE_SELECTOR.lastIndex,
257
- name: partial[1],
258
+ name: partial[1].toLowerCase(),
258
259
  nameStart: partialName[0],
259
260
  nameEnd: partialName[1],
260
261
  operator: void 0,
@@ -325,16 +326,40 @@ function selectorEdits(text, css, report, tailwind) {
325
326
  function rewriteTailwind(text, report) {
326
327
  return applyEdits(text, tailwindEdits(text, report));
327
328
  }
329
+ /**
330
+ * Reports a data variant that tests the state of another element and returns true; false for a
331
+ * variant on the element itself. Those are the `group-`, `peer-` and `in-` variants, their
332
+ * `-has-` forms and `not-` forms: they style an element by the state of an ancestor or a sibling,
333
+ * which need not be a cmdk element (a cmdk item has no cmdk ancestor with a selected state), so
334
+ * rewriting them to data-active would be a guess. A bare `has-` tests the descendants, which are
335
+ * cmdk items in the common case, and stays rewritable.
336
+ */
337
+ function reportedAsOther(text, m, report) {
338
+ const start = /(?<![\w-])(?:not-)?(?:(?:group|peer)(?:-has)?|in)-$/.exec(text.slice(0, m.index))?.index;
339
+ if (start === void 0) return false;
340
+ report(start, `${text.slice(start, m.index + m[0].length)}: styles this element by the state of another element (an ancestor or a sibling) that need not be a cmdk element, so it cannot be migrated automatically; left unchanged: check it by hand`);
341
+ return true;
342
+ }
343
+ /**
344
+ * Reports the other-element data variants of a class list on an element that is not cmdk-bound:
345
+ * nothing there is rewritten, but a `group-data-[selected=true]:` under a cmdk item stops working
346
+ * once the item renders data-active.
347
+ */
348
+ function reportOtherElements(text, report) {
349
+ for (const pattern of [TAILWIND_PRESENCE, TAILWIND_STATE]) for (const m of text.matchAll(pattern)) reportedAsOther(text, m, report);
350
+ }
328
351
  /** The edits rewriteTailwind applies, as ranges of `text`. */
329
352
  function tailwindEdits(text, report) {
330
353
  const edits = [];
331
354
  for (const m of text.matchAll(TAILWIND_PRESENCE)) {
332
- const name = `data-${m[1]}`;
355
+ if (reportedAsOther(text, m, report)) continue;
356
+ const name = `data-${m[1].toLowerCase()}`;
333
357
  const presence = STATE_ATTRS.get(name);
334
358
  report(m.index, `${m[0]}: on a cmdk element cannot be migrated automatically: cmdk rendered ${name} with a "true" or "false" value, modern-cmdk sets ${presence} only while the state holds; left unchanged`);
335
359
  }
336
360
  for (const m of text.matchAll(TAILWIND_STATE)) {
337
- const presence = STATE_ATTRS.get(`data-${m[1]}`);
361
+ if (reportedAsOther(text, m, report)) continue;
362
+ const presence = STATE_ATTRS.get(`data-${m[1].toLowerCase()}`);
338
363
  if ((m[3] ?? m[4]) === "true") edits.push({
339
364
  start: m.index,
340
365
  end: m.index + m[0].length,
@@ -444,7 +469,7 @@ function applyCookedEdits(inner, decoded, edits) {
444
469
  * literals, and Tailwind state variants on cmdk-bound elements.
445
470
  */
446
471
  function transform(fileInfo, api) {
447
- if (!fileInfo.source.includes("cmdk")) return fileInfo.source;
472
+ if (!/cmdk/i.test(fileInfo.source)) return fileInfo.source;
448
473
  const j = api.jscodeshift;
449
474
  const root = j(fileInfo.source);
450
475
  const sites = [];
@@ -471,19 +496,20 @@ function transform(fileInfo, api) {
471
496
  return current?.type === "JSXIdentifier" && bound.has(current.name);
472
497
  };
473
498
  const tailwind = /* @__PURE__ */ new WeakSet();
499
+ const unbound = /* @__PURE__ */ new WeakSet();
474
500
  root.find(j.JSXAttribute).forEach((path) => {
475
501
  const attrName = path.node.name;
476
502
  if (attrName.type !== "JSXIdentifier" || !CLASS_ATTRS.has(attrName.name)) return;
477
503
  const element = path.parent?.node;
478
- if (!isBound(element?.name)) return;
479
- j(path).find(j.Literal).forEach((p) => void tailwind.add(p.node));
480
- j(path).find(j.TemplateLiteral).forEach((p) => void tailwind.add(p.node));
504
+ const into = isBound(element?.name) ? tailwind : unbound;
505
+ j(path).find(j.Literal).forEach((p) => void into.add(p.node));
506
+ j(path).find(j.TemplateLiteral).forEach((p) => void into.add(p.node));
481
507
  });
482
508
  root.find(j.JSXAttribute).forEach((path) => {
483
509
  const attrName = path.node.name;
484
510
  if (attrName.type !== "JSXIdentifier") return;
485
- const target = ATTR_MAP.get(attrName.name);
486
- const reason = UNMAPPED_ATTRS.get(attrName.name);
511
+ const target = ATTR_MAP.get(attrName.name.toLowerCase());
512
+ const reason = UNMAPPED_ATTRS.get(attrName.name.toLowerCase());
487
513
  if (target === void 0 && reason === void 0) return;
488
514
  const node = path.node;
489
515
  sites.push({
@@ -503,12 +529,14 @@ function transform(fileInfo, api) {
503
529
  const raw = rawOf(node);
504
530
  const inJsxAttribute = path.parent?.node?.type === "JSXAttribute";
505
531
  const useTailwind = tailwind.has(path.node);
532
+ const reportOther = unbound.has(path.node);
506
533
  sites.push({
507
534
  pos: startOf(node),
508
535
  run: () => {
509
536
  const quote = raw?.[0];
510
537
  if (!(quote === "\"" || quote === "'")) {
511
538
  const report = reportAt(original, node.loc?.start?.line);
539
+ if (reportOther) reportOtherElements(original, report);
512
540
  let rewritten = rewriteSelectors(original, false, report, useTailwind);
513
541
  if (useTailwind) rewritten = rewriteTailwind(rewritten, report);
514
542
  if (rewritten === original) return;
@@ -523,6 +551,7 @@ function transform(fileInfo, api) {
523
551
  rawEnd: Array.from({ length: inner.length }, (_, k) => k + 1)
524
552
  } : decodeRaw(inner);
525
553
  const report = reportAt(decoded.cooked, node.loc?.start?.line);
554
+ if (reportOther) reportOtherElements(decoded.cooked, report);
526
555
  const edits = selectorEdits(decoded.cooked, false, report, useTailwind);
527
556
  if (useTailwind) edits.push(...tailwindEdits(decoded.cooked, report));
528
557
  if (edits.length === 0) return;
@@ -537,6 +566,7 @@ function transform(fileInfo, api) {
537
566
  root.find(j.TemplateLiteral).forEach((path) => {
538
567
  const literal = path.node;
539
568
  const useTailwind = tailwind.has(path.node);
569
+ const reportOther = unbound.has(path.node);
540
570
  sites.push({
541
571
  pos: startOf(literal),
542
572
  run: () => {
@@ -557,6 +587,7 @@ function transform(fileInfo, api) {
557
587
  }
558
588
  api.report(line + message.replaceAll(PLACEHOLDER, PLACEHOLDER_TEXT));
559
589
  };
590
+ if (reportOther) reportOtherElements(original, report);
560
591
  let rewritten = rewriteSelectors(original, false, report, useTailwind);
561
592
  if (useTailwind) rewritten = rewriteTailwind(rewritten, report);
562
593
  if (rewritten === original) return;
@@ -2,14 +2,23 @@
2
2
  interface SearchResult {
3
3
  readonly id: ItemId;
4
4
  readonly score: number;
5
- /** Character ranges that matched (for highlighting) — half-open [start, end) tuples, end exclusive */
5
+ /**
6
+ * Character ranges that matched (for highlighting) — half-open [start, end) tuples, end exclusive. The
7
+ * ranges index the original `item.value`, not its lowercased copy. A match on keywords only reports
8
+ * `matches: []`: there is nothing in the value to highlight.
9
+ */
6
10
  readonly matches: ReadonlyArray<readonly [start: number, end: number]>;
7
11
  }
8
12
  /** A scoring function that evaluates a single item against a query */
9
13
  type ScorerFn = (query: string, item: CommandItem) => SearchResult | null;
10
14
  /** Search engine interface — pluggable into the state machine */
11
15
  interface SearchEngine extends Disposable {
12
- /** Index items for fast lookup (call on registration) */
16
+ /**
17
+ * Index items for fast lookup. The machine calls it on registration (the initial items, ITEMS_LOADED,
18
+ * REGISTER_ITEM) and again on UPDATE_ITEM with the updated item, so an id that is already indexed must
19
+ * be replaced by the new item, not appended to the index. Indexing is incremental: ids not in `items`
20
+ * stay indexed.
21
+ */
13
22
  index(items: readonly CommandItem[]): void;
14
23
  /** Search items by query, return ordered results — uses Iterator pipeline */
15
24
  search(query: string, items: readonly CommandItem[]): IteratorObject<SearchResult>;
@@ -42,6 +51,12 @@ declare const __groupIdBrand: unique symbol;
42
51
  type GroupId = string & {
43
52
  readonly [__groupIdBrand]: never;
44
53
  };
54
+ /**
55
+ * The `groupedIds` key of items that have no group. The value is an observable key of
56
+ * `CommandState.groupedIds` and stays `'__ungrouped'`. A group registered under this id shares the
57
+ * bucket: its items are listed with the ungrouped ones and the bucket takes its priority.
58
+ */
59
+ export declare const UNGROUPED_GROUP_ID: GroupId;
45
60
  /** Create a branded {@link ItemId} from a plain string. Ensures well-formed Unicode. */
46
61
  export declare function itemId(id: string): ItemId;
47
62
  /** Create a branded {@link GroupId} from a plain string. Ensures well-formed Unicode. */
@@ -61,7 +76,7 @@ interface CommandItem {
61
76
  readonly groupId?: GroupId | undefined;
62
77
  /** Keyboard shortcut string (e.g., "Ctrl+C", "Mod+K") */
63
78
  readonly shortcut?: string | undefined;
64
- /** Whether this item is disabled (excluded from filtering and selection) */
79
+ /** Whether this item is disabled: excluded from navigation and selection; rendered unless `disabledItems: 'hide'` */
65
80
  readonly disabled?: boolean | undefined;
66
81
  /** Callback fired when this item is selected */
67
82
  readonly onSelect?: (() => void) | undefined;
@@ -92,7 +107,12 @@ interface CommandState {
92
107
  readonly activeId: ItemId | null;
93
108
  /** Ordered list of item IDs that pass the current filter */
94
109
  readonly filteredIds: readonly ItemId[];
95
- /** Map of group IDs to their visible item IDs */
110
+ /**
111
+ * Map of group IDs to their visible item IDs. The keys are in group priority order (lower first; ungrouped
112
+ * items and unregistered group ids count as 0, in first-appearance order), while `filteredIds` and
113
+ * `NAVIGATE` keep the search order: a renderer that draws by `groupedIds` shows the groups in this order,
114
+ * so arrow-key order and display order can differ when group priorities differ.
115
+ */
96
116
  readonly groupedIds: ReadonlyMap<GroupId, readonly ItemId[]>;
97
117
  /** Count of visible items (shortcut for `filteredIds.length`) */
98
118
  readonly filteredCount: number;
@@ -141,6 +161,9 @@ type CommandEvent = {
141
161
  } | {
142
162
  readonly type: "REGISTER_ITEM";
143
163
  readonly item: CommandItem;
164
+ } | {
165
+ readonly type: "UPDATE_ITEM";
166
+ readonly item: CommandItem;
144
167
  } | {
145
168
  readonly type: "UNREGISTER_ITEM";
146
169
  readonly id: ItemId;
@@ -178,6 +201,7 @@ interface FrecencyDecayConfig {
178
201
  interface FrecencyOptions {
179
202
  /** Turns frecency tracking and re-ranking on. Default false. */
180
203
  readonly enabled?: boolean | undefined;
204
+ /** Where the history persists; a storage you pass is yours to dispose, the engine disposes only the in-memory one it creates when you pass none. */
181
205
  readonly storage?: FrecencyStorage | undefined;
182
206
  readonly namespace?: string | undefined;
183
207
  readonly decayConfig?: FrecencyDecayConfig | undefined;
@@ -190,7 +214,10 @@ interface CommandMachineOptions {
190
214
  readonly items?: readonly CommandItem[] | undefined;
191
215
  /** Pre-registered groups for item categorization */
192
216
  readonly groups?: readonly CommandGroup[] | undefined;
193
- /** Custom filter function, or `false` to disable filtering entirely */
217
+ /**
218
+ * Custom filter function, or `false` to disable filtering entirely. It returns a score: above 0 keeps the item
219
+ * (higher ranks first); 0, a negative score or `false` excludes it.
220
+ */
194
221
  readonly filter?: ((item: CommandItem, query: string) => number | false) | false | undefined;
195
222
  /** Accessible label for the command palette */
196
223
  readonly label?: string | undefined;
@@ -210,15 +237,31 @@ interface CommandMachineOptions {
210
237
  readonly onSearchChange?: ((query: string) => void) | undefined;
211
238
  /** Whether keyboard navigation loops from last to first item (default: true) */
212
239
  readonly loop?: boolean | undefined;
240
+ /**
241
+ * How disabled items are listed: `'show'` keeps them in `filteredIds` under every query (rendered with
242
+ * `aria-disabled`, never active, skipped by navigation and activation, not selectable); `'hide'` drops them
243
+ * from `filteredIds` in every branch (default: `'show'`)
244
+ */
245
+ readonly disabledItems?: "hide" | "show" | undefined;
246
+ /**
247
+ * What an item's keyboard shortcut does. `'direct'` (the default through 1.x) calls the item's `onSelect`
248
+ * and binds only items that have one; `'select'` dispatches `ITEM_SELECT` for the item, so the disabled guard,
249
+ * frecency, the select event and `options.onSelect` apply, and binds every enabled item with a shortcut. 2.0 flips
250
+ * the default to `'select'`. Under both, a disabled item is not bound: its shortcut does nothing and leaves the key to the page.
251
+ */
252
+ readonly shortcutAction?: "select" | "direct" | undefined;
213
253
  /** Accepted but not read yet: Command.List uses fixed thresholds (on above 100 matches, off at 50 or fewer) (default: 100) */
214
254
  readonly virtualizeThreshold?: number | undefined;
215
255
  }
216
- /** Default machine options */
256
+ /**
257
+ * Default machine options. The machine reads `loop` and `open` from here when its options leave them out
258
+ * (the other options are opt-ins whose absence is their default); `virtualizeThreshold` is not read yet.
259
+ */
217
260
  export declare const DEFAULT_MACHINE_OPTIONS: Partial<CommandMachineOptions>;
218
261
  /** Initial state factory */
219
262
  export declare function createInitialState(options?: CommandMachineOptions): CommandState;
220
263
  /**
221
- * IndexedDB-backed frecency storage using idb-keyval.
264
+ * IndexedDB-backed frecency storage using idb-keyval's get, set and del.
222
265
  *
223
266
  * Persists frecency data across browser sessions. Implements both
224
267
  * `Disposable` and `AsyncDisposable` for use with `using` and `await using`.
@@ -260,15 +303,27 @@ export declare class IdbFrecencyStorage implements FrecencyStorage, AsyncDisposa
260
303
  export declare function computeFrecencyBonus(history: FrecencyRecord, now?: number, config?: FrecencyDecayConfig): number;
261
304
  /**
262
305
  * Stateful frecency engine that tracks item selection history and provides
263
- * ranking boosts for search results. Supports pluggable storage backends
264
- * and automatic flush on dispose. Uses Date.now() for timestamps.
306
+ * ranking boosts for search results. Supports pluggable storage backends.
307
+ * Dirty data is saved 1000 ms after the last `recordSelection`, when the page
308
+ * becomes hidden or is hidden for good (`visibilitychange`, `pagehide`), on
309
+ * `flush()` and on dispose; a save waits for a `load()` in flight, so it writes the merged
310
+ * history. Dispose disposes the storage only when the engine created it (no `storage`
311
+ * option): a storage the caller passes is the caller's to dispose. Uses Date.now() for
312
+ * timestamps.
265
313
  */
266
314
  export declare class FrecencyEngine implements Disposable, AsyncDisposable {
267
315
  #private;
268
316
  constructor(options: FrecencyOptions);
269
- /** Load frecency data from storage */
317
+ /**
318
+ * Merge the data in storage into the engine: frequencies add, `lastUsed` takes the later
319
+ * time, and selections recorded before the load resolves are kept. Call it once per
320
+ * engine; a second load would add the persisted frequencies again. A save started while
321
+ * the load is in flight waits for it, so the history it writes is the merged one, and a load
322
+ * that never settles holds every save, and `await using`, forever. A failed load rejects here
323
+ * and turns persistence off for this engine: saving would replace a history it could not read.
324
+ */
270
325
  load(): Promise<void>;
271
- /** Record a selection event for an item */
326
+ /** Record a selection event for an item and schedule a save 1000 ms after the last one */
272
327
  recordSelection(itemId: ItemId): void;
273
328
  /** Get frecency bonus for an item */
274
329
  getBonus(itemId: ItemId, now?: number): number;
@@ -278,12 +333,20 @@ export declare class FrecencyEngine implements Disposable, AsyncDisposable {
278
333
  getConfig(): FrecencyDecayConfig;
279
334
  /** Update decay configuration at runtime */
280
335
  setConfig(config: FrecencyDecayConfig): void;
281
- /** Flush dirty data to storage */
336
+ /**
337
+ * Flush dirty data to storage and wait for every save in flight; saves run one after
338
+ * another, each after the load in flight (one that never settles holds them all), none after a
339
+ * rejected load, and a rejected save leaves the data dirty and rethrows to the flush that started it
340
+ */
282
341
  flush(): Promise<void>;
283
342
  /** Get the raw frecency data */
284
343
  getData(): FrecencyData;
285
- /** Async dispose — flushes dirty data before cleanup. Use with `await using`. */
344
+ /**
345
+ * Async dispose — flushes dirty data, then disposes the storage the engine created, even
346
+ * when the save rejects (the rejection propagates). Use with `await using`.
347
+ */
286
348
  [Symbol.asyncDispose](): Promise<void>;
349
+ /** Sync dispose — starts the flush without awaiting it and disposes the storage the engine created once it settles */
287
350
  [Symbol.dispose](): void;
288
351
  }
289
352
  /**
@@ -345,7 +408,9 @@ interface ShortcutBinding {
345
408
  /**
346
409
  * Manages keyboard shortcut bindings with global keydown listening.
347
410
  * Supports `using` for automatic cleanup of both individual bindings and the
348
- * global document listener. Detects conflicts with the local mapGroupBy helper (core/utils/group-by.ts).
411
+ * global document listener. Every normalized shortcut keeps a stack of bindings, one per item: the later
412
+ * binding wins the keydown, disposing it restores the earlier one, and `getConflicts()` reports the keys with
413
+ * two or more bindings (through `detectConflicts`, which groups with the local mapGroupBy helper).
349
414
  */
350
415
  export declare class KeyboardShortcutRegistry implements Disposable {
351
416
  #private;
@@ -354,15 +419,18 @@ export declare class KeyboardShortcutRegistry implements Disposable {
354
419
  });
355
420
  /** Register a shortcut binding — returns Disposable for `using` auto-deregister */
356
421
  register(shortcutStr: string, itemId: ItemId, handler: () => void): Disposable;
357
- /** Unregister a shortcut by its string representation */
422
+ /** Unregister a shortcut by its string representation — every binding of that key */
358
423
  unregister(shortcutStr: string): void;
359
- /** Unregister all shortcuts for a given item — Map spec allows deletion during iteration */
360
- unregisterByItem(itemId: ItemId): void;
361
- /** Check for conflicting shortcuts */
424
+ /**
425
+ * Unregister all shortcuts for a given item — only the stacks of the keys it is bound under are touched.
426
+ * `keep` names one shortcut whose binding stays (a re-bind then replaces it in place, see register).
427
+ */
428
+ unregisterByItem(itemId: ItemId, keep?: string): void;
429
+ /** Keys bound by two or more items (normalized shortcut -> the parsed shortcuts, earliest first) */
362
430
  getConflicts(): ReadonlyMap<string, readonly ParsedShortcut[]>;
363
431
  /** Enable or disable global shortcut listening */
364
432
  setEnabled(enabled: boolean): void;
365
- /** Get all registered bindings */
433
+ /** The winning binding of every key (a snapshot: the later of the bindings that share a key) */
366
434
  getBindings(): ReadonlyMap<string, ShortcutBinding>;
367
435
  [Symbol.dispose](): void;
368
436
  }
@@ -377,7 +445,7 @@ export declare class CommandRegistry implements Disposable {
377
445
  registerItem(item: CommandItem): Disposable;
378
446
  /** Register multiple items at once — single Disposable for batch cleanup. for...of for hot path. */
379
447
  registerItems(items: readonly CommandItem[]): Disposable;
380
- /** Unregister a single item — O(1) Map/Set delete, indexOf+toSpliced for order */
448
+ /** Unregister a single item — O(1) Map/Set delete */
381
449
  unregisterItem(id: ItemId): void;
382
450
  /** Bulk unregister — for...of for hot path */
383
451
  unregisterItems(ids: ReadonlySet<ItemId>): void;
@@ -437,20 +505,31 @@ interface CommandMachine extends Disposable {
437
505
  }
438
506
  /**
439
507
  * Creates a headless command palette state machine with search, frecency,
440
- * and keyboard shortcut support. Implements Disposable for `using` cleanup.
508
+ * and keyboard shortcut support. Implements Disposable and AsyncDisposable for
509
+ * `using` and `await using` cleanup (the latter awaits the pending frecency save).
441
510
  *
442
511
  * @example
443
512
  * ```ts
444
- * using machine = createCommandMachine({ items, loop: true });
445
- * machine.subscribe(() => console.log(machine.getState()));
513
+ * const machine = createCommandMachine({ items, loop: true });
514
+ * const unsubscribe = machine.subscribe(() => console.log(machine.getState()));
515
+ * machine.send({ type: 'SEARCH_CHANGE', query: 'app' }); // handled in the next frame
516
+ *
517
+ * // on teardown
518
+ * unsubscribe();
519
+ * machine[Symbol.dispose]();
446
520
  * ```
447
521
  */
448
- export declare function createCommandMachine(options?: CommandMachineOptions): CommandMachine;
522
+ export declare function createCommandMachine(options?: CommandMachineOptions): CommandMachine & AsyncDisposable;
449
523
  /**
450
524
  * Public API: scores a command item against a search query.
451
525
  * Multi-strategy: exact → prefix → substring → word-boundary → fuzzy.
452
526
  * Returns null if no match. For batch use, prefer the search engine
453
527
  * which pre-caches lowercase and normalizes query once.
528
+ *
529
+ * `matches` are half-open ranges into `item.value` itself (UTF-16 indices of the original
530
+ * string, even where lowercasing changes its length, as 'İ' → 'i̇' does). A keyword has no
531
+ * position in the label: when a keyword outscores the value, `matches` keeps the value's own
532
+ * ranges if the value matched and is `[]` otherwise.
454
533
  */
455
534
  export declare function scoreItem(query: string, item: CommandItem): SearchResult | null;
456
535
  /**
@@ -468,9 +547,10 @@ interface SearchEngineOptions {
468
547
  readonly scorer?: ScorerFn | undefined;
469
548
  }
470
549
  /**
471
- * Creates a search engine with pluggable scorer and incremental filtering.
472
- * When the query extends the previous one, only prior matches are re-scored.
473
- * Pre-caches lowercase at index time — zero toLowerCase() calls during search.
550
+ * Creates a search engine with pluggable scorer.
551
+ * With the default scorer, a query that extends the previous one (compared lowercased) re-scores
552
+ * only the prior matches; a custom scorer is called for every item on every non-empty query.
553
+ * Pre-caches lowercase at index time; indexed items are not re-lowered during search.
474
554
  * Uses Iterator Helpers (ES2025) for result pipelines.
475
555
  */
476
556
  export declare function createSearchEngine(options?: SearchEngineOptions): SearchEngine;
@@ -504,18 +584,19 @@ interface TelemetryMiddleware {
504
584
  * ```
505
585
  */
506
586
  export declare function createTelemetryMiddleware(hooks: CommandTelemetryHooks): TelemetryMiddleware;
507
- type EventMap = {
508
- [key: string]: unknown;
509
- };
510
587
  /**
511
588
  * Type-safe event emitter with strong references and Disposable subscriptions.
589
+ * `T` maps event names to payload types; an interface or a type alias both work (`object`, not an index signature, which an interface lacks).
512
590
  * Returns Disposable from `on()` for automatic unsubscription via `using`.
513
591
  */
514
- export declare class TypedEmitter<T extends EventMap> implements Disposable {
592
+ export declare class TypedEmitter<T extends object> implements Disposable {
515
593
  #private;
516
594
  /** Subscribe to an event. Returns a Disposable that removes the listener when disposed. */
517
595
  on<K extends keyof T>(event: K, listener: (data: T[K]) => void): Disposable;
518
- /** Emit an event to all listeners. Snapshots the set to prevent live-iteration issues. */
596
+ /**
597
+ * Emit an event to all listeners. Snapshots the set to prevent live-iteration issues.
598
+ * A throwing listener does not stop the later ones: its error is rethrown from a microtask, as the scheduler does.
599
+ */
519
600
  emit<K extends keyof T>(event: K, data: T[K]): void;
520
601
  /** Check if any listeners exist for an event */
521
602
  has<K extends keyof T>(event: K): boolean;
@@ -527,7 +608,8 @@ export declare class TypedEmitter<T extends EventMap> implements Disposable {
527
608
  }
528
609
  /**
529
610
  * Batched update scheduler that coalesces state updates into a single tick.
530
- * Uses requestAnimationFrame in browsers, queueMicrotask in Node.js/tests.
611
+ * Uses requestAnimationFrame in browsers, queueMicrotask where requestAnimationFrame is absent (Node.js, SSR).
612
+ * flush() synchronously applies the updates pending when it is called, in the order scheduled; one scheduled during the flush runs on the next frame or microtask.
531
613
  */
532
614
  interface Scheduler extends Disposable {
533
615
  schedule(update: () => void): void;
@@ -536,7 +618,7 @@ interface Scheduler extends Disposable {
536
618
  }
537
619
  /**
538
620
  * Creates a batched update scheduler. Coalesces rapid state updates into a single
539
- * execution pass using rAF (browser) or queueMicrotask (Node.js).
621
+ * execution pass using rAF (browser) or queueMicrotask (where rAF is absent: Node.js, SSR).
540
622
  */
541
623
  export declare function createScheduler(): Scheduler;
542
624
  export type { CommandEvent, CommandGroup, CommandItem, CommandMachine, CommandMachineOptions, CommandState, CommandTelemetryHooks, FrecencyData, FrecencyDecayConfig, FrecencyOptions, FrecencyRecord, FrecencyStorage, GroupId, ItemId, ParsedShortcut, Scheduler, ScorerFn, SearchEngine, SearchResult, TelemetryMiddleware };