@cosxai/ui 0.18.2 → 0.20.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/package.json +1 -1
- package/src/command/CommandPalette.tsx +70 -18
- package/src/command/types.ts +5 -0
package/package.json
CHANGED
|
@@ -21,11 +21,19 @@ export interface CommandPaletteProps {
|
|
|
21
21
|
// here come last in registration order.
|
|
22
22
|
groupOrder?: string[];
|
|
23
23
|
placeholder?: string;
|
|
24
|
+
// Fires whenever the query changes — on every keystroke and on the
|
|
25
|
+
// reset-to-empty when the palette opens. Lets a consumer drive an
|
|
26
|
+
// ASYNC command source (debounce → fetch → register the results as
|
|
27
|
+
// items via useCommandSource) that the built-in client-side filter
|
|
28
|
+
// can't provide on its own. Optional + backward compatible: omit it
|
|
29
|
+
// and the palette behaves exactly as before (static items only).
|
|
30
|
+
onQueryChange?: ((query: string) => void) | undefined;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
export function CommandPalette({
|
|
27
34
|
groupOrder = [],
|
|
28
35
|
placeholder = "Type a command or search…",
|
|
36
|
+
onQueryChange,
|
|
29
37
|
}: CommandPaletteProps) {
|
|
30
38
|
const ctx = useContext(CommandContext);
|
|
31
39
|
if (!ctx) throw new Error("<CommandPalette> must be inside <CommandProvider>");
|
|
@@ -34,12 +42,21 @@ export function CommandPalette({
|
|
|
34
42
|
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
35
43
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
36
44
|
const listRef = useRef<HTMLDivElement>(null);
|
|
45
|
+
// Latest onQueryChange held in a ref so the open-reset effect can
|
|
46
|
+
// notify it WITHOUT taking it as a dep — an inline callback changes
|
|
47
|
+
// identity every render, which would otherwise re-run the effect and
|
|
48
|
+
// clear the query on every keystroke.
|
|
49
|
+
const onQueryChangeRef = useRef(onQueryChange);
|
|
50
|
+
onQueryChangeRef.current = onQueryChange;
|
|
37
51
|
|
|
38
52
|
// Reset query + focus on every open.
|
|
39
53
|
useEffect(() => {
|
|
40
54
|
if (open) {
|
|
41
55
|
setQ("");
|
|
42
56
|
setSelectedIdx(0);
|
|
57
|
+
// Tell the consumer the query cleared, so an async source drops
|
|
58
|
+
// its stale results when the palette reopens.
|
|
59
|
+
onQueryChangeRef.current?.("");
|
|
43
60
|
// RAF so the modal element exists before focus.
|
|
44
61
|
requestAnimationFrame(() => inputRef.current?.focus());
|
|
45
62
|
}
|
|
@@ -70,10 +87,17 @@ export function CommandPalette({
|
|
|
70
87
|
// Flat array for keyboard navigation (sequential across groups).
|
|
71
88
|
const flat = useMemo(() => grouped.flatMap((g) => g.items), [grouped]);
|
|
72
89
|
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
90
|
+
// Effective selection: `selectedIdx` is the raw state the keyboard/mouse
|
|
91
|
+
// set, but disabled rows and a shrunk list can leave it pointing at a
|
|
92
|
+
// non-interactive or out-of-range item. Derive the index actually shown
|
|
93
|
+
// as selected — the first enabled row when the raw one isn't usable — so
|
|
94
|
+
// a disabled item (e.g. a "coming soon" teaser) is never highlighted and
|
|
95
|
+
// the default lands on the first real result.
|
|
96
|
+
const selIdx = useMemo(() => {
|
|
97
|
+
if (flat.length === 0) return 0;
|
|
98
|
+
if (selectedIdx < flat.length && !flat[selectedIdx]?.disabled) return selectedIdx;
|
|
99
|
+
return firstEnabled(flat);
|
|
100
|
+
}, [flat, selectedIdx]);
|
|
77
101
|
|
|
78
102
|
const close = () => setOpen(false);
|
|
79
103
|
|
|
@@ -87,34 +111,34 @@ export function CommandPalette({
|
|
|
87
111
|
}
|
|
88
112
|
if (e.key === "ArrowDown") {
|
|
89
113
|
e.preventDefault();
|
|
90
|
-
setSelectedIdx((
|
|
114
|
+
setSelectedIdx(stepEnabled(flat, selIdx, 1));
|
|
91
115
|
return;
|
|
92
116
|
}
|
|
93
117
|
if (e.key === "ArrowUp") {
|
|
94
118
|
e.preventDefault();
|
|
95
|
-
setSelectedIdx((
|
|
119
|
+
setSelectedIdx(stepEnabled(flat, selIdx, -1));
|
|
96
120
|
return;
|
|
97
121
|
}
|
|
98
122
|
if (e.key === "Enter") {
|
|
99
123
|
e.preventDefault();
|
|
100
|
-
const pick = flat[
|
|
101
|
-
if (pick) pick.run({ close });
|
|
124
|
+
const pick = flat[selIdx];
|
|
125
|
+
if (pick && !pick.disabled) pick.run({ close });
|
|
102
126
|
return;
|
|
103
127
|
}
|
|
104
128
|
};
|
|
105
129
|
window.addEventListener("keydown", onKey);
|
|
106
130
|
return () => window.removeEventListener("keydown", onKey);
|
|
107
|
-
// close + flat +
|
|
108
|
-
//
|
|
131
|
+
// close + flat + selIdx are stable enough; intentionally omit close
|
|
132
|
+
// from deps so we don't re-bind every render.
|
|
109
133
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
110
|
-
}, [open, flat,
|
|
134
|
+
}, [open, flat, selIdx]);
|
|
111
135
|
|
|
112
136
|
// Scroll selected row into view.
|
|
113
137
|
useEffect(() => {
|
|
114
138
|
if (!open) return;
|
|
115
|
-
const node = listRef.current?.querySelector(`[data-row-idx="${
|
|
139
|
+
const node = listRef.current?.querySelector(`[data-row-idx="${selIdx}"]`);
|
|
116
140
|
(node as HTMLElement | null)?.scrollIntoView({ block: "nearest" });
|
|
117
|
-
}, [open,
|
|
141
|
+
}, [open, selIdx]);
|
|
118
142
|
|
|
119
143
|
if (!open) return null;
|
|
120
144
|
|
|
@@ -164,6 +188,7 @@ export function CommandPalette({
|
|
|
164
188
|
onChange={(e) => {
|
|
165
189
|
setQ(e.target.value);
|
|
166
190
|
setSelectedIdx(0);
|
|
191
|
+
onQueryChange?.(e.target.value);
|
|
167
192
|
}}
|
|
168
193
|
placeholder={placeholder}
|
|
169
194
|
style={{
|
|
@@ -200,13 +225,15 @@ export function CommandPalette({
|
|
|
200
225
|
</div>
|
|
201
226
|
{g.items.map((it) => {
|
|
202
227
|
const idx = flat.indexOf(it);
|
|
203
|
-
const
|
|
228
|
+
const disabled = !!it.disabled;
|
|
229
|
+
const selected = idx === selIdx && !disabled;
|
|
204
230
|
return (
|
|
205
231
|
<div
|
|
206
232
|
key={it.key}
|
|
207
233
|
data-row-idx={idx}
|
|
208
|
-
|
|
209
|
-
|
|
234
|
+
aria-disabled={disabled || undefined}
|
|
235
|
+
onMouseEnter={disabled ? undefined : () => setSelectedIdx(idx)}
|
|
236
|
+
onClick={disabled ? undefined : () => it.run({ close })}
|
|
210
237
|
style={{
|
|
211
238
|
display: "flex",
|
|
212
239
|
alignItems: "center",
|
|
@@ -214,8 +241,13 @@ export function CommandPalette({
|
|
|
214
241
|
padding: "8px 12px",
|
|
215
242
|
borderRadius: "var(--ck-radius-sm)",
|
|
216
243
|
background: selected ? "var(--ck-accent-muted)" : "transparent",
|
|
217
|
-
color:
|
|
218
|
-
|
|
244
|
+
color: disabled
|
|
245
|
+
? "var(--ck-text-tertiary)"
|
|
246
|
+
: selected
|
|
247
|
+
? "var(--ck-accent)"
|
|
248
|
+
: "var(--ck-text-primary)",
|
|
249
|
+
opacity: disabled ? 0.6 : 1,
|
|
250
|
+
cursor: disabled ? "default" : "pointer",
|
|
219
251
|
font: "400 13px/1.3 var(--ck-font-sans)",
|
|
220
252
|
}}
|
|
221
253
|
>
|
|
@@ -275,3 +307,23 @@ export function CommandPalette({
|
|
|
275
307
|
document.body,
|
|
276
308
|
);
|
|
277
309
|
}
|
|
310
|
+
|
|
311
|
+
// First index whose item isn't disabled, or 0 if every item is (or the
|
|
312
|
+
// list is empty) — the palette has to point somewhere.
|
|
313
|
+
function firstEnabled(list: CommandItem[]): number {
|
|
314
|
+
const i = list.findIndex((it) => !it.disabled);
|
|
315
|
+
return i === -1 ? 0 : i;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Step from `from` in `dir` (+1 down / -1 up), wrapping, skipping disabled
|
|
319
|
+
// rows. Returns `from` when there's no other enabled row to land on.
|
|
320
|
+
function stepEnabled(list: CommandItem[], from: number, dir: 1 | -1): number {
|
|
321
|
+
const n = list.length;
|
|
322
|
+
if (n === 0) return 0;
|
|
323
|
+
let i = from;
|
|
324
|
+
for (let step = 0; step < n; step++) {
|
|
325
|
+
i = (i + dir + n) % n;
|
|
326
|
+
if (!list[i]?.disabled) return i;
|
|
327
|
+
}
|
|
328
|
+
return from;
|
|
329
|
+
}
|
package/src/command/types.ts
CHANGED
|
@@ -29,4 +29,9 @@ export interface CommandItem {
|
|
|
29
29
|
// matches. Use for hidden debug/test menus; pair with a "Debug"
|
|
30
30
|
// group so the reveal is self-describing.
|
|
31
31
|
secret?: string;
|
|
32
|
+
// Non-interactive placeholder: the row still renders (dimmed) but is
|
|
33
|
+
// skipped by keyboard nav, never auto-selected, and `run` never fires
|
|
34
|
+
// on it. Use for "coming soon" teasers. Distinct from `secret`, which
|
|
35
|
+
// HIDES the row; `disabled` SHOWS it as unavailable.
|
|
36
|
+
disabled?: boolean | undefined;
|
|
32
37
|
}
|