@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosxai/ui",
3
- "version": "0.18.2",
3
+ "version": "0.20.0",
4
4
  "description": "COSX design system — React 19 component primitives shared across product-meta and other consumers",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -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
- // Clamp selectedIdx when results shrink.
74
- useEffect(() => {
75
- if (selectedIdx >= flat.length) setSelectedIdx(Math.max(0, flat.length - 1));
76
- }, [flat.length, selectedIdx]);
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((i) => (i + 1) % Math.max(1, flat.length));
114
+ setSelectedIdx(stepEnabled(flat, selIdx, 1));
91
115
  return;
92
116
  }
93
117
  if (e.key === "ArrowUp") {
94
118
  e.preventDefault();
95
- setSelectedIdx((i) => (i - 1 + Math.max(1, flat.length)) % Math.max(1, flat.length));
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[selectedIdx];
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 + selectedIdx are stable enough; intentionally omit
108
- // close from deps so we don't re-bind every render.
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, selectedIdx]);
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="${selectedIdx}"]`);
139
+ const node = listRef.current?.querySelector(`[data-row-idx="${selIdx}"]`);
116
140
  (node as HTMLElement | null)?.scrollIntoView({ block: "nearest" });
117
- }, [open, selectedIdx]);
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 selected = idx === selectedIdx;
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
- onMouseEnter={() => setSelectedIdx(idx)}
209
- onClick={() => it.run({ close })}
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: selected ? "var(--ck-accent)" : "var(--ck-text-primary)",
218
- cursor: "pointer",
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
+ }
@@ -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
  }