@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 +1 -1
- package/src/primitives/Select.tsx +733 -0
- package/src/primitives/index.ts +2 -0
- package/src/styles/chrome-terminal.css +28 -0
- package/src/styles/index.css +25 -0
package/package.json
CHANGED
|
@@ -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
|
+
});
|
package/src/primitives/index.ts
CHANGED
|
@@ -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 {
|
package/src/styles/index.css
CHANGED
|
@@ -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;
|