modern-cmdk 1.4.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.
@@ -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;
@@ -134,9 +154,16 @@ type CommandEvent = {
134
154
  } | {
135
155
  readonly type: "ITEMS_LOADED";
136
156
  readonly items: readonly CommandItem[];
157
+ } | {
158
+ readonly type: "LOADING";
159
+ readonly source: string;
160
+ readonly pending: boolean;
137
161
  } | {
138
162
  readonly type: "REGISTER_ITEM";
139
163
  readonly item: CommandItem;
164
+ } | {
165
+ readonly type: "UPDATE_ITEM";
166
+ readonly item: CommandItem;
140
167
  } | {
141
168
  readonly type: "UNREGISTER_ITEM";
142
169
  readonly id: ItemId;
@@ -172,7 +199,9 @@ interface FrecencyDecayConfig {
172
199
  }
173
200
  /** Frecency configuration options */
174
201
  interface FrecencyOptions {
175
- readonly enabled: boolean;
202
+ /** Turns frecency tracking and re-ranking on. Default false. */
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. */
176
205
  readonly storage?: FrecencyStorage | undefined;
177
206
  readonly namespace?: string | undefined;
178
207
  readonly decayConfig?: FrecencyDecayConfig | undefined;
@@ -185,7 +214,10 @@ interface CommandMachineOptions {
185
214
  readonly items?: readonly CommandItem[] | undefined;
186
215
  /** Pre-registered groups for item categorization */
187
216
  readonly groups?: readonly CommandGroup[] | undefined;
188
- /** 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
+ */
189
221
  readonly filter?: ((item: CommandItem, query: string) => number | false) | false | undefined;
190
222
  /** Accessible label for the command palette */
191
223
  readonly label?: string | undefined;
@@ -205,15 +237,31 @@ interface CommandMachineOptions {
205
237
  readonly onSearchChange?: ((query: string) => void) | undefined;
206
238
  /** Whether keyboard navigation loops from last to first item (default: true) */
207
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;
208
253
  /** Accepted but not read yet: Command.List uses fixed thresholds (on above 100 matches, off at 50 or fewer) (default: 100) */
209
254
  readonly virtualizeThreshold?: number | undefined;
210
255
  }
211
- /** 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
+ */
212
260
  export declare const DEFAULT_MACHINE_OPTIONS: Partial<CommandMachineOptions>;
213
261
  /** Initial state factory */
214
262
  export declare function createInitialState(options?: CommandMachineOptions): CommandState;
215
263
  /**
216
- * IndexedDB-backed frecency storage using idb-keyval.
264
+ * IndexedDB-backed frecency storage using idb-keyval's get, set and del.
217
265
  *
218
266
  * Persists frecency data across browser sessions. Implements both
219
267
  * `Disposable` and `AsyncDisposable` for use with `using` and `await using`.
@@ -255,15 +303,27 @@ export declare class IdbFrecencyStorage implements FrecencyStorage, AsyncDisposa
255
303
  export declare function computeFrecencyBonus(history: FrecencyRecord, now?: number, config?: FrecencyDecayConfig): number;
256
304
  /**
257
305
  * Stateful frecency engine that tracks item selection history and provides
258
- * ranking boosts for search results. Supports pluggable storage backends
259
- * 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.
260
313
  */
261
314
  export declare class FrecencyEngine implements Disposable, AsyncDisposable {
262
315
  #private;
263
316
  constructor(options: FrecencyOptions);
264
- /** 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
+ */
265
325
  load(): Promise<void>;
266
- /** 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 */
267
327
  recordSelection(itemId: ItemId): void;
268
328
  /** Get frecency bonus for an item */
269
329
  getBonus(itemId: ItemId, now?: number): number;
@@ -273,12 +333,20 @@ export declare class FrecencyEngine implements Disposable, AsyncDisposable {
273
333
  getConfig(): FrecencyDecayConfig;
274
334
  /** Update decay configuration at runtime */
275
335
  setConfig(config: FrecencyDecayConfig): void;
276
- /** 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
+ */
277
341
  flush(): Promise<void>;
278
342
  /** Get the raw frecency data */
279
343
  getData(): FrecencyData;
280
- /** 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
+ */
281
348
  [Symbol.asyncDispose](): Promise<void>;
349
+ /** Sync dispose — starts the flush without awaiting it and disposes the storage the engine created once it settles */
282
350
  [Symbol.dispose](): void;
283
351
  }
284
352
  /**
@@ -340,7 +408,9 @@ interface ShortcutBinding {
340
408
  /**
341
409
  * Manages keyboard shortcut bindings with global keydown listening.
342
410
  * Supports `using` for automatic cleanup of both individual bindings and the
343
- * 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).
344
414
  */
345
415
  export declare class KeyboardShortcutRegistry implements Disposable {
346
416
  #private;
@@ -349,15 +419,18 @@ export declare class KeyboardShortcutRegistry implements Disposable {
349
419
  });
350
420
  /** Register a shortcut binding — returns Disposable for `using` auto-deregister */
351
421
  register(shortcutStr: string, itemId: ItemId, handler: () => void): Disposable;
352
- /** Unregister a shortcut by its string representation */
422
+ /** Unregister a shortcut by its string representation — every binding of that key */
353
423
  unregister(shortcutStr: string): void;
354
- /** Unregister all shortcuts for a given item — Map spec allows deletion during iteration */
355
- unregisterByItem(itemId: ItemId): void;
356
- /** 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) */
357
430
  getConflicts(): ReadonlyMap<string, readonly ParsedShortcut[]>;
358
431
  /** Enable or disable global shortcut listening */
359
432
  setEnabled(enabled: boolean): void;
360
- /** Get all registered bindings */
433
+ /** The winning binding of every key (a snapshot: the later of the bindings that share a key) */
361
434
  getBindings(): ReadonlyMap<string, ShortcutBinding>;
362
435
  [Symbol.dispose](): void;
363
436
  }
@@ -372,7 +445,7 @@ export declare class CommandRegistry implements Disposable {
372
445
  registerItem(item: CommandItem): Disposable;
373
446
  /** Register multiple items at once — single Disposable for batch cleanup. for...of for hot path. */
374
447
  registerItems(items: readonly CommandItem[]): Disposable;
375
- /** Unregister a single item — O(1) Map/Set delete, indexOf+toSpliced for order */
448
+ /** Unregister a single item — O(1) Map/Set delete */
376
449
  unregisterItem(id: ItemId): void;
377
450
  /** Bulk unregister — for...of for hot path */
378
451
  unregisterItems(ids: ReadonlySet<ItemId>): void;
@@ -432,20 +505,31 @@ interface CommandMachine extends Disposable {
432
505
  }
433
506
  /**
434
507
  * Creates a headless command palette state machine with search, frecency,
435
- * 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).
436
510
  *
437
511
  * @example
438
512
  * ```ts
439
- * using machine = createCommandMachine({ items, loop: true });
440
- * 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]();
441
520
  * ```
442
521
  */
443
- export declare function createCommandMachine(options?: CommandMachineOptions): CommandMachine;
522
+ export declare function createCommandMachine(options?: CommandMachineOptions): CommandMachine & AsyncDisposable;
444
523
  /**
445
524
  * Public API: scores a command item against a search query.
446
525
  * Multi-strategy: exact → prefix → substring → word-boundary → fuzzy.
447
526
  * Returns null if no match. For batch use, prefer the search engine
448
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.
449
533
  */
450
534
  export declare function scoreItem(query: string, item: CommandItem): SearchResult | null;
451
535
  /**
@@ -463,9 +547,10 @@ interface SearchEngineOptions {
463
547
  readonly scorer?: ScorerFn | undefined;
464
548
  }
465
549
  /**
466
- * Creates a search engine with pluggable scorer and incremental filtering.
467
- * When the query extends the previous one, only prior matches are re-scored.
468
- * 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.
469
554
  * Uses Iterator Helpers (ES2025) for result pipelines.
470
555
  */
471
556
  export declare function createSearchEngine(options?: SearchEngineOptions): SearchEngine;
@@ -499,18 +584,19 @@ interface TelemetryMiddleware {
499
584
  * ```
500
585
  */
501
586
  export declare function createTelemetryMiddleware(hooks: CommandTelemetryHooks): TelemetryMiddleware;
502
- type EventMap = {
503
- [key: string]: unknown;
504
- };
505
587
  /**
506
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).
507
590
  * Returns Disposable from `on()` for automatic unsubscription via `using`.
508
591
  */
509
- export declare class TypedEmitter<T extends EventMap> implements Disposable {
592
+ export declare class TypedEmitter<T extends object> implements Disposable {
510
593
  #private;
511
594
  /** Subscribe to an event. Returns a Disposable that removes the listener when disposed. */
512
595
  on<K extends keyof T>(event: K, listener: (data: T[K]) => void): Disposable;
513
- /** 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
+ */
514
600
  emit<K extends keyof T>(event: K, data: T[K]): void;
515
601
  /** Check if any listeners exist for an event */
516
602
  has<K extends keyof T>(event: K): boolean;
@@ -522,7 +608,8 @@ export declare class TypedEmitter<T extends EventMap> implements Disposable {
522
608
  }
523
609
  /**
524
610
  * Batched update scheduler that coalesces state updates into a single tick.
525
- * 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.
526
613
  */
527
614
  interface Scheduler extends Disposable {
528
615
  schedule(update: () => void): void;
@@ -531,7 +618,7 @@ interface Scheduler extends Disposable {
531
618
  }
532
619
  /**
533
620
  * Creates a batched update scheduler. Coalesces rapid state updates into a single
534
- * execution pass using rAF (browser) or queueMicrotask (Node.js).
621
+ * execution pass using rAF (browser) or queueMicrotask (where rAF is absent: Node.js, SSR).
535
622
  */
536
623
  export declare function createScheduler(): Scheduler;
537
624
  export type { CommandEvent, CommandGroup, CommandItem, CommandMachine, CommandMachineOptions, CommandState, CommandTelemetryHooks, FrecencyData, FrecencyDecayConfig, FrecencyOptions, FrecencyRecord, FrecencyStorage, GroupId, ItemId, ParsedShortcut, Scheduler, ScorerFn, SearchEngine, SearchResult, TelemetryMiddleware };