modern-cmdk 1.3.0 → 1.5.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.
@@ -1,8 +1,9 @@
1
1
  'use client';
2
+ import { a as CommandGroupContext, c as CommandStateContext, i as useCommand, l as useStableContext, n as useCommandSetup, o as CommandListStatusContext, r as createKeydownHandler, s as CommandStableContext, t as CommandDialog, u as useStateContext } from "./chunk-dialog.mjs";
2
3
  import * as React from "react";
3
- import { Component, Suspense, createContext, use, useCallback, useEffect, useId, useLayoutEffect, useMemo, useOptimistic, useRef, useState, useSyncExternalStore, useTransition } from "react";
4
+ import { Component, Suspense, use, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
4
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
- import { Dialog } from "radix-ui";
6
+ import { createCommandMachine, formatShortcut, groupId, itemId, parseShortcut } from "modern-cmdk";
6
7
  import { createPortal } from "react-dom";
7
8
  // src/react/activity.tsx
8
9
  const ActivityComponent = "Activity" in React ? React.Activity : null;
@@ -19,28 +20,6 @@ function CommandActivity({ mode, children }) {
19
20
  if (mode === "hidden") return null;
20
21
  return /* @__PURE__ */ jsx(Fragment, { children });
21
22
  }
22
- // src/react/context.ts
23
- const CommandStableContext = createContext(null);
24
- CommandStableContext.displayName = "CommandStableContext";
25
- const CommandStateContext = createContext(null);
26
- CommandStateContext.displayName = "CommandStateContext";
27
- /** Consume stable context with a descriptive error — replaces 3-line boilerplate in every component */
28
- function useStableContext(component) {
29
- const ctx = use(CommandStableContext);
30
- if (!ctx) throw new Error(`${component} must be used within a <Command> component`);
31
- return ctx;
32
- }
33
- /** Consume state context with a descriptive error — replaces 3-line boilerplate in every component */
34
- function useStateContext(component) {
35
- const ctx = use(CommandStateContext);
36
- if (!ctx) throw new Error(`${component} must be used within a <Command> component`);
37
- return ctx;
38
- }
39
- const CommandGroupContext = createContext(null);
40
- CommandGroupContext.displayName = "CommandGroupContext";
41
- /** Context for the status container rendered outside the listbox — used by Command.Empty */
42
- const CommandListStatusContext = createContext(null);
43
- CommandListStatusContext.displayName = "CommandListStatusContext";
44
23
  // src/react/async-items.tsx
45
24
  /** Inner component that suspends via use() until items resolve */
46
25
  function AsyncItemsInner({ items: itemsPromise, children }) {
@@ -54,14 +33,42 @@ function AsyncItemsInner({ items: itemsPromise, children }) {
54
33
  }, [stable.machine, items]);
55
34
  return /* @__PURE__ */ jsx(Fragment, { children: children(items) });
56
35
  }
57
- /** Suspense-based async item loader that resolves a promise of items using React 19 `use()`. */
36
+ /**
37
+ * Rendered next to the Suspense fallback: while it is mounted its source counts as pending,
38
+ * so `state.loading` (Command.Loading, the list's aria-busy) follows the suspended promise.
39
+ * The fallback unmounts when the items resolve, reject into an error boundary, or this
40
+ * AsyncItems unmounts, and the cleanup then clears the source.
41
+ */
42
+ function PendingSignal() {
43
+ const stable = useStableContext("Command.AsyncItems");
44
+ const source = useId();
45
+ useLayoutEffect(() => {
46
+ stable.machine.send({
47
+ type: "LOADING",
48
+ source,
49
+ pending: true
50
+ });
51
+ return () => {
52
+ stable.machine.send({
53
+ type: "LOADING",
54
+ source,
55
+ pending: false
56
+ });
57
+ };
58
+ }, [stable.machine, source]);
59
+ return null;
60
+ }
61
+ /**
62
+ * Suspense-based async item loader that resolves a promise of items using React 19 `use()`.
63
+ *
64
+ * The `items` promise must be kept stable across renders (held in state, a ref, or `useMemo`):
65
+ * a new promise on every render, such as `items={fetchItems()}`, suspends again on every render.
66
+ */
58
67
  function CommandAsyncItems({ items, fallback, children }) {
59
- const stableRef = useRef(items);
60
- if (stableRef.current !== items) stableRef.current = items;
61
68
  return /* @__PURE__ */ jsx(Suspense, {
62
- fallback,
69
+ fallback: /* @__PURE__ */ jsxs(Fragment, { children: [fallback, /* @__PURE__ */ jsx(PendingSignal, {})] }),
63
70
  children: /* @__PURE__ */ jsx(AsyncItemsInner, {
64
- items: stableRef.current,
71
+ items,
65
72
  children
66
73
  })
67
74
  });
@@ -76,1353 +83,6 @@ function CommandBadge({ ref, children, ...props }) {
76
83
  children
77
84
  });
78
85
  }
