@octanejs/cmdk 0.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/LICENSE +21 -0
- package/README.md +57 -0
- package/package.json +52 -0
- package/src/cmdk.tsrx +52 -0
- package/src/cmdk.tsrx.d.ts +49 -0
- package/src/command-score.ts +177 -0
- package/src/components/command.tsrx +729 -0
- package/src/components/dialog.tsrx +40 -0
- package/src/components/empty.tsrx +28 -0
- package/src/components/group.tsrx +52 -0
- package/src/components/input.tsrx +44 -0
- package/src/components/item.tsrx +95 -0
- package/src/components/list.tsrx +50 -0
- package/src/components/loading.tsrx +19 -0
- package/src/components/separator.tsrx +14 -0
- package/src/context.tsrx +12 -0
- package/src/filter.ts +14 -0
- package/src/hooks.tsrx +125 -0
- package/src/index.ts +31 -0
- package/src/lib/constants.ts +23 -0
- package/src/types.ts +171 -0
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
// Command (root). Owns the external store, item/group registration, filtering,
|
|
2
|
+
// score sort, keyboard navigation, and controlled `value` handling, and provides
|
|
3
|
+
// the store + command contexts to its children.
|
|
4
|
+
import { useLayoutEffect, useMemo, useRef } from 'octane';
|
|
5
|
+
import { CommandContext, StoreContext } from '../context.tsrx';
|
|
6
|
+
import { useAsRef, useId, useLazyRef, useScheduleLayoutEffect } from '../hooks.tsrx';
|
|
7
|
+
import { defaultFilter } from '../filter';
|
|
8
|
+
import {
|
|
9
|
+
GROUP_HEADING_SELECTOR,
|
|
10
|
+
GROUP_ITEMS_SELECTOR,
|
|
11
|
+
GROUP_SELECTOR,
|
|
12
|
+
ITEM_SELECTOR,
|
|
13
|
+
SELECT_EVENT,
|
|
14
|
+
VALID_ITEM_SELECTOR,
|
|
15
|
+
VALUE_ATTR,
|
|
16
|
+
srOnlyStyles,
|
|
17
|
+
} from '../lib/constants';
|
|
18
|
+
import type { CommandProps, Context, State, Store } from '../types';
|
|
19
|
+
|
|
20
|
+
export function CommandRoot(props: CommandProps) @{
|
|
21
|
+
const state = useLazyRef<State>(
|
|
22
|
+
() => ({
|
|
23
|
+
search: '',
|
|
24
|
+
value: props.value ?? props.defaultValue ?? '',
|
|
25
|
+
selectedItemId: undefined,
|
|
26
|
+
filtered: { count: 0, items: new Map(), groups: new Set() },
|
|
27
|
+
forceMountedCount: 0,
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
const allItems = useLazyRef<Set<string>>(() => new Set());
|
|
31
|
+
const allForceMounted = useLazyRef<Set<string>>(() => new Set());
|
|
32
|
+
const allGroups = useLazyRef<Map<string, Set<string>>>(() => new Map());
|
|
33
|
+
const ids = useLazyRef<Map<string, { value: string; keywords?: string[] }>>(() => new Map());
|
|
34
|
+
const listeners = useLazyRef<Set<() => void>>(() => new Set());
|
|
35
|
+
// DEV ONLY: how many live items claim each value, so a duplicate can be
|
|
36
|
+
// reported. Maintained incrementally (O(1) per registration) rather than by
|
|
37
|
+
// scanning `ids` on every value change.
|
|
38
|
+
const valueOwners = useLazyRef<Map<string, number>>(() => new Map());
|
|
39
|
+
const propsRef = useAsRef(props);
|
|
40
|
+
|
|
41
|
+
// Destructure the cmdk-specific props out of `etc` so they are not spread as
|
|
42
|
+
// DOM attributes; the reactive reads below use `propsRef.current` instead.
|
|
43
|
+
const {
|
|
44
|
+
label,
|
|
45
|
+
children,
|
|
46
|
+
value,
|
|
47
|
+
onKeyDown,
|
|
48
|
+
vimBindings = true,
|
|
49
|
+
onValueChange: _onValueChange,
|
|
50
|
+
filter: _filter,
|
|
51
|
+
shouldFilter: _shouldFilter,
|
|
52
|
+
loop: _loop,
|
|
53
|
+
disablePointerSelection: _disablePointerSelection,
|
|
54
|
+
defaultValue: _defaultValue,
|
|
55
|
+
...etc
|
|
56
|
+
} = props;
|
|
57
|
+
|
|
58
|
+
const listId = useId();
|
|
59
|
+
const labelId = useId();
|
|
60
|
+
const inputId = useId();
|
|
61
|
+
|
|
62
|
+
const listInnerRef = useRef<HTMLDivElement | null>(null);
|
|
63
|
+
|
|
64
|
+
const schedule = useScheduleLayoutEffect();
|
|
65
|
+
|
|
66
|
+
/** Controlled mode `value` handling. */
|
|
67
|
+
useLayoutEffect(() => {
|
|
68
|
+
if (value !== undefined) {
|
|
69
|
+
state.current.value = value.trim();
|
|
70
|
+
scheduleSelectedItemIdSync();
|
|
71
|
+
store.emit();
|
|
72
|
+
}
|
|
73
|
+
}, [value]);
|
|
74
|
+
|
|
75
|
+
useLayoutEffect(() => {
|
|
76
|
+
schedule(6, scrollSelectedIntoView);
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
const store: Store = useMemo(() => {
|
|
80
|
+
return {
|
|
81
|
+
subscribe: (cb) => {
|
|
82
|
+
listeners.current.add(cb);
|
|
83
|
+
return () => listeners.current.delete(cb);
|
|
84
|
+
},
|
|
85
|
+
snapshot: () => state.current,
|
|
86
|
+
setState: (key, next, _opts) => {
|
|
87
|
+
if (Object.is(state.current[key], next)) return;
|
|
88
|
+
(state.current as Record<string, unknown>)[key] = next;
|
|
89
|
+
|
|
90
|
+
if (key === 'search') {
|
|
91
|
+
// Filter synchronously before emitting back to children.
|
|
92
|
+
filterItems();
|
|
93
|
+
sort();
|
|
94
|
+
schedule(1, selectFirstItem);
|
|
95
|
+
} else if (key === 'value') {
|
|
96
|
+
// Force focus input or root so accessibility works.
|
|
97
|
+
const active = document.activeElement;
|
|
98
|
+
if (active?.hasAttribute('cmdk-input') || active?.hasAttribute('cmdk-root')) {
|
|
99
|
+
const input = document.getElementById(inputId);
|
|
100
|
+
if (input) input.focus();
|
|
101
|
+
else document.getElementById(listId)?.focus();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
scheduleSelectedItemIdSync();
|
|
105
|
+
|
|
106
|
+
// `_opts` truthy means "do not scroll the selection into view".
|
|
107
|
+
if (!_opts) schedule(5, scrollSelectedIntoView);
|
|
108
|
+
|
|
109
|
+
if (propsRef.current?.value !== undefined) {
|
|
110
|
+
// Controlled: call the callback instead of updating internal state.
|
|
111
|
+
propsRef.current.onValueChange?.(String(next ?? ''));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
store.emit();
|
|
117
|
+
},
|
|
118
|
+
emit: () => {
|
|
119
|
+
listeners.current.forEach((l) => l());
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
const context: Context = useMemo(
|
|
125
|
+
() => ({
|
|
126
|
+
value: (id, itemValue, keywords) => {
|
|
127
|
+
if (itemValue !== ids.current.get(id)?.value) {
|
|
128
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
129
|
+
trackValueOwner(ids.current.get(id)?.value, itemValue ?? '');
|
|
130
|
+
}
|
|
131
|
+
ids.current.set(id, { value: itemValue ?? '', keywords });
|
|
132
|
+
state.current.filtered.items.set(id, score(itemValue ?? '', keywords));
|
|
133
|
+
// Re-derive the aggregate before sorting. Scoring this one item
|
|
134
|
+
// updates `filtered.items` but leaves `filtered.count` and
|
|
135
|
+
// `filtered.groups` describing the previous values, and those two
|
|
136
|
+
// drive Empty and group visibility — so a value that starts or stops
|
|
137
|
+
// matching an active search would otherwise render an item and "no
|
|
138
|
+
// results" at the same time. filterItems() is the single place that
|
|
139
|
+
// derives all three, so reuse it rather than patching the aggregate
|
|
140
|
+
// here (the batcher collapses a burst of value changes into one).
|
|
141
|
+
schedule(2, () => {
|
|
142
|
+
filterItems();
|
|
143
|
+
sort();
|
|
144
|
+
store.emit();
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
item: (id, groupId) => {
|
|
149
|
+
allItems.current.add(id);
|
|
150
|
+
|
|
151
|
+
if (groupId) {
|
|
152
|
+
if (!allGroups.current.has(groupId)) allGroups.current.set(groupId, new Set([id]));
|
|
153
|
+
else allGroups.current.get(groupId)!.add(id);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Batch: multiple items can mount in one pass.
|
|
157
|
+
schedule(3, () => {
|
|
158
|
+
filterItems();
|
|
159
|
+
sort();
|
|
160
|
+
if (!state.current.value) selectFirstItem();
|
|
161
|
+
store.emit();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return () => {
|
|
165
|
+
releaseValue(id);
|
|
166
|
+
allItems.current.delete(id);
|
|
167
|
+
// An item leaving its group has to be dropped from that group's member
|
|
168
|
+
// set too, or the set accumulates dead ids for the Command's lifetime
|
|
169
|
+
// (unbounded growth in a menu whose results churn) and filterItems()
|
|
170
|
+
// walks them on every keystroke.
|
|
171
|
+
if (groupId) allGroups.current.get(groupId)?.delete(id);
|
|
172
|
+
const selectedItem = getSelectedItem();
|
|
173
|
+
|
|
174
|
+
// Keyed per ITEM, not per teardown path. The batcher is a Map, so a
|
|
175
|
+
// shared key means the last teardown in a multi-item removal
|
|
176
|
+
// overwrites the others — and the callback that survives captured a
|
|
177
|
+
// different `selectedItem`, so nothing notices the selected item
|
|
178
|
+
// left. Removing the selected item alongside any sibling then
|
|
179
|
+
// stranded the selection on a node that no longer exists.
|
|
180
|
+
scheduleFromTeardown(`item-remove:${id}`, () => {
|
|
181
|
+
filterItems();
|
|
182
|
+
if (selectedItem?.getAttribute('id') === id) selectFirstItem();
|
|
183
|
+
store.emit();
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
// A force-mounted item bypasses registration and filtering, so it never
|
|
188
|
+
// reaches `filtered.count` — but it IS rendered, and Empty must not
|
|
189
|
+
// announce "no results" over the top of it. Track the live count
|
|
190
|
+
// separately and let Empty consult both.
|
|
191
|
+
forceItem: (id) => {
|
|
192
|
+
allForceMounted.current.add(id);
|
|
193
|
+
state.current.forceMountedCount = allForceMounted.current.size;
|
|
194
|
+
schedule(8, () => store.emit());
|
|
195
|
+
|
|
196
|
+
return () => {
|
|
197
|
+
releaseValue(id);
|
|
198
|
+
allForceMounted.current.delete(id);
|
|
199
|
+
state.current.forceMountedCount = allForceMounted.current.size;
|
|
200
|
+
// A force-mounted item is selectable like any other, so losing the
|
|
201
|
+
// selected one has to move the selection on — exactly as the plain
|
|
202
|
+
// item teardown does. Without this the value stays pointing at a node
|
|
203
|
+
// that is gone: nothing renders `aria-selected`, the combobox's
|
|
204
|
+
// `aria-activedescendant` dangles at a removed id, and Enter does
|
|
205
|
+
// nothing until the user arrows away.
|
|
206
|
+
const selectedItem = getSelectedItem();
|
|
207
|
+
|
|
208
|
+
// Per item for the same reason as the plain path, and distinct from
|
|
209
|
+
// the mount emit above, which also used key 8 — a mount and a
|
|
210
|
+
// teardown in one pass cancelled each other out.
|
|
211
|
+
scheduleFromTeardown(`force-remove:${id}`, () => {
|
|
212
|
+
if (selectedItem?.getAttribute('id') === id) selectFirstItem();
|
|
213
|
+
store.emit();
|
|
214
|
+
});
|
|
215
|
+
};
|
|
216
|
+
},
|
|
217
|
+
group: (id) => {
|
|
218
|
+
if (!allGroups.current.has(id)) allGroups.current.set(id, new Set());
|
|
219
|
+
return () => {
|
|
220
|
+
releaseValue(id);
|
|
221
|
+
allGroups.current.delete(id);
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
filter: () => propsRef.current.shouldFilter !== false,
|
|
225
|
+
label: label || props['aria-label'] as string,
|
|
226
|
+
getDisablePointerSelection: () => propsRef.current.disablePointerSelection ?? false,
|
|
227
|
+
listId,
|
|
228
|
+
inputId,
|
|
229
|
+
labelId,
|
|
230
|
+
listInnerRef,
|
|
231
|
+
}),
|
|
232
|
+
[],
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* DEV ONLY. Selection is keyed by VALUE, not by item id, so two items sharing
|
|
237
|
+
* a value both report `aria-selected="true"` while only the first one — the
|
|
238
|
+
* one `getSelectedItem()`'s `querySelector` finds — actually responds to
|
|
239
|
+
* Enter. That is an authoring error (cmdk requires unique values) rather than
|
|
240
|
+
* something the runtime can resolve, so report it instead of silently
|
|
241
|
+
* rendering a listbox with two selected options.
|
|
242
|
+
*/
|
|
243
|
+
function trackValueOwner(previous: string | undefined, next: string) {
|
|
244
|
+
if (previous) {
|
|
245
|
+
const remaining = (valueOwners.current.get(previous) ?? 1) - 1;
|
|
246
|
+
if (remaining > 0) valueOwners.current.set(previous, remaining);
|
|
247
|
+
else valueOwners.current.delete(previous);
|
|
248
|
+
}
|
|
249
|
+
if (!next) return;
|
|
250
|
+
const owners = (valueOwners.current.get(next) ?? 0) + 1;
|
|
251
|
+
valueOwners.current.set(next, owners);
|
|
252
|
+
if (owners > 1) {
|
|
253
|
+
console.warn(
|
|
254
|
+
`[@octanejs/cmdk] ${owners} items share the value ${JSON.stringify(next)}. ` +
|
|
255
|
+
`Selection is keyed by value, so they will all appear selected at once and ` +
|
|
256
|
+
`only the first will respond to Enter. Give each item a unique \`value\` prop.`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Queue a teardown's follow-up work.
|
|
263
|
+
*
|
|
264
|
+
* Octane runs a removed child's effect cleanups DURING the parent's render
|
|
265
|
+
* (`renderBranchSlot` → `unmountBlock` → `unmountScope` →
|
|
266
|
+
* `runEffectCleanupCallback`), so calling `schedule` straight from a teardown
|
|
267
|
+
* lands its `ss({})` mid-render and the runtime reports "Cannot update a
|
|
268
|
+
* component (`CommandRoot`) while rendering a different component" — advice a
|
|
269
|
+
* teardown cannot act on, since it does not choose when it runs. Hopping a
|
|
270
|
+
* microtask moves the update out of the render pass; the queued work itself is
|
|
271
|
+
* unchanged and still runs before paint.
|
|
272
|
+
*
|
|
273
|
+
* Only the teardowns defer. Every other caller schedules from a layout effect,
|
|
274
|
+
* where the synchronous update is both correct and load-bearing (the search →
|
|
275
|
+
* filter → select-first chain has to settle inside the same commit).
|
|
276
|
+
*/
|
|
277
|
+
function scheduleFromTeardown(id: string | number, cb: () => void) {
|
|
278
|
+
queueMicrotask(() => schedule(id, cb));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Drop everything `context.value` registered for `id`. Items, force-mounted
|
|
283
|
+
* items AND groups all register their value through the same `useValue` hook,
|
|
284
|
+
* so all three teardown paths have to release through here. Releasing on only
|
|
285
|
+
* one of them leaves the entry alive past the component: the dev-only owner
|
|
286
|
+
* count then reports a duplicate for a value just one live component holds
|
|
287
|
+
* (re-showing a force-mounted item counts 2, then 3, ...), and `ids` /
|
|
288
|
+
* `filtered.items` keep growing for the Command's lifetime.
|
|
289
|
+
*/
|
|
290
|
+
function releaseValue(id: string) {
|
|
291
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
292
|
+
trackValueOwner(ids.current.get(id)?.value, '');
|
|
293
|
+
}
|
|
294
|
+
ids.current.delete(id);
|
|
295
|
+
state.current.filtered.items.delete(id);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function score(itemValue: string, keywords?: string[]) {
|
|
299
|
+
const filter = propsRef.current?.filter ?? defaultFilter;
|
|
300
|
+
return itemValue ? filter(itemValue, state.current.search, keywords) : 0;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function selectFirstItem() {
|
|
304
|
+
const item = getValidItems().find((i) => i.getAttribute('aria-disabled') !== 'true');
|
|
305
|
+
const value = item?.getAttribute(VALUE_ATTR);
|
|
306
|
+
store.setState('value', value || '');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Filters the current items. */
|
|
310
|
+
function filterItems() {
|
|
311
|
+
if (!state.current.search || propsRef.current.shouldFilter === false) {
|
|
312
|
+
state.current.filtered.count = allItems.current.size;
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
state.current.filtered.groups = new Set();
|
|
317
|
+
let itemCount = 0;
|
|
318
|
+
|
|
319
|
+
// Only filterable items carry a filter score. Force-mounted items and
|
|
320
|
+
// groups register their value through the same `useValue` hook, so
|
|
321
|
+
// `context.value` scores them too — against whatever the search was at
|
|
322
|
+
// registration time, and nothing refreshes it because the loop below walks
|
|
323
|
+
// `allItems`, which holds neither. A force-mounted item registered before
|
|
324
|
+
// the user typed therefore kept the score of the EMPTY search (0.99, higher
|
|
325
|
+
// than a genuine prefix match) for the rest of its life, and outranked
|
|
326
|
+
// every real match once ranking read these scores.
|
|
327
|
+
for (const id of state.current.filtered.items.keys()) {
|
|
328
|
+
if (!allItems.current.has(id)) state.current.filtered.items.delete(id);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
for (const id of allItems.current) {
|
|
332
|
+
const value = ids.current.get(id)?.value ?? '';
|
|
333
|
+
const keywords = ids.current.get(id)?.keywords ?? [];
|
|
334
|
+
const rank = score(value, keywords);
|
|
335
|
+
state.current.filtered.items.set(id, rank);
|
|
336
|
+
if (rank > 0) itemCount++;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
for (const [groupId, group] of allGroups.current) {
|
|
340
|
+
for (const itemId of group) {
|
|
341
|
+
if ((state.current.filtered.items.get(itemId) ?? 0) > 0) {
|
|
342
|
+
state.current.filtered.groups.add(groupId);
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
state.current.filtered.count = itemCount;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Inline styles this component applied, mapped to the values that were there
|
|
353
|
+
* before, so clearing a search restores the consumer's own inline styling
|
|
354
|
+
* rather than deleting it.
|
|
355
|
+
*/
|
|
356
|
+
const styled = useLazyRef<Map<HTMLElement, Record<string, string>>>(() => new Map());
|
|
357
|
+
|
|
358
|
+
function setStyle(element: HTMLElement, property: string, value: string) {
|
|
359
|
+
const previous = styled.current.get(element) ?? {};
|
|
360
|
+
if (!(property in previous)) {
|
|
361
|
+
previous[property] = element.style.getPropertyValue(property);
|
|
362
|
+
styled.current.set(element, previous);
|
|
363
|
+
}
|
|
364
|
+
element.style.setProperty(property, value);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Restore every inline style this component set, in one pass. */
|
|
368
|
+
function clearStyles() {
|
|
369
|
+
styled.current.forEach((previous, element) => {
|
|
370
|
+
for (const property of Object.keys(previous)) {
|
|
371
|
+
if (previous[property]) element.style.setProperty(property, previous[property]);
|
|
372
|
+
else element.style.removeProperty(property);
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
styled.current.clear();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The direct child of `container` that positions `item` — the item itself, or
|
|
379
|
+
* the wrapper element it sits inside. */
|
|
380
|
+
function positionedNode(item: Element, container: Element): HTMLElement | null {
|
|
381
|
+
// Walk up to the child the container actually lays out. `order` applies to
|
|
382
|
+
// a flex CHILD, so an item nested in a wrapper has to rank through that
|
|
383
|
+
// wrapper — resolving only the item itself leaves the wrapper unranked, and
|
|
384
|
+
// an unranked flex child keeps `order: 0` and paints ahead of every ranked
|
|
385
|
+
// one. Upstream resolves a single level (`closest(GROUP_ITEMS > *)`), which
|
|
386
|
+
// covers a wrapper inside a group but not one directly under the list.
|
|
387
|
+
let node: Element | null = item;
|
|
388
|
+
while (node && node.parentElement !== container) node = node.parentElement;
|
|
389
|
+
return node instanceof HTMLElement ? node : null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Make `container` order its children by the `order` property. */
|
|
393
|
+
function asOrderedContainer(container: Element) {
|
|
394
|
+
if (!(container instanceof HTMLElement)) return;
|
|
395
|
+
setStyle(container, 'display', 'flex');
|
|
396
|
+
setStyle(container, 'flex-direction', 'column');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Order items by score, and groups by their best item score, using CSS `order`.
|
|
401
|
+
*
|
|
402
|
+
* OCTANE DIVERGENCE: cmdk's sort is DOM-authoritative — it puts matches in
|
|
403
|
+
* score order by physically relocating item nodes into the list sizer. Octane
|
|
404
|
+
* fences every component's DOM with a pair of comment markers and tracks the
|
|
405
|
+
* range between them, and a template construct like `@for` or `@if` wraps each
|
|
406
|
+
* item in a SECOND, outer range. Relocating a node carries it out of every one
|
|
407
|
+
* of those ranges at once: the loop later clears a range that is now empty and
|
|
408
|
+
* the real node is orphaned in the list forever (observed as every item
|
|
409
|
+
* surviving a removal, stacked above a "No results found." empty state).
|
|
410
|
+
*
|
|
411
|
+
* Carrying the flanking markers along only ever repairs the innermost range,
|
|
412
|
+
* so it breaks again at each new nesting construct. Expressing the order
|
|
413
|
+
* declaratively removes the class of bug instead of an instance of it: no node
|
|
414
|
+
* is ever moved, so no range is ever violated, and clearing the search
|
|
415
|
+
* restores true source order for free — physical reordering left a scrambled
|
|
416
|
+
* residue that accumulated across searches and never recovered.
|
|
417
|
+
*
|
|
418
|
+
* The cost is that the sizer and each group's item container become flex
|
|
419
|
+
* columns while a filter is active, so a consumer who relies on physical DOM
|
|
420
|
+
* order (`:nth-child` styling, drag handles) or on a custom container
|
|
421
|
+
* `display` sees a divergence from upstream. Recorded in status.json.
|
|
422
|
+
*/
|
|
423
|
+
function sort() {
|
|
424
|
+
if (!state.current.search || propsRef.current.shouldFilter === false) {
|
|
425
|
+
// No active filter: source order is the answer, so drop everything we set.
|
|
426
|
+
clearStyles();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const scores = state.current.filtered.items;
|
|
431
|
+
|
|
432
|
+
// Highest score per visible group.
|
|
433
|
+
const groups: [string, number][] = [];
|
|
434
|
+
state.current.filtered.groups.forEach((groupId) => {
|
|
435
|
+
const items = allGroups.current.get(groupId);
|
|
436
|
+
let max = 0;
|
|
437
|
+
items?.forEach((item) => {
|
|
438
|
+
max = Math.max(scores.get(item) ?? 0, max);
|
|
439
|
+
});
|
|
440
|
+
groups.push([groupId, max]);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
const listInsertionElement = listInnerRef.current;
|
|
444
|
+
if (!listInsertionElement) return;
|
|
445
|
+
|
|
446
|
+
clearStyles();
|
|
447
|
+
asOrderedContainer(listInsertionElement);
|
|
448
|
+
|
|
449
|
+
// Ungrouped items and group hosts are siblings in the sizer, so their ranks
|
|
450
|
+
// share one space and must not collide. Upstream appends the ranked items
|
|
451
|
+
// first and the ranked groups after them, which puts groups below any
|
|
452
|
+
// directly-listed item; `topLevel` reserves that many slots so the group
|
|
453
|
+
// ranks below continue past them instead of tying.
|
|
454
|
+
let topLevel = 0;
|
|
455
|
+
|
|
456
|
+
// EVERY valid item gets a rank, not just the matches. A non-match usually
|
|
457
|
+
// unmounts and never needs one, but a force-mounted item never scores and
|
|
458
|
+
// never unmounts — and an unranked child keeps the initial `order` of 0,
|
|
459
|
+
// which sorts it AHEAD of every ranked match instead of out of the way. So
|
|
460
|
+
// a mid-list forceMount jumped to the top of its container whenever the
|
|
461
|
+
// search did not match it. Ranking everything puts zero-scoring items last,
|
|
462
|
+
// which is also what upstream does: it sorts all valid items by score and
|
|
463
|
+
// appends them, so the zero-scorers end up at the bottom.
|
|
464
|
+
//
|
|
465
|
+
// The sort is stable on the DOM index, so items that score equally keep
|
|
466
|
+
// their source order.
|
|
467
|
+
getValidItems().map(
|
|
468
|
+
(item, index) => ({
|
|
469
|
+
item,
|
|
470
|
+
index,
|
|
471
|
+
score: scores.get(item.getAttribute('id') ?? '') ?? 0,
|
|
472
|
+
}),
|
|
473
|
+
).sort((a, b) => b.score - a.score || a.index - b.index).map(({ item }) => item).forEach((
|
|
474
|
+
item,
|
|
475
|
+
rank,
|
|
476
|
+
) => {
|
|
477
|
+
const group = item.closest(GROUP_ITEMS_SELECTOR);
|
|
478
|
+
const container = group ?? listInsertionElement;
|
|
479
|
+
if (group) asOrderedContainer(group);
|
|
480
|
+
const node = positionedNode(item, container);
|
|
481
|
+
if (!node) return;
|
|
482
|
+
// A grouped item is ranked against its own container's children, so the
|
|
483
|
+
// global rank works there — it is sparse but monotonic. A sizer child
|
|
484
|
+
// shares one rank space with the group hosts numbered below, so it needs
|
|
485
|
+
// a DENSE counter: the global rank skips ahead whenever a grouped item
|
|
486
|
+
// outranks it, which let an ungrouped item tie a group host and leave the
|
|
487
|
+
// two painting in DOM order.
|
|
488
|
+
setStyle(node, 'order', String(group ? rank + 1 : topLevel += 1));
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// OCTANE DIVERGENCE: upstream looks each group element up by
|
|
492
|
+
// `[cmdk-group=""][data-value="<groupId>"]`, but `data-value` holds the
|
|
493
|
+
// group's VALUE (its heading text), never its id — so upstream's selector
|
|
494
|
+
// can never match and its group reordering is effectively dead code.
|
|
495
|
+
// Resolve the id to its registered value and match on that instead, so
|
|
496
|
+
// groups genuinely reorder by their best item score. Comparing the
|
|
497
|
+
// attribute directly also avoids escaping problems for values containing
|
|
498
|
+
// quotes (upstream encodes the selector but writes `data-value` raw).
|
|
499
|
+
const groupElements = Array.from(listInsertionElement.querySelectorAll(GROUP_SELECTOR));
|
|
500
|
+
groups.sort((a, b) => b[1] - a[1]).forEach(([groupId], rank) => {
|
|
501
|
+
const groupValue = ids.current.get(groupId)?.value;
|
|
502
|
+
if (!groupValue) return;
|
|
503
|
+
const element = groupElements.find((el) => el.getAttribute(VALUE_ATTR) === groupValue);
|
|
504
|
+
// Through the positioned node for the same reason items do: a group
|
|
505
|
+
// wrapped in another element is not itself the sizer's flex child, so
|
|
506
|
+
// ordering the group host would do nothing and leave the wrapper
|
|
507
|
+
// unranked at `order: 0`, ahead of everything.
|
|
508
|
+
const node =
|
|
509
|
+
element ? positionedNode(element, listInsertionElement) : null;
|
|
510
|
+
if (node) setStyle(node, 'order', String(topLevel + rank + 1));
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function getSelectedItem() {
|
|
515
|
+
return listInnerRef.current?.querySelector(`${ITEM_SELECTOR}[aria-selected="true"]`);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function scheduleSelectedItemIdSync() {
|
|
519
|
+
// Selection state renders before its DOM id can be observed. Defer the
|
|
520
|
+
// lookup until the selected Item has committed, then publish the id to
|
|
521
|
+
// Input/List. Controlled prop reconciliation bypasses setState('value')
|
|
522
|
+
// so it does not call onValueChange, but it still needs this same
|
|
523
|
+
// accessibility follow-up.
|
|
524
|
+
schedule(7, () => {
|
|
525
|
+
state.current.selectedItemId = getSelectedItem()?.id;
|
|
526
|
+
store.emit();
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Element children of `container`, in the order they are painted. */
|
|
531
|
+
function orderedChildren(container: Element): HTMLElement[] {
|
|
532
|
+
return Array.from(container.children).filter(
|
|
533
|
+
(child): child is HTMLElement => child instanceof HTMLElement,
|
|
534
|
+
).map((child, index) => ({ child, index, order: Number(child.style.order) || 0 })).sort(
|
|
535
|
+
(a, b) => a.order - b.order || a.index - b.index,
|
|
536
|
+
).map(({ child }) => child);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Selectable items in the order the user sees them.
|
|
541
|
+
*
|
|
542
|
+
* Selection and arrow-key navigation both read this, so it has to agree with
|
|
543
|
+
* what is on screen. While a filter is active the ranking lives in CSS `order`
|
|
544
|
+
* rather than in DOM order (see `sort()`), and the two disagree — walking the
|
|
545
|
+
* DOM would select and navigate in source order behind a differently-ordered
|
|
546
|
+
* list. Descending through the ranked containers keeps group ranking and
|
|
547
|
+
* within-group ranking composed in the right order.
|
|
548
|
+
*
|
|
549
|
+
* With no filter active nothing carries an `order`, so DOM order already IS
|
|
550
|
+
* visual order and the flat query is both correct and cheaper.
|
|
551
|
+
*/
|
|
552
|
+
function getValidItems(): Element[] {
|
|
553
|
+
const root = listInnerRef.current;
|
|
554
|
+
if (!root) return [];
|
|
555
|
+
if (styled.current.size === 0) return Array.from(root.querySelectorAll(VALID_ITEM_SELECTOR));
|
|
556
|
+
|
|
557
|
+
const items: Element[] = [];
|
|
558
|
+
const walk = (container: Element) => {
|
|
559
|
+
for (const child of orderedChildren(container)) {
|
|
560
|
+
if (child.matches(ITEM_SELECTOR)) {
|
|
561
|
+
if (child.matches(VALID_ITEM_SELECTOR)) items.push(child);
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
walk(child);
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
walk(root);
|
|
568
|
+
return items;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function updateSelectedToIndex(index: number) {
|
|
572
|
+
const items = getValidItems();
|
|
573
|
+
const item = items[index];
|
|
574
|
+
if (item) store.setState('value', item.getAttribute(VALUE_ATTR) || '');
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function updateSelectedByItem(change: 1 | -1) {
|
|
578
|
+
const selected = getSelectedItem();
|
|
579
|
+
const items = getValidItems();
|
|
580
|
+
const index = items.findIndex((item) => item === selected);
|
|
581
|
+
|
|
582
|
+
let newSelected = items[index + change];
|
|
583
|
+
|
|
584
|
+
if (propsRef.current?.loop) {
|
|
585
|
+
newSelected = index + change < 0
|
|
586
|
+
? items[items.length - 1]
|
|
587
|
+
: index + change === items.length
|
|
588
|
+
? items[0]
|
|
589
|
+
: items[index + change];
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (newSelected) store.setState('value', newSelected.getAttribute(VALUE_ATTR) || '');
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Groups in the order they are painted, for the same reason `getValidItems`
|
|
596
|
+
* exists: while a filter is active the ranking lives in CSS `order`, so DOM
|
|
597
|
+
* order and on-screen order disagree. */
|
|
598
|
+
function getOrderedGroups(): Element[] {
|
|
599
|
+
const root = listInnerRef.current;
|
|
600
|
+
if (!root) return [];
|
|
601
|
+
const groups = Array.from(root.querySelectorAll(GROUP_SELECTOR));
|
|
602
|
+
if (styled.current.size === 0) return groups;
|
|
603
|
+
// Read the rank off the positioned node, not the group host: a wrapped group
|
|
604
|
+
// carries its `order` on the wrapper, so reading the host would see 0 for
|
|
605
|
+
// every such group and collapse navigation back to DOM order.
|
|
606
|
+
return groups.map(
|
|
607
|
+
(group, index) => ({
|
|
608
|
+
group,
|
|
609
|
+
index,
|
|
610
|
+
order: Number(positionedNode(group, root)?.style.order) || 0,
|
|
611
|
+
}),
|
|
612
|
+
).sort((a, b) => a.order - b.order || a.index - b.index).map(({ group }) => group);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function updateSelectedByGroup(change: 1 | -1) {
|
|
616
|
+
const selected = getSelectedItem();
|
|
617
|
+
const group = selected?.closest(GROUP_SELECTOR);
|
|
618
|
+
|
|
619
|
+
// Step through the groups as the user sees them, and take each candidate's
|
|
620
|
+
// first item in RANKED order too — `querySelector` would return whichever
|
|
621
|
+
// item happens to come first in the DOM, which is a different item once a
|
|
622
|
+
// filter is active.
|
|
623
|
+
const groups = getOrderedGroups();
|
|
624
|
+
const items = getValidItems();
|
|
625
|
+
let index =
|
|
626
|
+
group ? groups.indexOf(group) : -1;
|
|
627
|
+
let item: Element | undefined;
|
|
628
|
+
|
|
629
|
+
while (index >= 0 && !item) {
|
|
630
|
+
index += change;
|
|
631
|
+
const candidate = groups[index];
|
|
632
|
+
if (!candidate) break;
|
|
633
|
+
item = items.find((element) => element.closest(GROUP_SELECTOR) === candidate);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (item) {
|
|
637
|
+
store.setState('value', item.getAttribute(VALUE_ATTR) || '');
|
|
638
|
+
} else {
|
|
639
|
+
updateSelectedByItem(change);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function scrollSelectedIntoView() {
|
|
644
|
+
const item = getSelectedItem();
|
|
645
|
+
if (!item) return;
|
|
646
|
+
// Non-browser hosts (jsdom, SSR) do not implement scrollIntoView. Guard it
|
|
647
|
+
// here rather than relying on the scheduler swallowing the failure, so the
|
|
648
|
+
// scheduler is free to report genuine errors.
|
|
649
|
+
if (typeof item.scrollIntoView !== 'function') return;
|
|
650
|
+
|
|
651
|
+
if (item.parentElement?.firstChild === item) {
|
|
652
|
+
// First item in a group — keep the group heading in view too.
|
|
653
|
+
item.closest(GROUP_SELECTOR)?.querySelector(GROUP_HEADING_SELECTOR)?.scrollIntoView({
|
|
654
|
+
block: 'nearest',
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
item.scrollIntoView({ block: 'nearest' });
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const last = () => updateSelectedToIndex(getValidItems().length - 1);
|
|
661
|
+
|
|
662
|
+
const next = (e: KeyboardEvent) => {
|
|
663
|
+
e.preventDefault();
|
|
664
|
+
if (e.metaKey) last();
|
|
665
|
+
else if (e.altKey) updateSelectedByGroup(1);
|
|
666
|
+
else updateSelectedByItem(1);
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
const prev = (e: KeyboardEvent) => {
|
|
670
|
+
e.preventDefault();
|
|
671
|
+
if (e.metaKey) updateSelectedToIndex(0);
|
|
672
|
+
else if (e.altKey) updateSelectedByGroup(-1);
|
|
673
|
+
else updateSelectedByItem(-1);
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
<div
|
|
677
|
+
ref={props.ref}
|
|
678
|
+
tabIndex={-1}
|
|
679
|
+
{...etc}
|
|
680
|
+
cmdk-root=""
|
|
681
|
+
onKeyDown={(e) => {
|
|
682
|
+
onKeyDown?.(e);
|
|
683
|
+
|
|
684
|
+
const isComposing = e.isComposing || e.keyCode === 229;
|
|
685
|
+
if (e.defaultPrevented || isComposing) return;
|
|
686
|
+
|
|
687
|
+
switch (e.key) {
|
|
688
|
+
case 'n':
|
|
689
|
+
case 'j': {
|
|
690
|
+
if (vimBindings && e.ctrlKey) next(e);
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
case 'ArrowDown': {
|
|
694
|
+
next(e);
|
|
695
|
+
break;
|
|
696
|
+
}
|
|
697
|
+
case 'p':
|
|
698
|
+
case 'k': {
|
|
699
|
+
if (vimBindings && e.ctrlKey) prev(e);
|
|
700
|
+
break;
|
|
701
|
+
}
|
|
702
|
+
case 'ArrowUp': {
|
|
703
|
+
prev(e);
|
|
704
|
+
break;
|
|
705
|
+
}
|
|
706
|
+
case 'Home': {
|
|
707
|
+
e.preventDefault();
|
|
708
|
+
updateSelectedToIndex(0);
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
case 'End': {
|
|
712
|
+
e.preventDefault();
|
|
713
|
+
last();
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
case 'Enter': {
|
|
717
|
+
e.preventDefault();
|
|
718
|
+
const item = getSelectedItem();
|
|
719
|
+
if (item) item.dispatchEvent(new Event(SELECT_EVENT));
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}}
|
|
723
|
+
>
|
|
724
|
+
<label cmdk-label="" for={inputId} id={labelId} style={srOnlyStyles}>{label}</label>
|
|
725
|
+
<StoreContext.Provider value={store}>
|
|
726
|
+
<CommandContext.Provider value={context}>{children}</CommandContext.Provider>
|
|
727
|
+
</StoreContext.Provider>
|
|
728
|
+
</div>
|
|
729
|
+
}
|