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