@cosxai/ui 0.2.4 → 0.2.6

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.4",
3
+ "version": "0.2.6",
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,733 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useId,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type CSSProperties,
11
+ type KeyboardEvent,
12
+ type ReactNode,
13
+ } from "react";
14
+ import { createPortal } from "react-dom";
15
+
16
+ import { cn } from "../lib/cn";
17
+
18
+ // Custom listbox-pattern select. We deliberately do NOT use native
19
+ // <select> — browsers refuse to style the popup, so terminal /
20
+ // editorial / swiss etc. would punch through with macOS/Win blue
21
+ // and break the visual contract.
22
+ //
23
+ // Two modes via `searchable`:
24
+ //
25
+ // searchable=false (default)
26
+ // Trigger button. Keyboard handlers live on the trigger.
27
+ // Typeahead (A-Z / 0-9) jumps to next matching label.
28
+ // Right for ≤ ~10 short options.
29
+ //
30
+ // searchable=true
31
+ // Trigger button + a search input pinned at the top of the
32
+ // popover. Focus moves to the search input on open; the
33
+ // listbox below filters by case-insensitive label substring.
34
+ // Right for long lists (countries, currencies, time zones,
35
+ // user pickers).
36
+ //
37
+ // Popover ALWAYS renders via createPortal to document.body so it
38
+ // escapes Card / Drawer / Dialog parents whose overflow:hidden
39
+ // would otherwise clip it. Position is computed against the
40
+ // trigger's bounding rect and recomputed on resize; we
41
+ // intentionally close on scroll to avoid the "popover drifts off
42
+ // the trigger" failure mode (matches Radix / Headless UI default).
43
+ //
44
+ // Keyboard model (mirrors ARIA 1.2 combobox-as-listbox spec):
45
+ // trigger CLOSED:
46
+ // Space / Enter → open
47
+ // ArrowDown / ArrowUp → open (first / last)
48
+ // A-Z / 0-9 (!searchable) → open + typeahead
49
+ // open, !searchable, on trigger:
50
+ // ArrowDown / ArrowUp → move highlight
51
+ // Home / End → first / last
52
+ // Enter → commit highlighted
53
+ // Escape → close
54
+ // Tab → close, focus advances
55
+ // A-Z / 0-9 → typeahead (500 ms reset window)
56
+ // open, searchable, on search input:
57
+ // Typing → filter
58
+ // ArrowDown / ArrowUp → move highlight inside filtered list
59
+ // Home / End → first / last in filtered list
60
+ // Enter → commit highlighted
61
+ // Escape → close
62
+ // Tab → close, focus advances
63
+
64
+ export interface SelectOption {
65
+ value: string;
66
+ label: string;
67
+ // Optional disabled flag — the option renders dimmed and isn't
68
+ // selectable via click / keyboard. Useful for "Coming soon" rows.
69
+ disabled?: boolean | undefined;
70
+ }
71
+
72
+ export interface SelectProps {
73
+ value: string;
74
+ onChange: (value: string) => void;
75
+ options: SelectOption[];
76
+ label?: ReactNode | undefined;
77
+ helper?: ReactNode | undefined;
78
+ error?: string | null | undefined;
79
+ placeholder?: string | undefined;
80
+ // full = stretches to container width; auto = intrinsic.
81
+ fit?: "full" | "auto" | undefined;
82
+ disabled?: boolean | undefined;
83
+ required?: boolean | undefined;
84
+ name?: string | undefined;
85
+ id?: string | undefined;
86
+ className?: string | undefined;
87
+ // Max height of the popover. Long lists scroll inside.
88
+ maxOptionsHeight?: number | undefined;
89
+ // Show a search input pinned at the top of the popover. Filters
90
+ // options by case-insensitive label substring. Recommended for
91
+ // lists > ~10 items.
92
+ searchable?: boolean | undefined;
93
+ // Placeholder for the search input (when searchable).
94
+ searchPlaceholder?: string | undefined;
95
+ }
96
+
97
+ const TRIGGER_BASE_STYLE: CSSProperties = {
98
+ display: "inline-flex",
99
+ alignItems: "center",
100
+ justifyContent: "space-between",
101
+ gap: 8,
102
+ width: "100%",
103
+ height: 36,
104
+ padding: "0 12px",
105
+ font: "400 13px/1 var(--ck-font-sans)",
106
+ background: "var(--ck-bg-surface)",
107
+ color: "var(--ck-text-primary)",
108
+ borderRadius: "var(--ck-radius-sm)",
109
+ outline: "none",
110
+ textAlign: "left",
111
+ cursor: "pointer",
112
+ transition: "border-color var(--ck-dur-fast) var(--ck-ease)",
113
+ };
114
+
115
+ // Visual gap between the trigger's bottom edge and the popover's
116
+ // top edge. Matches Radix / Headless UI default; small enough to
117
+ // read as "attached", large enough not to feel sticky.
118
+ const POPOVER_GAP = 4;
119
+
120
+ interface PopoverRect {
121
+ top: number;
122
+ left: number;
123
+ width: number;
124
+ }
125
+
126
+ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select(
127
+ {
128
+ value,
129
+ onChange,
130
+ options,
131
+ label,
132
+ helper,
133
+ error,
134
+ placeholder,
135
+ fit = "full",
136
+ disabled,
137
+ required,
138
+ name,
139
+ id,
140
+ className,
141
+ maxOptionsHeight = 280,
142
+ searchable = false,
143
+ searchPlaceholder = "Search…",
144
+ },
145
+ ref,
146
+ ) {
147
+ const autoId = useId();
148
+ const triggerId = id ?? `${autoId}-trigger`;
149
+ const listboxId = `${autoId}-listbox`;
150
+
151
+ const [open, setOpen] = useState(false);
152
+ const [highlight, setHighlight] = useState(-1);
153
+ const [query, setQuery] = useState("");
154
+ const [rect, setRect] = useState<PopoverRect | null>(null);
155
+
156
+ const triggerRef = useRef<HTMLButtonElement | null>(null);
157
+ const popoverRef = useRef<HTMLDivElement | null>(null);
158
+ const searchInputRef = useRef<HTMLInputElement | null>(null);
159
+ const optionRefs = useRef<(HTMLLIElement | null)[]>([]);
160
+ const typeaheadRef = useRef<{ buffer: string; resetAt: number }>({
161
+ buffer: "",
162
+ resetAt: 0,
163
+ });
164
+
165
+ // Filtered options when searchable; identical otherwise. Filter
166
+ // by case-insensitive label substring. Disabled options remain
167
+ // visible but unselectable; consumers can pre-filter their
168
+ // options array if they want them hidden when search is non-empty.
169
+ const filteredOptions = useMemo(() => {
170
+ if (!searchable || query.trim() === "") return options;
171
+ const q = query.trim().toLowerCase();
172
+ return options.filter((o) => o.label.toLowerCase().includes(q));
173
+ }, [searchable, options, query]);
174
+
175
+ const selectedIndex = useMemo(
176
+ () => options.findIndex((o) => o.value === value),
177
+ [options, value],
178
+ );
179
+ const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : undefined;
180
+
181
+ const setTriggerRef = useCallback(
182
+ (el: HTMLButtonElement | null) => {
183
+ triggerRef.current = el;
184
+ if (typeof ref === "function") ref(el);
185
+ else if (ref) ref.current = el;
186
+ },
187
+ [ref],
188
+ );
189
+
190
+ const computeRect = useCallback((): PopoverRect | null => {
191
+ const el = triggerRef.current;
192
+ if (!el) return null;
193
+ const r = el.getBoundingClientRect();
194
+ return {
195
+ top: r.bottom + POPOVER_GAP,
196
+ left: r.left,
197
+ width: r.width,
198
+ };
199
+ }, []);
200
+
201
+ // Position the popover on open + on window resize. Close on
202
+ // page scroll — keeps the popover from drifting off the trigger
203
+ // while the user pages around.
204
+ useLayoutEffect(() => {
205
+ if (!open) return;
206
+ setRect(computeRect());
207
+ }, [open, computeRect]);
208
+
209
+ useEffect(() => {
210
+ if (!open) return;
211
+ const onResize = () => setRect(computeRect());
212
+ const onScroll = () => setOpen(false);
213
+ window.addEventListener("resize", onResize);
214
+ window.addEventListener("scroll", onScroll, true);
215
+ return () => {
216
+ window.removeEventListener("resize", onResize);
217
+ window.removeEventListener("scroll", onScroll, true);
218
+ };
219
+ }, [open, computeRect]);
220
+
221
+ // Close on outside click + Esc (Esc handled in onKey below).
222
+ useEffect(() => {
223
+ if (!open) return;
224
+ const onDocMouseDown = (e: MouseEvent) => {
225
+ const t = e.target as Node | null;
226
+ if (!t) return;
227
+ if (triggerRef.current?.contains(t)) return;
228
+ if (popoverRef.current?.contains(t)) return;
229
+ setOpen(false);
230
+ };
231
+ document.addEventListener("mousedown", onDocMouseDown);
232
+ return () => document.removeEventListener("mousedown", onDocMouseDown);
233
+ }, [open]);
234
+
235
+ // Auto-scroll highlighted option into view inside the popover.
236
+ useEffect(() => {
237
+ if (!open || highlight < 0) return;
238
+ const el = optionRefs.current[highlight];
239
+ if (el) el.scrollIntoView({ block: "nearest" });
240
+ }, [open, highlight]);
241
+
242
+ // Focus management on open:
243
+ // searchable=true → focus the search input
244
+ // searchable=false → trigger keeps focus (kbd handler lives there)
245
+ useEffect(() => {
246
+ if (!open) return;
247
+ if (searchable) {
248
+ // requestAnimationFrame to let the portal mount before focusing.
249
+ const raf = requestAnimationFrame(() => searchInputRef.current?.focus());
250
+ return () => cancelAnimationFrame(raf);
251
+ }
252
+ }, [open, searchable]);
253
+
254
+ // When the filtered list changes (user types), reset highlight
255
+ // to the first selectable option. Otherwise the highlight could
256
+ // point at an index that no longer exists.
257
+ useEffect(() => {
258
+ if (!open) return;
259
+ if (filteredOptions.length === 0) {
260
+ setHighlight(-1);
261
+ return;
262
+ }
263
+ // Try to keep the existing selection visible; else first
264
+ // non-disabled option.
265
+ const selectedInFiltered = filteredOptions.findIndex((o) => o.value === value);
266
+ if (selectedInFiltered >= 0) {
267
+ setHighlight(selectedInFiltered);
268
+ } else {
269
+ const firstEnabled = filteredOptions.findIndex((o) => !o.disabled);
270
+ setHighlight(firstEnabled >= 0 ? firstEnabled : 0);
271
+ }
272
+ }, [open, filteredOptions, value]);
273
+
274
+ const openPopover = useCallback(
275
+ (startHighlight?: number) => {
276
+ if (disabled) return;
277
+ setOpen(true);
278
+ setQuery("");
279
+ setHighlight(
280
+ startHighlight !== undefined
281
+ ? startHighlight
282
+ : selectedIndex >= 0
283
+ ? selectedIndex
284
+ : 0,
285
+ );
286
+ },
287
+ [disabled, selectedIndex],
288
+ );
289
+
290
+ const closePopover = useCallback(() => {
291
+ setOpen(false);
292
+ setHighlight(-1);
293
+ setQuery("");
294
+ triggerRef.current?.focus();
295
+ }, []);
296
+
297
+ const commit = useCallback(
298
+ (idx: number) => {
299
+ const opt = filteredOptions[idx];
300
+ if (!opt || opt.disabled) return;
301
+ onChange(opt.value);
302
+ closePopover();
303
+ },
304
+ [filteredOptions, onChange, closePopover],
305
+ );
306
+
307
+ // Move highlight skipping disabled rows.
308
+ const moveHighlight = useCallback(
309
+ (delta: 1 | -1, from?: number) => {
310
+ const n = filteredOptions.length;
311
+ if (n === 0) return;
312
+ let i = (from ?? highlight) + delta;
313
+ for (let attempt = 0; attempt < n; attempt++) {
314
+ if (i < 0) i = n - 1;
315
+ if (i >= n) i = 0;
316
+ if (!filteredOptions[i]?.disabled) {
317
+ setHighlight(i);
318
+ return;
319
+ }
320
+ i += delta;
321
+ }
322
+ },
323
+ [filteredOptions, highlight],
324
+ );
325
+
326
+ const typeahead = useCallback(
327
+ (char: string) => {
328
+ const now = Date.now();
329
+ const t = typeaheadRef.current;
330
+ if (now > t.resetAt) t.buffer = "";
331
+ t.buffer += char.toLowerCase();
332
+ t.resetAt = now + 500;
333
+
334
+ const start = highlight >= 0 ? highlight : 0;
335
+ const n = filteredOptions.length;
336
+ for (let off = 1; off <= n; off++) {
337
+ const idx = (start + off) % n;
338
+ const o = filteredOptions[idx];
339
+ if (o && !o.disabled && o.label.toLowerCase().startsWith(t.buffer)) {
340
+ setHighlight(idx);
341
+ return;
342
+ }
343
+ }
344
+ if (t.buffer.length > 1) {
345
+ t.buffer = char.toLowerCase();
346
+ for (let off = 1; off <= n; off++) {
347
+ const idx = (start + off) % n;
348
+ const o = filteredOptions[idx];
349
+ if (o && !o.disabled && o.label.toLowerCase().startsWith(t.buffer)) {
350
+ setHighlight(idx);
351
+ return;
352
+ }
353
+ }
354
+ }
355
+ },
356
+ [filteredOptions, highlight],
357
+ );
358
+
359
+ // Keyboard handler for the trigger (non-searchable mode + closed
360
+ // state in searchable mode).
361
+ const onTriggerKey = (e: KeyboardEvent<HTMLButtonElement>) => {
362
+ if (disabled) return;
363
+ if (!open) {
364
+ if (e.key === "Enter" || e.key === " ") {
365
+ e.preventDefault();
366
+ openPopover();
367
+ return;
368
+ }
369
+ if (e.key === "ArrowDown") {
370
+ e.preventDefault();
371
+ openPopover(0);
372
+ return;
373
+ }
374
+ if (e.key === "ArrowUp") {
375
+ e.preventDefault();
376
+ openPopover(options.length - 1);
377
+ return;
378
+ }
379
+ if (!searchable && e.key.length === 1 && /[a-z0-9]/i.test(e.key)) {
380
+ openPopover();
381
+ typeahead(e.key);
382
+ return;
383
+ }
384
+ return;
385
+ }
386
+ // Open + on trigger (only happens when !searchable).
387
+ if (e.key === "Escape") {
388
+ e.preventDefault();
389
+ closePopover();
390
+ return;
391
+ }
392
+ if (e.key === "Tab") {
393
+ setOpen(false);
394
+ setHighlight(-1);
395
+ return;
396
+ }
397
+ if (e.key === "ArrowDown") {
398
+ e.preventDefault();
399
+ moveHighlight(1);
400
+ return;
401
+ }
402
+ if (e.key === "ArrowUp") {
403
+ e.preventDefault();
404
+ moveHighlight(-1);
405
+ return;
406
+ }
407
+ if (e.key === "Home") {
408
+ e.preventDefault();
409
+ moveHighlight(1, -1);
410
+ return;
411
+ }
412
+ if (e.key === "End") {
413
+ e.preventDefault();
414
+ moveHighlight(-1, options.length);
415
+ return;
416
+ }
417
+ if (e.key === "Enter") {
418
+ e.preventDefault();
419
+ if (highlight >= 0) commit(highlight);
420
+ return;
421
+ }
422
+ if (e.key.length === 1 && /[a-z0-9]/i.test(e.key)) {
423
+ e.preventDefault();
424
+ typeahead(e.key);
425
+ return;
426
+ }
427
+ };
428
+
429
+ // Keyboard handler for the search input (searchable mode, open).
430
+ const onSearchKey = (e: KeyboardEvent<HTMLInputElement>) => {
431
+ if (e.key === "Escape") {
432
+ e.preventDefault();
433
+ closePopover();
434
+ return;
435
+ }
436
+ if (e.key === "Tab") {
437
+ setOpen(false);
438
+ setHighlight(-1);
439
+ return;
440
+ }
441
+ if (e.key === "ArrowDown") {
442
+ e.preventDefault();
443
+ moveHighlight(1);
444
+ return;
445
+ }
446
+ if (e.key === "ArrowUp") {
447
+ e.preventDefault();
448
+ moveHighlight(-1);
449
+ return;
450
+ }
451
+ if (e.key === "Home") {
452
+ e.preventDefault();
453
+ moveHighlight(1, -1);
454
+ return;
455
+ }
456
+ if (e.key === "End") {
457
+ e.preventDefault();
458
+ moveHighlight(-1, filteredOptions.length);
459
+ return;
460
+ }
461
+ if (e.key === "Enter") {
462
+ e.preventDefault();
463
+ if (highlight >= 0) commit(highlight);
464
+ return;
465
+ }
466
+ // Plain typing flows to the input value via React's onChange.
467
+ };
468
+
469
+ const triggerLabel = selectedOption ? selectedOption.label : placeholder ?? "Select…";
470
+ const showPlaceholder = !selectedOption;
471
+
472
+ const popoverNode = open && rect && (
473
+ <div
474
+ ref={popoverRef}
475
+ className="ck-select-popover"
476
+ style={{
477
+ position: "fixed",
478
+ top: rect.top,
479
+ left: rect.left,
480
+ width: rect.width,
481
+ zIndex: 1000,
482
+ background: "var(--ck-bg-surface)",
483
+ border: "1px solid var(--ck-border-strong)",
484
+ borderRadius: "var(--ck-radius-sm)",
485
+ boxShadow: "var(--ck-shadow-3, 0 16px 48px rgba(0,0,0,0.12))",
486
+ // Popover constrained vertically by maxOptionsHeight + an
487
+ // allowance for the search row. The listbox itself scrolls.
488
+ maxHeight: maxOptionsHeight + (searchable ? 48 : 0),
489
+ display: "flex",
490
+ flexDirection: "column",
491
+ padding: 4,
492
+ }}
493
+ >
494
+ {searchable && (
495
+ <div
496
+ style={{
497
+ display: "flex",
498
+ alignItems: "center",
499
+ padding: "4px 6px",
500
+ borderBottom: "1px solid var(--ck-border-subtle)",
501
+ marginBottom: 4,
502
+ }}
503
+ >
504
+ <svg
505
+ width="12"
506
+ height="12"
507
+ viewBox="0 0 24 24"
508
+ fill="none"
509
+ stroke="currentColor"
510
+ strokeWidth="2"
511
+ strokeLinecap="round"
512
+ strokeLinejoin="round"
513
+ aria-hidden
514
+ style={{ color: "var(--ck-text-tertiary)", marginRight: 6, flexShrink: 0 }}
515
+ >
516
+ <circle cx="11" cy="11" r="7" />
517
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
518
+ </svg>
519
+ <input
520
+ ref={searchInputRef}
521
+ type="text"
522
+ value={query}
523
+ onChange={(e) => setQuery(e.target.value)}
524
+ onKeyDown={onSearchKey}
525
+ placeholder={searchPlaceholder}
526
+ aria-autocomplete="list"
527
+ aria-controls={listboxId}
528
+ aria-activedescendant={
529
+ highlight >= 0 ? `${listboxId}-opt-${highlight}` : undefined
530
+ }
531
+ className="ck-select-search"
532
+ style={{
533
+ flex: "1 1 auto",
534
+ minWidth: 0,
535
+ height: 28,
536
+ padding: "0 4px",
537
+ border: "none",
538
+ outline: "none",
539
+ background: "transparent",
540
+ color: "var(--ck-text-primary)",
541
+ font: "400 13px/1 var(--ck-font-sans)",
542
+ }}
543
+ />
544
+ </div>
545
+ )}
546
+ <ul
547
+ id={listboxId}
548
+ role="listbox"
549
+ aria-labelledby={triggerId}
550
+ style={{
551
+ listStyle: "none",
552
+ margin: 0,
553
+ padding: 0,
554
+ overflowY: "auto",
555
+ maxHeight: maxOptionsHeight,
556
+ }}
557
+ >
558
+ {filteredOptions.map((opt, i) => {
559
+ const selected = opt.value === value;
560
+ const highlighted = i === highlight;
561
+ return (
562
+ <li
563
+ ref={(el) => {
564
+ optionRefs.current[i] = el;
565
+ }}
566
+ key={opt.value}
567
+ id={`${listboxId}-opt-${i}`}
568
+ role="option"
569
+ aria-selected={selected}
570
+ aria-disabled={opt.disabled}
571
+ onMouseEnter={() => !opt.disabled && setHighlight(i)}
572
+ onMouseDown={(e) => e.preventDefault()}
573
+ onClick={() => commit(i)}
574
+ className={cn(
575
+ "ck-select-option",
576
+ selected && "ck-select-option--selected",
577
+ highlighted && "ck-select-option--active",
578
+ opt.disabled && "ck-select-option--disabled",
579
+ )}
580
+ style={{
581
+ padding: "8px 10px",
582
+ borderRadius: "calc(var(--ck-radius-sm) - 2px)",
583
+ cursor: opt.disabled ? "not-allowed" : "pointer",
584
+ color: opt.disabled
585
+ ? "var(--ck-text-tertiary)"
586
+ : "var(--ck-text-primary)",
587
+ background: highlighted ? "var(--ck-bg-muted)" : "transparent",
588
+ font: "400 13px/1.2 var(--ck-font-sans)",
589
+ display: "flex",
590
+ alignItems: "center",
591
+ justifyContent: "space-between",
592
+ gap: 8,
593
+ transition: "background var(--ck-dur-fast) var(--ck-ease)",
594
+ }}
595
+ >
596
+ <span
597
+ style={{
598
+ overflow: "hidden",
599
+ textOverflow: "ellipsis",
600
+ whiteSpace: "nowrap",
601
+ }}
602
+ >
603
+ {opt.label}
604
+ </span>
605
+ {selected && (
606
+ <svg
607
+ width="12"
608
+ height="12"
609
+ viewBox="0 0 24 24"
610
+ fill="none"
611
+ stroke="currentColor"
612
+ strokeWidth="2.4"
613
+ strokeLinecap="round"
614
+ strokeLinejoin="round"
615
+ aria-hidden
616
+ style={{ color: "var(--ck-accent)", flexShrink: 0 }}
617
+ >
618
+ <polyline points="20 6 9 17 4 12" />
619
+ </svg>
620
+ )}
621
+ </li>
622
+ );
623
+ })}
624
+ {filteredOptions.length === 0 && (
625
+ <li
626
+ role="presentation"
627
+ style={{
628
+ padding: "8px 10px",
629
+ color: "var(--ck-text-tertiary)",
630
+ font: "400 13px/1.2 var(--ck-font-sans)",
631
+ }}
632
+ >
633
+ {searchable && query.trim() !== "" ? "No matches" : "No options"}
634
+ </li>
635
+ )}
636
+ </ul>
637
+ </div>
638
+ );
639
+
640
+ return (
641
+ <div
642
+ className={cn("ck-select-field", className)}
643
+ style={{
644
+ display: "flex",
645
+ flexDirection: "column",
646
+ gap: 6,
647
+ width: fit === "full" ? "100%" : undefined,
648
+ }}
649
+ >
650
+ {label && (
651
+ <label
652
+ htmlFor={triggerId}
653
+ className="ck-eyebrow"
654
+ style={{ color: "var(--ck-text-secondary)" }}
655
+ >
656
+ {label}
657
+ </label>
658
+ )}
659
+
660
+ {name && <input type="hidden" name={name} value={value} required={required} />}
661
+
662
+ <button
663
+ ref={setTriggerRef}
664
+ id={triggerId}
665
+ type="button"
666
+ role="combobox"
667
+ aria-haspopup="listbox"
668
+ aria-expanded={open}
669
+ aria-controls={listboxId}
670
+ aria-invalid={error ? true : undefined}
671
+ aria-required={required}
672
+ disabled={disabled}
673
+ onClick={() => (open ? closePopover() : openPopover())}
674
+ onKeyDown={onTriggerKey}
675
+ className={cn(
676
+ "ck-select-trigger",
677
+ error && "ck-select-trigger--invalid",
678
+ disabled && "ck-select-trigger--disabled",
679
+ )}
680
+ style={{
681
+ ...TRIGGER_BASE_STYLE,
682
+ border: `1px solid ${error ? "var(--ck-critical)" : "var(--ck-border-strong)"}`,
683
+ color: showPlaceholder ? "var(--ck-text-tertiary)" : "var(--ck-text-primary)",
684
+ opacity: disabled ? 0.55 : 1,
685
+ }}
686
+ >
687
+ <span
688
+ style={{
689
+ overflow: "hidden",
690
+ textOverflow: "ellipsis",
691
+ whiteSpace: "nowrap",
692
+ }}
693
+ >
694
+ {triggerLabel}
695
+ </span>
696
+ <svg
697
+ width="10"
698
+ height="10"
699
+ viewBox="0 0 24 24"
700
+ fill="none"
701
+ stroke="currentColor"
702
+ strokeWidth="2.2"
703
+ strokeLinecap="round"
704
+ strokeLinejoin="round"
705
+ aria-hidden
706
+ style={{
707
+ flexShrink: 0,
708
+ opacity: 0.7,
709
+ transform: open ? "rotate(180deg)" : "rotate(0deg)",
710
+ transition: "transform 180ms var(--ck-ease)",
711
+ }}
712
+ >
713
+ <polyline points="6 9 12 15 18 9" />
714
+ </svg>
715
+ </button>
716
+
717
+ {(helper || error) && (
718
+ <div
719
+ style={{
720
+ font: "400 11px/1.4 var(--ck-font-sans)",
721
+ color: error ? "var(--ck-critical)" : "var(--ck-text-tertiary)",
722
+ }}
723
+ >
724
+ {error ?? helper}
725
+ </div>
726
+ )}
727
+
728
+ {typeof document !== "undefined" && popoverNode
729
+ ? createPortal(popoverNode, document.body)
730
+ : null}
731
+ </div>
732
+ );
733
+ });
@@ -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";
@@ -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;