@cosxai/ui 0.15.0 → 0.16.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.15.0",
3
+ "version": "0.16.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",
@@ -0,0 +1,430 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ type KeyboardEvent,
7
+ type ReactNode,
8
+ } from "react";
9
+
10
+ import { Input } from "./Input";
11
+
12
+ // Combobox — async-search commit picker (recipient fields, entity
13
+ // lookups). Extracted from product-meta's CustomerPicker so every
14
+ // product surface shares one commit model (QA 2026-07-21: typed-but-
15
+ // uncommitted text was visually identical to a committed pick, and on
16
+ // slow networks the dropdown never even appeared before the user
17
+ // moved on).
18
+ //
19
+ // The model has exactly two visible states, both always unambiguous:
20
+ //
21
+ // · The INPUT is always rendered and always editable — it carries
22
+ // the "in progress" state (typing / searching). Editing it after
23
+ // a commit clears the commit (via onUncommit) and resumes search.
24
+ // · The COMMITTED CARD below the input carries the "confirmed"
25
+ // state — title + subtitle + × to clear + an optional extra slot
26
+ // (e.g. a display-name input for brand-new entities).
27
+ //
28
+ // Commit paths:
29
+ // · click / Enter on a dropdown row → onCommit(option)
30
+ // · click / Enter on the free-entry row → onCommit(free)
31
+ // · BLUR with committable text (auto-commit) → exact option match
32
+ // when the search already returned one, else free entry when
33
+ // `allowFreeEntry(raw)` passes. Deliberately independent of the
34
+ // dropdown having rendered — on a slow network the free-entry
35
+ // path still commits from the local validity check alone.
36
+ // · blur with non-committable text → invalid state
37
+ // (error border + `invalidHint`), never a silent look-alike.
38
+ //
39
+ // The committed value is PARENT-OWNED (controlled): the parent maps
40
+ // options / free entries onto its own domain state and passes back
41
+ // `committed` for display. This keeps the primitive product-agnostic.
42
+
43
+ export interface ComboboxOption {
44
+ // Stable identity for list keys + commit payloads.
45
+ key: string;
46
+ // Primary row line (e.g. a person's name).
47
+ title: string;
48
+ // Secondary row line (e.g. their email). Also used by the default
49
+ // auto-commit matcher, so put the canonical typed value here.
50
+ subtitle?: string | undefined;
51
+ }
52
+
53
+ export type ComboboxCommit =
54
+ | { kind: "option"; option: ComboboxOption }
55
+ | { kind: "free"; raw: string };
56
+
57
+ export interface ComboboxCommitted {
58
+ title: ReactNode;
59
+ subtitle?: ReactNode | undefined;
60
+ }
61
+
62
+ export interface ComboboxProps {
63
+ label?: ReactNode | undefined;
64
+ placeholder?: string | undefined;
65
+ autoFocus?: boolean | undefined;
66
+ disabled?: boolean | undefined;
67
+ // Async search. Called after `debounceMs` of quiet with a non-empty
68
+ // trimmed query; the AbortSignal cancels superseded requests.
69
+ search: (query: string, signal: AbortSignal) => Promise<ComboboxOption[]>;
70
+ debounceMs?: number | undefined;
71
+ // Free-entry gate: return true when the raw text is committable on
72
+ // its own (e.g. a syntactically valid email). Omit to disable free
73
+ // entry entirely (only listed options commit).
74
+ allowFreeEntry?: ((raw: string) => boolean) | undefined;
75
+ // Dropdown row copy for the free-entry affordance.
76
+ freeEntryLabel?: ((raw: string) => ReactNode) | undefined;
77
+ // Rendered in the dropdown when a search returned nothing and free
78
+ // entry doesn't apply.
79
+ emptyHint?: ReactNode | undefined;
80
+ // Error copy for "blurred with text that can't commit".
81
+ invalidHint?: string | undefined;
82
+ // Matcher backing blur auto-commit against loaded options. Default:
83
+ // case-insensitive equality on subtitle, then title.
84
+ matchOption?: ((raw: string, option: ComboboxOption) => boolean) | undefined;
85
+ // Parent-owned committed state; null/undefined = nothing committed.
86
+ committed?: ComboboxCommitted | null | undefined;
87
+ // Extra content inside the committed card (below subtitle).
88
+ committedExtra?: ReactNode | undefined;
89
+ onCommit: (commit: ComboboxCommit) => void;
90
+ // Fired when the user edits the input after a commit, or clicks the
91
+ // card's ×. Parent clears its committed state.
92
+ onUncommit?: (() => void) | undefined;
93
+ testid?: string | undefined;
94
+ }
95
+
96
+ const DEFAULT_DEBOUNCE_MS = 250;
97
+
98
+ function defaultMatch(raw: string, option: ComboboxOption): boolean {
99
+ const norm = raw.trim().toLowerCase();
100
+ return (
101
+ (option.subtitle ?? "").trim().toLowerCase() === norm ||
102
+ option.title.trim().toLowerCase() === norm
103
+ );
104
+ }
105
+
106
+ export function Combobox({
107
+ label,
108
+ placeholder,
109
+ autoFocus,
110
+ disabled,
111
+ search,
112
+ debounceMs = DEFAULT_DEBOUNCE_MS,
113
+ allowFreeEntry,
114
+ freeEntryLabel,
115
+ emptyHint,
116
+ invalidHint,
117
+ matchOption = defaultMatch,
118
+ committed,
119
+ committedExtra,
120
+ onCommit,
121
+ onUncommit,
122
+ testid = "combobox",
123
+ }: ComboboxProps): ReactNode {
124
+ const [query, setQuery] = useState("");
125
+ const [results, setResults] = useState<ComboboxOption[]>([]);
126
+ const [loading, setLoading] = useState(false);
127
+ const [focused, setFocused] = useState(false);
128
+ const [highlight, setHighlight] = useState(0);
129
+ const [invalid, setInvalid] = useState(false);
130
+
131
+ const abortRef = useRef<AbortController | null>(null);
132
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
133
+ const inputRef = useRef<HTMLInputElement | null>(null);
134
+
135
+ const isCommitted = !!committed;
136
+
137
+ useEffect(() => {
138
+ if (debounceRef.current) clearTimeout(debounceRef.current);
139
+ // Search is suspended while a commit is displayed — the input
140
+ // still holds the committed text but shouldn't re-open the list.
141
+ if (!query.trim() || isCommitted) {
142
+ setResults([]);
143
+ setLoading(false);
144
+ return;
145
+ }
146
+ debounceRef.current = setTimeout(() => {
147
+ abortRef.current?.abort();
148
+ const ac = new AbortController();
149
+ abortRef.current = ac;
150
+ setLoading(true);
151
+ search(query.trim(), ac.signal)
152
+ .then((rows) => {
153
+ if (ac.signal.aborted) return;
154
+ setResults(rows);
155
+ setHighlight(0);
156
+ setLoading(false);
157
+ })
158
+ .catch(() => {
159
+ if (ac.signal.aborted) return;
160
+ // Search failure degrades to "no suggestions" — free-entry
161
+ // auto-commit still works, which is the whole point on a
162
+ // flaky network.
163
+ setResults([]);
164
+ setLoading(false);
165
+ });
166
+ }, debounceMs);
167
+ return () => {
168
+ if (debounceRef.current) clearTimeout(debounceRef.current);
169
+ abortRef.current?.abort();
170
+ };
171
+ }, [query, isCommitted, search, debounceMs]);
172
+
173
+ const trimmed = query.trim();
174
+ const freeEntryOk = !!allowFreeEntry && trimmed.length > 0 && allowFreeEntry(trimmed);
175
+ const hasNoResults = !loading && trimmed.length > 0 && results.length === 0;
176
+ const offerFree = !isCommitted && freeEntryOk && !results.some((r) => matchOption(trimmed, r));
177
+ const totalEntries = results.length + (offerFree ? 1 : 0);
178
+
179
+ const commitOption = useCallback(
180
+ (option: ComboboxOption) => {
181
+ setInvalid(false);
182
+ setResults([]);
183
+ setHighlight(0);
184
+ // Reflect the commit in the input so a half-typed query never
185
+ // sits next to a card naming someone else.
186
+ setQuery(option.subtitle ?? option.title);
187
+ onCommit({ kind: "option", option });
188
+ },
189
+ [onCommit],
190
+ );
191
+
192
+ const commitFree = useCallback(
193
+ (raw: string) => {
194
+ setInvalid(false);
195
+ setResults([]);
196
+ setHighlight(0);
197
+ setQuery(raw);
198
+ onCommit({ kind: "free", raw });
199
+ },
200
+ [onCommit],
201
+ );
202
+
203
+ // Blur auto-commit — the fix for "typed it, tabbed on, looked
204
+ // committed but wasn't". Exact option match wins (brings the
205
+ // entity's identity along); otherwise free entry; otherwise flag
206
+ // invalid. Runs on a short delay so a dropdown row click (which
207
+ // blurs first) still lands on the row handler.
208
+ const handleBlur = useCallback(() => {
209
+ setTimeout(() => {
210
+ setFocused(false);
211
+ if (isCommitted) return;
212
+ const raw = trimmed;
213
+ if (!raw) {
214
+ setInvalid(false);
215
+ return;
216
+ }
217
+ const exact = results.find((r) => matchOption(raw, r));
218
+ if (exact) {
219
+ commitOption(exact);
220
+ return;
221
+ }
222
+ if (allowFreeEntry?.(raw)) {
223
+ commitFree(raw);
224
+ return;
225
+ }
226
+ setInvalid(true);
227
+ }, 150);
228
+ }, [isCommitted, trimmed, results, matchOption, allowFreeEntry, commitOption, commitFree]);
229
+
230
+ const handleQueryChange = useCallback(
231
+ (next: string) => {
232
+ setQuery(next);
233
+ setInvalid(false);
234
+ if (isCommitted) onUncommit?.();
235
+ },
236
+ [isCommitted, onUncommit],
237
+ );
238
+
239
+ const clearCommitted = useCallback(() => {
240
+ onUncommit?.();
241
+ // Keep the committed text in the input for editing; focus so the
242
+ // user can immediately type.
243
+ inputRef.current?.focus();
244
+ }, [onUncommit]);
245
+
246
+ const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
247
+ if (isCommitted) return;
248
+ if (e.key === "ArrowDown") {
249
+ e.preventDefault();
250
+ setHighlight((h) => Math.min(h + 1, Math.max(totalEntries - 1, 0)));
251
+ } else if (e.key === "ArrowUp") {
252
+ e.preventDefault();
253
+ setHighlight((h) => Math.max(h - 1, 0));
254
+ } else if (e.key === "Enter") {
255
+ if (totalEntries > 0) {
256
+ e.preventDefault();
257
+ if (highlight < results.length) {
258
+ const chosen = results[highlight];
259
+ if (chosen) commitOption(chosen);
260
+ } else if (offerFree) {
261
+ commitFree(trimmed);
262
+ }
263
+ } else if (freeEntryOk) {
264
+ // No dropdown (e.g. search still in flight on a slow
265
+ // network) — Enter on a valid free entry commits directly.
266
+ e.preventDefault();
267
+ commitFree(trimmed);
268
+ }
269
+ } else if (e.key === "Escape") {
270
+ setQuery("");
271
+ setResults([]);
272
+ setInvalid(false);
273
+ }
274
+ };
275
+
276
+ const showDropdown =
277
+ focused && !isCommitted && (results.length > 0 || offerFree || (hasNoResults && !!emptyHint));
278
+
279
+ return (
280
+ <div style={{ position: "relative" }}>
281
+ <Input
282
+ ref={inputRef}
283
+ label={label}
284
+ data-testid={`${testid}-input`}
285
+ value={query}
286
+ disabled={disabled}
287
+ onChange={(e) => handleQueryChange(e.target.value)}
288
+ onFocus={() => {
289
+ setFocused(true);
290
+ setInvalid(false);
291
+ }}
292
+ onBlur={handleBlur}
293
+ onKeyDown={handleKeyDown}
294
+ placeholder={placeholder}
295
+ autoFocus={autoFocus}
296
+ autoComplete="off"
297
+ error={invalid ? (invalidHint ?? "Pick from the list to continue.") : null}
298
+ />
299
+
300
+ {showDropdown ? (
301
+ <div
302
+ data-testid={`${testid}-dropdown`}
303
+ style={{
304
+ position: "absolute",
305
+ top: "100%",
306
+ marginTop: 4,
307
+ left: 0,
308
+ right: 0,
309
+ background: "var(--ck-bg-surface)",
310
+ border: "1px solid var(--ck-border-strong)",
311
+ borderRadius: "var(--ck-radius-sm)",
312
+ boxShadow: "var(--ck-shadow-menu, 0 4px 12px rgba(0,0,0,0.08))",
313
+ zIndex: 10,
314
+ overflow: "hidden",
315
+ }}
316
+ >
317
+ {results.map((row, i) => (
318
+ <button
319
+ key={row.key}
320
+ type="button"
321
+ onMouseDown={(e) => {
322
+ e.preventDefault();
323
+ commitOption(row);
324
+ }}
325
+ onMouseEnter={() => setHighlight(i)}
326
+ style={{
327
+ display: "block",
328
+ width: "100%",
329
+ textAlign: "left",
330
+ padding: "8px 12px",
331
+ border: "none",
332
+ background: i === highlight ? "var(--ck-bg-muted)" : "transparent",
333
+ cursor: "pointer",
334
+ font: "inherit",
335
+ fontSize: 13,
336
+ color: "var(--ck-text-primary)",
337
+ }}
338
+ >
339
+ <div style={{ fontWeight: 500 }}>{row.title}</div>
340
+ {row.subtitle ? (
341
+ <div style={{ fontSize: 12, color: "var(--ck-text-secondary)" }}>
342
+ {row.subtitle}
343
+ </div>
344
+ ) : null}
345
+ </button>
346
+ ))}
347
+ {offerFree ? (
348
+ <button
349
+ type="button"
350
+ data-testid={`${testid}-free-entry`}
351
+ onMouseDown={(e) => {
352
+ e.preventDefault();
353
+ commitFree(trimmed);
354
+ }}
355
+ onMouseEnter={() => setHighlight(results.length)}
356
+ style={{
357
+ display: "block",
358
+ width: "100%",
359
+ textAlign: "left",
360
+ padding: "8px 12px",
361
+ border: "none",
362
+ background:
363
+ highlight === results.length ? "var(--ck-bg-muted)" : "transparent",
364
+ borderTop: results.length > 0 ? "1px solid var(--ck-border-subtle)" : "none",
365
+ cursor: "pointer",
366
+ font: "inherit",
367
+ fontSize: 13,
368
+ color: "var(--ck-accent)",
369
+ }}
370
+ >
371
+ {freeEntryLabel ? freeEntryLabel(trimmed) : <>Use “{trimmed}”</>}
372
+ </button>
373
+ ) : hasNoResults && emptyHint ? (
374
+ <div style={{ padding: "8px 12px", fontSize: 12, color: "var(--ck-text-secondary)" }}>
375
+ {emptyHint}
376
+ </div>
377
+ ) : null}
378
+ </div>
379
+ ) : null}
380
+
381
+ {committed ? (
382
+ <div
383
+ data-testid={`${testid}-committed`}
384
+ style={{
385
+ marginTop: 8,
386
+ padding: "10px 12px",
387
+ background: "var(--ck-bg-muted)",
388
+ border: "1px solid var(--ck-border-subtle)",
389
+ borderRadius: "var(--ck-radius-sm)",
390
+ display: "flex",
391
+ alignItems: "flex-start",
392
+ gap: 8,
393
+ }}
394
+ >
395
+ <div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 2 }}>
396
+ <div style={{ fontSize: 14, fontWeight: 500, color: "var(--ck-text-primary)" }}>
397
+ {committed.title}
398
+ </div>
399
+ {committed.subtitle ? (
400
+ <div style={{ fontSize: 12, color: "var(--ck-text-secondary)" }}>
401
+ {committed.subtitle}
402
+ </div>
403
+ ) : null}
404
+ {committedExtra ? <div style={{ marginTop: 6 }}>{committedExtra}</div> : null}
405
+ </div>
406
+ {onUncommit ? (
407
+ <button
408
+ type="button"
409
+ aria-label="Clear selection"
410
+ data-testid={`${testid}-clear`}
411
+ onClick={clearCommitted}
412
+ style={{
413
+ border: "none",
414
+ background: "none",
415
+ cursor: "pointer",
416
+ color: "var(--ck-text-secondary)",
417
+ fontSize: 14,
418
+ lineHeight: 1,
419
+ padding: 2,
420
+ flexShrink: 0,
421
+ }}
422
+ >
423
+ ×
424
+ </button>
425
+ ) : null}
426
+ </div>
427
+ ) : null}
428
+ </div>
429
+ );
430
+ }
@@ -14,6 +14,13 @@ export { Input } from "./Input";
14
14
  export type { InputProps } from "./Input";
