@cosxai/ui 0.2.3 → 0.2.5

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.2.3",
3
+ "version": "0.2.5",
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,512 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useId,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type CSSProperties,
10
+ type KeyboardEvent,
11
+ type ReactNode,
12
+ } from "react";
13
+
14
+ import { cn } from "../lib/cn";
15
+
16
+ // Custom listbox-pattern select. We deliberately do NOT use native
17
+ // <select> — browsers refuse to style the popup, so terminal /
18
+ // editorial / swiss etc. would punch through with macOS/Win blue and
19
+ // break the visual contract. Trade-off: ~200 LOC of state +
20
+ // keyboard handling we'd otherwise get for free.
21
+ //
22
+ // Keyboard model (mirrors ARIA 1.2 combobox-as-listbox spec):
23
+ // Space / Enter on the trigger → open
24
+ // ArrowDown / ArrowUp → open (highlight first / last)
25
+ // ArrowDown / ArrowUp (open) → move highlight
26
+ // Home / End (open) → first / last option
27
+ // Enter (open) → commit highlighted option
28
+ // Escape (open) → close, restore previous value
29
+ // Tab → close + advance focus
30
+ // A-Z / 0-9 (open or closed) → jump to next option whose label
31
+ // starts with that character
32
+ // (typeahead; 500 ms reset window)
33
+ //
34
+ // Layout model:
35
+ // - Trigger is a styled <button> that LOOKS like .ck-input
36
+ // - Popover is absolutely positioned beneath the trigger
37
+ // - We do NOT portal — keeps the markup simple. If a parent has
38
+ // `overflow: hidden` that clips the popover, wrap the Select
39
+ // in a sibling rather than mounting it inside that container.
40
+
41
+ export interface SelectOption {
42
+ value: string;
43
+ label: string;
44
+ // Optional disabled flag — the option renders dimmed and isn't
45
+ // selectable via click / keyboard. Useful for "Coming soon" rows.
46
+ disabled?: boolean | undefined;
47
+ }
48
+
49
+ export interface SelectProps {
50
+ value: string;
51
+ onChange: (value: string) => void;
52
+ options: SelectOption[];
53
+ label?: ReactNode | undefined;
54
+ helper?: ReactNode | undefined;
55
+ error?: string | null | undefined;
56
+ placeholder?: string | undefined;
57
+ // full = stretches to container width; auto = intrinsic.
58
+ fit?: "full" | "auto" | undefined;
59
+ disabled?: boolean | undefined;
60
+ required?: boolean | undefined;
61
+ name?: string | undefined;
62
+ id?: string | undefined;
63
+ className?: string | undefined;
64
+ // Max height of the popover. Long lists scroll inside.
65
+ maxOptionsHeight?: number | undefined;
66
+ }
67
+
68
+ const TRIGGER_BASE_STYLE: CSSProperties = {
69
+ display: "inline-flex",
70
+ alignItems: "center",
71
+ justifyContent: "space-between",
72
+ gap: 8,
73
+ width: "100%",
74
+ height: 36,
75
+ padding: "0 12px",
76
+ font: "400 13px/1 var(--ck-font-sans)",
77
+ background: "var(--ck-bg-surface)",
78
+ color: "var(--ck-text-primary)",
79
+ borderRadius: "var(--ck-radius-sm)",
80
+ outline: "none",
81
+ textAlign: "left",
82
+ cursor: "pointer",
83
+ transition: "border-color var(--ck-dur-fast) var(--ck-ease)",
84
+ };
85
+
86
+ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select(
87
+ {
88
+ value,
89
+ onChange,
90
+ options,
91
+ label,
92
+ helper,
93
+ error,
94
+ placeholder,
95
+ fit = "full",
96
+ disabled,
97
+ required,
98
+ name,
99
+ id,
100
+ className,
101
+ maxOptionsHeight = 280,
102
+ },
103
+ ref,
104
+ ) {
105
+ const autoId = useId();
106
+ const triggerId = id ?? `${autoId}-trigger`;
107
+ const listboxId = `${autoId}-listbox`;
108
+
109
+ const [open, setOpen] = useState(false);
110
+ // Highlighted index while the popover is open. -1 = nothing
111
+ // highlighted; first open with no selection lands on 0.
112
+ const [highlight, setHighlight] = useState(-1);
113
+ const triggerRef = useRef<HTMLButtonElement | null>(null);
114
+ const popoverRef = useRef<HTMLDivElement | null>(null);
115
+ const optionRefs = useRef<(HTMLLIElement | null)[]>([]);
116
+ // Typeahead buffer + reset timer for "press a letter to jump".
117
+ const typeaheadRef = useRef<{ buffer: string; resetAt: number }>({
118
+ buffer: "",
119
+ resetAt: 0,
120
+ });
121
+
122
+ const selectedIndex = useMemo(
123
+ () => options.findIndex((o) => o.value === value),
124
+ [options, value],
125
+ );
126
+ const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : undefined;
127
+
128
+ const setTriggerRef = useCallback(
129
+ (el: HTMLButtonElement | null) => {
130
+ triggerRef.current = el;
131
+ if (typeof ref === "function") ref(el);
132
+ else if (ref) ref.current = el;
133
+ },
134
+ [ref],
135
+ );
136
+
137
+ // Close on outside click + Esc.
138
+ useEffect(() => {
139
+ if (!open) return;
140
+ const onDocClick = (e: MouseEvent) => {
141
+ const t = e.target as Node | null;
142
+ if (!t) return;
143
+ if (triggerRef.current?.contains(t)) return;
144
+ if (popoverRef.current?.contains(t)) return;
145
+ setOpen(false);
146
+ };
147
+ document.addEventListener("mousedown", onDocClick);
148
+ return () => document.removeEventListener("mousedown", onDocClick);
149
+ }, [open]);
150
+
151
+ // Auto-scroll highlighted option into view inside the popover.
152
+ useEffect(() => {
153
+ if (!open || highlight < 0) return;
154
+ const el = optionRefs.current[highlight];
155
+ if (el) el.scrollIntoView({ block: "nearest" });
156
+ }, [open, highlight]);
157
+
158
+ // When opening, seed the highlight to the currently selected option
159
+ // so the user lands on what they've already chosen.
160
+ const openPopover = useCallback(
161
+ (startHighlight?: number) => {
162
+ if (disabled) return;
163
+ setOpen(true);
164
+ setHighlight(
165
+ startHighlight !== undefined ? startHighlight : selectedIndex >= 0 ? selectedIndex : 0,
166
+ );
167
+ },
168
+ [disabled, selectedIndex],
169
+ );
170
+
171
+ const closePopover = useCallback(() => {
172
+ setOpen(false);
173
+ setHighlight(-1);
174
+ // Re-focus the trigger so keyboard users stay on context.
175
+ triggerRef.current?.focus();
176
+ }, []);
177
+
178
+ const commit = useCallback(
179
+ (idx: number) => {
180
+ const opt = options[idx];
181
+ if (!opt || opt.disabled) return;
182
+ onChange(opt.value);
183
+ closePopover();
184
+ },
185
+ [options, onChange, closePopover],
186
+ );
187
+
188
+ // Move highlight skipping disabled rows.
189
+ const moveHighlight = useCallback(
190
+ (delta: 1 | -1, from?: number) => {
191
+ const n = options.length;
192
+ if (n === 0) return;
193
+ let i = (from ?? highlight) + delta;
194
+ // Loop max n times to avoid infinite hunt when ALL options
195
+ // are disabled.
196
+ for (let attempt = 0; attempt < n; attempt++) {
197
+ if (i < 0) i = n - 1;
198
+ if (i >= n) i = 0;
199
+ if (!options[i]?.disabled) {
200
+ setHighlight(i);
201
+ return;
202
+ }
203
+ i += delta;
204
+ }
205
+ },
206
+ [options, highlight],
207
+ );
208
+
209
+ // Typeahead — press a letter to jump to the next option whose
210
+ // label starts with that letter (or the typed prefix, if pressed
211
+ // within the reset window).
212
+ const typeahead = useCallback(
213
+ (char: string) => {
214
+ const now = Date.now();
215
+ const t = typeaheadRef.current;
216
+ if (now > t.resetAt) t.buffer = "";
217
+ t.buffer += char.toLowerCase();
218
+ t.resetAt = now + 500;
219
+
220
+ const start = highlight >= 0 ? highlight : 0;
221
+ const n = options.length;
222
+ for (let off = 1; off <= n; off++) {
223
+ const idx = (start + off) % n;
224
+ const o = options[idx];
225
+ if (o && !o.disabled && o.label.toLowerCase().startsWith(t.buffer)) {
226
+ setHighlight(idx);
227
+ return;
228
+ }
229
+ }
230
+ // No prefix match — try single-char from current.
231
+ if (t.buffer.length > 1) {
232
+ t.buffer = char.toLowerCase();
233
+ for (let off = 1; off <= n; off++) {
234
+ const idx = (start + off) % n;
235
+ const o = options[idx];
236
+ if (o && !o.disabled && o.label.toLowerCase().startsWith(t.buffer)) {
237
+ setHighlight(idx);
238
+ return;
239
+ }
240
+ }
241
+ }
242
+ },
243
+ [options, highlight],
244
+ );
245
+
246
+ const onTriggerKey = (e: KeyboardEvent<HTMLButtonElement>) => {
247
+ if (disabled) return;
248
+ if (!open) {
249
+ if (e.key === "Enter" || e.key === " ") {
250
+ e.preventDefault();
251
+ openPopover();
252
+ return;
253
+ }
254
+ if (e.key === "ArrowDown") {
255
+ e.preventDefault();
256
+ openPopover(0);
257
+ return;
258
+ }
259
+ if (e.key === "ArrowUp") {
260
+ e.preventDefault();
261
+ openPopover(options.length - 1);
262
+ return;
263
+ }
264
+ if (e.key.length === 1 && /[a-z0-9]/i.test(e.key)) {
265
+ openPopover();
266
+ typeahead(e.key);
267
+ return;
268
+ }
269
+ return;
270
+ }
271
+ // Open
272
+ if (e.key === "Escape") {
273
+ e.preventDefault();
274
+ closePopover();
275
+ return;
276
+ }
277
+ if (e.key === "Tab") {
278
+ // Close but DON'T preventDefault — let focus advance.
279
+ setOpen(false);
280
+ setHighlight(-1);
281
+ return;
282
+ }
283
+ if (e.key === "ArrowDown") {
284
+ e.preventDefault();
285
+ moveHighlight(1);
286
+ return;
287
+ }
288
+ if (e.key === "ArrowUp") {
289
+ e.preventDefault();
290
+ moveHighlight(-1);
291
+ return;
292
+ }
293
+ if (e.key === "Home") {
294
+ e.preventDefault();
295
+ moveHighlight(1, -1);
296
+ return;
297
+ }
298
+ if (e.key === "End") {
299
+ e.preventDefault();
300
+ moveHighlight(-1, options.length);
301
+ return;
302
+ }
303
+ if (e.key === "Enter") {
304
+ e.preventDefault();
305
+ if (highlight >= 0) commit(highlight);
306
+ return;
307
+ }
308
+ if (e.key.length === 1 && /[a-z0-9]/i.test(e.key)) {
309
+ e.preventDefault();
310
+ typeahead(e.key);
311
+ return;
312
+ }
313
+ };
314
+
315
+ const triggerLabel = selectedOption ? selectedOption.label : placeholder ?? "Select…";
316
+ const showPlaceholder = !selectedOption;
317
+
318
+ return (
319
+ <div
320
+ className={cn("ck-select-field", className)}
321
+ style={{
322
+ display: "flex",
323
+ flexDirection: "column",
324
+ gap: 6,
325
+ width: fit === "full" ? "100%" : undefined,
326
+ position: "relative",
327
+ }}
328
+ >
329
+ {label && (
330
+ <label
331
+ htmlFor={triggerId}
332
+ className="ck-eyebrow"
333
+ style={{ color: "var(--ck-text-secondary)" }}
334
+ >
335
+ {label}
336
+ </label>
337
+ )}
338
+
339
+ {/* Hidden native input mirrors the value so plain <form> POSTs
340
+ still carry the field. Consumers using the controlled value
341
+ directly can ignore this. */}
342
+ {name && <input type="hidden" name={name} value={value} required={required} />}
343
+
344
+ <button
345
+ ref={setTriggerRef}
346
+ id={triggerId}
347
+ type="button"
348
+ role="combobox"
349
+ aria-haspopup="listbox"
350
+ aria-expanded={open}
351
+ aria-controls={listboxId}
352
+ aria-invalid={error ? true : undefined}
353
+ aria-required={required}
354
+ disabled={disabled}
355
+ onClick={() => (open ? closePopover() : openPopover())}
356
+ onKeyDown={onTriggerKey}
357
+ className={cn(
358
+ "ck-select-trigger",
359
+ error && "ck-select-trigger--invalid",
360
+ disabled && "ck-select-trigger--disabled",
361
+ )}
362
+ style={{
363
+ ...TRIGGER_BASE_STYLE,
364
+ border: `1px solid ${error ? "var(--ck-critical)" : "var(--ck-border-strong)"}`,
365
+ color: showPlaceholder ? "var(--ck-text-tertiary)" : "var(--ck-text-primary)",
366
+ opacity: disabled ? 0.55 : 1,
367
+ }}
368
+ >
369
+ <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
370
+ {triggerLabel}
371
+ </span>
372
+ <svg
373
+ width="10"
374
+ height="10"
375
+ viewBox="0 0 24 24"
376
+ fill="none"
377
+ stroke="currentColor"
378
+ strokeWidth="2.2"
379
+ strokeLinecap="round"
380
+ strokeLinejoin="round"
381
+ aria-hidden
382
+ style={{
383
+ flexShrink: 0,
384
+ opacity: 0.7,
385
+ transform: open ? "rotate(180deg)" : "rotate(0deg)",
386
+ transition: "transform 180ms var(--ck-ease)",
387
+ }}
388
+ >
389
+ <polyline points="6 9 12 15 18 9" />
390
+ </svg>
391
+ </button>
392
+
393
+ {open && (
394
+ <div
395
+ ref={popoverRef}
396
+ className="ck-select-popover"
397
+ style={{
398
+ position: "absolute",
399
+ top: "calc(100% + 4px)",
400
+ left: 0,
401
+ right: 0,
402
+ zIndex: 50,
403
+ background: "var(--ck-bg-surface)",
404
+ border: "1px solid var(--ck-border-strong)",
405
+ borderRadius: "var(--ck-radius-sm)",
406
+ boxShadow: "var(--ck-shadow-3, 0 16px 48px rgba(0,0,0,0.12))",
407
+ maxHeight: maxOptionsHeight,
408
+ overflowY: "auto",
409
+ padding: 4,
410
+ }}
411
+ >
412
+ <ul
413
+ id={listboxId}
414
+ role="listbox"
415
+ aria-labelledby={triggerId}
416
+ style={{ listStyle: "none", margin: 0, padding: 0 }}
417
+ >
418
+ {options.map((opt, i) => {
419
+ const selected = opt.value === value;
420
+ const highlighted = i === highlight;
421
+ return (
422
+ <li
423
+ ref={(el) => {
424
+ optionRefs.current[i] = el;
425
+ }}
426
+ key={opt.value}
427
+ role="option"
428
+ aria-selected={selected}
429
+ aria-disabled={opt.disabled}
430
+ onMouseEnter={() => !opt.disabled && setHighlight(i)}
431
+ onMouseDown={(e) => {
432
+ // Prevent the trigger from losing focus before
433
+ // commit (otherwise the popover closes via
434
+ // outside-click before onClick fires).
435
+ e.preventDefault();
436
+ }}
437
+ onClick={() => commit(i)}
438
+ className={cn(
439
+ "ck-select-option",
440
+ selected && "ck-select-option--selected",
441
+ highlighted && "ck-select-option--active",
442
+ opt.disabled && "ck-select-option--disabled",
443
+ )}
444
+ style={{
445
+ padding: "8px 10px",
446
+ borderRadius: "calc(var(--ck-radius-sm) - 2px)",
447
+ cursor: opt.disabled ? "not-allowed" : "pointer",
448
+ color: opt.disabled
449
+ ? "var(--ck-text-tertiary)"
450
+ : "var(--ck-text-primary)",
451
+ background: highlighted
452
+ ? "var(--ck-bg-muted)"
453
+ : "transparent",
454
+ font: "400 13px/1.2 var(--ck-font-sans)",
455
+ display: "flex",
456
+ alignItems: "center",
457
+ justifyContent: "space-between",
458
+ gap: 8,
459
+ transition: "background var(--ck-dur-fast) var(--ck-ease)",
460
+ }}
461
+ >
462
+ <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
463
+ {opt.label}
464
+ </span>
465
+ {selected && (
466
+ <svg
467
+ width="12"
468
+ height="12"
469
+ viewBox="0 0 24 24"
470
+ fill="none"
471
+ stroke="currentColor"
472
+ strokeWidth="2.4"
473
+ strokeLinecap="round"
474
+ strokeLinejoin="round"
475
+ aria-hidden
476
+ style={{ color: "var(--ck-accent)", flexShrink: 0 }}
477
+ >
478
+ <polyline points="20 6 9 17 4 12" />
479
+ </svg>
480
+ )}
481
+ </li>
482
+ );
483
+ })}
484
+ {options.length === 0 && (
485
+ <li
486
+ role="presentation"
487
+ style={{
488
+ padding: "8px 10px",
489
+ color: "var(--ck-text-tertiary)",
490
+ font: "400 13px/1.2 var(--ck-font-sans)",
491
+ }}
492
+ >
493
+ No options
494
+ </li>
495
+ )}
496
+ </ul>
497
+ </div>
498
+ )}
499
+
500
+ {(helper || error) && (
501
+ <div
502
+ style={{
503
+ font: "400 11px/1.4 var(--ck-font-sans)",
504
+ color: error ? "var(--ck-critical)" : "var(--ck-text-tertiary)",
505
+ }}
506
+ >
507
+ {error ?? helper}
508
+ </div>
509
+ )}
510
+ </div>
511
+ );
512
+ });
@@ -12,6 +12,8 @@ export { Avatar } from "./Avatar";
12
12
  export type { AvatarProps } from "./Avatar";
