modern-cmdk 1.0.2 → 1.1.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/dist/codemod/cli.mjs +1 -3
- package/dist/codemod/transforms/data-attrs.d.mts +1 -2
- package/dist/codemod/transforms/data-attrs.mjs +0 -2
- package/dist/codemod/transforms/forward-ref.d.mts +1 -2
- package/dist/codemod/transforms/forward-ref.mjs +0 -2
- package/dist/codemod/transforms/import-rewrite.d.mts +1 -2
- package/dist/codemod/transforms/import-rewrite.mjs +0 -2
- package/dist/codemod/transforms/should-filter.d.mts +1 -2
- package/dist/codemod/transforms/should-filter.mjs +0 -2
- package/dist/core/index.d.mts +568 -2
- package/dist/core/index.mjs +1268 -1
- package/dist/react/index.d.mts +349 -3
- package/dist/react/index.mjs +1292 -209
- package/package.json +26 -12
- package/dist/codemod/cli.mjs.map +0 -1
- package/dist/codemod/transforms/data-attrs.d.mts.map +0 -1
- package/dist/codemod/transforms/data-attrs.mjs.map +0 -1
- package/dist/codemod/transforms/forward-ref.d.mts.map +0 -1
- package/dist/codemod/transforms/forward-ref.mjs.map +0 -1
- package/dist/codemod/transforms/import-rewrite.d.mts.map +0 -1
- package/dist/codemod/transforms/import-rewrite.mjs.map +0 -1
- package/dist/codemod/transforms/should-filter.d.mts.map +0 -1
- package/dist/codemod/transforms/should-filter.mjs.map +0 -1
- package/dist/core-CgQYpHK3.mjs +0 -1235
- package/dist/core-CgQYpHK3.mjs.map +0 -1
- package/dist/index-C6g94P7V.d.mts +0 -563
- package/dist/index-C6g94P7V.d.mts.map +0 -1
- package/dist/react/index.d.mts.map +0 -1
- package/dist/react/index.mjs.map +0 -1
- /package/{src → dist}/react/styles.css +0 -0
package/dist/react/index.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
'use client';
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
import { Component, Suspense, createContext, use, useCallback, useEffect, useId, useLayoutEffect, useMemo, useOptimistic, useRef, useState, useSyncExternalStore, useTransition } from "react";
|
|
4
4
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
5
5
|
import { Dialog } from "radix-ui";
|
|
6
|
+
import { createPortal } from "react-dom";
|
|
6
7
|
//#region src/react/activity.tsx
|
|
7
8
|
const ActivityComponent = "Activity" in React ? React.Activity : null;
|
|
8
9
|
/**
|
|
@@ -24,20 +25,28 @@ const CommandStableContext = createContext(null);
|
|
|
24
25
|
CommandStableContext.displayName = "CommandStableContext";
|
|
25
26
|
const CommandStateContext = createContext(null);
|
|
26
27
|
CommandStateContext.displayName = "CommandStateContext";
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
/** Consume stable context with a descriptive error — replaces 3-line boilerplate in every component */
|
|
29
|
+
function useStableContext(component) {
|
|
30
|
+
const ctx = use(CommandStableContext);
|
|
31
|
+
if (!ctx) throw new Error(`${component} must be used within a <Command> component`);
|
|
32
|
+
return ctx;
|
|
33
|
+
}
|
|
34
|
+
/** Consume state context with a descriptive error — replaces 3-line boilerplate in every component */
|
|
35
|
+
function useStateContext(component) {
|
|
36
|
+
const ctx = use(CommandStateContext);
|
|
37
|
+
if (!ctx) throw new Error(`${component} must be used within a <Command> component`);
|
|
38
|
+
return ctx;
|
|
39
|
+
}
|
|
33
40
|
const CommandGroupContext = createContext(null);
|
|
34
41
|
CommandGroupContext.displayName = "CommandGroupContext";
|
|
42
|
+
/** Context for the status container rendered outside the listbox — used by Command.Empty */
|
|
43
|
+
const CommandListStatusContext = createContext(null);
|
|
44
|
+
CommandListStatusContext.displayName = "CommandListStatusContext";
|
|
35
45
|
//#endregion
|
|
36
46
|
//#region src/react/async-items.tsx
|
|
37
47
|
/** Inner component that suspends via use() until items resolve */
|
|
38
48
|
function AsyncItemsInner({ items: itemsPromise, children }) {
|
|
39
|
-
const stable =
|
|
40
|
-
if (!stable) throw new Error("Command.AsyncItems must be used within a <Command> component");
|
|
49
|
+
const stable = useStableContext("Command.AsyncItems");
|
|
41
50
|
const items = use(itemsPromise);
|
|
42
51
|
useLayoutEffect(() => {
|
|
43
52
|
stable.machine.send({
|
|
@@ -47,6 +56,7 @@ function AsyncItemsInner({ items: itemsPromise, children }) {
|
|
|
47
56
|
}, [stable.machine, items]);
|
|
48
57
|
return /* @__PURE__ */ jsx(Fragment, { children: children(items) });
|
|
49
58
|
}
|
|
59
|
+
/** Suspense-based async item loader that resolves a promise of items using React 19 `use()`. */
|
|
50
60
|
function CommandAsyncItems({ items, fallback, children }) {
|
|
51
61
|
const stableRef = useRef(items);
|
|
52
62
|
if (stableRef.current !== items) stableRef.current = items;
|
|
@@ -60,6 +70,7 @@ function CommandAsyncItems({ items, fallback, children }) {
|
|
|
60
70
|
}
|
|
61
71
|
//#endregion
|
|
62
72
|
//#region src/react/badge.tsx
|
|
73
|
+
/** Inline badge for categorization or status indicators on command items. */
|
|
63
74
|
function CommandBadge({ ref, children, ...props }) {
|
|
64
75
|
return /* @__PURE__ */ jsx("span", {
|
|
65
76
|
ref,
|
|
@@ -69,13 +80,1078 @@ function CommandBadge({ ref, children, ...props }) {
|
|
|
69
80
|
});
|
|
70
81
|
}
|
|
71
82
|
//#endregion
|
|
83
|
+
//#region src/core/types.ts
|
|
84
|
+
/** Create a branded {@link ItemId} from a plain string. Ensures well-formed Unicode (ES2026). */
|
|
85
|
+
function itemId(id) {
|
|
86
|
+
return id.isWellFormed() ? id : id.toWellFormed();
|
|
87
|
+
}
|
|
88
|
+
/** Create a branded {@link GroupId} from a plain string. Ensures well-formed Unicode (ES2026). */
|
|
89
|
+
function groupId(id) {
|
|
90
|
+
return id.isWellFormed() ? id : id.toWellFormed();
|
|
91
|
+
}
|
|
92
|
+
/** Default frecency decay configuration */
|
|
93
|
+
const DEFAULT_FRECENCY_DECAY = {
|
|
94
|
+
hourWeight: 4,
|
|
95
|
+
dayWeight: 2,
|
|
96
|
+
weekWeight: 1.5,
|
|
97
|
+
monthWeight: 1,
|
|
98
|
+
olderWeight: .5
|
|
99
|
+
};
|
|
100
|
+
/** Initial state factory */
|
|
101
|
+
function createInitialState(options) {
|
|
102
|
+
return {
|
|
103
|
+
search: "",
|
|
104
|
+
activeId: null,
|
|
105
|
+
filteredIds: [],
|
|
106
|
+
groupedIds: /* @__PURE__ */ new Map(),
|
|
107
|
+
filteredCount: 0,
|
|
108
|
+
loading: false,
|
|
109
|
+
page: "root",
|
|
110
|
+
pageStack: [],
|
|
111
|
+
open: options?.open ?? false,
|
|
112
|
+
lastUpdated: Temporal.Now.instant()
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region src/core/frecency/memory-storage.ts
|
|
117
|
+
/**
|
|
118
|
+
* In-memory implementation of FrecencyStorage using a Map.
|
|
119
|
+
* Data does not persist across page reloads. Suitable for ephemeral
|
|
120
|
+
* sessions or as the default when no persistent storage is configured.
|
|
121
|
+
*/
|
|
122
|
+
var MemoryFrecencyStorage = class {
|
|
123
|
+
#store = /* @__PURE__ */ new Map();
|
|
124
|
+
load(namespace) {
|
|
125
|
+
return this.#store.get(namespace) ?? { records: /* @__PURE__ */ new Map() };
|
|
126
|
+
}
|
|
127
|
+
save(namespace, data) {
|
|
128
|
+
this.#store.set(namespace, data);
|
|
129
|
+
}
|
|
130
|
+
/** Serialize all stored data — Iterator Helpers pipeline (ES2026) */
|
|
131
|
+
toJSON() {
|
|
132
|
+
return Object.fromEntries(this.#store.entries().map(([namespace, data]) => [namespace, { records: data.records.entries().toArray() }]));
|
|
133
|
+
}
|
|
134
|
+
[Symbol.dispose]() {
|
|
135
|
+
this.#store.clear();
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region src/core/frecency/index.ts
|
|
140
|
+
/** Shared empty bonus map — avoids allocation when no frecency records exist */
|
|
141
|
+
const EMPTY_BONUS_MAP = /* @__PURE__ */ new Map();
|
|
142
|
+
/** Pre-computed bucket boundaries in hours — avoids Duration.total() per call */
|
|
143
|
+
const BUCKET_HOURS = {
|
|
144
|
+
hour: 1,
|
|
145
|
+
day: 24,
|
|
146
|
+
week: 168,
|
|
147
|
+
month: 720
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* Computes a frecency bonus for an item based on usage frequency and recency.
|
|
151
|
+
* Uses Temporal.Duration bucket boundaries (hour/day/week/month) with
|
|
152
|
+
* configurable exponential decay weights per time bucket.
|
|
153
|
+
*/
|
|
154
|
+
function computeFrecencyBonus(history, now = Temporal.Now.instant(), config = DEFAULT_FRECENCY_DECAY) {
|
|
155
|
+
const hours = now.since(history.lastUsed).total("hours");
|
|
156
|
+
const { hourWeight = 4, dayWeight = 2, weekWeight = 1.5, monthWeight = 1, olderWeight = .5 } = config;
|
|
157
|
+
const recencyWeight = hours < BUCKET_HOURS.hour ? hourWeight : hours < BUCKET_HOURS.day ? dayWeight : hours < BUCKET_HOURS.week ? weekWeight : hours < BUCKET_HOURS.month ? monthWeight : olderWeight;
|
|
158
|
+
return history.frequency * recencyWeight;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Stateful frecency engine that tracks item selection history and provides
|
|
162
|
+
* ranking boosts for search results. Supports pluggable storage backends
|
|
163
|
+
* and automatic flush on dispose. Uses Temporal.Now.instant() for timestamps.
|
|
164
|
+
*/
|
|
165
|
+
var FrecencyEngine = class {
|
|
166
|
+
#storage;
|
|
167
|
+
#namespace;
|
|
168
|
+
#data;
|
|
169
|
+
#config;
|
|
170
|
+
#dirty = false;
|
|
171
|
+
constructor(options) {
|
|
172
|
+
this.#storage = options.storage ?? new MemoryFrecencyStorage();
|
|
173
|
+
this.#namespace = options.namespace ?? "default";
|
|
174
|
+
this.#config = options.decayConfig ?? DEFAULT_FRECENCY_DECAY;
|
|
175
|
+
this.#data = { records: /* @__PURE__ */ new Map() };
|
|
176
|
+
}
|
|
177
|
+
/** Load frecency data from storage */
|
|
178
|
+
async load() {
|
|
179
|
+
this.#data = await this.#storage.load(this.#namespace);
|
|
180
|
+
}
|
|
181
|
+
/** Record a selection event for an item */
|
|
182
|
+
recordSelection(itemId) {
|
|
183
|
+
const now = Temporal.Now.instant();
|
|
184
|
+
const updated = {
|
|
185
|
+
frequency: (this.#data.records.get(itemId)?.frequency ?? 0) + 1,
|
|
186
|
+
lastUsed: now
|
|
187
|
+
};
|
|
188
|
+
this.#data.records.set(itemId, updated);
|
|
189
|
+
this.#dirty = true;
|
|
190
|
+
}
|
|
191
|
+
/** Get frecency bonus for an item */
|
|
192
|
+
getBonus(itemId, now) {
|
|
193
|
+
const record = this.#data.records.get(itemId);
|
|
194
|
+
if (!record) return 0;
|
|
195
|
+
return computeFrecencyBonus(record, now, this.#config);
|
|
196
|
+
}
|
|
197
|
+
/** Get all frecency bonuses as a map — for...of for hot ranking path (no closure overhead) */
|
|
198
|
+
getAllBonuses(now = Temporal.Now.instant()) {
|
|
199
|
+
if (this.#data.records.size === 0) return EMPTY_BONUS_MAP;
|
|
200
|
+
const bonuses = /* @__PURE__ */ new Map();
|
|
201
|
+
for (const [id, record] of this.#data.records) bonuses.set(id, computeFrecencyBonus(record, now, this.#config));
|
|
202
|
+
return bonuses;
|
|
203
|
+
}
|
|
204
|
+
/** Get the decay configuration */
|
|
205
|
+
getConfig() {
|
|
206
|
+
return this.#config;
|
|
207
|
+
}
|
|
208
|
+
/** Update decay configuration at runtime */
|
|
209
|
+
setConfig(config) {
|
|
210
|
+
this.#config = {
|
|
211
|
+
...this.#config,
|
|
212
|
+
...config
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/** Flush dirty data to storage */
|
|
216
|
+
async flush() {
|
|
217
|
+
if (!this.#dirty) return;
|
|
218
|
+
await this.#storage.save(this.#namespace, this.#data);
|
|
219
|
+
this.#dirty = false;
|
|
220
|
+
}
|
|
221
|
+
/** Get the raw frecency data */
|
|
222
|
+
getData() {
|
|
223
|
+
return this.#data;
|
|
224
|
+
}
|
|
225
|
+
/** Async dispose — flushes dirty data before cleanup. Use with `await using`. */
|
|
226
|
+
async [Symbol.asyncDispose]() {
|
|
227
|
+
if (this.#dirty) {
|
|
228
|
+
await this.#storage.save(this.#namespace, this.#data);
|
|
229
|
+
this.#dirty = false;
|
|
230
|
+
}
|
|
231
|
+
this.#storage[Symbol.dispose]();
|
|
232
|
+
}
|
|
233
|
+
[Symbol.dispose]() {
|
|
234
|
+
if (this.#dirty) Promise.try(() => this.#storage.save(this.#namespace, this.#data)).catch((err) => {
|
|
235
|
+
console.warn("[modern-cmdk] FrecencyEngine dispose flush failed:", err);
|
|
236
|
+
});
|
|
237
|
+
this.#storage[Symbol.dispose]();
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
//#endregion
|
|
241
|
+
//#region src/core/keyboard/matcher.ts
|
|
242
|
+
/**
|
|
243
|
+
* Tests whether a KeyboardEvent exactly matches a ParsedShortcut,
|
|
244
|
+
* comparing all four modifier keys (meta, ctrl, shift, alt) and the primary key.
|
|
245
|
+
*/
|
|
246
|
+
function matchesShortcut(event, shortcut) {
|
|
247
|
+
if (shortcut.meta !== event.metaKey) return false;
|
|
248
|
+
if (shortcut.ctrl !== event.ctrlKey) return false;
|
|
249
|
+
if (shortcut.shift !== event.shiftKey) return false;
|
|
250
|
+
if (shortcut.alt !== event.altKey) return false;
|
|
251
|
+
return event.key.toLowerCase() === shortcut.key;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Detects conflicting shortcut bindings by grouping on their normalized form
|
|
255
|
+
* via Map.groupBy (ES2026). Returns only groups with two or more colliding shortcuts.
|
|
256
|
+
* Map.groupBy returns a Map directly — no Object.entries conversion needed.
|
|
257
|
+
*/
|
|
258
|
+
function detectConflicts(shortcuts) {
|
|
259
|
+
const grouped = Map.groupBy(shortcuts, (s) => s.normalized);
|
|
260
|
+
return new Map(grouped.entries().filter(([, group]) => group.length > 1));
|
|
261
|
+
}
|
|
262
|
+
//#endregion
|
|
263
|
+
//#region src/core/keyboard/parser.ts
|
|
264
|
+
const isMac = typeof navigator !== "undefined" && (navigator.userAgentData?.platform === "macOS" || /Mac|iPod|iPhone|iPad/.test(navigator.platform));
|
|
265
|
+
/** Modifier aliases — cross-platform Mod resolution */
|
|
266
|
+
const MODIFIER_MAP = {
|
|
267
|
+
mod: isMac ? "meta" : "ctrl",
|
|
268
|
+
meta: "meta",
|
|
269
|
+
cmd: "meta",
|
|
270
|
+
command: "meta",
|
|
271
|
+
ctrl: "ctrl",
|
|
272
|
+
control: "ctrl",
|
|
273
|
+
shift: "shift",
|
|
274
|
+
alt: "alt",
|
|
275
|
+
option: "alt",
|
|
276
|
+
opt: "alt"
|
|
277
|
+
};
|
|
278
|
+
/**
|
|
279
|
+
* Parse a shortcut string like "Mod+Shift+K" into a structured ParsedShortcut.
|
|
280
|
+
* Uses String.isWellFormed() (ES2026) to ensure valid Unicode input.
|
|
281
|
+
*/
|
|
282
|
+
function parseShortcut(shortcut) {
|
|
283
|
+
const safeShortcut = shortcut.isWellFormed() ? shortcut : shortcut.toWellFormed();
|
|
284
|
+
const parts = safeShortcut.split("+").map((p) => p.trim().toLowerCase());
|
|
285
|
+
let meta = false;
|
|
286
|
+
let ctrl = false;
|
|
287
|
+
let shift = false;
|
|
288
|
+
let alt = false;
|
|
289
|
+
let key = "";
|
|
290
|
+
for (const part of parts) {
|
|
291
|
+
const modifier = MODIFIER_MAP[part];
|
|
292
|
+
if (modifier) switch (modifier) {
|
|
293
|
+
case "meta":
|
|
294
|
+
meta = true;
|
|
295
|
+
break;
|
|
296
|
+
case "ctrl":
|
|
297
|
+
ctrl = true;
|
|
298
|
+
break;
|
|
299
|
+
case "shift":
|
|
300
|
+
shift = true;
|
|
301
|
+
break;
|
|
302
|
+
case "alt":
|
|
303
|
+
alt = true;
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
else key = part;
|
|
307
|
+
}
|
|
308
|
+
const modifiers = [];
|
|
309
|
+
if (meta) modifiers.push("meta");
|
|
310
|
+
if (ctrl) modifiers.push("ctrl");
|
|
311
|
+
if (shift) modifiers.push("shift");
|
|
312
|
+
if (alt) modifiers.push("alt");
|
|
313
|
+
modifiers.push(key);
|
|
314
|
+
return {
|
|
315
|
+
key,
|
|
316
|
+
meta,
|
|
317
|
+
ctrl,
|
|
318
|
+
shift,
|
|
319
|
+
alt,
|
|
320
|
+
raw: safeShortcut,
|
|
321
|
+
normalized: modifiers.join("+")
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Formats a ParsedShortcut for display using platform-appropriate symbols.
|
|
326
|
+
* On macOS: renders glyphs (⌘, ⇧, ⌥, ⌃). On other platforms: text labels joined by "+".
|
|
327
|
+
*/
|
|
328
|
+
function formatShortcut(parsed) {
|
|
329
|
+
const parts = [];
|
|
330
|
+
if (parsed.meta) parts.push(isMac ? "⌘" : "Ctrl");
|
|
331
|
+
if (parsed.ctrl && !(!isMac && parsed.meta)) parts.push(isMac ? "⌃" : "Ctrl");
|
|
332
|
+
if (parsed.shift) parts.push(isMac ? "⇧" : "Shift");
|
|
333
|
+
if (parsed.alt) parts.push(isMac ? "⌥" : "Alt");
|
|
334
|
+
const displayKey = parsed.key.length === 1 ? parsed.key.toUpperCase() : parsed.key.charAt(0).toUpperCase() + parsed.key.slice(1);
|
|
335
|
+
parts.push(displayKey);
|
|
336
|
+
return isMac ? parts.join("") : parts.join("+");
|
|
337
|
+
}
|
|
338
|
+
//#endregion
|
|
339
|
+
//#region src/core/keyboard/index.ts
|
|
340
|
+
/**
|
|
341
|
+
* Manages keyboard shortcut bindings with global keydown listening.
|
|
342
|
+
* Supports `using` for automatic cleanup of both individual bindings and the
|
|
343
|
+
* global document listener. Detects conflicts via Map.groupBy (ES2026).
|
|
344
|
+
*/
|
|
345
|
+
var KeyboardShortcutRegistry = class {
|
|
346
|
+
#bindings = /* @__PURE__ */ new Map();
|
|
347
|
+
#globalListener = null;
|
|
348
|
+
#enabled = true;
|
|
349
|
+
constructor(options) {
|
|
350
|
+
this.#enabled = options?.enabled ?? true;
|
|
351
|
+
this.#attachGlobalListener();
|
|
352
|
+
}
|
|
353
|
+
/** Register a shortcut binding — returns Disposable for `using` auto-deregister */
|
|
354
|
+
register(shortcutStr, itemId, handler) {
|
|
355
|
+
const shortcut = parseShortcut(shortcutStr);
|
|
356
|
+
this.#bindings.set(shortcut.normalized, {
|
|
357
|
+
shortcut,
|
|
358
|
+
itemId,
|
|
359
|
+
handler
|
|
360
|
+
});
|
|
361
|
+
return { [Symbol.dispose]: () => {
|
|
362
|
+
this.#bindings.delete(shortcut.normalized);
|
|
363
|
+
} };
|
|
364
|
+
}
|
|
365
|
+
/** Unregister a shortcut by its string representation */
|
|
366
|
+
unregister(shortcutStr) {
|
|
367
|
+
const shortcut = parseShortcut(shortcutStr);
|
|
368
|
+
this.#bindings.delete(shortcut.normalized);
|
|
369
|
+
}
|
|
370
|
+
/** Unregister all shortcuts for a given item — Map spec allows deletion during iteration */
|
|
371
|
+
unregisterByItem(itemId) {
|
|
372
|
+
for (const [key, binding] of this.#bindings) if (binding.itemId === itemId) this.#bindings.delete(key);
|
|
373
|
+
}
|
|
374
|
+
/** Check for conflicting shortcuts */
|
|
375
|
+
getConflicts() {
|
|
376
|
+
return detectConflicts(this.#bindings.values().map((b) => b.shortcut).toArray());
|
|
377
|
+
}
|
|
378
|
+
/** Enable or disable global shortcut listening */
|
|
379
|
+
setEnabled(enabled) {
|
|
380
|
+
this.#enabled = enabled;
|
|
381
|
+
}
|
|
382
|
+
/** Get all registered bindings */
|
|
383
|
+
getBindings() {
|
|
384
|
+
return this.#bindings;
|
|
385
|
+
}
|
|
386
|
+
#attachGlobalListener() {
|
|
387
|
+
if (typeof document === "undefined") return;
|
|
388
|
+
this.#globalListener = (event) => {
|
|
389
|
+
if (!this.#enabled) return;
|
|
390
|
+
for (const binding of this.#bindings.values()) if (matchesShortcut(event, binding.shortcut)) {
|
|
391
|
+
event.preventDefault();
|
|
392
|
+
event.stopPropagation();
|
|
393
|
+
binding.handler();
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
document.addEventListener("keydown", this.#globalListener);
|
|
398
|
+
}
|
|
399
|
+
#detachGlobalListener() {
|
|
400
|
+
if (this.#globalListener && typeof document !== "undefined") {
|
|
401
|
+
document.removeEventListener("keydown", this.#globalListener);
|
|
402
|
+
this.#globalListener = null;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
[Symbol.dispose]() {
|
|
406
|
+
this.#detachGlobalListener();
|
|
407
|
+
this.#bindings.clear();
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
//#endregion
|
|
411
|
+
//#region src/core/registry.ts
|
|
412
|
+
/**
|
|
413
|
+
* Manages registration and lookup of command items and groups.
|
|
414
|
+
* Uses ES2026 Set methods (union/intersection/difference) for efficient ID operations.
|
|
415
|
+
* All registration methods return Disposable for automatic cleanup via `using`.
|
|
416
|
+
*/
|
|
417
|
+
var CommandRegistry = class {
|
|
418
|
+
#items = /* @__PURE__ */ new Map();
|
|
419
|
+
#groups = /* @__PURE__ */ new Map();
|
|
420
|
+
#itemOrder = [];
|
|
421
|
+
#itemOrderSet = /* @__PURE__ */ new Set();
|
|
422
|
+
#cachedItems = null;
|
|
423
|
+
#cachedGroups = null;
|
|
424
|
+
#invalidateCache() {
|
|
425
|
+
this.#cachedItems = null;
|
|
426
|
+
}
|
|
427
|
+
#invalidateGroupCache() {
|
|
428
|
+
this.#cachedGroups = null;
|
|
429
|
+
}
|
|
430
|
+
/** Register an item — returns Disposable for `using` auto-deregister */
|
|
431
|
+
registerItem(item) {
|
|
432
|
+
this.#items.set(item.id, item);
|
|
433
|
+
if (!this.#itemOrderSet.has(item.id)) {
|
|
434
|
+
this.#itemOrder.push(item.id);
|
|
435
|
+
this.#itemOrderSet.add(item.id);
|
|
436
|
+
}
|
|
437
|
+
this.#invalidateCache();
|
|
438
|
+
return { [Symbol.dispose]: () => {
|
|
439
|
+
this.unregisterItem(item.id);
|
|
440
|
+
} };
|
|
441
|
+
}
|
|
442
|
+
/** Register multiple items at once — single Disposable for batch cleanup. for...of for hot path. */
|
|
443
|
+
registerItems(items) {
|
|
444
|
+
for (const item of items) {
|
|
445
|
+
this.#items.set(item.id, item);
|
|
446
|
+
if (!this.#itemOrderSet.has(item.id)) {
|
|
447
|
+
this.#itemOrder.push(item.id);
|
|
448
|
+
this.#itemOrderSet.add(item.id);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
this.#invalidateCache();
|
|
452
|
+
const ids = new Set(items.values().map((i) => i.id));
|
|
453
|
+
return { [Symbol.dispose]: () => {
|
|
454
|
+
this.unregisterItems(ids);
|
|
455
|
+
} };
|
|
456
|
+
}
|
|
457
|
+
/** Unregister a single item — O(1) Map/Set delete, indexOf+toSpliced for order */
|
|
458
|
+
unregisterItem(id) {
|
|
459
|
+
if (!this.#items.has(id)) return;
|
|
460
|
+
this.#items.delete(id);
|
|
461
|
+
this.#itemOrderSet.delete(id);
|
|
462
|
+
const idx = this.#itemOrder.indexOf(id);
|
|
463
|
+
if (idx !== -1) this.#itemOrder = this.#itemOrder.toSpliced(idx, 1);
|
|
464
|
+
this.#invalidateCache();
|
|
465
|
+
}
|
|
466
|
+
/** Bulk unregister using Set.difference (ES2026) — for...of for hot path */
|
|
467
|
+
unregisterItems(ids) {
|
|
468
|
+
for (const id of ids) this.#items.delete(id);
|
|
469
|
+
this.#itemOrderSet = this.#itemOrderSet.difference(ids);
|
|
470
|
+
this.#itemOrder = this.#itemOrder.filter((id) => !ids.has(id));
|
|
471
|
+
this.#invalidateCache();
|
|
472
|
+
}
|
|
473
|
+
/** Register a group */
|
|
474
|
+
registerGroup(group) {
|
|
475
|
+
this.#groups.set(group.id, group);
|
|
476
|
+
this.#invalidateGroupCache();
|
|
477
|
+
return { [Symbol.dispose]: () => {
|
|
478
|
+
this.#groups.delete(group.id);
|
|
479
|
+
this.#invalidateGroupCache();
|
|
480
|
+
} };
|
|
481
|
+
}
|
|
482
|
+
/** Unregister a group */
|
|
483
|
+
unregisterGroup(id) {
|
|
484
|
+
this.#groups.delete(id);
|
|
485
|
+
this.#invalidateGroupCache();
|
|
486
|
+
}
|
|
487
|
+
/** Get an item by ID — O(1) */
|
|
488
|
+
getItem(id) {
|
|
489
|
+
return this.#items.get(id);
|
|
490
|
+
}
|
|
491
|
+
/** Get a group by ID — O(1) */
|
|
492
|
+
getGroup(id) {
|
|
493
|
+
return this.#groups.get(id);
|
|
494
|
+
}
|
|
495
|
+
/** Get all registered items in insertion order — cached between mutations */
|
|
496
|
+
getItems() {
|
|
497
|
+
if (this.#cachedItems !== null) return this.#cachedItems;
|
|
498
|
+
this.#cachedItems = this.#itemOrder.values().map((id) => this.#items.get(id)).filter((item) => item != null).toArray();
|
|
499
|
+
return this.#cachedItems;
|
|
500
|
+
}
|
|
501
|
+
/** Get all registered groups sorted by priority — cached between group mutations */
|
|
502
|
+
getGroups() {
|
|
503
|
+
if (this.#cachedGroups !== null) return this.#cachedGroups;
|
|
504
|
+
this.#cachedGroups = this.#groups.values().toArray().toSorted((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
|
|
505
|
+
return this.#cachedGroups;
|
|
506
|
+
}
|
|
507
|
+
/** Get items grouped by groupId using Map.groupBy (ES2026) — direct Map, no Object.entries conversion */
|
|
508
|
+
getGroupedItems() {
|
|
509
|
+
const items = this.getItems();
|
|
510
|
+
const grouped = Map.groupBy(items, (item) => item.groupId ?? "__ungrouped");
|
|
511
|
+
const result = /* @__PURE__ */ new Map();
|
|
512
|
+
for (const group of this.getGroups()) {
|
|
513
|
+
const groupItems = grouped.get(group.id);
|
|
514
|
+
if (groupItems != null) result.set(group.id, groupItems);
|
|
515
|
+
}
|
|
516
|
+
const ungrouped = grouped.get("__ungrouped");
|
|
517
|
+
if (ungrouped) result.set("__ungrouped", ungrouped);
|
|
518
|
+
return result;
|
|
519
|
+
}
|
|
520
|
+
/** Get item IDs as a Set — returns internal set (O(1), no allocation) */
|
|
521
|
+
getItemIds() {
|
|
522
|
+
return this.#itemOrderSet;
|
|
523
|
+
}
|
|
524
|
+
/** Find intersection of registered items with a candidate set (ES2026 Set.intersection) */
|
|
525
|
+
intersectWith(candidateIds) {
|
|
526
|
+
return this.#itemOrderSet.intersection(candidateIds);
|
|
527
|
+
}
|
|
528
|
+
/** Find items registered but not in a given set (ES2026 Set.difference) */
|
|
529
|
+
differenceFrom(otherIds) {
|
|
530
|
+
return this.#itemOrderSet.difference(otherIds);
|
|
531
|
+
}
|
|
532
|
+
/** Union of registered IDs with another set (ES2026 Set.union) */
|
|
533
|
+
unionWith(otherIds) {
|
|
534
|
+
return this.#itemOrderSet.union(otherIds);
|
|
535
|
+
}
|
|
536
|
+
/** Check if registered items are a subset of the given set (ES2026 Set.isSubsetOf) */
|
|
537
|
+
isSubsetOf(otherIds) {
|
|
538
|
+
return this.#itemOrderSet.isSubsetOf(otherIds);
|
|
539
|
+
}
|
|
540
|
+
/** Check if registered items are a superset of the given set (ES2026 Set.isSupersetOf) */
|
|
541
|
+
isSupersetOf(otherIds) {
|
|
542
|
+
return this.#itemOrderSet.isSupersetOf(otherIds);
|
|
543
|
+
}
|
|
544
|
+
/** Check if registered items share no IDs with the given set (ES2026 Set.isDisjointFrom) */
|
|
545
|
+
isDisjointFrom(otherIds) {
|
|
546
|
+
return this.#itemOrderSet.isDisjointFrom(otherIds);
|
|
547
|
+
}
|
|
548
|
+
/** Symmetric difference between registered IDs and another set (ES2026 Set.symmetricDifference) */
|
|
549
|
+
symmetricDifferenceWith(otherIds) {
|
|
550
|
+
return this.#itemOrderSet.symmetricDifference(otherIds);
|
|
551
|
+
}
|
|
552
|
+
/** Total number of registered items */
|
|
553
|
+
get size() {
|
|
554
|
+
return this.#items.size;
|
|
555
|
+
}
|
|
556
|
+
/** Total number of registered groups */
|
|
557
|
+
get groupCount() {
|
|
558
|
+
return this.#groups.size;
|
|
559
|
+
}
|
|
560
|
+
/** Clear all items and groups */
|
|
561
|
+
clear() {
|
|
562
|
+
this.#items.clear();
|
|
563
|
+
this.#groups.clear();
|
|
564
|
+
this.#itemOrder = [];
|
|
565
|
+
this.#itemOrderSet.clear();
|
|
566
|
+
this.#invalidateCache();
|
|
567
|
+
this.#invalidateGroupCache();
|
|
568
|
+
}
|
|
569
|
+
[Symbol.dispose]() {
|
|
570
|
+
this.clear();
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
//#endregion
|
|
574
|
+
//#region src/core/search/default-scorer.ts
|
|
575
|
+
function isWordSeparator(code) {
|
|
576
|
+
return code === 32 || code === 9 || code === 45 || code === 95 || code === 46 || code === 47 || code === 92;
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Internal fast path: called by search engine with pre-lowered query and targets.
|
|
580
|
+
* Eliminates all toLowerCase() calls on the hot path.
|
|
581
|
+
*/
|
|
582
|
+
function scoreItemPreLowered(lowerQuery, id, lowerValue, lowerKeywords) {
|
|
583
|
+
let bestScore = 0;
|
|
584
|
+
let bestMatches = null;
|
|
585
|
+
const valueResult = scoreTarget(lowerQuery, lowerValue);
|
|
586
|
+
if (valueResult !== null) {
|
|
587
|
+
bestScore = valueResult.score;
|
|
588
|
+
bestMatches = valueResult.matches;
|
|
589
|
+
if (bestScore >= 1) return {
|
|
590
|
+
id,
|
|
591
|
+
score: 1,
|
|
592
|
+
matches: bestMatches
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
if (lowerKeywords !== void 0) for (let i = 0; i < lowerKeywords.length; i++) {
|
|
596
|
+
const result = scoreTarget(lowerQuery, lowerKeywords[i]);
|
|
597
|
+
if (result !== null && result.score > bestScore) {
|
|
598
|
+
bestScore = result.score;
|
|
599
|
+
bestMatches = result.matches;
|
|
600
|
+
if (bestScore >= 1) break;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return bestMatches !== null ? {
|
|
604
|
+
id,
|
|
605
|
+
score: bestScore,
|
|
606
|
+
matches: bestMatches
|
|
607
|
+
} : null;
|
|
608
|
+
}
|
|
609
|
+
function scoreTarget(query, lowerTarget) {
|
|
610
|
+
const tLen = lowerTarget.length;
|
|
611
|
+
if (tLen === 0) return null;
|
|
612
|
+
const qLen = query.length;
|
|
613
|
+
if (qLen === 0) return {
|
|
614
|
+
score: 1,
|
|
615
|
+
matches: []
|
|
616
|
+
};
|
|
617
|
+
if (qLen > tLen) return null;
|
|
618
|
+
if (lowerTarget === query) return {
|
|
619
|
+
score: 1,
|
|
620
|
+
matches: [[0, qLen]]
|
|
621
|
+
};
|
|
622
|
+
if (lowerTarget.startsWith(query)) return {
|
|
623
|
+
score: .9 + .1 * (qLen / tLen),
|
|
624
|
+
matches: [[0, qLen]]
|
|
625
|
+
};
|
|
626
|
+
const substringIdx = lowerTarget.indexOf(query);
|
|
627
|
+
if (substringIdx !== -1) {
|
|
628
|
+
const positionBonus = 1 - substringIdx / tLen;
|
|
629
|
+
const lengthRatio = qLen / tLen;
|
|
630
|
+
return {
|
|
631
|
+
score: .5 + .3 * positionBonus + .2 * lengthRatio,
|
|
632
|
+
matches: [[substringIdx, substringIdx + qLen]]
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
const wordBoundaryResult = scoreWordBoundary(query, qLen, lowerTarget, tLen);
|
|
636
|
+
if (wordBoundaryResult) return wordBoundaryResult;
|
|
637
|
+
return scoreFuzzy(query, qLen, lowerTarget, tLen);
|
|
638
|
+
}
|
|
639
|
+
function scoreWordBoundary(query, qLen, lowerTarget, tLen) {
|
|
640
|
+
let queryIdx = 0;
|
|
641
|
+
const matches = [];
|
|
642
|
+
let totalWeightedLen = 0;
|
|
643
|
+
let i = 0;
|
|
644
|
+
while (i < tLen && queryIdx < qLen) if ((i === 0 || isWordSeparator(lowerTarget.charCodeAt(i - 1))) && lowerTarget.charCodeAt(i) === query.charCodeAt(queryIdx)) {
|
|
645
|
+
const matchStart = i;
|
|
646
|
+
let matchLen = 0;
|
|
647
|
+
while (queryIdx < qLen && i < tLen && !isWordSeparator(lowerTarget.charCodeAt(i)) && lowerTarget.charCodeAt(i) === query.charCodeAt(queryIdx)) {
|
|
648
|
+
queryIdx++;
|
|
649
|
+
i++;
|
|
650
|
+
matchLen++;
|
|
651
|
+
}
|
|
652
|
+
matches.push([matchStart, matchStart + matchLen]);
|
|
653
|
+
totalWeightedLen += matchLen * 2;
|
|
654
|
+
} else i++;
|
|
655
|
+
if (queryIdx !== qLen) return null;
|
|
656
|
+
const maxPossible = qLen * 2;
|
|
657
|
+
return {
|
|
658
|
+
score: .3 + .4 * (totalWeightedLen / maxPossible),
|
|
659
|
+
matches
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
function scoreFuzzy(query, qLen, lowerTarget, tLen) {
|
|
663
|
+
let queryIdx = 0;
|
|
664
|
+
let targetIdx = 0;
|
|
665
|
+
const matches = [];
|
|
666
|
+
let currentMatchStart = -1;
|
|
667
|
+
let contiguityScore = 0;
|
|
668
|
+
while (queryIdx < qLen && targetIdx < tLen) if (query.charCodeAt(queryIdx) === lowerTarget.charCodeAt(targetIdx)) {
|
|
669
|
+
if (currentMatchStart === -1) currentMatchStart = targetIdx;
|
|
670
|
+
queryIdx++;
|
|
671
|
+
targetIdx++;
|
|
672
|
+
} else {
|
|
673
|
+
if (currentMatchStart !== -1) {
|
|
674
|
+
const segmentLen = targetIdx - currentMatchStart;
|
|
675
|
+
matches.push([currentMatchStart, targetIdx]);
|
|
676
|
+
contiguityScore += segmentLen * segmentLen;
|
|
677
|
+
currentMatchStart = -1;
|
|
678
|
+
}
|
|
679
|
+
targetIdx++;
|
|
680
|
+
}
|
|
681
|
+
if (currentMatchStart !== -1) {
|
|
682
|
+
const segmentLen = targetIdx - currentMatchStart;
|
|
683
|
+
matches.push([currentMatchStart, targetIdx]);
|
|
684
|
+
contiguityScore += segmentLen * segmentLen;
|
|
685
|
+
}
|
|
686
|
+
if (queryIdx !== qLen) return null;
|
|
687
|
+
const maxContiguity = qLen * qLen;
|
|
688
|
+
const contiguityRatio = contiguityScore / maxContiguity;
|
|
689
|
+
const positionBonus = 1 - (matches[0]?.[0] ?? 0) / tLen;
|
|
690
|
+
const lengthRatio = qLen / tLen;
|
|
691
|
+
return {
|
|
692
|
+
score: .1 + .3 * contiguityRatio + .15 * positionBonus + .1 * lengthRatio,
|
|
693
|
+
matches
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
//#endregion
|
|
697
|
+
//#region src/core/search/index.ts
|
|
698
|
+
const __DEV__$1 = globalThis.process ? globalThis.process.env.NODE_ENV !== "production" : false;
|
|
699
|
+
let leakRegistry;
|
|
700
|
+
if (__DEV__$1) leakRegistry = new FinalizationRegistry((label) => {
|
|
701
|
+
console.warn(`[modern-cmdk] ${label} was garbage collected without being disposed. Use \`using\` or call [Symbol.dispose]() to prevent leaks.`);
|
|
702
|
+
});
|
|
703
|
+
/**
|
|
704
|
+
* Creates a search engine with pluggable scorer and incremental filtering.
|
|
705
|
+
* When the query extends the previous one, only prior matches are re-scored.
|
|
706
|
+
* Pre-caches lowercase at index time — zero toLowerCase() calls during search.
|
|
707
|
+
* Uses Iterator Helpers (ES2026) for result pipelines and Set.difference for pruning.
|
|
708
|
+
*/
|
|
709
|
+
function createSearchEngine(options) {
|
|
710
|
+
const customScorer = options?.scorer;
|
|
711
|
+
const useDefaultScorer = customScorer === void 0;
|
|
712
|
+
const indexedEntries = /* @__PURE__ */ new Map();
|
|
713
|
+
let previousQuery = "";
|
|
714
|
+
let previousResults = /* @__PURE__ */ new Set();
|
|
715
|
+
const sentinel = {};
|
|
716
|
+
if (__DEV__$1 && leakRegistry) leakRegistry.register(sentinel, "SearchEngine", sentinel);
|
|
717
|
+
return {
|
|
718
|
+
index(items) {
|
|
719
|
+
for (const item of items) indexedEntries.set(item.id, {
|
|
720
|
+
item,
|
|
721
|
+
lowerValue: item.value.toLowerCase(),
|
|
722
|
+
lowerKeywords: item.keywords?.map((k) => k.toLowerCase())
|
|
723
|
+
});
|
|
724
|
+
previousQuery = "";
|
|
725
|
+
previousResults = /* @__PURE__ */ new Set();
|
|
726
|
+
},
|
|
727
|
+
search(query, items) {
|
|
728
|
+
if (query === "") {
|
|
729
|
+
previousQuery = "";
|
|
730
|
+
previousResults = new Set(items.values().map((i) => i.id));
|
|
731
|
+
return items.values().map((item) => ({
|
|
732
|
+
id: item.id,
|
|
733
|
+
score: 1,
|
|
734
|
+
matches: []
|
|
735
|
+
}));
|
|
736
|
+
}
|
|
737
|
+
const lowerQuery = query.toLowerCase();
|
|
738
|
+
const candidateItems = query.startsWith(previousQuery) && previousQuery.length > 0 ? items.values().filter((item) => previousResults.has(item.id)) : items;
|
|
739
|
+
const results = [];
|
|
740
|
+
if (useDefaultScorer) for (const item of candidateItems) {
|
|
741
|
+
const entry = indexedEntries.get(item.id);
|
|
742
|
+
const result = entry ? scoreItemPreLowered(lowerQuery, item.id, entry.lowerValue, entry.lowerKeywords) : scoreItemPreLowered(lowerQuery, item.id, item.value.toLowerCase(), item.keywords?.map((k) => k.toLowerCase()));
|
|
743
|
+
if (result !== null) results.push(result);
|
|
744
|
+
}
|
|
745
|
+
else for (const item of candidateItems) {
|
|
746
|
+
const result = customScorer(query, item);
|
|
747
|
+
if (result != null) results.push(result);
|
|
748
|
+
}
|
|
749
|
+
results.sort((a, b) => b.score - a.score);
|
|
750
|
+
previousQuery = query;
|
|
751
|
+
previousResults = new Set(results.values().map((r) => r.id));
|
|
752
|
+
return results.values();
|
|
753
|
+
},
|
|
754
|
+
remove(ids) {
|
|
755
|
+
for (const id of ids) indexedEntries.delete(id);
|
|
756
|
+
previousResults = previousResults.difference(ids);
|
|
757
|
+
},
|
|
758
|
+
clear() {
|
|
759
|
+
indexedEntries.clear();
|
|
760
|
+
previousQuery = "";
|
|
761
|
+
previousResults.clear();
|
|
762
|
+
},
|
|
763
|
+
[Symbol.dispose]() {
|
|
764
|
+
indexedEntries.clear();
|
|
765
|
+
previousResults.clear();
|
|
766
|
+
if (__DEV__$1 && leakRegistry) leakRegistry.unregister(sentinel);
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
//#endregion
|
|
771
|
+
//#region src/core/utils/event-emitter.ts
|
|
772
|
+
/**
|
|
773
|
+
* Type-safe event emitter with strong references and Disposable subscriptions.
|
|
774
|
+
* Returns Disposable from `on()` for automatic unsubscription via `using`.
|
|
775
|
+
*/
|
|
776
|
+
var TypedEmitter = class {
|
|
777
|
+
#listeners = /* @__PURE__ */ new Map();
|
|
778
|
+
/** Subscribe to an event. Returns a Disposable that removes the listener when disposed. */
|
|
779
|
+
on(event, listener) {
|
|
780
|
+
let set = this.#listeners.get(event);
|
|
781
|
+
if (!set) {
|
|
782
|
+
set = /* @__PURE__ */ new Set();
|
|
783
|
+
this.#listeners.set(event, set);
|
|
784
|
+
}
|
|
785
|
+
const fn = listener;
|
|
786
|
+
set.add(fn);
|
|
787
|
+
return { [Symbol.dispose]: () => {
|
|
788
|
+
set.delete(fn);
|
|
789
|
+
} };
|
|
790
|
+
}
|
|
791
|
+
/** Emit an event to all listeners. Snapshots the set to prevent live-iteration issues. */
|
|
792
|
+
emit(event, data) {
|
|
793
|
+
const set = this.#listeners.get(event);
|
|
794
|
+
if (!set || set.size === 0) return;
|
|
795
|
+
for (const fn of [...set]) fn(data);
|
|
796
|
+
}
|
|
797
|
+
/** Check if any listeners exist for an event */
|
|
798
|
+
has(event) {
|
|
799
|
+
const set = this.#listeners.get(event);
|
|
800
|
+
return set !== void 0 && set.size > 0;
|
|
801
|
+
}
|
|
802
|
+
/** Get count of listeners for an event */
|
|
803
|
+
listenerCount(event) {
|
|
804
|
+
return this.#listeners.get(event)?.size ?? 0;
|
|
805
|
+
}
|
|
806
|
+
/** Remove all listeners */
|
|
807
|
+
removeAll() {
|
|
808
|
+
this.#listeners.clear();
|
|
809
|
+
}
|
|
810
|
+
[Symbol.dispose]() {
|
|
811
|
+
this.#listeners.clear();
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
//#endregion
|
|
815
|
+
//#region src/core/utils/scheduler.ts
|
|
816
|
+
/** Whether we're in a browser environment with rAF available */
|
|
817
|
+
const hasRAF = typeof requestAnimationFrame === "function";
|
|
818
|
+
/** Feature detection for cutting-edge scheduling APIs */
|
|
819
|
+
const canYield = typeof globalThis.scheduler?.yield === "function";
|
|
820
|
+
const hasInputPending = typeof navigator?.scheduling?.isInputPending === "function";
|
|
821
|
+
/**
|
|
822
|
+
* Creates a batched update scheduler. Coalesces rapid state updates into a single
|
|
823
|
+
* execution pass using rAF (browser) or queueMicrotask (Node.js).
|
|
824
|
+
*/
|
|
825
|
+
function createScheduler() {
|
|
826
|
+
let pending = [];
|
|
827
|
+
let rafId = null;
|
|
828
|
+
let microtaskScheduled = false;
|
|
829
|
+
function executeBatch() {
|
|
830
|
+
const batch = pending;
|
|
831
|
+
pending = [];
|
|
832
|
+
rafId = null;
|
|
833
|
+
microtaskScheduled = false;
|
|
834
|
+
if (batch.length > 10 && hasInputPending && navigator.scheduling?.isInputPending?.()) {
|
|
835
|
+
pending = [...batch, ...pending];
|
|
836
|
+
if (canYield) globalThis.scheduler?.yield?.().then(() => void executeBatch());
|
|
837
|
+
else rafId ??= requestAnimationFrame(() => void executeBatch());
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
for (const update of batch) try {
|
|
841
|
+
update();
|
|
842
|
+
} catch (error) {
|
|
843
|
+
queueMicrotask(() => {
|
|
844
|
+
throw error;
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
function flush() {
|
|
849
|
+
if (rafId !== null) {
|
|
850
|
+
cancelAnimationFrame(rafId);
|
|
851
|
+
rafId = null;
|
|
852
|
+
}
|
|
853
|
+
microtaskScheduled = false;
|
|
854
|
+
executeBatch();
|
|
855
|
+
}
|
|
856
|
+
return {
|
|
857
|
+
schedule(update) {
|
|
858
|
+
pending.push(update);
|
|
859
|
+
if (hasRAF) rafId ??= requestAnimationFrame(() => void executeBatch());
|
|
860
|
+
else if (!microtaskScheduled) {
|
|
861
|
+
microtaskScheduled = true;
|
|
862
|
+
queueMicrotask(executeBatch);
|
|
863
|
+
}
|
|
864
|
+
},
|
|
865
|
+
flush,
|
|
866
|
+
[Symbol.dispose]() {
|
|
867
|
+
if (rafId !== null) {
|
|
868
|
+
cancelAnimationFrame(rafId);
|
|
869
|
+
rafId = null;
|
|
870
|
+
}
|
|
871
|
+
microtaskScheduled = false;
|
|
872
|
+
pending = [];
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
//#endregion
|
|
877
|
+
//#region src/core/machine.ts
|
|
878
|
+
/**
|
|
879
|
+
* Creates a headless command palette state machine with search, frecency,
|
|
880
|
+
* and keyboard shortcut support. Implements Disposable for `using` cleanup.
|
|
881
|
+
*
|
|
882
|
+
* @example
|
|
883
|
+
* ```ts
|
|
884
|
+
* using machine = createCommandMachine({ items, loop: true });
|
|
885
|
+
* machine.subscribe(() => console.log(machine.getState()));
|
|
886
|
+
* ```
|
|
887
|
+
*/
|
|
888
|
+
function createCommandMachine(options = {}) {
|
|
889
|
+
let state = createInitialState(options);
|
|
890
|
+
let filteredIdSet = /* @__PURE__ */ new Set();
|
|
891
|
+
let filteredIdIndex = /* @__PURE__ */ new Map();
|
|
892
|
+
const registry = new CommandRegistry();
|
|
893
|
+
const searchEngine = options.search ?? createSearchEngine(typeof options.filter === "function" ? { scorer: (query, item) => {
|
|
894
|
+
const score = options.filter(item, query);
|
|
895
|
+
if (score === false) return null;
|
|
896
|
+
return {
|
|
897
|
+
id: item.id,
|
|
898
|
+
score,
|
|
899
|
+
matches: []
|
|
900
|
+
};
|
|
901
|
+
} } : void 0);
|
|
902
|
+
const scheduler = createScheduler();
|
|
903
|
+
const emitter = new TypedEmitter();
|
|
904
|
+
const keyboardRegistry = new KeyboardShortcutRegistry();
|
|
905
|
+
let frecencyEngine = null;
|
|
906
|
+
if (options.frecency?.enabled) {
|
|
907
|
+
frecencyEngine = new FrecencyEngine(options.frecency);
|
|
908
|
+
frecencyEngine.load();
|
|
909
|
+
}
|
|
910
|
+
if (options.items) {
|
|
911
|
+
registry.registerItems(options.items);
|
|
912
|
+
searchEngine.index(options.items);
|
|
913
|
+
for (const item of options.items) if (item.shortcut != null && item.onSelect != null) keyboardRegistry.register(item.shortcut, item.id, item.onSelect);
|
|
914
|
+
}
|
|
915
|
+
if (options.groups) for (const group of options.groups) registry.registerGroup(group);
|
|
916
|
+
refilter("");
|
|
917
|
+
function setState(newState) {
|
|
918
|
+
state = newState;
|
|
919
|
+
emitter.emit("stateChange", state);
|
|
920
|
+
}
|
|
921
|
+
function refilter(query) {
|
|
922
|
+
const items = registry.getItems();
|
|
923
|
+
const disableFilter = options.filter === false;
|
|
924
|
+
let filteredIds;
|
|
925
|
+
if (disableFilter || query === "") filteredIds = items.values().filter((i) => !i.disabled).map((i) => i.id).toArray();
|
|
926
|
+
else {
|
|
927
|
+
let results = searchEngine.search(query, items).toArray();
|
|
928
|
+
if (frecencyEngine) {
|
|
929
|
+
const bonuses = frecencyEngine.getAllBonuses();
|
|
930
|
+
results = results.toSorted((a, b) => {
|
|
931
|
+
const aBonus = bonuses.get(a.id) ?? 0;
|
|
932
|
+
const bBonus = bonuses.get(b.id) ?? 0;
|
|
933
|
+
return b.score + bBonus - (a.score + aBonus);
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
filteredIds = results.map((r) => r.id);
|
|
937
|
+
}
|
|
938
|
+
filteredIdSet = new Set(filteredIds);
|
|
939
|
+
filteredIdIndex = new Map(filteredIds.entries().map(([i, id]) => [id, i]));
|
|
940
|
+
const groupedIds = Map.groupBy(filteredIds, (id) => {
|
|
941
|
+
return registry.getItem(id)?.groupId ?? "__ungrouped";
|
|
942
|
+
});
|
|
943
|
+
const activeId = state.activeId && filteredIdSet.has(state.activeId) ? state.activeId : filteredIds[0] ?? null;
|
|
944
|
+
setState({
|
|
945
|
+
...state,
|
|
946
|
+
search: query,
|
|
947
|
+
filteredIds,
|
|
948
|
+
groupedIds,
|
|
949
|
+
filteredCount: filteredIds.length,
|
|
950
|
+
activeId,
|
|
951
|
+
lastUpdated: Temporal.Now.instant()
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
function navigate(direction) {
|
|
955
|
+
const { filteredIds, activeId } = state;
|
|
956
|
+
if (filteredIds.length === 0) return;
|
|
957
|
+
if (direction === "first") {
|
|
958
|
+
const newActiveId = filteredIds[0] ?? null;
|
|
959
|
+
if (newActiveId !== activeId) {
|
|
960
|
+
setState({
|
|
961
|
+
...state,
|
|
962
|
+
activeId: newActiveId,
|
|
963
|
+
lastUpdated: Temporal.Now.instant()
|
|
964
|
+
});
|
|
965
|
+
options.onActiveChange?.(newActiveId);
|
|
966
|
+
}
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (direction === "last") {
|
|
970
|
+
const newActiveId = filteredIds[filteredIds.length - 1] ?? null;
|
|
971
|
+
if (newActiveId !== activeId) {
|
|
972
|
+
setState({
|
|
973
|
+
...state,
|
|
974
|
+
activeId: newActiveId,
|
|
975
|
+
lastUpdated: Temporal.Now.instant()
|
|
976
|
+
});
|
|
977
|
+
options.onActiveChange?.(newActiveId);
|
|
978
|
+
}
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
const loop = options.loop ?? true;
|
|
982
|
+
const currentIdx = activeId ? filteredIdIndex.get(activeId) ?? -1 : -1;
|
|
983
|
+
let newIdx;
|
|
984
|
+
if (direction === "next") if (currentIdx === -1 || currentIdx >= filteredIds.length - 1) newIdx = loop ? 0 : filteredIds.length - 1;
|
|
985
|
+
else newIdx = currentIdx + 1;
|
|
986
|
+
else if (currentIdx <= 0) newIdx = loop ? filteredIds.length - 1 : 0;
|
|
987
|
+
else newIdx = currentIdx - 1;
|
|
988
|
+
const newActiveId = filteredIds[newIdx] ?? null;
|
|
989
|
+
if (newActiveId !== activeId) {
|
|
990
|
+
setState({
|
|
991
|
+
...state,
|
|
992
|
+
activeId: newActiveId,
|
|
993
|
+
lastUpdated: Temporal.Now.instant()
|
|
994
|
+
});
|
|
995
|
+
options.onActiveChange?.(newActiveId);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
function handleEvent(event) {
|
|
999
|
+
switch (event.type) {
|
|
1000
|
+
case "SEARCH_CHANGE":
|
|
1001
|
+
refilter(event.query);
|
|
1002
|
+
options.onSearchChange?.(event.query);
|
|
1003
|
+
break;
|
|
1004
|
+
case "ITEM_SELECT": {
|
|
1005
|
+
const item = registry.getItem(event.id);
|
|
1006
|
+
if (item && !item.disabled) {
|
|
1007
|
+
item.onSelect?.();
|
|
1008
|
+
frecencyEngine?.recordSelection(event.id);
|
|
1009
|
+
emitter.emit("select", event.id);
|
|
1010
|
+
options.onSelect?.(event.id);
|
|
1011
|
+
}
|
|
1012
|
+
break;
|
|
1013
|
+
}
|
|
1014
|
+
case "ITEM_ACTIVATE":
|
|
1015
|
+
if (filteredIdSet.has(event.id)) {
|
|
1016
|
+
setState({
|
|
1017
|
+
...state,
|
|
1018
|
+
activeId: event.id,
|
|
1019
|
+
lastUpdated: Temporal.Now.instant()
|
|
1020
|
+
});
|
|
1021
|
+
options.onActiveChange?.(event.id);
|
|
1022
|
+
}
|
|
1023
|
+
break;
|
|
1024
|
+
case "NAVIGATE":
|
|
1025
|
+
navigate(event.direction);
|
|
1026
|
+
break;
|
|
1027
|
+
case "PAGE_PUSH":
|
|
1028
|
+
setState({
|
|
1029
|
+
...state,
|
|
1030
|
+
page: event.page,
|
|
1031
|
+
pageStack: [...state.pageStack, state.page],
|
|
1032
|
+
lastUpdated: Temporal.Now.instant()
|
|
1033
|
+
});
|
|
1034
|
+
break;
|
|
1035
|
+
case "PAGE_POP": {
|
|
1036
|
+
const prevPage = state.pageStack.at(-1);
|
|
1037
|
+
if (prevPage != null) setState({
|
|
1038
|
+
...state,
|
|
1039
|
+
page: prevPage,
|
|
1040
|
+
pageStack: state.pageStack.toSpliced(-1, 1),
|
|
1041
|
+
lastUpdated: Temporal.Now.instant()
|
|
1042
|
+
});
|
|
1043
|
+
break;
|
|
1044
|
+
}
|
|
1045
|
+
case "OPEN":
|
|
1046
|
+
setState({
|
|
1047
|
+
...state,
|
|
1048
|
+
open: true,
|
|
1049
|
+
lastUpdated: Temporal.Now.instant()
|
|
1050
|
+
});
|
|
1051
|
+
emitter.emit("openChange", true);
|
|
1052
|
+
options.onOpenChange?.(true);
|
|
1053
|
+
break;
|
|
1054
|
+
case "CLOSE":
|
|
1055
|
+
setState({
|
|
1056
|
+
...state,
|
|
1057
|
+
open: false,
|
|
1058
|
+
lastUpdated: Temporal.Now.instant()
|
|
1059
|
+
});
|
|
1060
|
+
emitter.emit("openChange", false);
|
|
1061
|
+
options.onOpenChange?.(false);
|
|
1062
|
+
break;
|
|
1063
|
+
case "TOGGLE": {
|
|
1064
|
+
const newOpen = !state.open;
|
|
1065
|
+
setState({
|
|
1066
|
+
...state,
|
|
1067
|
+
open: newOpen,
|
|
1068
|
+
lastUpdated: Temporal.Now.instant()
|
|
1069
|
+
});
|
|
1070
|
+
emitter.emit("openChange", newOpen);
|
|
1071
|
+
options.onOpenChange?.(newOpen);
|
|
1072
|
+
break;
|
|
1073
|
+
}
|
|
1074
|
+
case "ITEMS_LOADED":
|
|
1075
|
+
registry.registerItems(event.items);
|
|
1076
|
+
searchEngine.index(event.items);
|
|
1077
|
+
state = {
|
|
1078
|
+
...state,
|
|
1079
|
+
loading: false
|
|
1080
|
+
};
|
|
1081
|
+
refilter(state.search);
|
|
1082
|
+
break;
|
|
1083
|
+
case "REGISTER_ITEM":
|
|
1084
|
+
registry.registerItem(event.item);
|
|
1085
|
+
searchEngine.index([event.item]);
|
|
1086
|
+
if (event.item.shortcut && event.item.onSelect) keyboardRegistry.register(event.item.shortcut, event.item.id, event.item.onSelect);
|
|
1087
|
+
refilter(state.search);
|
|
1088
|
+
break;
|
|
1089
|
+
case "UNREGISTER_ITEM":
|
|
1090
|
+
registry.unregisterItem(event.id);
|
|
1091
|
+
searchEngine.remove(new Set([event.id]));
|
|
1092
|
+
keyboardRegistry.unregisterByItem(event.id);
|
|
1093
|
+
refilter(state.search);
|
|
1094
|
+
break;
|
|
1095
|
+
case "REGISTER_GROUP":
|
|
1096
|
+
registry.registerGroup(event.group);
|
|
1097
|
+
refilter(state.search);
|
|
1098
|
+
break;
|
|
1099
|
+
case "UNREGISTER_GROUP":
|
|
1100
|
+
registry.unregisterGroup(event.id);
|
|
1101
|
+
refilter(state.search);
|
|
1102
|
+
break;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
return {
|
|
1106
|
+
getState() {
|
|
1107
|
+
return state;
|
|
1108
|
+
},
|
|
1109
|
+
send(event) {
|
|
1110
|
+
scheduler.schedule(() => handleEvent(event));
|
|
1111
|
+
},
|
|
1112
|
+
subscribe(listener) {
|
|
1113
|
+
const disposable = emitter.on("stateChange", listener);
|
|
1114
|
+
return () => disposable[Symbol.dispose]();
|
|
1115
|
+
},
|
|
1116
|
+
subscribeState(listener) {
|
|
1117
|
+
return emitter.on("stateChange", listener);
|
|
1118
|
+
},
|
|
1119
|
+
getRegistry() {
|
|
1120
|
+
return registry;
|
|
1121
|
+
},
|
|
1122
|
+
getKeyboardRegistry() {
|
|
1123
|
+
return keyboardRegistry;
|
|
1124
|
+
},
|
|
1125
|
+
getFilteredIdSet() {
|
|
1126
|
+
return filteredIdSet;
|
|
1127
|
+
},
|
|
1128
|
+
getFilteredIdIndex() {
|
|
1129
|
+
return filteredIdIndex;
|
|
1130
|
+
},
|
|
1131
|
+
[Symbol.dispose]() {
|
|
1132
|
+
scheduler[Symbol.dispose]();
|
|
1133
|
+
registry[Symbol.dispose]();
|
|
1134
|
+
searchEngine[Symbol.dispose]();
|
|
1135
|
+
emitter[Symbol.dispose]();
|
|
1136
|
+
keyboardRegistry[Symbol.dispose]();
|
|
1137
|
+
frecencyEngine?.[Symbol.dispose]();
|
|
1138
|
+
}
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
//#endregion
|
|
72
1142
|
//#region src/react/hooks/use-command.ts
|
|
1143
|
+
/** Core hook that binds a CommandMachine to React via useSyncExternalStore, useTransition, and useOptimistic. */
|
|
73
1144
|
function useCommand(machine) {
|
|
74
1145
|
const id = useId();
|
|
75
1146
|
const [isPending, startTransition] = useTransition();
|
|
76
1147
|
const state = useSyncExternalStore(machine.subscribe, machine.getState, machine.getState);
|
|
77
|
-
const filteredIdSet = useMemo(() =>
|
|
78
|
-
const [optimisticActiveId,
|
|
1148
|
+
const filteredIdSet = useMemo(() => machine.getFilteredIdSet(), [state.filteredIds, machine]);
|
|
1149
|
+
const [optimisticActiveId, rawSetOptimisticActiveId] = useOptimistic(state.activeId);
|
|
1150
|
+
const setOptimisticActiveId = useCallback((id) => {
|
|
1151
|
+
startTransition(() => {
|
|
1152
|
+
rawSetOptimisticActiveId(id);
|
|
1153
|
+
});
|
|
1154
|
+
}, [rawSetOptimisticActiveId]);
|
|
79
1155
|
const updateSearch = useCallback((query) => {
|
|
80
1156
|
startTransition(() => {
|
|
81
1157
|
machine.send({
|
|
@@ -143,30 +1219,77 @@ function createKeydownHandler(machine, getState) {
|
|
|
143
1219
|
};
|
|
144
1220
|
}
|
|
145
1221
|
//#endregion
|
|
146
|
-
//#region src/react/
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
1222
|
+
//#region src/react/hooks/use-command-setup.ts
|
|
1223
|
+
/**
|
|
1224
|
+
* Shared setup for Command and Command.Dialog — creates machine once,
|
|
1225
|
+
* subscribes via useSyncExternalStore, memoizes context values, and
|
|
1226
|
+
* registers disposal on unmount.
|
|
1227
|
+
*
|
|
1228
|
+
* @param machineFactory - Called exactly once (first render) to create the machine.
|
|
1229
|
+
* Subsequent renders skip it via ref guard.
|
|
1230
|
+
* @param label - Accessible label for the command palette (included in stable context).
|
|
1231
|
+
*/
|
|
1232
|
+
function useCommandSetup(machineFactory, label) {
|
|
153
1233
|
const machineRef = useRef(null);
|
|
154
|
-
if (machineRef.current === null) machineRef.current =
|
|
155
|
-
...machineOptions,
|
|
156
|
-
open: controlledOpen ?? false,
|
|
157
|
-
onOpenChange: (open) => {
|
|
158
|
-
onOpenChangeRef.current?.(open);
|
|
159
|
-
}
|
|
160
|
-
});
|
|
1234
|
+
if (machineRef.current === null) machineRef.current = machineFactory();
|
|
161
1235
|
const machine = machineRef.current;
|
|
162
1236
|
const { state, isPending, updateSearch, setOptimisticActiveId, filteredIdSet, id } = useCommand(machine);
|
|
1237
|
+
const listId = `${id}-list`;
|
|
1238
|
+
const inputId = `${id}-input`;
|
|
163
1239
|
useEffect(() => {
|
|
164
1240
|
return () => {
|
|
165
1241
|
machineRef.current?.[Symbol.dispose]();
|
|
166
1242
|
machineRef.current = null;
|
|
167
1243
|
};
|
|
168
1244
|
}, []);
|
|
169
|
-
|
|
1245
|
+
return {
|
|
1246
|
+
machine,
|
|
1247
|
+
state,
|
|
1248
|
+
isPending,
|
|
1249
|
+
listId,
|
|
1250
|
+
inputId,
|
|
1251
|
+
handleKeyDown: useMemo(() => createKeydownHandler(machine, () => machine.getState()), [machine]),
|
|
1252
|
+
stableContextValue: useMemo(() => ({
|
|
1253
|
+
machine,
|
|
1254
|
+
rootId: id,
|
|
1255
|
+
listId,
|
|
1256
|
+
inputId,
|
|
1257
|
+
label,
|
|
1258
|
+
updateSearch,
|
|
1259
|
+
setOptimisticActiveId
|
|
1260
|
+
}), [
|
|
1261
|
+
machine,
|
|
1262
|
+
id,
|
|
1263
|
+
listId,
|
|
1264
|
+
inputId,
|
|
1265
|
+
label,
|
|
1266
|
+
updateSearch,
|
|
1267
|
+
setOptimisticActiveId
|
|
1268
|
+
]),
|
|
1269
|
+
stateContextValue: useMemo(() => ({
|
|
1270
|
+
state,
|
|
1271
|
+
isPending,
|
|
1272
|
+
filteredIdSet
|
|
1273
|
+
}), [
|
|
1274
|
+
state,
|
|
1275
|
+
isPending,
|
|
1276
|
+
filteredIdSet
|
|
1277
|
+
])
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
//#endregion
|
|
1281
|
+
//#region src/react/dialog.tsx
|
|
1282
|
+
/** Dialog variant that wraps the command palette in a Radix UI Dialog with focus trap and overlay. */
|
|
1283
|
+
function CommandDialog({ children, className, overlayClassName, contentClassName, label = "Command palette", description = "Type a command or search...", open: controlledOpen, onOpenChange, container, ...machineOptions }) {
|
|
1284
|
+
const onOpenChangeRef = useRef(onOpenChange);
|
|
1285
|
+
onOpenChangeRef.current = onOpenChange;
|
|
1286
|
+
const { machine, state, handleKeyDown, stableContextValue, stateContextValue, inputId } = useCommandSetup(() => createCommandMachine({
|
|
1287
|
+
...machineOptions,
|
|
1288
|
+
open: controlledOpen ?? false,
|
|
1289
|
+
onOpenChange: (open) => {
|
|
1290
|
+
onOpenChangeRef.current?.(open);
|
|
1291
|
+
}
|
|
1292
|
+
}), label);
|
|
170
1293
|
useEffect(() => {
|
|
171
1294
|
if (!state.open) return;
|
|
172
1295
|
document.addEventListener("keydown", handleKeyDown);
|
|
@@ -174,42 +1297,16 @@ function CommandDialog({ children, className, overlayClassName, contentClassName
|
|
|
174
1297
|
document.removeEventListener("keydown", handleKeyDown);
|
|
175
1298
|
};
|
|
176
1299
|
}, [handleKeyDown, state.open]);
|
|
177
|
-
const prevControlledOpen = useRef(controlledOpen);
|
|
178
1300
|
useEffect(() => {
|
|
179
|
-
if (controlledOpen !== void 0
|
|
180
|
-
prevControlledOpen.current = controlledOpen;
|
|
181
|
-
machine.send({ type: controlledOpen ? "OPEN" : "CLOSE" });
|
|
182
|
-
}
|
|
1301
|
+
if (controlledOpen !== void 0) machine.send({ type: controlledOpen ? "OPEN" : "CLOSE" });
|
|
183
1302
|
}, [controlledOpen, machine]);
|
|
1303
|
+
const handleOpenAutoFocus = useCallback((e) => {
|
|
1304
|
+
e.preventDefault();
|
|
1305
|
+
document.getElementById(inputId)?.focus();
|
|
1306
|
+
}, [inputId]);
|
|
184
1307
|
const handleRadixOpenChange = useCallback((open) => {
|
|
185
1308
|
machine.send({ type: open ? "OPEN" : "CLOSE" });
|
|
186
1309
|
}, [machine]);
|
|
187
|
-
const stableContextValue = useMemo(() => ({
|
|
188
|
-
machine,
|
|
189
|
-
rootId: id,
|
|
190
|
-
listId,
|
|
191
|
-
inputId,
|
|
192
|
-
label,
|
|
193
|
-
updateSearch,
|
|
194
|
-
setOptimisticActiveId
|
|
195
|
-
}), [
|
|
196
|
-
machine,
|
|
197
|
-
id,
|
|
198
|
-
listId,
|
|
199
|
-
inputId,
|
|
200
|
-
label,
|
|
201
|
-
updateSearch,
|
|
202
|
-
setOptimisticActiveId
|
|
203
|
-
]);
|
|
204
|
-
const stateContextValue = useMemo(() => ({
|
|
205
|
-
state,
|
|
206
|
-
isPending,
|
|
207
|
-
filteredIdSet
|
|
208
|
-
}), [
|
|
209
|
-
state,
|
|
210
|
-
isPending,
|
|
211
|
-
filteredIdSet
|
|
212
|
-
]);
|
|
213
1310
|
return /* @__PURE__ */ jsx(Dialog.Root, {
|
|
214
1311
|
open: state.open,
|
|
215
1312
|
onOpenChange: handleRadixOpenChange,
|
|
@@ -224,10 +1321,7 @@ function CommandDialog({ children, className, overlayClassName, contentClassName
|
|
|
224
1321
|
"data-state": state.open ? "open" : "closed",
|
|
225
1322
|
className,
|
|
226
1323
|
"aria-label": label,
|
|
227
|
-
onOpenAutoFocus:
|
|
228
|
-
e.preventDefault();
|
|
229
|
-
document.getElementById(inputId)?.focus();
|
|
230
|
-
},
|
|
1324
|
+
onOpenAutoFocus: handleOpenAutoFocus,
|
|
231
1325
|
children: [
|
|
232
1326
|
/* @__PURE__ */ jsx(Dialog.Title, {
|
|
233
1327
|
className: "sr-only",
|
|
@@ -235,7 +1329,7 @@ function CommandDialog({ children, className, overlayClassName, contentClassName
|
|
|
235
1329
|
}),
|
|
236
1330
|
/* @__PURE__ */ jsx(Dialog.Description, {
|
|
237
1331
|
className: "sr-only",
|
|
238
|
-
children:
|
|
1332
|
+
children: description
|
|
239
1333
|
}),
|
|
240
1334
|
/* @__PURE__ */ jsx(CommandStableContext, {
|
|
241
1335
|
value: stableContextValue,
|
|
@@ -260,11 +1354,12 @@ function CommandDialog({ children, className, overlayClassName, contentClassName
|
|
|
260
1354
|
}
|
|
261
1355
|
//#endregion
|
|
262
1356
|
//#region src/react/empty.tsx
|
|
1357
|
+
/** Rendered when no items match the current search query. */
|
|
263
1358
|
function CommandEmpty({ ref, children, ...props }) {
|
|
264
|
-
const stateCtx =
|
|
265
|
-
|
|
1359
|
+
const stateCtx = useStateContext("Command.Empty");
|
|
1360
|
+
const statusContainerRef = use(CommandListStatusContext);
|
|
266
1361
|
if (stateCtx.state.filteredCount > 0 || stateCtx.state.loading) return null;
|
|
267
|
-
|
|
1362
|
+
const content = /* @__PURE__ */ jsx("div", {
|
|
268
1363
|
ref,
|
|
269
1364
|
"data-command-empty": "",
|
|
270
1365
|
role: "status",
|
|
@@ -272,15 +1367,21 @@ function CommandEmpty({ ref, children, ...props }) {
|
|
|
272
1367
|
...props,
|
|
273
1368
|
children
|
|
274
1369
|
});
|
|
1370
|
+
if (statusContainerRef?.current) return createPortal(content, statusContainerRef.current);
|
|
1371
|
+
return content;
|
|
275
1372
|
}
|
|
276
1373
|
//#endregion
|
|
277
1374
|
//#region src/react/hooks/use-register.ts
|
|
278
1375
|
/** Register a command item with the machine — auto-deregisters on unmount */
|
|
279
1376
|
function useRegisterItem(value, options) {
|
|
280
|
-
const stable =
|
|
281
|
-
if (!stable) throw new Error("useRegisterItem must be used within a <Command> component");
|
|
1377
|
+
const stable = useStableContext("useRegisterItem");
|
|
282
1378
|
const generatedId = useId();
|
|
283
1379
|
const id = itemId(options?.forceId ?? generatedId);
|
|
1380
|
+
const onSelectRef = useRef(options?.onSelect);
|
|
1381
|
+
onSelectRef.current = options?.onSelect;
|
|
1382
|
+
const dataRef = useRef(options?.data);
|
|
1383
|
+
dataRef.current = options?.data;
|
|
1384
|
+
const keywordsDep = options?.keywords?.join("\0");
|
|
284
1385
|
useLayoutEffect(() => {
|
|
285
1386
|
const item = {
|
|
286
1387
|
id,
|
|
@@ -289,8 +1390,8 @@ function useRegisterItem(value, options) {
|
|
|
289
1390
|
groupId: options?.groupId ? groupId(options.groupId) : void 0,
|
|
290
1391
|
shortcut: options?.shortcut,
|
|
291
1392
|
disabled: options?.disabled,
|
|
292
|
-
onSelect:
|
|
293
|
-
data:
|
|
1393
|
+
onSelect: () => onSelectRef.current?.(),
|
|
1394
|
+
data: dataRef.current
|
|
294
1395
|
};
|
|
295
1396
|
stable.machine.send({
|
|
296
1397
|
type: "REGISTER_ITEM",
|
|
@@ -307,18 +1408,15 @@ function useRegisterItem(value, options) {
|
|
|
307
1408
|
id,
|
|
308
1409
|
value,
|
|
309
1410
|
options?.groupId,
|
|
310
|
-
options?.data,
|
|
311
1411
|
options?.disabled,
|
|
312
|
-
|
|
313
|
-
options?.onSelect,
|
|
1412
|
+
keywordsDep,
|
|
314
1413
|
options?.shortcut
|
|
315
1414
|
]);
|
|
316
1415
|
return id;
|
|
317
1416
|
}
|
|
318
1417
|
/** Register a command group with the machine — auto-deregisters on unmount */
|
|
319
1418
|
function useRegisterGroup(heading, options) {
|
|
320
|
-
const stable =
|
|
321
|
-
if (!stable) throw new Error("useRegisterGroup must be used within a <Command> component");
|
|
1419
|
+
const stable = useStableContext("useRegisterGroup");
|
|
322
1420
|
const generatedId = useId();
|
|
323
1421
|
const id = groupId(options?.forceId ?? generatedId);
|
|
324
1422
|
useLayoutEffect(() => {
|
|
@@ -347,9 +1445,11 @@ function useRegisterGroup(heading, options) {
|
|
|
347
1445
|
}
|
|
348
1446
|
//#endregion
|
|
349
1447
|
//#region src/react/group.tsx
|
|
1448
|
+
/** Module-level constant — avoids allocating a new object on every render of hidden groups */
|
|
1449
|
+
const HIDDEN_STYLE = { display: "none" };
|
|
1450
|
+
/** Visual grouping container with an optional heading that hides when no items match. */
|
|
350
1451
|
function CommandGroup({ ref, children, heading, priority, forceId, ...props }) {
|
|
351
|
-
const stateCtx =
|
|
352
|
-
if (!stateCtx) throw new Error("Command.Group must be used within a <Command> component");
|
|
1452
|
+
const stateCtx = useStateContext("Command.Group");
|
|
353
1453
|
const headingId = useId();
|
|
354
1454
|
const groupId = useRegisterGroup(heading, {
|
|
355
1455
|
...priority !== void 0 && { priority },
|
|
@@ -361,7 +1461,13 @@ function CommandGroup({ ref, children, heading, priority, forceId, ...props }) {
|
|
|
361
1461
|
groupId,
|
|
362
1462
|
headingId
|
|
363
1463
|
}), [groupId, headingId]);
|
|
364
|
-
if (!hasVisibleItems) return
|
|
1464
|
+
if (!hasVisibleItems) return /* @__PURE__ */ jsx(CommandGroupContext, {
|
|
1465
|
+
value: groupContext,
|
|
1466
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
1467
|
+
style: HIDDEN_STYLE,
|
|
1468
|
+
children
|
|
1469
|
+
})
|
|
1470
|
+
});
|
|
365
1471
|
return /* @__PURE__ */ jsx(CommandGroupContext, {
|
|
366
1472
|
value: groupContext,
|
|
367
1473
|
children: /* @__PURE__ */ jsxs("div", {
|
|
@@ -384,13 +1490,14 @@ function CommandGroup({ ref, children, heading, priority, forceId, ...props }) {
|
|
|
384
1490
|
}
|
|
385
1491
|
//#endregion
|
|
386
1492
|
//#region src/react/highlight.tsx
|
|
1493
|
+
/** Renders search match highlighting by splitting text at scored match ranges. */
|
|
387
1494
|
function CommandHighlight({ text, ranges, highlightClassName }) {
|
|
388
1495
|
return useMemo(() => {
|
|
389
1496
|
if (ranges.length === 0) return text;
|
|
390
|
-
const sorted =
|
|
1497
|
+
const sorted = ranges.toSorted((a, b) => a[0] - b[0]);
|
|
391
1498
|
const segments = [];
|
|
392
1499
|
let lastEnd = 0;
|
|
393
|
-
|
|
1500
|
+
sorted.forEach(([start, end], i) => {
|
|
394
1501
|
if (start > lastEnd) segments.push(text.slice(lastEnd, start));
|
|
395
1502
|
segments.push(/* @__PURE__ */ jsx("mark", {
|
|
396
1503
|
"data-command-highlight": "",
|
|
@@ -409,11 +1516,10 @@ function CommandHighlight({ text, ranges, highlightClassName }) {
|
|
|
409
1516
|
}
|
|
410
1517
|
//#endregion
|
|
411
1518
|
//#region src/react/input.tsx
|
|
1519
|
+
/** Combobox input that drives the command palette search state. */
|
|
412
1520
|
function CommandInput({ ref, placeholder = "Search...", onValueChange, ...props }) {
|
|
413
|
-
const stable =
|
|
414
|
-
|
|
415
|
-
const stateCtx = use(CommandStateContext);
|
|
416
|
-
if (!stateCtx) throw new Error("Command.Input must be used within a <Command> component");
|
|
1521
|
+
const stable = useStableContext("Command.Input");
|
|
1522
|
+
const stateCtx = useStateContext("Command.Input");
|
|
417
1523
|
const handleChange = useCallback((event) => {
|
|
418
1524
|
const { value } = event.target;
|
|
419
1525
|
stable.updateSearch(value);
|
|
@@ -424,7 +1530,7 @@ function CommandInput({ ref, placeholder = "Search...", onValueChange, ...props
|
|
|
424
1530
|
"data-command-input": "",
|
|
425
1531
|
type: "text",
|
|
426
1532
|
role: "combobox",
|
|
427
|
-
"aria-expanded":
|
|
1533
|
+
"aria-expanded": true,
|
|
428
1534
|
"aria-controls": stable.listId,
|
|
429
1535
|
"aria-activedescendant": stateCtx.state.activeId ?? void 0,
|
|
430
1536
|
"aria-autocomplete": "list",
|
|
@@ -441,17 +1547,18 @@ function CommandInput({ ref, placeholder = "Search...", onValueChange, ...props
|
|
|
441
1547
|
}
|
|
442
1548
|
//#endregion
|
|
443
1549
|
//#region src/react/item.tsx
|
|
1550
|
+
/** Individual selectable item in the command list with keyboard and pointer activation. */
|
|
444
1551
|
function CommandItem({ ref, children, value, keywords, onSelect, disabled = false, shortcut, forceId, ...props }) {
|
|
445
|
-
const stable =
|
|
446
|
-
|
|
447
|
-
const
|
|
448
|
-
if (!stateCtx) throw new Error("Command.Item must be used within a <Command> component");
|
|
1552
|
+
const stable = useStableContext("Command.Item");
|
|
1553
|
+
const stateCtx = useStateContext("Command.Item");
|
|
1554
|
+
const groupCtx = use(CommandGroupContext);
|
|
449
1555
|
const id = useRegisterItem(value, {
|
|
450
1556
|
...keywords !== void 0 && { keywords },
|
|
451
1557
|
...shortcut !== void 0 && { shortcut },
|
|
452
1558
|
disabled,
|
|
453
1559
|
...onSelect !== void 0 && { onSelect },
|
|
454
|
-
...forceId !== void 0 && { forceId }
|
|
1560
|
+
...forceId !== void 0 && { forceId },
|
|
1561
|
+
...groupCtx !== null && { groupId: groupCtx.groupId }
|
|
455
1562
|
});
|
|
456
1563
|
const isActive = stateCtx.state.activeId === id;
|
|
457
1564
|
const isFiltered = stateCtx.filteredIdSet.has(id);
|
|
@@ -501,6 +1608,7 @@ function CommandItem({ ref, children, value, keywords, onSelect, disabled = fals
|
|
|
501
1608
|
}
|
|
502
1609
|
//#endregion
|
|
503
1610
|
//#region src/react/hooks/use-virtualizer.ts
|
|
1611
|
+
/** Lightweight virtualizer for large item lists using ResizeObserver and scroll tracking. */
|
|
504
1612
|
function useVirtualizer(options) {
|
|
505
1613
|
const { count, estimateSize, overscan = 8, scrollElement, enabled = true } = options;
|
|
506
1614
|
const [scrollOffset, setScrollOffset] = useState(0);
|
|
@@ -514,17 +1622,12 @@ function useVirtualizer(options) {
|
|
|
514
1622
|
if (lastEntry) setContainerHeight(lastEntry.contentRect.height);
|
|
515
1623
|
});
|
|
516
1624
|
observer.observe(scrollElement);
|
|
517
|
-
return () => {
|
|
518
|
-
observer.disconnect();
|
|
519
|
-
};
|
|
520
|
-
}, [scrollElement, enabled]);
|
|
521
|
-
useEffect(() => {
|
|
522
|
-
if (!scrollElement || !enabled) return;
|
|
523
1625
|
const handleScroll = () => {
|
|
524
1626
|
setScrollOffset(scrollElement.scrollTop);
|
|
525
1627
|
};
|
|
526
1628
|
scrollElement.addEventListener("scroll", handleScroll, { passive: true });
|
|
527
1629
|
return () => {
|
|
1630
|
+
observer.disconnect();
|
|
528
1631
|
scrollElement.removeEventListener("scroll", handleScroll);
|
|
529
1632
|
};
|
|
530
1633
|
}, [scrollElement, enabled]);
|
|
@@ -612,11 +1715,17 @@ function useVirtualizer(options) {
|
|
|
612
1715
|
}
|
|
613
1716
|
//#endregion
|
|
614
1717
|
//#region src/react/list.tsx
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
1718
|
+
/** Base style for virtual items — hoisted to avoid allocation per item per render */
|
|
1719
|
+
const VIRTUAL_ITEM_BASE_STYLE = {
|
|
1720
|
+
position: "absolute",
|
|
1721
|
+
top: 0,
|
|
1722
|
+
left: 0,
|
|
1723
|
+
width: "100%"
|
|
1724
|
+
};
|
|
1725
|
+
/** Scrollable list container with auto-virtualization and aria-live announcements. */
|
|
1726
|
+
function CommandList({ ref, children, virtualize, estimateSize = 44, overscan = 8, announceResults, style, ...props }) {
|
|
1727
|
+
const stable = useStableContext("Command.List");
|
|
1728
|
+
const stateCtx = useStateContext("Command.List");
|
|
620
1729
|
const innerRef = useRef(null);
|
|
621
1730
|
const scrollRef = useRef(null);
|
|
622
1731
|
const [height, setHeight] = useState(0);
|
|
@@ -645,7 +1754,7 @@ function CommandList({ ref, children, virtualize, estimateSize = 44, overscan =
|
|
|
645
1754
|
if (stateCtx.state.activeId && stateCtx.state.activeId !== prevActiveId.current) {
|
|
646
1755
|
prevActiveId.current = stateCtx.state.activeId;
|
|
647
1756
|
if (shouldVirtualize) {
|
|
648
|
-
const idx =
|
|
1757
|
+
const idx = stable.machine.getFilteredIdIndex().get(stateCtx.state.activeId) ?? -1;
|
|
649
1758
|
if (idx >= 0) virtualizer.scrollToIndex(idx);
|
|
650
1759
|
} else (scrollRef.current?.querySelector(`[id="${CSS.escape(stateCtx.state.activeId)}"]`))?.scrollIntoView({
|
|
651
1760
|
block: "nearest",
|
|
@@ -654,71 +1763,77 @@ function CommandList({ ref, children, virtualize, estimateSize = 44, overscan =
|
|
|
654
1763
|
}
|
|
655
1764
|
}, [
|
|
656
1765
|
stateCtx.state.activeId,
|
|
657
|
-
stateCtx.state.filteredIds,
|
|
658
1766
|
shouldVirtualize,
|
|
659
|
-
virtualizer
|
|
1767
|
+
virtualizer,
|
|
1768
|
+
stable.machine.getFilteredIdIndex
|
|
660
1769
|
]);
|
|
661
|
-
const mergedStyle = {
|
|
1770
|
+
const mergedStyle = useMemo(() => ({
|
|
662
1771
|
...style,
|
|
663
1772
|
"--command-list-height": `${height.toFixed(1)}px`
|
|
664
|
-
};
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
"
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
1773
|
+
}), [style, height]);
|
|
1774
|
+
const externalRefRef = useRef(ref);
|
|
1775
|
+
externalRefRef.current = ref;
|
|
1776
|
+
const setScrollRef = useCallback((el) => {
|
|
1777
|
+
scrollRef.current = el;
|
|
1778
|
+
const externalRef = externalRefRef.current;
|
|
1779
|
+
if (typeof externalRef === "function") externalRef(el);
|
|
1780
|
+
else if (externalRef) externalRef.current = el;
|
|
1781
|
+
}, []);
|
|
1782
|
+
const statusContainerRef = useRef(null);
|
|
1783
|
+
return /* @__PURE__ */ jsxs(CommandListStatusContext, {
|
|
1784
|
+
value: statusContainerRef,
|
|
1785
|
+
children: [
|
|
1786
|
+
/* @__PURE__ */ jsx("div", {
|
|
1787
|
+
ref: setScrollRef,
|
|
1788
|
+
"data-command-list": "",
|
|
1789
|
+
role: "listbox",
|
|
1790
|
+
"aria-label": stable.label,
|
|
1791
|
+
"aria-busy": stateCtx.state.loading,
|
|
1792
|
+
id: stable.listId,
|
|
1793
|
+
style: mergedStyle,
|
|
1794
|
+
...props,
|
|
1795
|
+
children: shouldVirtualize ? /* @__PURE__ */ jsxs("div", {
|
|
1796
|
+
"data-command-list-virtual": "",
|
|
1797
|
+
style: {
|
|
1798
|
+
height: `${virtualizer.totalSize}px`,
|
|
1799
|
+
position: "relative"
|
|
1800
|
+
},
|
|
1801
|
+
children: [virtualizer.virtualItems.map((vItem) => /* @__PURE__ */ jsx("div", {
|
|
1802
|
+
"data-command-virtual-item": "",
|
|
1803
|
+
"data-index": vItem.index,
|
|
1804
|
+
ref: virtualizer.measureElement,
|
|
1805
|
+
style: {
|
|
1806
|
+
...VIRTUAL_ITEM_BASE_STYLE,
|
|
1807
|
+
translate: `0 ${vItem.start}px`
|
|
1808
|
+
}
|
|
1809
|
+
}, vItem.key)), children]
|
|
1810
|
+
}) : /* @__PURE__ */ jsx("div", {
|
|
1811
|
+
ref: innerRef,
|
|
1812
|
+
"data-command-list-inner": "",
|
|
1813
|
+
children
|
|
1814
|
+
})
|
|
1815
|
+
}),
|
|
1816
|
+
/* @__PURE__ */ jsx("div", {
|
|
1817
|
+
ref: statusContainerRef,
|
|
1818
|
+
"data-command-list-status": ""
|
|
1819
|
+
}),
|
|
1820
|
+
/* @__PURE__ */ jsx("div", {
|
|
1821
|
+
"aria-live": "polite",
|
|
1822
|
+
"aria-atomic": true,
|
|
1823
|
+
role: "status",
|
|
1824
|
+
"data-command-aria-live": "",
|
|
1825
|
+
className: "sr-only",
|
|
1826
|
+
children: announceResults ? announceResults(stateCtx.state.filteredCount) : `${stateCtx.state.filteredCount} result${stateCtx.state.filteredCount !== 1 ? "s" : ""} available.`
|
|
1827
|
+
})
|
|
1828
|
+
]
|
|
714
1829
|
});
|
|
715
1830
|
}
|
|
716
1831
|
//#endregion
|
|
717
1832
|
//#region src/react/loading.tsx
|
|
1833
|
+
/** Shown during async operations, with configurable loading state override. */
|
|
718
1834
|
function CommandLoading({ ref, children, loading: loadingOverride, ...props }) {
|
|
719
|
-
const stateCtx =
|
|
720
|
-
if (!
|
|
721
|
-
if (!(loadingOverride ?? stateCtx.state.loading ?? stateCtx.isPending)) return null;
|
|
1835
|
+
const stateCtx = useStateContext("Command.Loading");
|
|
1836
|
+
if (!(loadingOverride ?? (stateCtx.state.loading || stateCtx.isPending))) return null;
|
|
722
1837
|
return /* @__PURE__ */ jsx("div", {
|
|
723
1838
|
ref,
|
|
724
1839
|
"data-command-loading": "",
|
|
@@ -731,10 +1846,9 @@ function CommandLoading({ ref, children, loading: loadingOverride, ...props }) {
|
|
|
731
1846
|
}
|
|
732
1847
|
//#endregion
|
|
733
1848
|
//#region src/react/page.tsx
|
|
1849
|
+
/** Page navigation container that only renders when its ID matches the active page. */
|
|
734
1850
|
function CommandPage({ id, children }) {
|
|
735
|
-
|
|
736
|
-
if (!stateCtx) throw new Error("Command.Page must be used within a <Command> component");
|
|
737
|
-
if (stateCtx.state.page !== id) return null;
|
|
1851
|
+
if (useStateContext("Command.Page").state.page !== id) return null;
|
|
738
1852
|
return /* @__PURE__ */ jsx("div", {
|
|
739
1853
|
"data-command-page": "",
|
|
740
1854
|
"data-command-page-id": id,
|
|
@@ -743,77 +1857,48 @@ function CommandPage({ id, children }) {
|
|
|
743
1857
|
}
|
|
744
1858
|
//#endregion
|
|
745
1859
|
//#region src/react/separator.tsx
|
|
1860
|
+
/** Visual divider between items or groups, rendered as a presentational element. */
|
|
746
1861
|
function CommandSeparator({ ref, ...props }) {
|
|
747
1862
|
return /* @__PURE__ */ jsx("div", {
|
|
748
1863
|
ref,
|
|
749
1864
|
"data-command-separator": "",
|
|
750
|
-
role: "
|
|
1865
|
+
role: "none",
|
|
751
1866
|
...props
|
|
752
1867
|
});
|
|
753
1868
|
}
|
|
754
1869
|
//#endregion
|
|
755
1870
|
//#region src/react/shortcut.tsx
|
|
1871
|
+
/** Keyboard shortcut display with platform-aware formatting (e.g., Cmd vs Ctrl). */
|
|
756
1872
|
function CommandShortcut({ ref, shortcut, children, ...props }) {
|
|
757
|
-
const
|
|
1873
|
+
const formattedShortcut = useMemo(() => shortcut ? formatShortcut(parseShortcut(shortcut)) : null, [shortcut]);
|
|
758
1874
|
return /* @__PURE__ */ jsx("kbd", {
|
|
759
1875
|
ref,
|
|
760
1876
|
"data-command-shortcut": "",
|
|
761
1877
|
...props,
|
|
762
|
-
children:
|
|
1878
|
+
children: formattedShortcut ?? children
|
|
763
1879
|
});
|
|
764
1880
|
}
|
|
765
1881
|
//#endregion
|
|
766
1882
|
//#region src/react/command.tsx
|
|
1883
|
+
/** Root compound component that provides the command state machine context to all children. */
|
|
767
1884
|
function CommandRoot({ children, className, label = "Command palette", ...machineOptions }) {
|
|
768
|
-
const rootId = useId();
|
|
769
|
-
const listId = `${rootId}-list`;
|
|
770
|
-
const inputId = `${rootId}-input`;
|
|
771
1885
|
const rootRef = useRef(null);
|
|
772
|
-
const
|
|
773
|
-
if (machineRef.current === null) machineRef.current = createCommandMachine(machineOptions);
|
|
774
|
-
const machine = machineRef.current;
|
|
775
|
-
const { state, isPending, updateSearch, setOptimisticActiveId, filteredIdSet, id } = useCommand(machine);
|
|
1886
|
+
const { state, handleKeyDown, stableContextValue, stateContextValue } = useCommandSetup(() => createCommandMachine(machineOptions), label);
|
|
776
1887
|
useEffect(() => {
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
1888
|
+
const root = rootRef.current;
|
|
1889
|
+
if (!root) return;
|
|
1890
|
+
const scopedHandler = (e) => {
|
|
1891
|
+
if (root.contains(e.target)) handleKeyDown(e);
|
|
780
1892
|
};
|
|
781
|
-
|
|
782
|
-
const handleKeyDown = useCallback(createKeydownHandler(machine, () => machine.getState()), []);
|
|
783
|
-
useEffect(() => {
|
|
784
|
-
document.addEventListener("keydown", handleKeyDown);
|
|
1893
|
+
document.addEventListener("keydown", scopedHandler);
|
|
785
1894
|
return () => {
|
|
786
|
-
document.removeEventListener("keydown",
|
|
1895
|
+
document.removeEventListener("keydown", scopedHandler);
|
|
787
1896
|
};
|
|
788
1897
|
}, [handleKeyDown]);
|
|
789
1898
|
return /* @__PURE__ */ jsx(CommandStableContext, {
|
|
790
|
-
value:
|
|
791
|
-
machine,
|
|
792
|
-
rootId: id,
|
|
793
|
-
listId,
|
|
794
|
-
inputId,
|
|
795
|
-
label,
|
|
796
|
-
updateSearch,
|
|
797
|
-
setOptimisticActiveId
|
|
798
|
-
}), [
|
|
799
|
-
machine,
|
|
800
|
-
id,
|
|
801
|
-
listId,
|
|
802
|
-
inputId,
|
|
803
|
-
label,
|
|
804
|
-
updateSearch,
|
|
805
|
-
setOptimisticActiveId
|
|
806
|
-
]),
|
|
1899
|
+
value: stableContextValue,
|
|
807
1900
|
children: /* @__PURE__ */ jsx(CommandStateContext, {
|
|
808
|
-
value:
|
|
809
|
-
state,
|
|
810
|
-
isPending,
|
|
811
|
-
filteredIdSet
|
|
812
|
-
}), [
|
|
813
|
-
state,
|
|
814
|
-
isPending,
|
|
815
|
-
filteredIdSet
|
|
816
|
-
]),
|
|
1901
|
+
value: stateContextValue,
|
|
817
1902
|
children: /* @__PURE__ */ jsx("div", {
|
|
818
1903
|
ref: rootRef,
|
|
819
1904
|
"data-command-root": "",
|
|
@@ -847,6 +1932,7 @@ const Command = Object.assign(CommandRoot, {
|
|
|
847
1932
|
//#region src/react/error-boundary.tsx
|
|
848
1933
|
/** Initial state — no error captured */
|
|
849
1934
|
const INITIAL_STATE = { error: null };
|
|
1935
|
+
/** Error boundary for graceful failure recovery in command subtrees. */
|
|
850
1936
|
var CommandErrorBoundary = class extends Component {
|
|
851
1937
|
state = INITIAL_STATE;
|
|
852
1938
|
static getDerivedStateFromError(error) {
|
|
@@ -868,8 +1954,7 @@ var CommandErrorBoundary = class extends Component {
|
|
|
868
1954
|
//#endregion
|
|
869
1955
|
//#region src/react/hooks/use-command-state.ts
|
|
870
1956
|
function useCommandState(selector) {
|
|
871
|
-
const stable =
|
|
872
|
-
if (!stable) throw new Error("useCommandState must be used within a <Command> component");
|
|
1957
|
+
const stable = useStableContext("useCommandState");
|
|
873
1958
|
const selectorRef = useRef(selector);
|
|
874
1959
|
selectorRef.current = selector;
|
|
875
1960
|
const getSnapshot = useCallback(() => selectorRef.current ? selectorRef.current(stable.machine.getState()) : stable.machine.getState(), [stable.machine]);
|
|
@@ -892,11 +1977,11 @@ const DEVTOOLS_KEY = "__CRIMSON_COMMAND_DEVTOOLS__";
|
|
|
892
1977
|
* ```
|
|
893
1978
|
*/
|
|
894
1979
|
function useCommandDevtools(label = "default") {
|
|
895
|
-
if (!__DEV__) return;
|
|
896
1980
|
const stable = use(CommandStableContext);
|
|
897
1981
|
const stateCtx = use(CommandStateContext);
|
|
898
1982
|
const stateRef = useRef(null);
|
|
899
1983
|
useEffect(() => {
|
|
1984
|
+
if (!__DEV__) return;
|
|
900
1985
|
if (!stable || !stateCtx) return;
|
|
901
1986
|
const update = () => {
|
|
902
1987
|
const s = stateCtx.state;
|
|
@@ -933,5 +2018,3 @@ function useCommandDevtools(label = "default") {
|
|
|
933
2018
|
}
|
|
934
2019
|
//#endregion
|
|
935
2020
|
export { Command, CommandActivity, CommandAsyncItems, CommandBadge, CommandDialog, CommandEmpty, CommandErrorBoundary, CommandGroup, CommandHighlight, CommandInput, CommandItem, CommandList, CommandLoading, CommandPage, CommandSeparator, CommandShortcut, CommandStableContext, CommandStateContext, createKeydownHandler, useCommand, useCommandDevtools, useCommandState, useRegisterGroup, useRegisterItem, useVirtualizer };
|
|
936
|
-
|
|
937
|
-
//# sourceMappingURL=index.mjs.map
|