15
15
  export { Select } from "./Select";
16
16
  export type { SelectProps, SelectOption } from "./Select";
17
+ export { Combobox } from "./Combobox";
18
+ export type {
19
+ ComboboxProps,
20
+ ComboboxOption,
21
+ ComboboxCommit,
22
+ ComboboxCommitted,
23
+ } from "./Combobox";
17
24
  export { Textarea } from "./Textarea";
18
25
  export type { TextareaProps } from "./Textarea";
19
26
  export { Checkbox } from "./Checkbox";
@@ -107,3 +107,18 @@ a:visited { color: var(--ck-accent); }
107
107
  animation: none;
108
108
  }
109
109
  }
110
+
111
+ /* Scrollbars — macOS renders auto-hiding overlay bars, but Windows
112
+ (and Linux) render CLASSIC opaque gutters that read as heavy grey
113
+ slabs against the kit's editorial surfaces (QA: Ben, 2026-07-19).
114
+ The standard properties slim them down and tint the thumb from
115
+ the theme's border token so both light and dark chrome stay
116
+ coherent. `scrollbar-color` inherits from :root; `scrollbar-width`
117
+ doesn't, hence the universal selector. Browsers without support
118
+ (older Safari) keep their native overlay bars — already fine. */
119
+ * {
120
+ scrollbar-width: thin;
121
+ }
122
+ :root {
123
+ scrollbar-color: var(--ck-border-strong, rgba(0, 0, 0, 0.25)) transparent;
124
+ }