13
13
  export { Input } from "./Input";
14
14
  export type { InputProps } from "./Input";
15
+ export { Select } from "./Select";
16
+ export type { SelectProps, SelectOption } from "./Select";
15
17
  export { Textarea } from "./Textarea";
16
18
  export type { TextareaProps } from "./Textarea";
17
19
  export { Checkbox } from "./Checkbox";
@@ -17,6 +17,22 @@ body {
17
17
  transition: background-color 200ms var(--ck-ease), color 200ms var(--ck-ease);
18
18
  }
19
19
 
20
+ /* Anchor — defaults to the active chrome's accent + a clean
21
+ underline. Without this rule, the browser's visited-link
22
+ purple bleeds through any theme (most notably terminal /
23
+ editorial). Components that want different styling (NavItem,
24
+ TopBar nav, breadcrumbs, in-card secondary links) override
25
+ locally with a more-specific selector. */
26
+ a {
27
+ color: var(--ck-accent);
28
+ text-decoration: underline;
29
+ text-decoration-thickness: 1px;
30
+ text-underline-offset: 2px;
31
+ transition: color var(--ck-dur-fast) var(--ck-ease);
32
+ }
33
+ a:hover { color: var(--ck-accent-hover); }
34
+ a:visited { color: var(--ck-accent); }
35
+
20
36
  /* ---------- Typography utilities ---------- */
