@cosxai/ui 0.24.0 → 1.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +577 -0
  2. package/NOTICE +11 -0
  3. package/README.md +52 -0
  4. package/package.json +33 -7
  5. package/src/fonts.css +20 -0
  6. package/src/index.ts +8 -25
  7. package/src/lib/cn.ts +23 -6
  8. package/src/styles.css +17 -0
  9. package/src/theme.css +132 -0
  10. package/src/tokens/colors.css +112 -0
  11. package/src/tokens/compat.css +36 -0
  12. package/src/tokens/elevation.css +14 -0
  13. package/src/tokens/motion.css +42 -0
  14. package/src/tokens/spacing.css +55 -0
  15. package/src/tokens/typography-cjk.css +42 -0
  16. package/src/tokens/typography.css +81 -0
  17. package/src/actionbar/ActionBar.tsx +0 -837
  18. package/src/actionbar/ActionBarButton.tsx +0 -111
  19. package/src/actionbar/ActionBarMenuGroup.tsx +0 -116
  20. package/src/actionbar/ActionBarModeHandle.tsx +0 -171
  21. package/src/actionbar/ActionBarProvider.tsx +0 -91
  22. package/src/actionbar/actionbar-context.ts +0 -45
  23. package/src/actionbar/index.ts +0 -28
  24. package/src/actionbar/types.ts +0 -147
  25. package/src/actionbar/useActionBarItems.ts +0 -60
  26. package/src/actionbar/useActionBarMode.ts +0 -144
  27. package/src/actionbar/useActionBarPanel.ts +0 -56
  28. package/src/actionbar/useActionBarStatusDot.ts +0 -49
  29. package/src/actionbar/useActionBarToast.ts +0 -33
  30. package/src/ambient/AmbientBackdrop.tsx +0 -74
  31. package/src/ambient/CommandInput.tsx +0 -107
  32. package/src/ambient/SuperbarStrip.tsx +0 -36
  33. package/src/ambient/index.ts +0 -6
  34. package/src/bento/BentoCell.tsx +0 -66
  35. package/src/bento/BentoGrid.tsx +0 -42
  36. package/src/bento/index.ts +0 -2
  37. package/src/command/CommandPalette.tsx +0 -329
  38. package/src/command/CommandProvider.tsx +0 -57
  39. package/src/command/command-context.ts +0 -12
  40. package/src/command/index.ts +0 -6
  41. package/src/command/rank.ts +0 -50
  42. package/src/command/types.ts +0 -37
  43. package/src/command/useCommandSource.ts +0 -37
  44. package/src/dialogs/DialogsProvider.tsx +0 -216
  45. package/src/dialogs/Modal.tsx +0 -260
  46. package/src/dialogs/Toast.tsx +0 -85
  47. package/src/dialogs/dialogs-context.ts +0 -6
  48. package/src/dialogs/index.ts +0 -10
  49. package/src/dialogs/types.ts +0 -37
  50. package/src/dialogs/useDialogs.ts +0 -8
  51. package/src/editorial/EditorialSpotlight.tsx +0 -63
  52. package/src/editorial/Folio.tsx +0 -52
  53. package/src/editorial/PlateMarker.tsx +0 -33
  54. package/src/editorial/RomanSection.tsx +0 -65
  55. package/src/editorial/RunningMarginalia.tsx +0 -65
  56. package/src/editorial/index.ts +0 -10
  57. package/src/hooks/index.ts +0 -5
  58. package/src/hooks/useKeyboardHotkey.ts +0 -93
  59. package/src/hooks/useReducedMotion.ts +0 -20
  60. package/src/hooks/useViewport.ts +0 -61
  61. package/src/layout/Breadcrumb.tsx +0 -74
  62. package/src/layout/LeftNavRail.tsx +0 -139
  63. package/src/layout/MobileTabBar.tsx +0 -101
  64. package/src/layout/NavItem.tsx +0 -128
  65. package/src/layout/NavSearchTrigger.tsx +0 -88
  66. package/src/layout/NavSection.tsx +0 -40
  67. package/src/layout/RightSidebarPanel.tsx +0 -111
  68. package/src/layout/Shell.tsx +0 -91
  69. package/src/layout/SidePanel.tsx +0 -316
  70. package/src/layout/StickyBanner.tsx +0 -83
  71. package/src/layout/Topbar.tsx +0 -68
  72. package/src/layout/index.ts +0 -24
  73. package/src/layout/useNavRailState.ts +0 -69
  74. package/src/lib/time-utils.ts +0 -44
  75. package/src/neobrutalism/Marquee.tsx +0 -81
  76. package/src/neobrutalism/Sticker.tsx +0 -71
  77. package/src/neobrutalism/index.ts +0 -4
  78. package/src/primitives/Avatar.tsx +0 -53
  79. package/src/primitives/Button.tsx +0 -67
  80. package/src/primitives/Card.tsx +0 -41
  81. package/src/primitives/Checkbox.tsx +0 -87
  82. package/src/primitives/Chip.tsx +0 -177
  83. package/src/primitives/Combobox.tsx +0 -475
  84. package/src/primitives/CopyField.tsx +0 -139
  85. package/src/primitives/CountBadge.tsx +0 -50
  86. package/src/primitives/Input.tsx +0 -161
  87. package/src/primitives/Kbd.tsx +0 -45
  88. package/src/primitives/MentionCombobox.tsx +0 -612
  89. package/src/primitives/MultiCombobox.tsx +0 -179
  90. package/src/primitives/PageHeader.tsx +0 -77
  91. package/src/primitives/Radio.tsx +0 -131
  92. package/src/primitives/SegmentedControl.tsx +0 -135
  93. package/src/primitives/Select.tsx +0 -779
  94. package/src/primitives/SignaturePad.tsx +0 -461
  95. package/src/primitives/Tag.tsx +0 -56
  96. package/src/primitives/Textarea.tsx +0 -62
  97. package/src/primitives/ToggleSwitch.tsx +0 -79
  98. package/src/primitives/Tooltip.tsx +0 -213
  99. package/src/primitives/index.ts +0 -48
  100. package/src/pwa/InstallPromptBanner.tsx +0 -132
  101. package/src/pwa/index.ts +0 -4
  102. package/src/pwa/manifest.template.json +0 -20
  103. package/src/pwa/registerSW.ts +0 -55
  104. package/src/riso/Halftone.tsx +0 -85
  105. package/src/riso/Misregister.tsx +0 -63
  106. package/src/riso/RisoStamp.tsx +0 -76
  107. package/src/riso/index.ts +0 -3
  108. package/src/sketch/HandUnderline.tsx +0 -53
  109. package/src/sketch/RoughArrow.tsx +0 -91
  110. package/src/sketch/RoughBox.tsx +0 -73
  111. package/src/sketch/StickyNote.tsx +0 -56
  112. package/src/sketch/index.ts +0 -4
  113. package/src/styles/base.css +0 -186
  114. package/src/styles/chrome-ambient.css +0 -235
  115. package/src/styles/chrome-bento.css +0 -192
  116. package/src/styles/chrome-editorial.css +0 -168
  117. package/src/styles/chrome-neobrutalism.css +0 -326
  118. package/src/styles/chrome-riso.css +0 -339
  119. package/src/styles/chrome-sketch.css +0 -362
  120. package/src/styles/chrome-swiss.css +0 -265
  121. package/src/styles/chrome-terminal.css +0 -272
  122. package/src/styles/fonts.css +0 -1034
  123. package/src/styles/index.css +0 -306
  124. package/src/styles/tokens.css +0 -941
  125. package/src/terminal/AsciiBox.tsx +0 -65
  126. package/src/terminal/BrailleSpinner.tsx +0 -46
  127. package/src/terminal/index.ts +0 -4
  128. package/src/theme/ThemeProvider.tsx +0 -99
  129. package/src/theme/index.ts +0 -6
  130. package/src/theme/inline-script.ts +0 -40
  131. package/src/theme/theme-context.ts +0 -7
  132. package/src/theme/types.ts +0 -39
  133. package/src/theme/useTheme.ts +0 -8
