@cosxai/ui 0.2.5 → 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.5",
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",
@@ -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,55 @@ 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 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).
33
43
  //
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.
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
40
63
 
41
64
  export interface SelectOption {
42
65
  value: string;
@@ -63,6 +86,12 @@ export interface SelectProps {
63
86
  className?: string | undefined;
64
87
  // Max height of the popover. Long lists scroll inside.
65
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;
66
95
  }
67
96
 
68
97
  const TRIGGER_BASE_STYLE: CSSProperties = {
@@ -83,6 +112,17 @@ const TRIGGER_BASE_STYLE: CSSProperties = {
83
112
  transition: "border-color var(--ck-dur-fast) var(--ck-ease)",
84
113
  };
85
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
+
86
126
  export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select(
87
127
  {
88
128
  value,
@@ -99,6 +139,8 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
99
139
  id,
100
140
  className,
101
141
  maxOptionsHeight = 280,
142
+ searchable = false,
143
+ searchPlaceholder = "Search…",
102
144
  },
103
145
  ref,
104
146
  ) {
@@ -107,18 +149,29 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
107
149
  const listboxId = `${autoId}-listbox`;
108
150
 
109
151
  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
152
  const [highlight, setHighlight] = useState(-1);
153
+ const [query, setQuery] = useState("");
154
+ const [rect, setRect] = useState<PopoverRect | null>(null);
155
+
113
156
  const triggerRef = useRef<HTMLButtonElement | null>(null);
114
157
  const popoverRef = useRef<HTMLDivElement | null>(null);
158
+ const searchInputRef = useRef<HTMLInputElement | null>(null);
115
159
  const optionRefs = useRef<(HTMLLIElement | null)[]>([]);
116
- // Typeahead buffer + reset timer for "press a letter to jump".
117
160
  const typeaheadRef = useRef<{ buffer: string; resetAt: number }>({
118
161
  buffer: "",
119
162
  resetAt: 0,
120
163
  });
121
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
+
122
175
  const selectedIndex = useMemo(
123
176
  () => options.findIndex((o) => o.value === value),
124
177
  [options, value],
@@ -134,18 +187,49 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
134
187
  [ref],
135
188
  );
136
189
 
137
- // Close on outside click + Esc.
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
+
138
209
  useEffect(() => {
139
210
  if (!open) return;
140
- const onDocClick = (e: MouseEvent) => {
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) => {
141
225
  const t = e.target as Node | null;
142
226
  if (!t) return;
143
227
  if (triggerRef.current?.contains(t)) return;
144
228
  if (popoverRef.current?.contains(t)) return;
145
229
  setOpen(false);
146
230
  };
147
- document.addEventListener("mousedown", onDocClick);
148
- return () => document.removeEventListener("mousedown", onDocClick);
231
+ document.addEventListener("mousedown", onDocMouseDown);
232
+ return () => document.removeEventListener("mousedown", onDocMouseDown);
149
233
  }, [open]);
150
234
 
151
235
  // Auto-scroll highlighted option into view inside the popover.
@@ -155,14 +239,49 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
155
239
  if (el) el.scrollIntoView({ block: "nearest" });
156
240
  }, [open, highlight]);
157
241
 
158
- // When opening, seed the highlight to the currently selected option
159
- // so the user lands on what they've already chosen.
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
+
160
274
  const openPopover = useCallback(
161
275
  (startHighlight?: number) => {
162
276
  if (disabled) return;
163
277
  setOpen(true);
278
+ setQuery("");
164
279
  setHighlight(
165
- startHighlight !== undefined ? startHighlight : selectedIndex >= 0 ? selectedIndex : 0,
280
+ startHighlight !== undefined
281
+ ? startHighlight
282
+ : selectedIndex >= 0
283
+ ? selectedIndex
284
+ : 0,
166
285
  );
167
286
  },
168
287
  [disabled, selectedIndex],
@@ -171,44 +290,39 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
171
290
  const closePopover = useCallback(() => {
172
291
  setOpen(false);
173
292
  setHighlight(-1);
174
- // Re-focus the trigger so keyboard users stay on context.
293
+ setQuery("");
175
294
  triggerRef.current?.focus();
176
295
  }, []);
177
296
 
178
297
  const commit = useCallback(
179
298
  (idx: number) => {
180
- const opt = options[idx];
299
+ const opt = filteredOptions[idx];
181
300
  if (!opt || opt.disabled) return;
182
301
  onChange(opt.value);
183
302
  closePopover();
184
303
  },
185
- [options, onChange, closePopover],
304
+ [filteredOptions, onChange, closePopover],
186
305
  );
187
306
 
188
307
  // Move highlight skipping disabled rows.
189
308
  const moveHighlight = useCallback(
190
309
  (delta: 1 | -1, from?: number) => {
191
- const n = options.length;
310
+ const n = filteredOptions.length;
192
311
  if (n === 0) return;
193
312
  let i = (from ?? highlight) + delta;
194
- // Loop max n times to avoid infinite hunt when ALL options
195
- // are disabled.
196
313
  for (let attempt = 0; attempt < n; attempt++) {
197
314
  if (i < 0) i = n - 1;
198
315
  if (i >= n) i = 0;
199
- if (!options[i]?.disabled) {
316
+ if (!filteredOptions[i]?.disabled) {
200
317
  setHighlight(i);
201
318
  return;
202
319
  }
203
320
  i += delta;
204
321
  }
205
322
  },
206
- [options, highlight],
323
+ [filteredOptions, highlight],
207
324
  );
208
325
 
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
326
  const typeahead = useCallback(
213
327
  (char: string) => {
214
328
  const now = Date.now();
@@ -218,21 +332,20 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
218
332
  t.resetAt = now + 500;
219
333
 
220
334
  const start = highlight >= 0 ? highlight : 0;
221
- const n = options.length;
335
+ const n = filteredOptions.length;
222
336
  for (let off = 1; off <= n; off++) {
223
337
  const idx = (start + off) % n;
224
- const o = options[idx];
338
+ const o = filteredOptions[idx];
225
339
  if (o && !o.disabled && o.label.toLowerCase().startsWith(t.buffer)) {
226
340
  setHighlight(idx);
227
341
  return;
228
342
  }
229
343
  }
230
- // No prefix match — try single-char from current.
231
344
  if (t.buffer.length > 1) {
232
345
  t.buffer = char.toLowerCase();
233
346
  for (let off = 1; off <= n; off++) {
234
347
  const idx = (start + off) % n;
235
- const o = options[idx];
348
+ const o = filteredOptions[idx];
236
349
  if (o && !o.disabled && o.label.toLowerCase().startsWith(t.buffer)) {
237
350
  setHighlight(idx);
238
351
  return;
@@ -240,9 +353,11 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
240
353
  }
241
354
  }
242
355
  },
243
- [options, highlight],
356
+ [filteredOptions, highlight],
244
357
  );
245
358
 
359
+ // Keyboard handler for the trigger (non-searchable mode + closed
360
+ // state in searchable mode).
246
361
  const onTriggerKey = (e: KeyboardEvent<HTMLButtonElement>) => {
247
362
  if (disabled) return;
248
363
  if (!open) {
@@ -261,21 +376,20 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
261
376
  openPopover(options.length - 1);
262
377
  return;
263
378
  }
264
- if (e.key.length === 1 && /[a-z0-9]/i.test(e.key)) {
379
+ if (!searchable && e.key.length === 1 && /[a-z0-9]/i.test(e.key)) {
265
380
  openPopover();
266
381
  typeahead(e.key);
267
382
  return;
268
383
  }
269
384
  return;
270
385
  }
271
- // Open
386
+ // Open + on trigger (only happens when !searchable).
272
387
  if (e.key === "Escape") {
273
388
  e.preventDefault();
274
389
  closePopover();
275
390
  return;
276
391
  }
277
392
  if (e.key === "Tab") {
278
- // Close but DON'T preventDefault — let focus advance.
279
393
  setOpen(false);
280
394
  setHighlight(-1);
281
395
  return;
@@ -312,9 +426,217 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
312
426
  }
313
427
  };
314
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
+
315
469
  const triggerLabel = selectedOption ? selectedOption.label : placeholder ?? "Select…";
316
470
  const showPlaceholder = !selectedOption;
317
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
+
318
640
  return (
319
641
  <div
320
642
  className={cn("ck-select-field", className)}
@@ -323,7 +645,6 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
323
645
  flexDirection: "column",
324
646
  gap: 6,
325
647
  width: fit === "full" ? "100%" : undefined,
326
- position: "relative",
327
648
  }}
328
649
  >
329
650
  {label && (
@@ -336,9 +657,6 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
336
657
  </label>
337
658
  )}
338
659
 
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
660
  {name && <input type="hidden" name={name} value={value} required={required} />}
343
661
 
344
662
  <button
@@ -366,7 +684,13 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
366
684
  opacity: disabled ? 0.55 : 1,
367
685
  }}
368
686
  >
369
- <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
687
+ <span
688
+ style={{
689
+ overflow: "hidden",
690
+ textOverflow: "ellipsis",
691
+ whiteSpace: "nowrap",
692
+ }}
693
+ >
370
694
  {triggerLabel}
371
695
  </span>
372
696
  <svg
@@ -390,113 +714,6 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
390
714
  </svg>
391
715
  </button>
392
716
 
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
717
  {(helper || error) && (
501
718
  <div
502
719
  style={{
@@ -507,6 +724,10 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
507
724
  {error ?? helper}
508
725
  </div>
509
726
  )}
727
+
728
+ {typeof document !== "undefined" && popoverNode
729
+ ? createPortal(popoverNode, document.body)
730
+ : null}
510
731
  </div>
511
732
  );
512
733
  });