@cosxai/ui 0.2.5 → 0.2.7

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