@@ -1,779 +0,0 @@
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 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.
44
- //
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
64
-
65
- export interface SelectOption {
66
- value: string;
67
- label: string;
68
- // Optional disabled flag — the option renders dimmed and isn't
69
- // selectable via click / keyboard. Useful for "Coming soon" rows.
70
- disabled?: boolean | undefined;
71
- }
72
-
73
- export interface SelectProps {
74
- value: string;
75
- onChange: (value: string) => void;
76
- options: SelectOption[];
77
- label?: ReactNode | undefined;
78
- helper?: ReactNode | undefined;
79
- error?: string | null | undefined;
80
- placeholder?: string | undefined;
81
- // full = stretches to container width; auto = intrinsic.
82
- fit?: "full" | "auto" | undefined;
83
- disabled?: boolean | undefined;
84
- required?: boolean | undefined;
85
- name?: string | undefined;
86
- id?: string | undefined;
87
- className?: string | undefined;
88
- // Max height of the popover. Long lists scroll inside.
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;
96
- }
97
-
98
- const TRIGGER_BASE_STYLE: CSSProperties = {
99
- display: "inline-flex",
100
- alignItems: "center",
101
- justifyContent: "space-between",
102
- gap: 8,
103
- width: "100%",
104
- height: 36,
105
- padding: "0 12px",
106
- font: "400 13px/1 var(--ck-font-sans)",
107
- background: "var(--ck-bg-surface)",
108
- color: "var(--ck-text-primary)",
109
- borderRadius: "var(--ck-radius-sm)",
110
- outline: "none",
111
- textAlign: "left",
112
- cursor: "pointer",
113
- transition: "border-color var(--ck-dur-fast) var(--ck-ease)",
114
- };
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
- /** Set when the menu opens UPWARD (trigger near the viewport
126
- * bottom): `top` is then the popover's bottom edge expressed as
127
- * a CSS `bottom` inset. */
128
- openUp: boolean;
129
- }
130
-
131
- export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select(
132
- {
133
- value,
134
- onChange,
135
- options,
136
- label,
137
- helper,
138
- error,
139
- placeholder,
140
- fit = "full",
141
- disabled,
142
- required,
143
- name,
144
- id,
145
- className,
146
- maxOptionsHeight = 280,
147
- searchable = false,
148
- searchPlaceholder = "Search…",
149
- },
150
- ref,
151
- ) {
152
- const autoId = useId();
153
- const triggerId = id ?? `${autoId}-trigger`;
154
- const listboxId = `${autoId}-listbox`;
155
-
156
- const [open, setOpen] = useState(false);
157
- const [highlight, setHighlight] = useState(-1);
158
- const [query, setQuery] = useState("");
159
- const [rect, setRect] = useState<PopoverRect | null>(null);
160
-
161
- const triggerRef = useRef<HTMLButtonElement | null>(null);
162
- const popoverRef = useRef<HTMLDivElement | null>(null);
163
- const searchInputRef = useRef<HTMLInputElement | null>(null);
164
- const optionRefs = useRef<(HTMLLIElement | null)[]>([]);
165
- const typeaheadRef = useRef<{ buffer: string; resetAt: number }>({
166
- buffer: "",
167
- resetAt: 0,
168
- });
169
-
170
- // Filtered options when searchable; identical otherwise. Filter
171
- // by case-insensitive label substring. Disabled options remain
172
- // visible but unselectable; consumers can pre-filter their
173
- // options array if they want them hidden when search is non-empty.
174
- const filteredOptions = useMemo(() => {
175
- if (!searchable || query.trim() === "") return options;
176
- const q = query.trim().toLowerCase();
177
- return options.filter((o) => o.label.toLowerCase().includes(q));
178
- }, [searchable, options, query]);
179
-
180
- const selectedIndex = useMemo(
181
- () => options.findIndex((o) => o.value === value),
182
- [options, value],
183
- );
184
- const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : undefined;
185
-
186
- const setTriggerRef = useCallback(
187
- (el: HTMLButtonElement | null) => {
188
- triggerRef.current = el;
189
- if (typeof ref === "function") ref(el);
190
- else if (ref) ref.current = el;
191
- },
192
- [ref],
193
- );
194
-
195
- const computeRect = useCallback((): PopoverRect | null => {
196
- const el = triggerRef.current;
197
- if (!el) return null;
198
- const r = el.getBoundingClientRect();
199
- // Flip upward when the space below can't fit the menu but the
200
- // space above can (QA 2026-08-20: the last options of a Select
201
- // at the bottom of a drawer sat under the fixed action bar).
202
- // Worst-case menu height; the listbox scrolls inside either way.
203
- const needed = maxOptionsHeight + (searchable ? 48 : 0) + POPOVER_GAP;
204
- const below = window.innerHeight - r.bottom;
205
- const openUp = below < needed && r.top > below;
206
- return {
207
- top: openUp ? window.innerHeight - r.top + POPOVER_GAP : r.bottom + POPOVER_GAP,
208
- left: r.left,
209
- width: r.width,
210
- openUp,
211
- };
212
- }, [maxOptionsHeight, searchable]);
213
-
214
- // Position the popover on open + on window resize. Close on
215
- // page scroll — keeps the popover from drifting off the trigger
216
- // while the user pages around.
217
- useLayoutEffect(() => {
218
- if (!open) return;
219
- setRect(computeRect());
220
- }, [open, computeRect]);
221
-
222
- useEffect(() => {
223
- if (!open) return;
224
- const reposition = () => {
225
- const r = computeRect();
226
- if (r) setRect(r);
227
- };
228
- const onResize = reposition;
229
- const onScroll = (e: Event) => {
230
- // Scrolls INSIDE the popover (the option list / search input)
231
- // must not re-trigger positioning — they're not page scrolls.
232
- // capture=true catches them before they bubble; we filter here.
233
- const target = e.target as Node | null;
234
- if (target && popoverRef.current?.contains(target)) return;
235
- reposition();
236
- };
237
- window.addEventListener("resize", onResize);
238
- window.addEventListener("scroll", onScroll, true);
239
- return () => {
240
- window.removeEventListener("resize", onResize);
241
- window.removeEventListener("scroll", onScroll, true);
242
- };
243
- }, [open, computeRect]);
244
-
245
- // Close on outside click + Esc (Esc handled in onKey below).
246
- useEffect(() => {
247
- if (!open) return;
248
- const onDocMouseDown = (e: MouseEvent) => {
249
- const t = e.target as Node | null;
250
- if (!t) return;
251
- if (triggerRef.current?.contains(t)) return;
252
- if (popoverRef.current?.contains(t)) return;
253
- setOpen(false);
254
- };
255
- document.addEventListener("mousedown", onDocMouseDown);
256
- return () => document.removeEventListener("mousedown", onDocMouseDown);
257
- }, [open]);
258
-
259
- // Auto-scroll highlighted option into view inside the popover.
260
- useEffect(() => {
261
- if (!open || highlight < 0) return;
262
- const el = optionRefs.current[highlight];
263
- if (el) el.scrollIntoView({ block: "nearest" });
264
- }, [open, highlight]);
265
-
266
- // Focus management on open:
267
- // searchable=true → focus the search input
268
- // searchable=false → trigger keeps focus (kbd handler lives there)
269
- useEffect(() => {
270
- if (!open) return;
271
- if (searchable) {
272
- // requestAnimationFrame to let the portal mount before focusing.
273
- const raf = requestAnimationFrame(() => searchInputRef.current?.focus());
274
- return () => cancelAnimationFrame(raf);
275
- }
276
- }, [open, searchable]);
277
-
278
- // When the filtered list changes (user types), reset highlight
279
- // to the first selectable option. Otherwise the highlight could
280
- // point at an index that no longer exists.
281
- useEffect(() => {
282
- if (!open) return;
283
- if (filteredOptions.length === 0) {
284
- setHighlight(-1);
285
- return;
286
- }
287
- // Try to keep the existing selection visible; else first
288
- // non-disabled option.
289
- const selectedInFiltered = filteredOptions.findIndex((o) => o.value === value);
290
- if (selectedInFiltered >= 0) {
291
- setHighlight(selectedInFiltered);
292
- } else {
293
- const firstEnabled = filteredOptions.findIndex((o) => !o.disabled);
294
- setHighlight(firstEnabled >= 0 ? firstEnabled : 0);
295
- }
296
- }, [open, filteredOptions, value]);
297
-
298
- const openPopover = useCallback(
299
- (startHighlight?: number) => {
300
- if (disabled) return;
301
- setOpen(true);
302
- setQuery("");
303
- setHighlight(
304
- startHighlight !== undefined
305
- ? startHighlight
306
- : selectedIndex >= 0
307
- ? selectedIndex
308
- : 0,
309
- );
310
- },
311
- [disabled, selectedIndex],
312
- );
313
-
314
- const closePopover = useCallback(() => {
315
- setOpen(false);
316
- setHighlight(-1);
317
- setQuery("");
318
- triggerRef.current?.focus();
319
- }, []);
320
-
321
- const commit = useCallback(
322
- (idx: number) => {
323
- const opt = filteredOptions[idx];
324
- if (!opt || opt.disabled) return;
325
- onChange(opt.value);
326
- closePopover();
327
- },
328
- [filteredOptions, onChange, closePopover],
329
- );
330
-
331
- // Move highlight skipping disabled rows.
332
- const moveHighlight = useCallback(
333
- (delta: 1 | -1, from?: number) => {
334
- const n = filteredOptions.length;
335
- if (n === 0) return;
336
- let i = (from ?? highlight) + delta;
337
- for (let attempt = 0; attempt < n; attempt++) {
338
- if (i < 0) i = n - 1;
339
- if (i >= n) i = 0;
340
- if (!filteredOptions[i]?.disabled) {
341
- setHighlight(i);
342
- return;
343
- }
344
- i += delta;
345
- }
346
- },
347
- [filteredOptions, highlight],
348
- );
349
-
350
- const typeahead = useCallback(
351
- (char: string) => {
352
- const now = Date.now();
353
- const t = typeaheadRef.current;
354
- if (now > t.resetAt) t.buffer = "";
355
- t.buffer += char.toLowerCase();
356
- t.resetAt = now + 500;
357
-
358
- const start = highlight >= 0 ? highlight : 0;
359
- const n = filteredOptions.length;
360
- for (let off = 1; off <= n; off++) {
361
- const idx = (start + off) % n;
362
- const o = filteredOptions[idx];
363
- if (o && !o.disabled && o.label.toLowerCase().startsWith(t.buffer)) {
364
- setHighlight(idx);
365
- return;
366
- }
367
- }
368
- if (t.buffer.length > 1) {
369
- t.buffer = char.toLowerCase();
370
- for (let off = 1; off <= n; off++) {
371
- const idx = (start + off) % n;
372
- const o = filteredOptions[idx];
373
- if (o && !o.disabled && o.label.toLowerCase().startsWith(t.buffer)) {
374
- setHighlight(idx);
375
- return;
376
- }
377
- }
378
- }
379
- },
380
- [filteredOptions, highlight],
381
- );
382
-
383
- // Keyboard handler for the trigger (non-searchable mode + closed
384
- // state in searchable mode).
385
- const onTriggerKey = (e: KeyboardEvent<HTMLButtonElement>) => {
386
- if (disabled) return;
387
- if (!open) {
388
- if (e.key === "Enter" || e.key === " ") {
389
- e.preventDefault();
390
- openPopover();
391
- return;
392
- }
393
- if (e.key === "ArrowDown") {
394
- e.preventDefault();
395
- openPopover(0);
396
- return;
397
- }
398
- if (e.key === "ArrowUp") {
399
- e.preventDefault();
400
- openPopover(options.length - 1);
401
- return;
402
- }
403
- if (!searchable && e.key.length === 1 && /[a-z0-9]/i.test(e.key)) {
404
- openPopover();
405
- typeahead(e.key);
406
- return;
407
- }
408
- return;
409
- }
410
- // Open + on trigger (only happens when !searchable).
411
- if (e.key === "Escape") {
412
- e.preventDefault();
413
- closePopover();
414
- return;
415
- }
416
- if (e.key === "Tab") {
417
- setOpen(false);
418
- setHighlight(-1);
419
- return;
420
- }
421
- if (e.key === "ArrowDown") {
422
- e.preventDefault();
423
- moveHighlight(1);
424
- return;
425
- }
426
- if (e.key === "ArrowUp") {
427
- e.preventDefault();
428
- moveHighlight(-1);
429
- return;
430
- }
431
- if (e.key === "Home") {
432
- e.preventDefault();
433
- moveHighlight(1, -1);
434
- return;
435
- }
436
- if (e.key === "End") {
437
- e.preventDefault();
438
- moveHighlight(-1, options.length);
439
- return;
440
- }
441
- if (e.key === "Enter") {
442
- e.preventDefault();
443
- if (highlight >= 0) commit(highlight);
444
- return;
445
- }
446
- if (e.key.length === 1 && /[a-z0-9]/i.test(e.key)) {
447
- e.preventDefault();
448
- typeahead(e.key);
449
- return;
450
- }
451
- };
452
-
453
- // Keyboard handler for the search input (searchable mode, open).
454
- const onSearchKey = (e: KeyboardEvent<HTMLInputElement>) => {
455
- if (e.key === "Escape") {
456
- e.preventDefault();
457
- closePopover();
458
- return;
459
- }
460
- if (e.key === "Tab") {
461
- setOpen(false);
462
- setHighlight(-1);
463
- return;
464
- }
465
- if (e.key === "ArrowDown") {
466
- e.preventDefault();
467
- moveHighlight(1);
468
- return;
469
- }
470
- if (e.key === "ArrowUp") {
471
- e.preventDefault();
472
- moveHighlight(-1);
473
- return;
474
- }
475
- if (e.key === "Home") {
476
- e.preventDefault();
477
- moveHighlight(1, -1);
478
- return;
479
- }
480
- if (e.key === "End") {
481
- e.preventDefault();
482
- moveHighlight(-1, filteredOptions.length);
483
- return;
484
- }
485
- if (e.key === "Enter") {
486
- e.preventDefault();
487
- if (highlight >= 0) commit(highlight);
488
- return;
489
- }
490
- // Plain typing flows to the input value via React's onChange.
491
- };
492
-
493
- const triggerLabel = selectedOption ? selectedOption.label : placeholder ?? "Select…";
494
- const showPlaceholder = !selectedOption;
495
-
496
- const popoverNode = open && rect && (
497
- <div
498
- ref={popoverRef}
499
- className="ck-select-popover"
500
- style={{
501
- position: "fixed",
502
- ...(rect.openUp ? { bottom: rect.top } : { top: rect.top }),
503
- left: rect.left,
504
- width: rect.width,
505
- zIndex: 1000,
506
- background: "var(--ck-bg-surface)",
507
- border: "1px solid var(--ck-border-strong)",
508
- borderRadius: "var(--ck-radius-sm)",
509
- boxShadow: "var(--ck-shadow-3, 0 16px 48px rgba(0,0,0,0.12))",
510
- // Popover constrained vertically by maxOptionsHeight + an
511
- // allowance for the search row. The listbox itself scrolls.
512
- maxHeight: maxOptionsHeight + (searchable ? 48 : 0),
513
- display: "flex",
514
- flexDirection: "column",
515
- padding: 4,
516
- }}
517
- >
518
- {searchable && (
519
- <div
520
- style={{
521
- display: "flex",
522
- alignItems: "center",
523
- padding: "4px 6px",
524
- borderBottom: "1px solid var(--ck-border-subtle)",
525
- marginBottom: 4,
526
- }}
527
- >
528
- <svg
529
- width="12"
530
- height="12"
531
- viewBox="0 0 24 24"
532
- fill="none"
533
- stroke="currentColor"
534
- strokeWidth="2"
535
- strokeLinecap="round"
536
- strokeLinejoin="round"
537
- aria-hidden
538
- style={{ color: "var(--ck-text-tertiary)", marginRight: 6, flexShrink: 0 }}
539
- >
540
- <circle cx="11" cy="11" r="7" />
541
- <line x1="21" y1="21" x2="16.65" y2="16.65" />
542
- </svg>
543
- <input
544
- ref={searchInputRef}
545
- type="text"
546
- value={query}
547
- onChange={(e) => setQuery(e.target.value)}
548
- onKeyDown={onSearchKey}
549
- placeholder={searchPlaceholder}
550
- aria-autocomplete="list"
551
- aria-controls={listboxId}
552
- aria-activedescendant={
553
- highlight >= 0 ? `${listboxId}-opt-${highlight}` : undefined
554
- }
555
- className="ck-select-search"
556
- style={{
557
- flex: "1 1 auto",
558
- minWidth: 0,
559
- height: 28,
560
- padding: "0 4px",
561
- border: "none",
562
- outline: "none",
563
- background: "transparent",
564
- color: "var(--ck-text-primary)",
565
- font: "400 13px/1 var(--ck-font-sans)",
566
- }}
567
- />
568
- </div>
569
- )}
570
- <ul
571
- id={listboxId}
572
- role="listbox"
573
- aria-labelledby={triggerId}
574
- style={{
575
- listStyle: "none",
576
- margin: 0,
577
- padding: 0,
578
- overflowY: "auto",
579
- maxHeight: maxOptionsHeight,
580
- }}
581
- >
582
- {filteredOptions.map((opt, i) => {
583
- const selected = opt.value === value;
584
- const highlighted = i === highlight;
585
- return (
586
- <li
587
- ref={(el) => {
588
- optionRefs.current[i] = el;
589
- }}
590
- key={opt.value}
591
- id={`${listboxId}-opt-${i}`}
592
- role="option"
593
- aria-selected={selected}
594
- aria-disabled={opt.disabled}
595
- onMouseEnter={() => !opt.disabled && setHighlight(i)}
596
- onMouseDown={(e) => e.preventDefault()}
597
- onClick={() => commit(i)}
598
- className={cn(
599
- "ck-select-option",
600
- selected && "ck-select-option--selected",
601
- highlighted && "ck-select-option--active",
602
- opt.disabled && "ck-select-option--disabled",
603
- )}
604
- style={{
605
- padding: "8px 10px",
606
- borderRadius: "calc(var(--ck-radius-sm) - 2px)",
607
- cursor: opt.disabled ? "not-allowed" : "pointer",
608
- color: opt.disabled
609
- ? "var(--ck-text-tertiary)"
610
- : "var(--ck-text-primary)",
611
- background: highlighted ? "var(--ck-bg-muted)" : "transparent",
612
- font: "400 13px/1.2 var(--ck-font-sans)",
613
- display: "flex",
614
- alignItems: "center",
615
- justifyContent: "space-between",
616
- gap: 8,
617
- transition: "background var(--ck-dur-fast) var(--ck-ease)",
618
- }}
619
- >
620
- <span
621
- style={{
622
- overflow: "hidden",
623
- textOverflow: "ellipsis",
624
- whiteSpace: "nowrap",
625
- }}
626
- >
627
- {opt.label}
628
- </span>
629
- {selected && (
630
- <svg
631
- width="12"
632
- height="12"
633
- viewBox="0 0 24 24"
634
- fill="none"
635
- stroke="currentColor"
636
- strokeWidth="2.4"
637
- strokeLinecap="round"
638
- strokeLinejoin="round"
639
- aria-hidden
640
- style={{ color: "var(--ck-accent)", flexShrink: 0 }}
641
- >
642
- <polyline points="20 6 9 17 4 12" />
643
- </svg>
644
- )}
645
- </li>
646
- );
647
- })}
648
- {filteredOptions.length === 0 && (
649
- <li
650
- role="presentation"
651
- style={{
652
- padding: "8px 10px",
653
- color: "var(--ck-text-tertiary)",
654
- font: "400 13px/1.2 var(--ck-font-sans)",
655
- }}
656
- >
657
- {searchable && query.trim() !== "" ? "No matches" : "No options"}
658
- </li>
659
- )}
660
- </ul>
661
- </div>
662
- );
663
-
664
- return (
665
- <div
666
- className={cn("ck-select-field", className)}
667
- style={{
668
- display: "flex",
669
- flexDirection: "column",
670
- gap: 6,
671
- width: fit === "full" ? "100%" : undefined,
672
- }}
673
- >
674
- {label && (
675
- <label
676
- htmlFor={triggerId}
677
- className="ck-eyebrow"
678
- style={{ color: "var(--ck-text-secondary)" }}
679
- >
680
- {label}
681
- </label>
682
- )}
683
-
684
- {name && <input type="hidden" name={name} value={value} required={required} />}
685
-
686
- <button
687
- ref={setTriggerRef}
688
- id={triggerId}
689
- type="button"
690
- role="combobox"
691
- aria-haspopup="listbox"
692
- aria-expanded={open}
693
- aria-controls={listboxId}
694
- aria-invalid={error ? true : undefined}
695
- aria-required={required}
696
- disabled={disabled}
697
- onClick={() => (open ? closePopover() : openPopover())}
698
- onKeyDown={onTriggerKey}
699
- className={cn(
700
- "ck-select-trigger",
701
- error && "ck-select-trigger--invalid",
702
- disabled && "ck-select-trigger--disabled",
703
- )}
704
- style={{
705
- ...TRIGGER_BASE_STYLE,
706
- border: `1px solid ${error ? "var(--ck-critical)" : "var(--ck-border-strong)"}`,
707
- color: showPlaceholder ? "var(--ck-text-tertiary)" : "var(--ck-text-primary)",
708
- opacity: disabled ? 0.55 : 1,
709
- }}
710
- >
711
- <span style={{ display: "inline-grid", overflow: "hidden" }}>
712
- {/* fit="auto" width stabiliser: every option label renders
713
- invisibly in the same grid cell, so the trigger takes
714
- the WIDEST option's width up front. Without it the
715
- trigger resized to each selection and the popover
716
- (which matches trigger width) truncated longer options
717
- to "E-sig…". Skipped for fit="full" (width is the
718
- container's) and for searchable lists (potentially
719
- hundreds of rows — and those popovers are wide anyway). */}
720
- {fit === "auto" &&
721
- !searchable &&
722
- options.map((o) => (
723
- <span
724
- key={o.value}
725
- aria-hidden
726
- style={{ gridArea: "1 / 1", visibility: "hidden", whiteSpace: "nowrap" }}
727
- >
728
- {o.label}
729
- </span>
730
- ))}
731
- <span
732
- style={{
733
- gridArea: "1 / 1",
734
- overflow: "hidden",
735
- textOverflow: "ellipsis",
736
- whiteSpace: "nowrap",
737
- }}
738
- >
739
- {triggerLabel}
740
- </span>
741
- </span>
742
- <svg
743
- width="10"
744
- height="10"
745
- viewBox="0 0 24 24"
746
- fill="none"
747
- stroke="currentColor"
748
- strokeWidth="2.2"
749
- strokeLinecap="round"
750
- strokeLinejoin="round"
751
- aria-hidden
752
- style={{
753
- flexShrink: 0,
754
- opacity: 0.7,
755
- transform: open ? "rotate(180deg)" : "rotate(0deg)",
756
- transition: "transform 180ms var(--ck-ease)",
757
- }}
758
- >
759
- <polyline points="6 9 12 15 18 9" />
760
- </svg>
761
- </button>
762
-
763
- {(helper || error) && (
764
- <div
765
- style={{
766
- font: "400 11px/1.4 var(--ck-font-sans)",
767
- color: error ? "var(--ck-critical)" : "var(--ck-text-tertiary)",
768
- }}
769
- >
770
- {error ?? helper}
771
- </div>
772
- )}
773
-
774
- {typeof document !== "undefined" && popoverNode
775
- ? createPortal(popoverNode, document.body)
776
- : null}
777
- </div>
778
- );
779
- });