21
37
  .ck-h1 { font: 500 32px/1.2 var(--ck-font-sans); letter-spacing: -0.01em; color: var(--ck-text-primary); margin: 0; }
22
38
  .ck-h2 { font: 500 24px/1.3 var(--ck-font-sans); letter-spacing: -0.005em; color: var(--ck-text-primary); margin: 0; }
@@ -107,6 +107,34 @@ html[data-ck-chrome="terminal"] .ck-textarea:focus-visible {
107
107
  box-shadow: none !important;
108
108
  }
109
109
 
110
+ /* Select trigger + popover — mirror the input look so a chrome
111
+ feels consistent across form controls. */
112
+ html[data-ck-chrome="terminal"] .ck-select-trigger {
113
+ background: var(--ck-bg-surface-2);
114
+ border: 1px solid var(--ck-border-subtle) !important;
115
+ border-radius: 2px !important;
116
+ font-family: var(--ck-font-mono);
117
+ color: var(--ck-text-primary);
118
+ }
119
+ html[data-ck-chrome="terminal"] .ck-select-trigger:focus-visible {
120
+ outline: none;
121
+ border-color: var(--ck-border-strong) !important;
122
+ border-top-color: var(--ck-accent) !important;
123
+ box-shadow: none !important;
124
+ }
125
+ html[data-ck-chrome="terminal"] .ck-select-popover {
126
+ background: var(--ck-bg-surface-2);
127
+ border: 1px solid var(--ck-border-strong);
128
+ border-radius: 2px;
129
+ font-family: var(--ck-font-mono);
130
+ }
131
+ html[data-ck-chrome="terminal"] .ck-select-option {
132
+ border-radius: 0;
133
+ }
134
+ html[data-ck-chrome="terminal"] .ck-select-option--active {
135
+ background: var(--ck-bg-muted) !important;
136
+ }
137
+
110
138
  /* ===== Cards ================================================ */
