modern-cmdk 1.0.1 → 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 +369 -4
- package/dist/react/index.mjs +1380 -265
- package/{src → dist}/react/styles.css +1 -3
- package/package.json +28 -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-DZT7Vwmo.mjs +0 -1219
- package/dist/core-DZT7Vwmo.mjs.map +0 -1
- package/dist/index-zBlDFopY.d.mts +0 -562
- package/dist/index-zBlDFopY.d.mts.map +0 -1
- package/dist/react/index.d.mts.map +0 -1
- package/dist/react/index.mjs.map +0 -1
package/dist/react/index.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
'use client';
|
|
2
2
|
import * as React from "react";
|
|
3
|
-
import { Component, Suspense, createContext, use, useCallback, useEffect, useId,
|
|
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
|
/**
|
|
@@ -20,25 +21,42 @@ function CommandActivity({ mode, children }) {
|
|
|
20
21
|
}
|
|
21
22
|
//#endregion
|
|
22
23
|
//#region src/react/context.ts
|
|
23
|
-
const
|
|
24
|
-
|
|
24
|
+
const CommandStableContext = createContext(null);
|
|
25
|
+
CommandStableContext.displayName = "CommandStableContext";
|
|
26
|
+
const CommandStateContext = createContext(null);
|
|
27
|
+
CommandStateContext.displayName = "CommandStateContext";
|
|
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
|
+
}
|
|
25
40
|
const CommandGroupContext = createContext(null);
|
|
26
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";
|
|
27
45
|
//#endregion
|
|
28
46
|
//#region src/react/async-items.tsx
|
|
29
47
|
/** Inner component that suspends via use() until items resolve */
|
|
30
48
|
function AsyncItemsInner({ items: itemsPromise, children }) {
|
|
31
|
-
const
|
|
32
|
-
if (!ctx) throw new Error("Command.AsyncItems must be used within a <Command> component");
|
|
49
|
+
const stable = useStableContext("Command.AsyncItems");
|
|
33
50
|
const items = use(itemsPromise);
|
|
34
|
-
|
|
35
|
-
|
|
51
|
+
useLayoutEffect(() => {
|
|
52
|
+
stable.machine.send({
|
|
36
53
|
type: "ITEMS_LOADED",
|
|
37
54
|
items
|
|
38
55
|
});
|
|
39
|
-
}, [
|
|
56
|
+
}, [stable.machine, items]);
|
|
40
57
|
return /* @__PURE__ */ jsx(Fragment, { children: children(items) });
|
|
41
58
|
}
|
|
59
|
+
/** Suspense-based async item loader that resolves a promise of items using React 19 `use()`. */
|
|
42
60
|
function CommandAsyncItems({ items, fallback, children }) {
|
|
43
61
|
const stableRef = useRef(items);
|
|
44
62
|
if (stableRef.current !== items) stableRef.current = items;
|
|
@@ -52,6 +70,7 @@ function CommandAsyncItems({ items, fallback, children }) {
|
|
|
52
70
|
}
|
|
53
71
|
//#endregion
|
|
54
72
|
//#region src/react/badge.tsx
|
|
73
|
+
/** Inline badge for categorization or status indicators on command items. */
|
|
55
74
|
function CommandBadge({ ref, children, ...props }) {
|
|
56
75
|
return /* @__PURE__ */ jsx("span", {
|
|
57
76
|
ref,
|
|
@@ -61,13 +80,1078 @@ function CommandBadge({ ref, children, ...props }) {
|
|
|
61
80
|
});
|
|
62
81
|
}
|
|
63
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
|
|
64
1142
|
//#region src/react/hooks/use-command.ts
|
|
1143
|
+
/** Core hook that binds a CommandMachine to React via useSyncExternalStore, useTransition, and useOptimistic. */
|
|
65
1144
|
function useCommand(machine) {
|
|
66
1145
|
const id = useId();
|
|
67
1146
|
const [isPending, startTransition] = useTransition();
|
|
68
1147
|
const state = useSyncExternalStore(machine.subscribe, machine.getState, machine.getState);
|
|
69
|
-
const filteredIdSet = useMemo(() =>
|
|
70
|
-
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]);
|
|
71
1155
|
const updateSearch = useCallback((query) => {
|
|
72
1156
|
startTransition(() => {
|
|
73
1157
|
machine.send({
|
|
@@ -135,28 +1219,77 @@ function createKeydownHandler(machine, getState) {
|
|
|
135
1219
|
};
|
|
136
1220
|
}
|
|
137
1221
|
//#endregion
|
|
138
|
-
//#region src/react/
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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) {
|
|
143
1233
|
const machineRef = useRef(null);
|
|
144
|
-
if (machineRef.current === null) machineRef.current =
|
|
145
|
-
...machineOptions,
|
|
146
|
-
open: controlledOpen ?? false,
|
|
147
|
-
onOpenChange: (open) => {
|
|
148
|
-
onOpenChange?.(open);
|
|
149
|
-
}
|
|
150
|
-
});
|
|
1234
|
+
if (machineRef.current === null) machineRef.current = machineFactory();
|
|
151
1235
|
const machine = machineRef.current;
|
|
152
1236
|
const { state, isPending, updateSearch, setOptimisticActiveId, filteredIdSet, id } = useCommand(machine);
|
|
1237
|
+
const listId = `${id}-list`;
|
|
1238
|
+
const inputId = `${id}-input`;
|
|
153
1239
|
useEffect(() => {
|
|
154
1240
|
return () => {
|
|
155
1241
|
machineRef.current?.[Symbol.dispose]();
|
|
156
1242
|
machineRef.current = null;
|
|
157
1243
|
};
|
|
158
1244
|
}, []);
|
|
159
|
-
|
|
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);
|
|
160
1293
|
useEffect(() => {
|
|
161
1294
|
if (!state.open) return;
|
|
162
1295
|
document.addEventListener("keydown", handleKeyDown);
|
|
@@ -164,39 +1297,16 @@ function CommandDialog({ children, className, overlayClassName, contentClassName
|
|
|
164
1297
|
document.removeEventListener("keydown", handleKeyDown);
|
|
165
1298
|
};
|
|
166
1299
|
}, [handleKeyDown, state.open]);
|
|
167
|
-
const prevControlledOpen = useRef(controlledOpen);
|
|
168
1300
|
useEffect(() => {
|
|
169
|
-
if (controlledOpen !== void 0
|
|
170
|
-
prevControlledOpen.current = controlledOpen;
|
|
171
|
-
machine.send({ type: controlledOpen ? "OPEN" : "CLOSE" });
|
|
172
|
-
}
|
|
1301
|
+
if (controlledOpen !== void 0) machine.send({ type: controlledOpen ? "OPEN" : "CLOSE" });
|
|
173
1302
|
}, [controlledOpen, machine]);
|
|
1303
|
+
const handleOpenAutoFocus = useCallback((e) => {
|
|
1304
|
+
e.preventDefault();
|
|
1305
|
+
document.getElementById(inputId)?.focus();
|
|
1306
|
+
}, [inputId]);
|
|
174
1307
|
const handleRadixOpenChange = useCallback((open) => {
|
|
175
1308
|
machine.send({ type: open ? "OPEN" : "CLOSE" });
|
|
176
1309
|
}, [machine]);
|
|
177
|
-
const contextValue = useMemo(() => ({
|
|
178
|
-
machine,
|
|
179
|
-
state,
|
|
180
|
-
isPending,
|
|
181
|
-
updateSearch,
|
|
182
|
-
setOptimisticActiveId,
|
|
183
|
-
filteredIdSet,
|
|
184
|
-
rootId: id,
|
|
185
|
-
listId,
|
|
186
|
-
inputId,
|
|
187
|
-
label
|
|
188
|
-
}), [
|
|
189
|
-
machine,
|
|
190
|
-
state,
|
|
191
|
-
isPending,
|
|
192
|
-
updateSearch,
|
|
193
|
-
setOptimisticActiveId,
|
|
194
|
-
filteredIdSet,
|
|
195
|
-
id,
|
|
196
|
-
listId,
|
|
197
|
-
inputId,
|
|
198
|
-
label
|
|
199
|
-
]);
|
|
200
1310
|
return /* @__PURE__ */ jsx(Dialog.Root, {
|
|
201
1311
|
open: state.open,
|
|
202
1312
|
onOpenChange: handleRadixOpenChange,
|
|
@@ -211,10 +1321,7 @@ function CommandDialog({ children, className, overlayClassName, contentClassName
|
|
|
211
1321
|
"data-state": state.open ? "open" : "closed",
|
|
212
1322
|
className,
|
|
213
1323
|
"aria-label": label,
|
|
214
|
-
onOpenAutoFocus:
|
|
215
|
-
e.preventDefault();
|
|
216
|
-
document.getElementById(inputId)?.focus();
|
|
217
|
-
},
|
|
1324
|
+
onOpenAutoFocus: handleOpenAutoFocus,
|
|
218
1325
|
children: [
|
|
219
1326
|
/* @__PURE__ */ jsx(Dialog.Title, {
|
|
220
1327
|
className: "sr-only",
|
|
@@ -222,18 +1329,21 @@ function CommandDialog({ children, className, overlayClassName, contentClassName
|
|
|
222
1329
|
}),
|
|
223
1330
|
/* @__PURE__ */ jsx(Dialog.Description, {
|
|
224
1331
|
className: "sr-only",
|
|
225
|
-
children:
|
|
1332
|
+
children: description
|
|
226
1333
|
}),
|
|
227
|
-
/* @__PURE__ */ jsx(
|
|
228
|
-
value:
|
|
229
|
-
children: /* @__PURE__ */ jsx(
|
|
230
|
-
|
|
231
|
-
className: contentClassName,
|
|
1334
|
+
/* @__PURE__ */ jsx(CommandStableContext, {
|
|
1335
|
+
value: stableContextValue,
|
|
1336
|
+
children: /* @__PURE__ */ jsx(CommandStateContext, {
|
|
1337
|
+
value: stateContextValue,
|
|
232
1338
|
children: /* @__PURE__ */ jsx("div", {
|
|
233
|
-
"data-command-
|
|
234
|
-
|
|
235
|
-
"
|
|
236
|
-
|
|
1339
|
+
"data-command-dialog-content": "",
|
|
1340
|
+
className: contentClassName,
|
|
1341
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
1342
|
+
"data-command-root": "",
|
|
1343
|
+
role: "search",
|
|
1344
|
+
"aria-label": label,
|
|
1345
|
+
children
|
|
1346
|
+
})
|
|
237
1347
|
})
|
|
238
1348
|
})
|
|
239
1349
|
})
|
|
@@ -244,11 +1354,12 @@ function CommandDialog({ children, className, overlayClassName, contentClassName
|
|
|
244
1354
|
}
|
|
245
1355
|
//#endregion
|
|
246
1356
|
//#region src/react/empty.tsx
|
|
1357
|
+
/** Rendered when no items match the current search query. */
|
|
247
1358
|
function CommandEmpty({ ref, children, ...props }) {
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
if (
|
|
251
|
-
|
|
1359
|
+
const stateCtx = useStateContext("Command.Empty");
|
|
1360
|
+
const statusContainerRef = use(CommandListStatusContext);
|
|
1361
|
+
if (stateCtx.state.filteredCount > 0 || stateCtx.state.loading) return null;
|
|
1362
|
+
const content = /* @__PURE__ */ jsx("div", {
|
|
252
1363
|
ref,
|
|
253
1364
|
"data-command-empty": "",
|
|
254
1365
|
role: "status",
|
|
@@ -256,16 +1367,22 @@ function CommandEmpty({ ref, children, ...props }) {
|
|
|
256
1367
|
...props,
|
|
257
1368
|
children
|
|
258
1369
|
});
|
|
1370
|
+
if (statusContainerRef?.current) return createPortal(content, statusContainerRef.current);
|
|
1371
|
+
return content;
|
|
259
1372
|
}
|
|
260
1373
|
//#endregion
|
|
261
1374
|
//#region src/react/hooks/use-register.ts
|
|
262
1375
|
/** Register a command item with the machine — auto-deregisters on unmount */
|
|
263
1376
|
function useRegisterItem(value, options) {
|
|
264
|
-
const
|
|
265
|
-
if (!ctx) throw new Error("useRegisterItem must be used within a <Command> component");
|
|
1377
|
+
const stable = useStableContext("useRegisterItem");
|
|
266
1378
|
const generatedId = useId();
|
|
267
1379
|
const id = itemId(options?.forceId ?? generatedId);
|
|
268
|
-
|
|
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");
|
|
1385
|
+
useLayoutEffect(() => {
|
|
269
1386
|
const item = {
|
|
270
1387
|
id,
|
|
271
1388
|
value,
|
|
@@ -273,56 +1390,53 @@ function useRegisterItem(value, options) {
|
|
|
273
1390
|
groupId: options?.groupId ? groupId(options.groupId) : void 0,
|
|
274
1391
|
shortcut: options?.shortcut,
|
|
275
1392
|
disabled: options?.disabled,
|
|
276
|
-
onSelect:
|
|
277
|
-
data:
|
|
1393
|
+
onSelect: () => onSelectRef.current?.(),
|
|
1394
|
+
data: dataRef.current
|
|
278
1395
|
};
|
|
279
|
-
|
|
1396
|
+
stable.machine.send({
|
|
280
1397
|
type: "REGISTER_ITEM",
|
|
281
1398
|
item
|
|
282
1399
|
});
|
|
283
1400
|
return () => {
|
|
284
|
-
|
|
1401
|
+
stable.machine.send({
|
|
285
1402
|
type: "UNREGISTER_ITEM",
|
|
286
1403
|
id
|
|
287
1404
|
});
|
|
288
1405
|
};
|
|
289
1406
|
}, [
|
|
290
|
-
|
|
1407
|
+
stable.machine,
|
|
291
1408
|
id,
|
|
292
1409
|
value,
|
|
293
1410
|
options?.groupId,
|
|
294
|
-
options?.data,
|
|
295
1411
|
options?.disabled,
|
|
296
|
-
|
|
297
|
-
options?.onSelect,
|
|
1412
|
+
keywordsDep,
|
|
298
1413
|
options?.shortcut
|
|
299
1414
|
]);
|
|
300
1415
|
return id;
|
|
301
1416
|
}
|
|
302
1417
|
/** Register a command group with the machine — auto-deregisters on unmount */
|
|
303
1418
|
function useRegisterGroup(heading, options) {
|
|
304
|
-
const
|
|
305
|
-
if (!ctx) throw new Error("useRegisterGroup must be used within a <Command> component");
|
|
1419
|
+
const stable = useStableContext("useRegisterGroup");
|
|
306
1420
|
const generatedId = useId();
|
|
307
1421
|
const id = groupId(options?.forceId ?? generatedId);
|
|
308
|
-
|
|
1422
|
+
useLayoutEffect(() => {
|
|
309
1423
|
const group = {
|
|
310
1424
|
id,
|
|
311
1425
|
heading,
|
|
312
1426
|
priority: options?.priority
|
|
313
1427
|
};
|
|
314
|
-
|
|
1428
|
+
stable.machine.send({
|
|
315
1429
|
type: "REGISTER_GROUP",
|
|
316
1430
|
group
|
|
317
1431
|
});
|
|
318
1432
|
return () => {
|
|
319
|
-
|
|
1433
|
+
stable.machine.send({
|
|
320
1434
|
type: "UNREGISTER_GROUP",
|
|
321
1435
|
id
|
|
322
1436
|
});
|
|
323
1437
|
};
|
|
324
1438
|
}, [
|
|
325
|
-
|
|
1439
|
+
stable.machine,
|
|
326
1440
|
id,
|
|
327
1441
|
heading,
|
|
328
1442
|
options?.priority
|
|
@@ -331,21 +1445,29 @@ function useRegisterGroup(heading, options) {
|
|
|
331
1445
|
}
|
|
332
1446
|
//#endregion
|
|
333
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. */
|
|
334
1451
|
function CommandGroup({ ref, children, heading, priority, forceId, ...props }) {
|
|
335
|
-
const
|
|
336
|
-
if (!ctx) throw new Error("Command.Group must be used within a <Command> component");
|
|
1452
|
+
const stateCtx = useStateContext("Command.Group");
|
|
337
1453
|
const headingId = useId();
|
|
338
1454
|
const groupId = useRegisterGroup(heading, {
|
|
339
1455
|
...priority !== void 0 && { priority },
|
|
340
1456
|
...forceId !== void 0 && { forceId }
|
|
341
1457
|
});
|
|
342
|
-
const groupItems =
|
|
1458
|
+
const groupItems = stateCtx.state.groupedIds.get(groupId);
|
|
343
1459
|
const hasVisibleItems = groupItems !== void 0 && groupItems.length > 0;
|
|
344
1460
|
const groupContext = useMemo(() => ({
|
|
345
1461
|
groupId,
|
|
346
1462
|
headingId
|
|
347
1463
|
}), [groupId, headingId]);
|
|
348
|
-
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
|
+
});
|
|
349
1471
|
return /* @__PURE__ */ jsx(CommandGroupContext, {
|
|
350
1472
|
value: groupContext,
|
|
351
1473
|
children: /* @__PURE__ */ jsxs("div", {
|
|
@@ -368,13 +1490,14 @@ function CommandGroup({ ref, children, heading, priority, forceId, ...props }) {
|
|
|
368
1490
|
}
|
|
369
1491
|
//#endregion
|
|
370
1492
|
//#region src/react/highlight.tsx
|
|
1493
|
+
/** Renders search match highlighting by splitting text at scored match ranges. */
|
|
371
1494
|
function CommandHighlight({ text, ranges, highlightClassName }) {
|
|
372
1495
|
return useMemo(() => {
|
|
373
1496
|
if (ranges.length === 0) return text;
|
|
374
|
-
const sorted =
|
|
1497
|
+
const sorted = ranges.toSorted((a, b) => a[0] - b[0]);
|
|
375
1498
|
const segments = [];
|
|
376
1499
|
let lastEnd = 0;
|
|
377
|
-
|
|
1500
|
+
sorted.forEach(([start, end], i) => {
|
|
378
1501
|
if (start > lastEnd) segments.push(text.slice(lastEnd, start));
|
|
379
1502
|
segments.push(/* @__PURE__ */ jsx("mark", {
|
|
380
1503
|
"data-command-highlight": "",
|
|
@@ -393,71 +1516,75 @@ function CommandHighlight({ text, ranges, highlightClassName }) {
|
|
|
393
1516
|
}
|
|
394
1517
|
//#endregion
|
|
395
1518
|
//#region src/react/input.tsx
|
|
1519
|
+
/** Combobox input that drives the command palette search state. */
|
|
396
1520
|
function CommandInput({ ref, placeholder = "Search...", onValueChange, ...props }) {
|
|
397
|
-
const
|
|
398
|
-
|
|
1521
|
+
const stable = useStableContext("Command.Input");
|
|
1522
|
+
const stateCtx = useStateContext("Command.Input");
|
|
399
1523
|
const handleChange = useCallback((event) => {
|
|
400
1524
|
const { value } = event.target;
|
|
401
|
-
|
|
1525
|
+
stable.updateSearch(value);
|
|
402
1526
|
onValueChange?.(value);
|
|
403
|
-
}, [
|
|
1527
|
+
}, [stable.updateSearch, onValueChange]);
|
|
404
1528
|
return /* @__PURE__ */ jsx("input", {
|
|
405
1529
|
ref,
|
|
406
1530
|
"data-command-input": "",
|
|
407
1531
|
type: "text",
|
|
408
1532
|
role: "combobox",
|
|
409
|
-
"aria-expanded":
|
|
410
|
-
"aria-controls":
|
|
411
|
-
"aria-activedescendant":
|
|
1533
|
+
"aria-expanded": true,
|
|
1534
|
+
"aria-controls": stable.listId,
|
|
1535
|
+
"aria-activedescendant": stateCtx.state.activeId ?? void 0,
|
|
412
1536
|
"aria-autocomplete": "list",
|
|
413
|
-
"aria-label":
|
|
414
|
-
id:
|
|
1537
|
+
"aria-label": stable.label,
|
|
1538
|
+
id: stable.inputId,
|
|
415
1539
|
autoComplete: "off",
|
|
416
1540
|
autoCorrect: "off",
|
|
417
1541
|
spellCheck: false,
|
|
418
1542
|
placeholder,
|
|
419
|
-
value:
|
|
1543
|
+
value: stateCtx.state.search,
|
|
420
1544
|
onChange: handleChange,
|
|
421
1545
|
...props
|
|
422
1546
|
});
|
|
423
1547
|
}
|
|
424
1548
|
//#endregion
|
|
425
1549
|
//#region src/react/item.tsx
|
|
1550
|
+
/** Individual selectable item in the command list with keyboard and pointer activation. */
|
|
426
1551
|
function CommandItem({ ref, children, value, keywords, onSelect, disabled = false, shortcut, forceId, ...props }) {
|
|
427
|
-
const
|
|
428
|
-
|
|
1552
|
+
const stable = useStableContext("Command.Item");
|
|
1553
|
+
const stateCtx = useStateContext("Command.Item");
|
|
1554
|
+
const groupCtx = use(CommandGroupContext);
|
|
429
1555
|
const id = useRegisterItem(value, {
|
|
430
1556
|
...keywords !== void 0 && { keywords },
|
|
431
1557
|
...shortcut !== void 0 && { shortcut },
|
|
432
1558
|
disabled,
|
|
433
1559
|
...onSelect !== void 0 && { onSelect },
|
|
434
|
-
...forceId !== void 0 && { forceId }
|
|
1560
|
+
...forceId !== void 0 && { forceId },
|
|
1561
|
+
...groupCtx !== null && { groupId: groupCtx.groupId }
|
|
435
1562
|
});
|
|
436
|
-
const isActive =
|
|
437
|
-
const isFiltered =
|
|
1563
|
+
const isActive = stateCtx.state.activeId === id;
|
|
1564
|
+
const isFiltered = stateCtx.filteredIdSet.has(id);
|
|
438
1565
|
const handleSelect = useCallback(() => {
|
|
439
|
-
if (!disabled)
|
|
1566
|
+
if (!disabled) stable.machine.send({
|
|
440
1567
|
type: "ITEM_SELECT",
|
|
441
1568
|
id
|
|
442
1569
|
});
|
|
443
1570
|
}, [
|
|
444
|
-
|
|
1571
|
+
stable.machine,
|
|
445
1572
|
id,
|
|
446
1573
|
disabled
|
|
447
1574
|
]);
|
|
448
|
-
const activeIdRef = useRef(
|
|
449
|
-
activeIdRef.current =
|
|
1575
|
+
const activeIdRef = useRef(stateCtx.state.activeId);
|
|
1576
|
+
activeIdRef.current = stateCtx.state.activeId;
|
|
450
1577
|
const handlePointerMove = useCallback(() => {
|
|
451
1578
|
if (!disabled && activeIdRef.current !== id) {
|
|
452
|
-
|
|
453
|
-
|
|
1579
|
+
stable.setOptimisticActiveId(id);
|
|
1580
|
+
stable.machine.send({
|
|
454
1581
|
type: "ITEM_ACTIVATE",
|
|
455
1582
|
id
|
|
456
1583
|
});
|
|
457
1584
|
}
|
|
458
1585
|
}, [
|
|
459
|
-
|
|
460
|
-
|
|
1586
|
+
stable.machine,
|
|
1587
|
+
stable.setOptimisticActiveId,
|
|
461
1588
|
id,
|
|
462
1589
|
disabled
|
|
463
1590
|
]);
|
|
@@ -481,6 +1608,7 @@ function CommandItem({ ref, children, value, keywords, onSelect, disabled = fals
|
|
|
481
1608
|
}
|
|
482
1609
|
//#endregion
|
|
483
1610
|
//#region src/react/hooks/use-virtualizer.ts
|
|
1611
|
+
/** Lightweight virtualizer for large item lists using ResizeObserver and scroll tracking. */
|
|
484
1612
|
function useVirtualizer(options) {
|
|
485
1613
|
const { count, estimateSize, overscan = 8, scrollElement, enabled = true } = options;
|
|
486
1614
|
const [scrollOffset, setScrollOffset] = useState(0);
|
|
@@ -494,26 +1622,20 @@ function useVirtualizer(options) {
|
|
|
494
1622
|
if (lastEntry) setContainerHeight(lastEntry.contentRect.height);
|
|
495
1623
|
});
|
|
496
1624
|
observer.observe(scrollElement);
|
|
497
|
-
return () => {
|
|
498
|
-
observer.disconnect();
|
|
499
|
-
};
|
|
500
|
-
}, [scrollElement, enabled]);
|
|
501
|
-
useEffect(() => {
|
|
502
|
-
if (!scrollElement || !enabled) return;
|
|
503
1625
|
const handleScroll = () => {
|
|
504
1626
|
setScrollOffset(scrollElement.scrollTop);
|
|
505
1627
|
};
|
|
506
1628
|
scrollElement.addEventListener("scroll", handleScroll, { passive: true });
|
|
507
1629
|
return () => {
|
|
1630
|
+
observer.disconnect();
|
|
508
1631
|
scrollElement.removeEventListener("scroll", handleScroll);
|
|
509
1632
|
};
|
|
510
1633
|
}, [scrollElement, enabled]);
|
|
511
1634
|
const getItemSize = useCallback((index) => measuredSizes.current.get(index) ?? estimateSize, [estimateSize]);
|
|
512
1635
|
const totalSize = useMemo(() => {
|
|
513
1636
|
if (!enabled) return 0;
|
|
514
|
-
const sizes =
|
|
515
|
-
|
|
516
|
-
} }).toArray();
|
|
1637
|
+
const sizes = [];
|
|
1638
|
+
for (let i = 0; i < count; i++) sizes.push(getItemSize(i));
|
|
517
1639
|
return Math.sumPrecise(sizes);
|
|
518
1640
|
}, [
|
|
519
1641
|
enabled,
|
|
@@ -531,9 +1653,8 @@ function useVirtualizer(options) {
|
|
|
531
1653
|
startIdx++;
|
|
532
1654
|
}
|
|
533
1655
|
startIdx = Math.max(0, startIdx - overscan);
|
|
534
|
-
const offsetSizes =
|
|
535
|
-
|
|
536
|
-
} }).toArray();
|
|
1656
|
+
const offsetSizes = [];
|
|
1657
|
+
for (let i = 0; i < startIdx; i++) offsetSizes.push(getItemSize(i));
|
|
537
1658
|
offset = Math.sumPrecise(offsetSizes);
|
|
538
1659
|
const items = [];
|
|
539
1660
|
let currentOffset = offset;
|
|
@@ -562,10 +1683,9 @@ function useVirtualizer(options) {
|
|
|
562
1683
|
totalSize,
|
|
563
1684
|
scrollToIndex: useCallback((index) => {
|
|
564
1685
|
if (!scrollElement || !enabled) return;
|
|
565
|
-
const clampedIndex = Math.min(index, count);
|
|
566
|
-
const sizes =
|
|
567
|
-
|
|
568
|
-
} }).toArray();
|
|
1686
|
+
const clampedIndex = Math.min(index, count - 1);
|
|
1687
|
+
const sizes = [];
|
|
1688
|
+
for (let i = 0; i < clampedIndex; i++) sizes.push(getItemSize(i));
|
|
569
1689
|
const targetOffset = Math.sumPrecise(sizes);
|
|
570
1690
|
scrollElement.scrollTo({
|
|
571
1691
|
top: targetOffset,
|
|
@@ -595,13 +1715,21 @@ function useVirtualizer(options) {
|
|
|
595
1715
|
}
|
|
596
1716
|
//#endregion
|
|
597
1717
|
//#region src/react/list.tsx
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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");
|
|
601
1729
|
const innerRef = useRef(null);
|
|
602
1730
|
const scrollRef = useRef(null);
|
|
603
1731
|
const [height, setHeight] = useState(0);
|
|
604
|
-
const shouldVirtualize = virtualize ??
|
|
1732
|
+
const shouldVirtualize = virtualize ?? stateCtx.state.filteredCount > 100;
|
|
605
1733
|
useEffect(() => {
|
|
606
1734
|
const element = innerRef.current;
|
|
607
1735
|
if (!element) return;
|
|
@@ -615,90 +1743,97 @@ function CommandList({ ref, children, virtualize, estimateSize = 44, overscan =
|
|
|
615
1743
|
};
|
|
616
1744
|
}, []);
|
|
617
1745
|
const virtualizer = useVirtualizer({
|
|
618
|
-
count:
|
|
1746
|
+
count: stateCtx.state.filteredCount,
|
|
619
1747
|
estimateSize,
|
|
620
1748
|
overscan,
|
|
621
1749
|
scrollElement: shouldVirtualize ? scrollRef.current : null,
|
|
622
1750
|
enabled: shouldVirtualize
|
|
623
1751
|
});
|
|
624
|
-
const prevActiveId = useRef(
|
|
1752
|
+
const prevActiveId = useRef(stateCtx.state.activeId);
|
|
625
1753
|
useEffect(() => {
|
|
626
|
-
if (
|
|
627
|
-
prevActiveId.current =
|
|
1754
|
+
if (stateCtx.state.activeId && stateCtx.state.activeId !== prevActiveId.current) {
|
|
1755
|
+
prevActiveId.current = stateCtx.state.activeId;
|
|
628
1756
|
if (shouldVirtualize) {
|
|
629
|
-
const idx =
|
|
1757
|
+
const idx = stable.machine.getFilteredIdIndex().get(stateCtx.state.activeId) ?? -1;
|
|
630
1758
|
if (idx >= 0) virtualizer.scrollToIndex(idx);
|
|
631
|
-
} else (scrollRef.current?.querySelector(`[id="${CSS.escape(
|
|
1759
|
+
} else (scrollRef.current?.querySelector(`[id="${CSS.escape(stateCtx.state.activeId)}"]`))?.scrollIntoView({
|
|
632
1760
|
block: "nearest",
|
|
633
1761
|
behavior: "smooth"
|
|
634
1762
|
});
|
|
635
1763
|
}
|
|
636
1764
|
}, [
|
|
637
|
-
|
|
638
|
-
ctx.state.filteredIds,
|
|
1765
|
+
stateCtx.state.activeId,
|
|
639
1766
|
shouldVirtualize,
|
|
640
|
-
virtualizer
|
|
1767
|
+
virtualizer,
|
|
1768
|
+
stable.machine.getFilteredIdIndex
|
|
641
1769
|
]);
|
|
642
|
-
const mergedStyle = {
|
|
1770
|
+
const mergedStyle = useMemo(() => ({
|
|
643
1771
|
...style,
|
|
644
1772
|
"--command-list-height": `${height.toFixed(1)}px`
|
|
645
|
-
};
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
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
|
+
]
|
|
694
1829
|
});
|
|
695
1830
|
}
|
|
696
1831
|
//#endregion
|
|
697
1832
|
//#region src/react/loading.tsx
|
|
1833
|
+
/** Shown during async operations, with configurable loading state override. */
|
|
698
1834
|
function CommandLoading({ ref, children, loading: loadingOverride, ...props }) {
|
|
699
|
-
const
|
|
700
|
-
if (!
|
|
701
|
-
if (!(loadingOverride ?? ctx.state.loading ?? ctx.isPending)) return null;
|
|
1835
|
+
const stateCtx = useStateContext("Command.Loading");
|
|
1836
|
+
if (!(loadingOverride ?? (stateCtx.state.loading || stateCtx.isPending))) return null;
|
|
702
1837
|
return /* @__PURE__ */ jsx("div", {
|
|
703
1838
|
ref,
|
|
704
1839
|
"data-command-loading": "",
|
|
@@ -711,10 +1846,9 @@ function CommandLoading({ ref, children, loading: loadingOverride, ...props }) {
|
|
|
711
1846
|
}
|
|
712
1847
|
//#endregion
|
|
713
1848
|
//#region src/react/page.tsx
|
|
1849
|
+
/** Page navigation container that only renders when its ID matches the active page. */
|
|
714
1850
|
function CommandPage({ id, children }) {
|
|
715
|
-
|
|
716
|
-
if (!ctx) throw new Error("Command.Page must be used within a <Command> component");
|
|
717
|
-
if (ctx.state.page !== id) return null;
|
|
1851
|
+
if (useStateContext("Command.Page").state.page !== id) return null;
|
|
718
1852
|
return /* @__PURE__ */ jsx("div", {
|
|
719
1853
|
"data-command-page": "",
|
|
720
1854
|
"data-command-page-id": id,
|
|
@@ -723,81 +1857,57 @@ function CommandPage({ id, children }) {
|
|
|
723
1857
|
}
|
|
724
1858
|
//#endregion
|
|
725
1859
|
//#region src/react/separator.tsx
|
|
1860
|
+
/** Visual divider between items or groups, rendered as a presentational element. */
|
|
726
1861
|
function CommandSeparator({ ref, ...props }) {
|
|
727
1862
|
return /* @__PURE__ */ jsx("div", {
|
|
728
1863
|
ref,
|
|
729
1864
|
"data-command-separator": "",
|
|
730
|
-
role: "
|
|
1865
|
+
role: "none",
|
|
731
1866
|
...props
|
|
732
1867
|
});
|
|
733
1868
|
}
|
|
734
1869
|
//#endregion
|
|
735
1870
|
//#region src/react/shortcut.tsx
|
|
1871
|
+
/** Keyboard shortcut display with platform-aware formatting (e.g., Cmd vs Ctrl). */
|
|
736
1872
|
function CommandShortcut({ ref, shortcut, children, ...props }) {
|
|
737
|
-
const
|
|
1873
|
+
const formattedShortcut = useMemo(() => shortcut ? formatShortcut(parseShortcut(shortcut)) : null, [shortcut]);
|
|
738
1874
|
return /* @__PURE__ */ jsx("kbd", {
|
|
739
1875
|
ref,
|
|
740
1876
|
"data-command-shortcut": "",
|
|
741
1877
|
...props,
|
|
742
|
-
children:
|
|
1878
|
+
children: formattedShortcut ?? children
|
|
743
1879
|
});
|
|
744
1880
|
}
|
|
745
1881
|
//#endregion
|
|
746
1882
|
//#region src/react/command.tsx
|
|
1883
|
+
/** Root compound component that provides the command state machine context to all children. */
|
|
747
1884
|
function CommandRoot({ children, className, label = "Command palette", ...machineOptions }) {
|
|
748
|
-
const rootId = useId();
|
|
749
|
-
const listId = `${rootId}-list`;
|
|
750
|
-
const inputId = `${rootId}-input`;
|
|
751
1885
|
const rootRef = useRef(null);
|
|
752
|
-
const
|
|
753
|
-
if (machineRef.current === null) machineRef.current = createCommandMachine(machineOptions);
|
|
754
|
-
const machine = machineRef.current;
|
|
755
|
-
const { state, isPending, updateSearch, setOptimisticActiveId, filteredIdSet, id } = useCommand(machine);
|
|
1886
|
+
const { state, handleKeyDown, stableContextValue, stateContextValue } = useCommandSetup(() => createCommandMachine(machineOptions), label);
|
|
756
1887
|
useEffect(() => {
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
1888
|
+
const root = rootRef.current;
|
|
1889
|
+
if (!root) return;
|
|
1890
|
+
const scopedHandler = (e) => {
|
|
1891
|
+
if (root.contains(e.target)) handleKeyDown(e);
|
|
760
1892
|
};
|
|
761
|
-
|
|
762
|
-
const handleKeyDown = useCallback(createKeydownHandler(machine, () => machine.getState()), []);
|
|
763
|
-
useEffect(() => {
|
|
764
|
-
document.addEventListener("keydown", handleKeyDown);
|
|
1893
|
+
document.addEventListener("keydown", scopedHandler);
|
|
765
1894
|
return () => {
|
|
766
|
-
document.removeEventListener("keydown",
|
|
1895
|
+
document.removeEventListener("keydown", scopedHandler);
|
|
767
1896
|
};
|
|
768
1897
|
}, [handleKeyDown]);
|
|
769
|
-
return /* @__PURE__ */ jsx(
|
|
770
|
-
value:
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
machine,
|
|
783
|
-
state,
|
|
784
|
-
isPending,
|
|
785
|
-
updateSearch,
|
|
786
|
-
setOptimisticActiveId,
|
|
787
|
-
filteredIdSet,
|
|
788
|
-
id,
|
|
789
|
-
listId,
|
|
790
|
-
inputId,
|
|
791
|
-
label
|
|
792
|
-
]),
|
|
793
|
-
children: /* @__PURE__ */ jsx("div", {
|
|
794
|
-
ref: rootRef,
|
|
795
|
-
"data-command-root": "",
|
|
796
|
-
"data-command-state": state.open ? "open" : "closed",
|
|
797
|
-
className,
|
|
798
|
-
role: "search",
|
|
799
|
-
"aria-label": label,
|
|
800
|
-
children
|
|
1898
|
+
return /* @__PURE__ */ jsx(CommandStableContext, {
|
|
1899
|
+
value: stableContextValue,
|
|
1900
|
+
children: /* @__PURE__ */ jsx(CommandStateContext, {
|
|
1901
|
+
value: stateContextValue,
|
|
1902
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
1903
|
+
ref: rootRef,
|
|
1904
|
+
"data-command-root": "",
|
|
1905
|
+
"data-command-state": state.open ? "open" : "closed",
|
|
1906
|
+
className,
|
|
1907
|
+
role: "search",
|
|
1908
|
+
"aria-label": label,
|
|
1909
|
+
children
|
|
1910
|
+
})
|
|
801
1911
|
})
|
|
802
1912
|
});
|
|
803
1913
|
}
|
|
@@ -822,6 +1932,7 @@ const Command = Object.assign(CommandRoot, {
|
|
|
822
1932
|
//#region src/react/error-boundary.tsx
|
|
823
1933
|
/** Initial state — no error captured */
|
|
824
1934
|
const INITIAL_STATE = { error: null };
|
|
1935
|
+
/** Error boundary for graceful failure recovery in command subtrees. */
|
|
825
1936
|
var CommandErrorBoundary = class extends Component {
|
|
826
1937
|
state = INITIAL_STATE;
|
|
827
1938
|
static getDerivedStateFromError(error) {
|
|
@@ -843,10 +1954,11 @@ var CommandErrorBoundary = class extends Component {
|
|
|
843
1954
|
//#endregion
|
|
844
1955
|
//#region src/react/hooks/use-command-state.ts
|
|
845
1956
|
function useCommandState(selector) {
|
|
846
|
-
const
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
1957
|
+
const stable = useStableContext("useCommandState");
|
|
1958
|
+
const selectorRef = useRef(selector);
|
|
1959
|
+
selectorRef.current = selector;
|
|
1960
|
+
const getSnapshot = useCallback(() => selectorRef.current ? selectorRef.current(stable.machine.getState()) : stable.machine.getState(), [stable.machine]);
|
|
1961
|
+
return useSyncExternalStore(stable.machine.subscribe, getSnapshot, getSnapshot);
|
|
850
1962
|
}
|
|
851
1963
|
//#endregion
|
|
852
1964
|
//#region src/react/hooks/use-devtools.ts
|
|
@@ -865,15 +1977,16 @@ const DEVTOOLS_KEY = "__CRIMSON_COMMAND_DEVTOOLS__";
|
|
|
865
1977
|
* ```
|
|
866
1978
|
*/
|
|
867
1979
|
function useCommandDevtools(label = "default") {
|
|
868
|
-
|
|
869
|
-
const
|
|
1980
|
+
const stable = use(CommandStableContext);
|
|
1981
|
+
const stateCtx = use(CommandStateContext);
|
|
870
1982
|
const stateRef = useRef(null);
|
|
871
1983
|
useEffect(() => {
|
|
872
|
-
if (!
|
|
1984
|
+
if (!__DEV__) return;
|
|
1985
|
+
if (!stable || !stateCtx) return;
|
|
873
1986
|
const update = () => {
|
|
874
|
-
const s =
|
|
1987
|
+
const s = stateCtx.state;
|
|
875
1988
|
const devState = {
|
|
876
|
-
registeredItems:
|
|
1989
|
+
registeredItems: stable.machine.getRegistry().size,
|
|
877
1990
|
filteredItems: s.filteredIds.length,
|
|
878
1991
|
searchQuery: s.search,
|
|
879
1992
|
activeId: s.activeId,
|
|
@@ -891,15 +2004,17 @@ function useCommandDevtools(label = "default") {
|
|
|
891
2004
|
state: devState
|
|
892
2005
|
} }));
|
|
893
2006
|
};
|
|
894
|
-
const unsub =
|
|
2007
|
+
const unsub = stable.machine.subscribe(update);
|
|
895
2008
|
update();
|
|
896
2009
|
return () => {
|
|
897
2010
|
unsub();
|
|
898
2011
|
globalThis[DEVTOOLS_KEY]?.delete(label);
|
|
899
2012
|
};
|
|
900
|
-
}, [
|
|
2013
|
+
}, [
|
|
2014
|
+
stable,
|
|
2015
|
+
stateCtx,
|
|
2016
|
+
label
|
|
2017
|
+
]);
|
|
901
2018
|
}
|
|
902
2019
|
//#endregion
|
|
903
|
-
export { Command, CommandActivity, CommandAsyncItems, CommandBadge, CommandDialog, CommandEmpty, CommandErrorBoundary, CommandGroup, CommandHighlight, CommandInput, CommandItem, CommandList, CommandLoading, CommandPage, CommandSeparator, CommandShortcut, createKeydownHandler, useCommand, useCommandDevtools, useCommandState, useRegisterGroup, useRegisterItem, useVirtualizer };
|
|
904
|
-
|
|
905
|
-
//# sourceMappingURL=index.mjs.map
|
|
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 };
|