79
- // src/core/utils/string-wellformed.ts
80
- /**
81
- * Regex matching lone surrogates (unpaired high or low surrogates).
82
- * A lone high surrogate (U+D800–U+DBFF) not followed by a low surrogate,
83
- * or a lone low surrogate (U+DC00–U+DFFF) not preceded by a high surrogate.
84
- */
85
- const LONE_SURROGATE_RE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
86
- /** Unicode replacement character used to replace lone surrogates. */
87
- const REPLACEMENT_CHAR = "�";
88
- /**
89
- * Ensures a string is well-formed Unicode by replacing lone surrogates
90
- * with the Unicode replacement character (U+FFFD).
91
- * Cross-browser replacement for `str.isWellFormed() ? str : str.toWellFormed()`.
92
- */
93
- function ensureWellFormed(str) {
94
- return str.replace(LONE_SURROGATE_RE, REPLACEMENT_CHAR);
95
- }
96
- // src/core/types.ts
97
- /** Create a branded {@link ItemId} from a plain string. Ensures well-formed Unicode. */
98
- function itemId(id) {
99
- return ensureWellFormed(id);
100
- }
101
- /** Create a branded {@link GroupId} from a plain string. Ensures well-formed Unicode. */
102
- function groupId(id) {
103
- return ensureWellFormed(id);
104
- }
105
- /** Default frecency decay configuration */
106
- const DEFAULT_FRECENCY_DECAY = {
107
- hourWeight: 4,
108
- dayWeight: 2,
109
- weekWeight: 1.5,
110
- monthWeight: 1,
111
- olderWeight: .5
112
- };
113
- /** Initial state factory */
114
- function createInitialState(options) {
115
- return {
116
- search: "",
117
- activeId: null,
118
- filteredIds: [],
119
- groupedIds: /* @__PURE__ */ new Map(),
120
- filteredCount: 0,
121
- loading: false,
122
- page: "root",
123
- pageStack: [],
124
- open: options?.open ?? false,
125
- lastUpdated: Date.now()
126
- };
127
- }
128
- // src/core/frecency/memory-storage.ts
129
- /**
130
- * In-memory implementation of FrecencyStorage using a Map.
131
- * Data does not persist across page reloads. Suitable for ephemeral
132
- * sessions or as the default when no persistent storage is configured.
133
- */
134
- var MemoryFrecencyStorage = class {
135
- #store = /* @__PURE__ */ new Map();
136
- load(namespace) {
137
- return this.#store.get(namespace) ?? { records: /* @__PURE__ */ new Map() };
138
- }
139
- save(namespace, data) {
140
- this.#store.set(namespace, data);
141
- }
142
- /** Serialize all stored data — Iterator Helpers pipeline (ES2025) */
143
- toJSON() {
144
- return Object.fromEntries(this.#store.entries().map(([namespace, data]) => [namespace, { records: data.records.entries().toArray() }]));
145
- }
146
- [Symbol.dispose]() {
147
- this.#store.clear();
148
- }
149
- };
150
- // src/core/frecency/index.ts
151
- /** Shared empty bonus map — avoids allocation when no frecency records exist */
152
- const EMPTY_BONUS_MAP = /* @__PURE__ */ new Map();
153
- /** Bucket boundaries as hour constants — no per-call unit conversion */
154
- const BUCKET_HOURS = {
155
- hour: 1,
156
- day: 24,
157
- week: 168,
158
- month: 720
159
- };
160
- /**
161
- * Computes a frecency bonus for an item based on usage frequency and recency.
162
- * Uses hour/day/week/month bucket boundaries with
163
- * configurable step weights per recency bucket (hour/day/week/month/older).
164
- */
165
- function computeFrecencyBonus(history, now = Date.now(), config = DEFAULT_FRECENCY_DECAY) {
166
- const hours = (now - history.lastUsed) / 36e5;
167
- const { hourWeight = 4, dayWeight = 2, weekWeight = 1.5, monthWeight = 1, olderWeight = .5 } = config;
168
- const recencyWeight = hours < BUCKET_HOURS.hour ? hourWeight : hours < BUCKET_HOURS.day ? dayWeight : hours < BUCKET_HOURS.week ? weekWeight : hours < BUCKET_HOURS.month ? monthWeight : olderWeight;
169
- return history.frequency * recencyWeight;
170
- }
171
- /**
172
- * Stateful frecency engine that tracks item selection history and provides
173
- * ranking boosts for search results. Supports pluggable storage backends
174
- * and automatic flush on dispose. Uses Date.now() for timestamps.
175
- */
176
- var FrecencyEngine = class {
177
- #storage;
178
- #namespace;
179
- #data;
180
- #config;
181
- #dirty = false;
182
- constructor(options) {
183
- this.#storage = options.storage ?? new MemoryFrecencyStorage();
184
- this.#namespace = options.namespace ?? "default";
185
- this.#config = options.decayConfig ?? DEFAULT_FRECENCY_DECAY;
186
- this.#data = { records: /* @__PURE__ */ new Map() };
187
- }
188
- /** Load frecency data from storage */
189
- async load() {
190
- const data = await this.#storage.load(this.#namespace);
191
- this.#data = data;
192
- }
193
- /** Record a selection event for an item */
194
- recordSelection(itemId) {
195
- const now = Date.now();
196
- const updated = {
197
- frequency: (this.#data.records.get(itemId)?.frequency ?? 0) + 1,
198
- lastUsed: now
199
- };
200
- this.#data.records.set(itemId, updated);
201
- this.#dirty = true;
202
- }
203
- /** Get frecency bonus for an item */
204
- getBonus(itemId, now) {
205
- const record = this.#data.records.get(itemId);
206
- if (!record) return 0;
207
- return computeFrecencyBonus(record, now, this.#config);
208
- }
209
- /** Get all frecency bonuses as a map — for...of for hot ranking path (no closure overhead) */
210
- getAllBonuses(now = Date.now()) {
211
- if (this.#data.records.size === 0) return EMPTY_BONUS_MAP;
212
- const bonuses = /* @__PURE__ */ new Map();
213
- for (const [id, record] of this.#data.records) bonuses.set(id, computeFrecencyBonus(record, now, this.#config));
214
- return bonuses;
215
- }
216
- /** Get the decay configuration */
217
- getConfig() {
218
- return this.#config;
219
- }
220
- /** Update decay configuration at runtime */
221
- setConfig(config) {
222
- this.#config = {
223
- ...this.#config,
224
- ...config
225
- };
226
- }
227
- /** Flush dirty data to storage */
228
- async flush() {
229
- if (!this.#dirty) return;
230
- await this.#storage.save(this.#namespace, this.#data);
231
- this.#dirty = false;
232
- }
233
- /** Get the raw frecency data */
234
- getData() {
235
- return this.#data;
236
- }
237
- /** Async dispose — flushes dirty data before cleanup. Use with `await using`. */
238
- async [Symbol.asyncDispose]() {
239
- if (this.#dirty) {
240
- await this.#storage.save(this.#namespace, this.#data);
241
- this.#dirty = false;
242
- }
243
- this.#storage[Symbol.dispose]();
244
- }
245
- [Symbol.dispose]() {
246
- if (this.#dirty) Promise.resolve().then(() => this.#storage.save(this.#namespace, this.#data)).catch((err) => {
247
- console.warn("[modern-cmdk] FrecencyEngine dispose flush failed:", err);
248
- });
249
- this.#storage[Symbol.dispose]();
250
- }
251
- };
252
- // src/core/utils/group-by.ts
253
- /**
254
- * Groups items from an iterable into a Map keyed by the result of `keyFn`.
255
- * Cross-browser replacement for `Map.groupBy()`.
256
- */
257
- function mapGroupBy(items, keyFn) {
258
- const map = /* @__PURE__ */ new Map();
259
- for (const item of items) {
260
- const key = keyFn(item);
261
- const group = map.get(key);
262
- if (group) group.push(item);
263
- else map.set(key, [item]);
264
- }
265
- return map;
266
- }
267
- // src/core/keyboard/matcher.ts
268
- /**
269
- * Tests whether a KeyboardEvent exactly matches a ParsedShortcut,
270
- * comparing all four modifier keys (meta, ctrl, shift, alt) and the primary key.
271
- */
272
- function matchesShortcut(event, shortcut) {
273
- if (shortcut.meta !== event.metaKey) return false;
274
- if (shortcut.ctrl !== event.ctrlKey) return false;
275
- if (shortcut.shift !== event.shiftKey) return false;
276
- if (shortcut.alt !== event.altKey) return false;
277
- return event.key.toLowerCase() === shortcut.key;
278
- }
279
- /**
280
- * Detects conflicting shortcut bindings by grouping on their normalized form
281
- * via mapGroupBy helper. Returns only groups with two or more colliding shortcuts.
282
- * mapGroupBy returns a Map directly — no Object.entries conversion needed.
283
- */
284
- function detectConflicts(shortcuts) {
285
- const grouped = mapGroupBy(shortcuts, (s) => s.normalized);
286
- return new Map(grouped.entries().filter(([, group]) => group.length > 1));
287
- }
288
- // src/core/keyboard/parser.ts
289
- const isMac = typeof navigator !== "undefined" && (navigator.userAgentData?.platform === "macOS" || /Mac|iPod|iPhone|iPad/.test(navigator.platform));
290
- /** Modifier aliases — cross-platform Mod resolution */
291
- const MODIFIER_MAP = {
292
- mod: isMac ? "meta" : "ctrl",
293
- meta: "meta",
294
- cmd: "meta",
295
- command: "meta",
296
- ctrl: "ctrl",
297
- control: "ctrl",
298
- shift: "shift",
299
- alt: "alt",
300
- option: "alt",
301
- opt: "alt"
302
- };
303
- /**
304
- * Parse a shortcut string like "Mod+Shift+K" into a structured ParsedShortcut.
305
- * Ensures valid Unicode input via ensureWellFormed helper.
306
- */
307
- function parseShortcut(shortcut) {
308
- const safeShortcut = ensureWellFormed(shortcut);
309
- const parts = safeShortcut.split("+").map((p) => p.trim().toLowerCase());
310
- let meta = false;
311
- let ctrl = false;
312
- let shift = false;
313
- let alt = false;
314
- let key = "";
315
- for (const part of parts) {
316
- const modifier = MODIFIER_MAP[part];
317
- if (modifier) switch (modifier) {
318
- case "meta":
319
- meta = true;
320
- break;
321
- case "ctrl":
322
- ctrl = true;
323
- break;
324
- case "shift":
325
- shift = true;
326
- break;
327
- case "alt": alt = true;
328
- }
329
- else key = part;
330
- }
331
- const modifiers = [];
332
- if (meta) modifiers.push("meta");
333
- if (ctrl) modifiers.push("ctrl");
334
- if (shift) modifiers.push("shift");
335
- if (alt) modifiers.push("alt");
336
- modifiers.push(key);
337
- return {
338
- key,
339
- meta,
340
- ctrl,
341
- shift,
342
- alt,
343
- raw: safeShortcut,
344
- normalized: modifiers.join("+")
345
- };
346
- }
347
- /**
348
- * Formats a ParsedShortcut for display using platform-appropriate symbols.
349
- * On macOS: renders glyphs (⌘, ⇧, ⌥, ⌃). On other platforms: text labels joined by "+".
350
- */
351
- function formatShortcut(parsed) {
352
- const parts = [];
353
- if (parsed.meta) parts.push(isMac ? "⌘" : "Ctrl");
354
- if (parsed.ctrl && !(!isMac && parsed.meta)) parts.push(isMac ? "⌃" : "Ctrl");
355
- if (parsed.shift) parts.push(isMac ? "⇧" : "Shift");
356
- if (parsed.alt) parts.push(isMac ? "⌥" : "Alt");
357
- const displayKey = parsed.key.length === 1 ? parsed.key.toUpperCase() : parsed.key.charAt(0).toUpperCase() + parsed.key.slice(1);
358
- parts.push(displayKey);
359
- return isMac ? parts.join("") : parts.join("+");
360
- }
361
- // src/core/keyboard/index.ts
362
- /**
363
- * Manages keyboard shortcut bindings with global keydown listening.
364
- * Supports `using` for automatic cleanup of both individual bindings and the
365
- * global document listener. Detects conflicts with the local mapGroupBy helper (core/utils/group-by.ts).
366
- */
367
- var KeyboardShortcutRegistry = class {
368
- #bindings = /* @__PURE__ */ new Map();
369
- #globalListener = null;
370
- #enabled = true;
371
- constructor(options) {
372
- this.#enabled = options?.enabled ?? true;
373
- this.#attachGlobalListener();
374
- }
375
- /** Register a shortcut binding — returns Disposable for `using` auto-deregister */
376
- register(shortcutStr, itemId, handler) {
377
- const shortcut = parseShortcut(shortcutStr);
378
- this.#bindings.set(shortcut.normalized, {
379
- shortcut,
380
- itemId,
381
- handler
382
- });
383
- return { [Symbol.dispose]: () => {
384
- this.#bindings.delete(shortcut.normalized);
385
- } };
386
- }
387
- /** Unregister a shortcut by its string representation */
388
- unregister(shortcutStr) {
389
- const shortcut = parseShortcut(shortcutStr);
390
- this.#bindings.delete(shortcut.normalized);
391
- }
392
- /** Unregister all shortcuts for a given item — Map spec allows deletion during iteration */
393
- unregisterByItem(itemId) {
394
- for (const [key, binding] of this.#bindings) if (binding.itemId === itemId) this.#bindings.delete(key);
395
- }
396
- /** Check for conflicting shortcuts */
397
- getConflicts() {
398
- return detectConflicts(this.#bindings.values().map((b) => b.shortcut).toArray());
399
- }
400
- /** Enable or disable global shortcut listening */
401
- setEnabled(enabled) {
402
- this.#enabled = enabled;
403
- }
404
- /** Get all registered bindings */
405
- getBindings() {
406
- return this.#bindings;
407
- }
408
- #attachGlobalListener() {
409
- if (typeof document === "undefined") return;
410
- this.#globalListener = (event) => {
411
- if (!this.#enabled) return;
412
- for (const binding of this.#bindings.values()) if (matchesShortcut(event, binding.shortcut)) {
413
- event.preventDefault();
414
- event.stopPropagation();
415
- binding.handler();
416
- return;
417
- }
418
- };
419
- document.addEventListener("keydown", this.#globalListener);
420
- }
421
- #detachGlobalListener() {
422
- if (this.#globalListener && typeof document !== "undefined") {
423
- document.removeEventListener("keydown", this.#globalListener);
424
- this.#globalListener = null;
425
- }
426
- }
427
- [Symbol.dispose]() {
428
- this.#detachGlobalListener();
429
- this.#bindings.clear();
430
- }
431
- };
432
- // src/core/utils/set-ops.ts
433
- /** Returns a new Set containing elements present in both `a` and `b`. */
434
- function setIntersection(a, b) {
435
- const result = /* @__PURE__ */ new Set();
436
- const [smaller, larger] = a.size <= b.size ? [a, b] : [b, a];
437
- for (const item of smaller) if (larger.has(item)) result.add(item);
438
- return result;
439
- }
440
- /** Returns a new Set containing elements in `a` but not in `b`. */
441
- function setDifference(a, b) {
442
- const result = /* @__PURE__ */ new Set();
443
- for (const item of a) if (!b.has(item)) result.add(item);
444
- return result;
445
- }
446
- /** Returns a new Set containing all elements from both `a` and `b`. */
447
- function setUnion(a, b) {
448
- const result = new Set(a);
449
- for (const item of b) result.add(item);
450
- return result;
451
- }
452
- /** Returns `true` if every element in `a` is also in `b`. */
453
- function setIsSubsetOf(a, b) {
454
- if (a.size > b.size) return false;
455
- for (const item of a) if (!b.has(item)) return false;
456
- return true;
457
- }
458
- /** Returns `true` if every element in `b` is also in `a`. */
459
- function setIsSupersetOf(a, b) {
460
- return setIsSubsetOf(b, a);
461
- }
462
- /** Returns `true` if `a` and `b` share no elements. */
463
- function setIsDisjointFrom(a, b) {
464
- const [smaller, larger] = a.size <= b.size ? [a, b] : [b, a];
465
- for (const item of smaller) if (larger.has(item)) return false;
466
- return true;
467
- }
468
- /** Returns a new Set containing elements in either `a` or `b` but not both. */
469
- function setSymmetricDifference(a, b) {
470
- const result = new Set(a);
471
- for (const item of b) if (result.has(item)) result.delete(item);
472
- else result.add(item);
473
- return result;
474
- }
475
- // src/core/registry.ts
476
- /**
477
- * Manages registration and lookup of command items and groups.
478
- * Uses set operation helpers (union/intersection/difference) for efficient ID operations.
479
- * All registration methods return Disposable for automatic cleanup via `using`.
480
- */
481
- var CommandRegistry = class {
482
- #items = /* @__PURE__ */ new Map();
483
- #groups = /* @__PURE__ */ new Map();
484
- #itemOrder = [];
485
- #itemOrderSet = /* @__PURE__ */ new Set();
486
- #cachedItems = null;
487
- #cachedGroups = null;
488
- #invalidateCache() {
489
- this.#cachedItems = null;
490
- }
491
- #invalidateGroupCache() {
492
- this.#cachedGroups = null;
493
- }
494
- /** Register an item — returns Disposable for `using` auto-deregister */
495
- registerItem(item) {
496
- this.#items.set(item.id, item);
497
- if (!this.#itemOrderSet.has(item.id)) {
498
- this.#itemOrder.push(item.id);
499
- this.#itemOrderSet.add(item.id);
500
- }
501
- this.#invalidateCache();
502
- return { [Symbol.dispose]: () => {
503
- this.unregisterItem(item.id);
504
- } };
505
- }
506
- /** Register multiple items at once — single Disposable for batch cleanup. for...of for hot path. */
507
- registerItems(items) {
508
- for (const item of items) {
509
- this.#items.set(item.id, item);
510
- if (!this.#itemOrderSet.has(item.id)) {
511
- this.#itemOrder.push(item.id);
512
- this.#itemOrderSet.add(item.id);
513
- }
514
- }
515
- this.#invalidateCache();
516
- const ids = new Set(items.values().map((i) => i.id));
517
- return { [Symbol.dispose]: () => {
518
- this.unregisterItems(ids);
519
- } };
520
- }
521
- /** Unregister a single item — O(1) Map/Set delete, indexOf+toSpliced for order */
522
- unregisterItem(id) {
523
- if (!this.#items.has(id)) return;
524
- this.#items.delete(id);
525
- this.#itemOrderSet.delete(id);
526
- const idx = this.#itemOrder.indexOf(id);
527
- if (idx !== -1) this.#itemOrder = this.#itemOrder.toSpliced(idx, 1);
528
- this.#invalidateCache();
529
- }
530
- /** Bulk unregister — for...of for hot path */
531
- unregisterItems(ids) {
532
- for (const id of ids) this.#items.delete(id);
533
- this.#itemOrderSet = setDifference(this.#itemOrderSet, ids);
534
- this.#itemOrder = this.#itemOrder.filter((id) => !ids.has(id));
535
- this.#invalidateCache();
536
- }
537
- /** Register a group */
538
- registerGroup(group) {
539
- this.#groups.set(group.id, group);
540
- this.#invalidateGroupCache();
541
- return { [Symbol.dispose]: () => {
542
- this.#groups.delete(group.id);
543
- this.#invalidateGroupCache();
544
- } };
545
- }
546
- /** Unregister a group */
547
- unregisterGroup(id) {
548
- this.#groups.delete(id);
549
- this.#invalidateGroupCache();
550
- }
551
- /** Get an item by ID — O(1) */
552
- getItem(id) {
553
- return this.#items.get(id);
554
- }
555
- /** Get a group by ID — O(1) */
556
- getGroup(id) {
557
- return this.#groups.get(id);
558
- }
559
- /** Get all registered items in insertion order — cached between mutations */
560
- getItems() {
561
- if (this.#cachedItems !== null) return this.#cachedItems;
562
- this.#cachedItems = this.#itemOrder.values().map((id) => this.#items.get(id)).filter((item) => item != null).toArray();
563
- return this.#cachedItems;
564
- }
565
- /** Get all registered groups sorted by priority — cached between group mutations */
566
- getGroups() {
567
- if (this.#cachedGroups !== null) return this.#cachedGroups;
568
- this.#cachedGroups = this.#groups.values().toArray().toSorted((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
569
- return this.#cachedGroups;
570
- }
571
- /** Get items grouped by groupId — direct Map, no Object.entries conversion */
572
- getGroupedItems() {
573
- const grouped = mapGroupBy(this.getItems(), (item) => item.groupId ?? "__ungrouped");
574
- const result = /* @__PURE__ */ new Map();
575
- for (const group of this.getGroups()) {
576
- const groupItems = grouped.get(group.id);
577
- if (groupItems != null) result.set(group.id, groupItems);
578
- }
579
- const ungrouped = grouped.get("__ungrouped");
580
- if (ungrouped) result.set("__ungrouped", ungrouped);
581
- return result;
582
- }
583
- /** Get item IDs as a Set — returns internal set (O(1), no allocation) */
584
- getItemIds() {
585
- return this.#itemOrderSet;
586
- }
587
- /** Find intersection of registered items with a candidate set */
588
- intersectWith(candidateIds) {
589
- return setIntersection(this.#itemOrderSet, candidateIds);
590
- }
591
- /** Find items registered but not in a given set */
592
- differenceFrom(otherIds) {
593
- return setDifference(this.#itemOrderSet, otherIds);
594
- }
595
- /** Union of registered IDs with another set */
596
- unionWith(otherIds) {
597
- return setUnion(this.#itemOrderSet, otherIds);
598
- }
599
- /** Check if registered items are a subset of the given set */
600
- isSubsetOf(otherIds) {
601
- return setIsSubsetOf(this.#itemOrderSet, otherIds);
602
- }
603
- /** Check if registered items are a superset of the given set */
604
- isSupersetOf(otherIds) {
605
- return setIsSupersetOf(this.#itemOrderSet, otherIds);
606
- }
607
- /** Check if registered items share no IDs with the given set */
608
- isDisjointFrom(otherIds) {
609
- return setIsDisjointFrom(this.#itemOrderSet, otherIds);
610
- }
611
- /** Symmetric difference between registered IDs and another set */
612
- symmetricDifferenceWith(otherIds) {
613
- return setSymmetricDifference(this.#itemOrderSet, otherIds);
614
- }
615
- /** Total number of registered items */
616
- get size() {
617
- return this.#items.size;
618
- }
619
- /** Total number of registered groups */
620
- get groupCount() {
621
- return this.#groups.size;
622
- }
623
- /** Clear all items and groups */
624
- clear() {
625
- this.#items.clear();
626
- this.#groups.clear();
627
- this.#itemOrder = [];
628
- this.#itemOrderSet.clear();
629
- this.#invalidateCache();
630
- this.#invalidateGroupCache();
631
- }
632
- [Symbol.dispose]() {
633
- this.clear();
634
- }
635
- };
636
- // src/core/utils/env.ts
637
- /**
638
- * `true` unless `process.env.NODE_ENV` is `'production'`.
639
- *
640
- * Consumer bundlers (Vite, webpack, Next, esbuild) replace `process.env.NODE_ENV` statically,
641
- * so the dev-only branches guarded by this flag fold away in production builds. A browser
642
- * running the unbundled module throws a ReferenceError on the bare `process` reference;
643
- * the catch turns that into the production value (`false`), so dev-only code stays off.
644
- */
645
- const isDev = (() => {
646
- try {
647
- return process.env.NODE_ENV !== "production";
648
- } catch {
649
- return false;
650
- }
651
- })();
652
- // src/core/search/default-scorer.ts
653
- function isWordSeparator(code) {
654
- return code === 32 || code === 9 || code === 45 || code === 95 || code === 46 || code === 47 || code === 92;
655
- }
656
- /**
657
- * Internal fast path: called by search engine with pre-lowered query and targets.
658
- * Eliminates all toLowerCase() calls on the hot path.
659
- */
660
- function scoreItemPreLowered(lowerQuery, id, lowerValue, lowerKeywords) {
661
- let bestScore = 0;
662
- let bestMatches = null;
663
- const valueResult = scoreTarget(lowerQuery, lowerValue);
664
- if (valueResult !== null) {
665
- bestScore = valueResult.score;
666
- bestMatches = valueResult.matches;
667
- if (bestScore >= 1) return {
668
- id,
669
- score: 1,
670
- matches: bestMatches
671
- };
672
- }
673
- if (lowerKeywords !== void 0) for (let i = 0; i < lowerKeywords.length; i++) {
674
- const result = scoreTarget(lowerQuery, lowerKeywords[i]);
675
- if (result !== null && result.score > bestScore) {
676
- bestScore = result.score;
677
- bestMatches = result.matches;
678
- if (bestScore >= 1) break;
679
- }
680
- }
681
- return bestMatches !== null ? {
682
- id,
683
- score: bestScore,
684
- matches: bestMatches
685
- } : null;
686
- }
687
- function scoreTarget(query, lowerTarget) {
688
- const tLen = lowerTarget.length;
689
- if (tLen === 0) return null;
690
- const qLen = query.length;
691
- if (qLen === 0) return {
692
- score: 1,
693
- matches: []
694
- };
695
- if (qLen > tLen) return null;
696
- if (lowerTarget === query) return {
697
- score: 1,
698
- matches: [[0, qLen]]
699
- };
700
- if (lowerTarget.startsWith(query)) return {
701
- score: .9 + .1 * (qLen / tLen),
702
- matches: [[0, qLen]]
703
- };
704
- const substringIdx = lowerTarget.indexOf(query);
705
- if (substringIdx !== -1) {
706
- const positionBonus = 1 - substringIdx / tLen;
707
- const lengthRatio = qLen / tLen;
708
- return {
709
- score: .5 + .3 * positionBonus + .2 * lengthRatio,
710
- matches: [[substringIdx, substringIdx + qLen]]
711
- };
712
- }
713
- const wordBoundaryResult = scoreWordBoundary(query, qLen, lowerTarget, tLen);
714
- if (wordBoundaryResult) return wordBoundaryResult;
715
- return scoreFuzzy(query, qLen, lowerTarget, tLen);
716
- }
717
- function scoreWordBoundary(query, qLen, lowerTarget, tLen) {
718
- let queryIdx = 0;
719
- const matches = [];
720
- let totalWeightedLen = 0;
721
- let i = 0;
722
- while (i < tLen && queryIdx < qLen) if ((i === 0 || isWordSeparator(lowerTarget.charCodeAt(i - 1))) && lowerTarget.charCodeAt(i) === query.charCodeAt(queryIdx)) {
723
- const matchStart = i;
724
- let matchLen = 0;
725
- while (queryIdx < qLen && i < tLen && !isWordSeparator(lowerTarget.charCodeAt(i)) && lowerTarget.charCodeAt(i) === query.charCodeAt(queryIdx)) {
726
- queryIdx++;
727
- i++;
728
- matchLen++;
729
- }
730
- matches.push([matchStart, matchStart + matchLen]);
731
- totalWeightedLen += matchLen * 2;
732
- } else i++;
733
- if (queryIdx !== qLen) return null;
734
- const maxPossible = qLen * 2;
735
- return {
736
- score: .3 + .4 * (totalWeightedLen / maxPossible),
737
- matches
738
- };
739
- }
740
- function scoreFuzzy(query, qLen, lowerTarget, tLen) {
741
- let queryIdx = 0;
742
- let targetIdx = 0;
743
- const matches = [];
744
- let currentMatchStart = -1;
745
- let contiguityScore = 0;
746
- while (queryIdx < qLen && targetIdx < tLen) if (query.charCodeAt(queryIdx) === lowerTarget.charCodeAt(targetIdx)) {
747
- if (currentMatchStart === -1) currentMatchStart = targetIdx;
748
- queryIdx++;
749
- targetIdx++;
750
- } else {
751
- if (currentMatchStart !== -1) {
752
- const segmentLen = targetIdx - currentMatchStart;
753
- matches.push([currentMatchStart, targetIdx]);
754
- contiguityScore += segmentLen * segmentLen;
755
- currentMatchStart = -1;
756
- }
757
- targetIdx++;
758
- }
759
- if (currentMatchStart !== -1) {
760
- const segmentLen = targetIdx - currentMatchStart;
761
- matches.push([currentMatchStart, targetIdx]);
762
- contiguityScore += segmentLen * segmentLen;
763
- }
764
- if (queryIdx !== qLen) return null;
765
- const maxContiguity = qLen * qLen;
766
- const contiguityRatio = contiguityScore / maxContiguity;
767
- const positionBonus = 1 - (matches[0]?.[0] ?? 0) / tLen;
768
- const lengthRatio = qLen / tLen;
769
- return {
770
- score: .1 + .3 * contiguityRatio + .15 * positionBonus + .1 * lengthRatio,
771
- matches
772
- };
773
- }
774
- // src/core/search/index.ts
775
- let leakRegistry;
776
- if (isDev && typeof window !== "undefined") leakRegistry = new FinalizationRegistry((label) => {
777
- console.warn(`[modern-cmdk] ${label} was garbage collected without being disposed. Use \`using\` or call [Symbol.dispose]() to prevent leaks.`);
778
- });
779
- /**
780
- * Creates a search engine with pluggable scorer and incremental filtering.
781
- * When the query extends the previous one, only prior matches are re-scored.
782
- * Pre-caches lowercase at index time — zero toLowerCase() calls during search.
783
- * Uses Iterator Helpers (ES2025) for result pipelines.
784
- */
785
- function createSearchEngine(options) {
786
- const customScorer = options?.scorer;
787
- const useDefaultScorer = customScorer === void 0;
788
- const indexedEntries = /* @__PURE__ */ new Map();
789
- let previousQuery = "";
790
- let previousResults = /* @__PURE__ */ new Set();
791
- const sentinel = {};
792
- if (leakRegistry) leakRegistry.register(sentinel, "SearchEngine", sentinel);
793
- return {
794
- index(items) {
795
- for (const item of items) indexedEntries.set(item.id, {
796
- item,
797
- lowerValue: item.value.toLowerCase(),
798
- lowerKeywords: item.keywords?.map((k) => k.toLowerCase())
799
- });
800
- previousQuery = "";
801
- previousResults = /* @__PURE__ */ new Set();
802
- },
803
- search(query, items) {
804
- if (query === "") {
805
- previousQuery = "";
806
- previousResults = new Set(items.values().map((i) => i.id));
807
- return items.values().map((item) => ({
808
- id: item.id,
809
- score: 1,
810
- matches: []
811
- }));
812
- }
813
- const lowerQuery = query.toLowerCase();
814
- const candidateItems = query.startsWith(previousQuery) && previousQuery.length > 0 ? items.values().filter((item) => previousResults.has(item.id)) : items;
815
- const results = [];
816
- if (useDefaultScorer) for (const item of candidateItems) {
817
- const entry = indexedEntries.get(item.id);
818
- const result = entry ? scoreItemPreLowered(lowerQuery, item.id, entry.lowerValue, entry.lowerKeywords) : scoreItemPreLowered(lowerQuery, item.id, item.value.toLowerCase(), item.keywords?.map((k) => k.toLowerCase()));
819
- if (result !== null) results.push(result);
820
- }
821
- else for (const item of candidateItems) {
822
- const result = customScorer(query, item);
823
- if (result != null) results.push(result);
824
- }
825
- results.sort((a, b) => b.score - a.score);
826
- previousQuery = query;
827
- previousResults = new Set(results.values().map((r) => r.id));
828
- return results.values();
829
- },
830
- remove(ids) {
831
- for (const id of ids) indexedEntries.delete(id);
832
- previousResults = setDifference(previousResults, ids);
833
- },
834
- clear() {
835
- indexedEntries.clear();
836
- previousQuery = "";
837
- previousResults.clear();
838
- },
839
- [Symbol.dispose]() {
840
- indexedEntries.clear();
841
- previousResults.clear();
842
- if (leakRegistry) leakRegistry.unregister(sentinel);
843
- }
844
- };
845
- }
846
- // src/core/utils/event-emitter.ts
847
- /**
848
- * Type-safe event emitter with strong references and Disposable subscriptions.
849
- * Returns Disposable from `on()` for automatic unsubscription via `using`.
850
- */
851
- var TypedEmitter = class {
852
- #listeners = /* @__PURE__ */ new Map();
853
- /** Subscribe to an event. Returns a Disposable that removes the listener when disposed. */
854
- on(event, listener) {
855
- let set = this.#listeners.get(event);
856
- if (!set) {
857
- set = /* @__PURE__ */ new Set();
858
- this.#listeners.set(event, set);
859
- }
860
- const fn = listener;
861
- set.add(fn);
862
- return { [Symbol.dispose]: () => {
863
- set.delete(fn);
864
- } };
865
- }
866
- /** Emit an event to all listeners. Snapshots the set to prevent live-iteration issues. */
867
- emit(event, data) {
868
- const set = this.#listeners.get(event);
869
- if (!set || set.size === 0) return;
870
- for (const fn of [...set]) fn(data);
871
- }
872
- /** Check if any listeners exist for an event */
873
- has(event) {
874
- const set = this.#listeners.get(event);
875
- return set !== void 0 && set.size > 0;
876
- }
877
- /** Get count of listeners for an event */
878
- listenerCount(event) {
879
- return this.#listeners.get(event)?.size ?? 0;
880
- }
881
- /** Remove all listeners */
882
- removeAll() {
883
- this.#listeners.clear();
884
- }
885
- [Symbol.dispose]() {
886
- this.#listeners.clear();
887
- }
888
- };
889
- // src/core/utils/scheduler.ts
890
- /** Whether we're in a browser environment with rAF available */
891
- const hasRAF = typeof requestAnimationFrame === "function";
892
- /** Feature detection for cutting-edge scheduling APIs, read through globalThis: typeof guards only a bare
893
- * identifier, so `typeof navigator?.scheduling` would throw where navigator is undeclared (runtimes without one). */
894
- const canYield = typeof globalThis.scheduler?.yield === "function";
895
- const hasInputPending = typeof globalThis.navigator?.scheduling?.isInputPending === "function";
896
- /**
897
- * Creates a batched update scheduler. Coalesces rapid state updates into a single
898
- * execution pass using rAF (browser) or queueMicrotask (Node.js).
899
- */
900
- function createScheduler() {
901
- let pending = [];
902
- let rafId = null;
903
- let microtaskScheduled = false;
904
- function executeBatch() {
905
- const batch = pending;
906
- pending = [];
907
- rafId = null;
908
- microtaskScheduled = false;
909
- if (batch.length > 10 && hasInputPending && navigator.scheduling?.isInputPending?.()) {
910
- pending = [...batch, ...pending];
911
- if (canYield) globalThis.scheduler?.yield?.().then(() => void executeBatch());
912
- else rafId ??= requestAnimationFrame(() => void executeBatch());
913
- return;
914
- }
915
- for (const update of batch) try {
916
- update();
917
- } catch (error) {
918
- queueMicrotask(() => {
919
- throw error;
920
- });
921
- }
922
- }
923
- function flush() {
924
- if (rafId !== null) {
925
- cancelAnimationFrame(rafId);
926
- rafId = null;
927
- }
928
- microtaskScheduled = false;
929
- executeBatch();
930
- }
931
- return {
932
- schedule(update) {
933
- pending.push(update);
934
- if (hasRAF) rafId ??= requestAnimationFrame(() => void executeBatch());
935
- else if (!microtaskScheduled) {
936
- microtaskScheduled = true;
937
- queueMicrotask(executeBatch);
938
- }
939
- },
940
- flush,
941
- [Symbol.dispose]() {
942
- if (rafId !== null) {
943
- cancelAnimationFrame(rafId);
944
- rafId = null;
945
- }
946
- microtaskScheduled = false;
947
- pending = [];
948
- }
949
- };
950
- }
951
- // src/core/machine.ts
952
- /**
953
- * Creates a headless command palette state machine with search, frecency,
954
- * and keyboard shortcut support. Implements Disposable for `using` cleanup.
955
- *
956
- * @example
957
- * ```ts
958
- * using machine = createCommandMachine({ items, loop: true });
959
- * machine.subscribe(() => console.log(machine.getState()));
960
- * ```
961
- */
962
- function createCommandMachine(options = {}) {
963
- let state = createInitialState(options);
964
- let filteredIdSet = /* @__PURE__ */ new Set();
965
- let filteredIdIndex = /* @__PURE__ */ new Map();
966
- const registry = new CommandRegistry();
967
- const searchEngine = options.search ?? createSearchEngine(typeof options.filter === "function" ? { scorer: (query, item) => {
968
- const score = options.filter(item, query);
969
- if (score === false) return null;
970
- return {
971
- id: item.id,
972
- score,
973
- matches: []
974
- };
975
- } } : void 0);
976
- const scheduler = createScheduler();
977
- const emitter = new TypedEmitter();
978
- const keyboardRegistry = new KeyboardShortcutRegistry();
979
- let frecencyEngine = null;
980
- if (options.frecency?.enabled) {
981
- frecencyEngine = new FrecencyEngine(options.frecency);
982
- frecencyEngine.load();
983
- }
984
- if (options.items) {
985
- registry.registerItems(options.items);
986
- searchEngine.index(options.items);
987
- for (const item of options.items) if (item.shortcut != null && item.onSelect != null) keyboardRegistry.register(item.shortcut, item.id, item.onSelect);
988
- }
989
- if (options.groups) for (const group of options.groups) registry.registerGroup(group);
990
- refilter("");
991
- function setState(newState) {
992
- state = newState;
993
- emitter.emit("stateChange", state);
994
- }
995
- function refilter(query) {
996
- const items = registry.getItems();
997
- const disableFilter = options.filter === false;
998
- let filteredIds;
999
- if (disableFilter || query === "") filteredIds = items.values().filter((i) => !i.disabled).map((i) => i.id).toArray();
1000
- else {
1001
- let results = searchEngine.search(query, items).toArray();
1002
- if (frecencyEngine) {
1003
- const bonuses = frecencyEngine.getAllBonuses();
1004
- results = results.toSorted((a, b) => {
1005
- const aBonus = bonuses.get(a.id) ?? 0;
1006
- const bBonus = bonuses.get(b.id) ?? 0;
1007
- return b.score + bBonus - (a.score + aBonus);
1008
- });
1009
- }
1010
- filteredIds = results.map((r) => r.id);
1011
- }
1012
- filteredIdSet = new Set(filteredIds);
1013
- filteredIdIndex = new Map(filteredIds.entries().map(([i, id]) => [id, i]));
1014
- const groupedIds = mapGroupBy(filteredIds, (id) => {
1015
- return registry.getItem(id)?.groupId ?? "__ungrouped";
1016
- });
1017
- const activeId = state.activeId && filteredIdSet.has(state.activeId) ? state.activeId : filteredIds[0] ?? null;
1018
- setState({
1019
- ...state,
1020
- search: query,
1021
- filteredIds,
1022
- groupedIds,
1023
- filteredCount: filteredIds.length,
1024
- activeId,
1025
- lastUpdated: Date.now()
1026
- });
1027
- }
1028
- function navigate(direction) {
1029
- const { filteredIds, activeId } = state;
1030
- if (filteredIds.length === 0) return;
1031
- if (direction === "first") {
1032
- const newActiveId = filteredIds[0] ?? null;
1033
- if (newActiveId !== activeId) {
1034
- setState({
1035
- ...state,
1036
- activeId: newActiveId,
1037
- lastUpdated: Date.now()
1038
- });
1039
- options.onActiveChange?.(newActiveId);
1040
- }
1041
- return;
1042
- }
1043
- if (direction === "last") {
1044
- const newActiveId = filteredIds[filteredIds.length - 1] ?? null;
1045
- if (newActiveId !== activeId) {
1046
- setState({
1047
- ...state,
1048
- activeId: newActiveId,
1049
- lastUpdated: Date.now()
1050
- });
1051
- options.onActiveChange?.(newActiveId);
1052
- }
1053
- return;
1054
- }
1055
- const loop = options.loop ?? true;
1056
- const currentIdx = activeId ? filteredIdIndex.get(activeId) ?? -1 : -1;
1057
- let newIdx;
1058
- if (direction === "next") {
1059
- if (currentIdx === -1 || currentIdx >= filteredIds.length - 1) newIdx = loop ? 0 : filteredIds.length - 1;
1060
- else newIdx = currentIdx + 1;
1061
- } else if (currentIdx <= 0) newIdx = loop ? filteredIds.length - 1 : 0;
1062
- else newIdx = currentIdx - 1;
1063
- const newActiveId = filteredIds[newIdx] ?? null;
1064
- if (newActiveId !== activeId) {
1065
- setState({
1066
- ...state,
1067
- activeId: newActiveId,
1068
- lastUpdated: Date.now()
1069
- });
1070
- options.onActiveChange?.(newActiveId);
1071
- }
1072
- }
1073
- function handleEvent(event) {
1074
- switch (event.type) {
1075
- case "SEARCH_CHANGE":
1076
- refilter(event.query);
1077
- options.onSearchChange?.(event.query);
1078
- break;
1079
- case "ITEM_SELECT": {
1080
- const item = registry.getItem(event.id);
1081
- if (item && !item.disabled) {
1082
- item.onSelect?.();
1083
- frecencyEngine?.recordSelection(event.id);
1084
- emitter.emit("select", event.id);
1085
- options.onSelect?.(event.id);
1086
- }
1087
- break;
1088
- }
1089
- case "ITEM_ACTIVATE":
1090
- if (filteredIdSet.has(event.id)) {
1091
- setState({
1092
- ...state,
1093
- activeId: event.id,
1094
- lastUpdated: Date.now()
1095
- });
1096
- options.onActiveChange?.(event.id);
1097
- }
1098
- break;
1099
- case "NAVIGATE":
1100
- navigate(event.direction);
1101
- break;
1102
- case "PAGE_PUSH":
1103
- setState({
1104
- ...state,
1105
- page: event.page,
1106
- pageStack: [...state.pageStack, state.page],
1107
- lastUpdated: Date.now()
1108
- });
1109
- break;
1110
- case "PAGE_POP": {
1111
- const prevPage = state.pageStack.at(-1);
1112
- if (prevPage != null) setState({
1113
- ...state,
1114
- page: prevPage,
1115
- pageStack: state.pageStack.toSpliced(-1, 1),
1116
- lastUpdated: Date.now()
1117
- });
1118
- break;
1119
- }
1120
- case "OPEN":
1121
- setState({
1122
- ...state,
1123
- open: true,
1124
- lastUpdated: Date.now()
1125
- });
1126
- emitter.emit("openChange", true);
1127
- options.onOpenChange?.(true);
1128
- break;
1129
- case "CLOSE":
1130
- setState({
1131
- ...state,
1132
- open: false,
1133
- lastUpdated: Date.now()
1134
- });
1135
- emitter.emit("openChange", false);
1136
- options.onOpenChange?.(false);
1137
- break;
1138
- case "TOGGLE": {
1139
- const newOpen = !state.open;
1140
- setState({
1141
- ...state,
1142
- open: newOpen,
1143
- lastUpdated: Date.now()
1144
- });
1145
- emitter.emit("openChange", newOpen);
1146
- options.onOpenChange?.(newOpen);
1147
- break;
1148
- }
1149
- case "ITEMS_LOADED":
1150
- registry.registerItems(event.items);
1151
- searchEngine.index(event.items);
1152
- state = {
1153
- ...state,
1154
- loading: false
1155
- };
1156
- refilter(state.search);
1157
- break;
1158
- case "REGISTER_ITEM":
1159
- registry.registerItem(event.item);
1160
- searchEngine.index([event.item]);
1161
- if (event.item.shortcut && event.item.onSelect) keyboardRegistry.register(event.item.shortcut, event.item.id, event.item.onSelect);
1162
- refilter(state.search);
1163
- break;
1164
- case "UNREGISTER_ITEM":
1165
- registry.unregisterItem(event.id);
1166
- searchEngine.remove(/* @__PURE__ */ new Set([event.id]));
1167
- keyboardRegistry.unregisterByItem(event.id);
1168
- refilter(state.search);
1169
- break;
1170
- case "REGISTER_GROUP":
1171
- registry.registerGroup(event.group);
1172
- refilter(state.search);
1173
- break;
1174
- case "UNREGISTER_GROUP":
1175
- registry.unregisterGroup(event.id);
1176
- refilter(state.search);
1177
- }
1178
- }
1179
- return {
1180
- getState() {
1181
- return state;
1182
- },
1183
- send(event) {
1184
- scheduler.schedule(() => handleEvent(event));
1185
- },
1186
- /** useSyncExternalStore-compatible subscribe — returns cleanup function */
1187
- subscribe(listener) {
1188
- const disposable = emitter.on("stateChange", listener);
1189
- return () => disposable[Symbol.dispose]();
1190
- },
1191
- /** Full state subscribe — returns Disposable for `using` pattern */
1192
- subscribeState(listener) {
1193
- return emitter.on("stateChange", listener);
1194
- },
1195
- getRegistry() {
1196
- return registry;
1197
- },
1198
- getKeyboardRegistry() {
1199
- return keyboardRegistry;
1200
- },
1201
- getFilteredIdSet() {
1202
- return filteredIdSet;
1203
- },
1204
- getFilteredIdIndex() {
1205
- return filteredIdIndex;
1206
- },
1207
- [Symbol.dispose]() {
1208
- scheduler[Symbol.dispose]();
1209
- registry[Symbol.dispose]();
1210
- searchEngine[Symbol.dispose]();
1211
- emitter[Symbol.dispose]();
1212
- keyboardRegistry[Symbol.dispose]();
1213
- frecencyEngine?.[Symbol.dispose]();
1214
- }
1215
- };
1216
- }
1217
- // src/react/hooks/use-command.ts
1218
- /** Core hook that binds a CommandMachine to React via useSyncExternalStore, useOptimistic, and a transition for optimistic active-item updates. */
1219
- function useCommand(machine) {
1220
- const id = useId();
1221
- const [isPending, startTransition] = useTransition();
1222
- const state = useSyncExternalStore(machine.subscribe, machine.getState, machine.getState);
1223
- const filteredIdSet = useMemo(() => machine.getFilteredIdSet(), [state.filteredIds, machine]);
1224
- const [optimisticActiveId, rawSetOptimisticActiveId] = useOptimistic(state.activeId);
1225
- const setOptimisticActiveId = useCallback((id) => {
1226
- startTransition(() => {
1227
- rawSetOptimisticActiveId(id);
1228
- });
1229
- }, [rawSetOptimisticActiveId]);
1230
- const updateSearch = useCallback((query) => {
1231
- machine.send({
1232
- type: "SEARCH_CHANGE",
1233
- query
1234
- });
1235
- }, [machine]);
1236
- return {
1237
- state: optimisticActiveId !== state.activeId ? {
1238
- ...state,
1239
- activeId: optimisticActiveId
1240
- } : state,
1241
- isPending,
1242
- updateSearch,
1243
- setOptimisticActiveId,
1244
- filteredIdSet,
1245
- id
1246
- };
1247
- }
1248
- // src/react/hooks/use-keyboard.ts
1249
- /** Navigation key to machine direction mapping */
1250
- const NAVIGATION_KEYS = {
1251
- ArrowDown: "next",
1252
- ArrowUp: "prev",
1253
- Home: "first",
1254
- End: "last"
1255
- };
1256
- /** Create a keydown handler for command palette keyboard navigation */
1257
- function createKeydownHandler(machine, getState) {
1258
- return (event) => {
1259
- const { key } = event;
1260
- const state = getState();
1261
- const direction = NAVIGATION_KEYS[key];
1262
- if (direction) {
1263
- event.preventDefault();
1264
- machine.send({
1265
- type: "NAVIGATE",
1266
- direction
1267
- });
1268
- return;
1269
- }
1270
- switch (key) {
1271
- case "Enter": {
1272
- event.preventDefault();
1273
- const { activeId } = state;
1274
- if (activeId) machine.send({
1275
- type: "ITEM_SELECT",
1276
- id: activeId
1277
- });
1278
- break;
1279
- }
1280
- case "Backspace":
1281
- if (state.search === "" && state.pageStack.length > 0) {
1282
- event.preventDefault();
1283
- machine.send({ type: "PAGE_POP" });
1284
- }
1285
- break;
1286
- case "Escape":
1287
- event.preventDefault();
1288
- machine.send({ type: "CLOSE" });
1289
- }
1290
- };
1291
- }
1292
- // src/react/hooks/use-command-setup.ts
1293
- /**
1294
- * Shared setup for Command and Command.Dialog — creates machine once,
1295
- * subscribes via useSyncExternalStore, memoizes context values, and
1296
- * registers disposal on unmount.
1297
- *
1298
- * @param machineFactory - Called exactly once (first render) to create the machine.
1299
- * Subsequent renders skip it via ref guard.
1300
- * @param label - Accessible label for the command palette (included in stable context).
1301
- */
1302
- function useCommandSetup(machineFactory, label) {
1303
- const machineRef = useRef(null);
1304
- if (machineRef.current === null) machineRef.current = machineFactory();
1305
- const machine = machineRef.current;
1306
- const { state, isPending, updateSearch, setOptimisticActiveId, filteredIdSet, id } = useCommand(machine);
1307
- const listId = `${id}-list`;
1308
- const inputId = `${id}-input`;
1309
- useEffect(() => {
1310
- return () => {
1311
- machineRef.current?.[Symbol.dispose]();
1312
- machineRef.current = null;
1313
- };
1314
- }, []);
1315
- return {
1316
- machine,
1317
- state,
1318
- isPending,
1319
- listId,
1320
- inputId,
1321
- handleKeyDown: useMemo(() => createKeydownHandler(machine, () => machine.getState()), [machine]),
1322
- stableContextValue: useMemo(() => ({
1323
- machine,
1324
- rootId: id,
1325
- listId,
1326
- inputId,
1327
- label,
1328
- updateSearch,
1329
- setOptimisticActiveId
1330
- }), [
1331
- machine,
1332
- id,
1333
- listId,
1334
- inputId,
1335
- label,
1336
- updateSearch,
1337
- setOptimisticActiveId
1338
- ]),
1339
- stateContextValue: useMemo(() => ({
1340
- state,
1341
- isPending,
1342
- filteredIdSet,
1343
- visibleIdSet: null,
1344
- virtualPositionMap: null
1345
- }), [
1346
- state,
1347
- isPending,
1348
- filteredIdSet
1349
- ])
1350
- };
1351
- }
1352
- // src/react/dialog.tsx
1353
- /** Dialog variant that wraps the command palette in a Radix UI Dialog with focus trap and overlay. */
1354
- function CommandDialog({ children, className, overlayClassName, contentClassName, label = "Command palette", description = "Type a command or search...", open: controlledOpen, onOpenChange, container, ...machineOptions }) {
1355
- const onOpenChangeRef = useRef(onOpenChange);
1356
- onOpenChangeRef.current = onOpenChange;
1357
- const { machine, state, handleKeyDown, stableContextValue, stateContextValue, inputId } = useCommandSetup(() => createCommandMachine({
1358
- ...machineOptions,
1359
- open: controlledOpen ?? false,
1360
- onOpenChange: (open) => {
1361
- onOpenChangeRef.current?.(open);
1362
- }
1363
- }), label);
1364
- useEffect(() => {
1365
- if (!state.open) return;
1366
- document.addEventListener("keydown", handleKeyDown);
1367
- return () => {
1368
- document.removeEventListener("keydown", handleKeyDown);
1369
- };
1370
- }, [handleKeyDown, state.open]);
1371
- useEffect(() => {
1372
- if (controlledOpen !== void 0) machine.send({ type: controlledOpen ? "OPEN" : "CLOSE" });
1373
- }, [controlledOpen, machine]);
1374
- const handleOpenAutoFocus = useCallback((e) => {
1375
- e.preventDefault();
1376
- document.getElementById(inputId)?.focus();
1377
- }, [inputId]);
1378
- const handleRadixOpenChange = useCallback((open) => {
1379
- machine.send({ type: open ? "OPEN" : "CLOSE" });
1380
- }, [machine]);
1381
- return /* @__PURE__ */ jsx(Dialog.Root, {
1382
- open: state.open,
1383
- onOpenChange: handleRadixOpenChange,
1384
- children: /* @__PURE__ */ jsxs(Dialog.Portal, {
1385
- container,
1386
- children: [/* @__PURE__ */ jsx(Dialog.Overlay, {
1387
- "data-command-overlay": "",
1388
- "data-state": state.open ? "open" : "closed",
1389
- className: overlayClassName
1390
- }), /* @__PURE__ */ jsxs(Dialog.Content, {
1391
- "data-command-dialog": "",
1392
- "data-state": state.open ? "open" : "closed",
1393
- className,
1394
- "aria-label": label,
1395
- onOpenAutoFocus: handleOpenAutoFocus,
1396
- children: [
1397
- /* @__PURE__ */ jsx(Dialog.Title, {
1398
- className: "sr-only",
1399
- children: label
1400
- }),
1401
- /* @__PURE__ */ jsx(Dialog.Description, {
1402
- className: "sr-only",
1403
- children: description
1404
- }),
1405
- /* @__PURE__ */ jsx(CommandStableContext, {
1406
- value: stableContextValue,
1407
- children: /* @__PURE__ */ jsx(CommandStateContext, {
1408
- value: stateContextValue,
1409
- children: /* @__PURE__ */ jsx("div", {
1410
- "data-command-dialog-content": "",
1411
- className: contentClassName,
1412
- children: /* @__PURE__ */ jsx("div", {
1413
- "data-command-root": "",
1414
- role: "search",
1415
- "aria-label": label,
1416
- children
1417
- })
1418
- })
1419
- })
1420
- })
1421
- ]
1422
- })]
1423
- })
1424
- });
1425
- }
1426
86
  // src/react/empty.tsx
1427
87
  /** Rendered when no items match the current search query. */
1428
88
  function CommandEmpty({ ref, children, ...props }) {
@@ -2046,10 +706,20 @@ var CommandErrorBoundary = class extends Component {
2046
706
  componentDidCatch(error, errorInfo) {
2047
707
  this.props.onError?.(error, errorInfo);
2048
708
  }
709
+ componentDidUpdate(prevProps, prevState) {
710
+ if (this.state.error === null || prevState.error === null) return;
711
+ const prev = prevProps.resetKeys;
712
+ const next = this.props.resetKeys;
713
+ if (prev !== next && (prev?.length !== next?.length || next?.some((key, index) => !Object.is(key, prev?.[index])))) this.reset();
714
+ }
715
+ /** Clears the captured error so the children render again. */
716
+ reset = () => {
717
+ this.setState(INITIAL_STATE);
718
+ };
2049
719
  render() {
2050
720
  if (this.state.error) {
2051
721
  const { fallback } = this.props;
2052
- if (typeof fallback === "function") return fallback(this.state.error);
722
+ if (typeof fallback === "function") return fallback(this.state.error, this.reset);
2053
723
  if (fallback) return fallback;
2054
724
  return null;
2055
725
  }
@@ -2065,6 +735,13 @@ function useCommandState(selector) {
2065
735
  return useSyncExternalStore(stable.machine.subscribe, getSnapshot, getSnapshot);
2066
736
  }
2067
737
  // src/react/hooks/use-devtools.ts
738
+ const isDev = (() => {
739
+ try {
740
+ return process.env.NODE_ENV !== "production";
741
+ } catch {
742
+ return false;
743
+ }
744
+ })();
2068
745
  /** Branded key for the global devtools instance map */
2069
746
  const DEVTOOLS_KEY = "__CRIMSON_COMMAND_DEVTOOLS__";
2070
747
  /**