111
139
 
112
140
  html[data-ck-chrome="terminal"] .ck-card {
@@ -116,6 +116,31 @@ html[data-ck-chrome="seamless"] .ck-card__foot {
116
116
  color: var(--ck-text-tertiary);
117
117
  }
118
118
 
119
+ /* ---------- Select (custom listbox — never native <select>) ----------
120
+ The trigger LOOKS like .ck-input so chrome restyles cascade
121
+ uniformly. Chromes that want a different vibe (terminal squared,
122
+ sketch hand-drawn, swiss underline-only) override at their own
123
+ scope. */
124
+ .ck-select-trigger:focus-visible {
125
+ border-color: var(--ck-accent) !important;
126
+ box-shadow: 0 0 0 3px var(--ck-accent-muted);
127
+ }
128
+ .ck-select-trigger--disabled {
129
+ cursor: not-allowed !important;
130
+ }
131
+ .ck-select-popover {
132
+ animation: ck-popover-enter 140ms var(--ck-ease) both;
133
+ transform-origin: top center;
134
+ }
135
+ .ck-select-option--active {
136
+ /* Inline style already paints --ck-bg-muted; this class hook is
137
+ left for chrome overrides that want to draw a left-border or
138
+ other indicator on the active row. */
139
+ }
140
+ .ck-select-option--selected {
141
+ font-weight: 500;
142
+ }
143
+
119
144
  /* ---------- ActionBar — button row inside <ActionBar> ---------- */
120
145
  .ck-actionbar-btn {
121
146
  display: inline-flex;