@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 +1 -1
- package/src/primitives/Select.tsx +382 -161
package/package.json
CHANGED
|
@@ -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
|
|
19
|
-
// break the visual contract.
|
|
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
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
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
|
-
|
|
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
|
|
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",
|
|
148
|
-
return () => document.removeEventListener("mousedown",
|
|
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
|
-
//
|
|
159
|
-
//
|
|
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
|
|
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
|
-
|
|
293
|
+
setQuery("");
|
|
175
294
|
triggerRef.current?.focus();
|
|
176
295
|
}, []);
|
|
177
296
|
|
|
178
297
|
const commit = useCallback(
|
|
179
298
|
(idx: number) => {
|
|
180
|
-
const opt =
|
|
299
|
+
const opt = filteredOptions[idx];
|
|
181
300
|
if (!opt || opt.disabled) return;
|
|
182
301
|
onChange(opt.value);
|
|
183
302
|
closePopover();
|
|
184
303
|
},
|
|
185
|
-
[
|
|
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 =
|
|
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 (!
|
|
316
|
+
if (!filteredOptions[i]?.disabled) {
|
|
200
317
|
setHighlight(i);
|
|
201
318
|
return;
|
|
202
319
|
}
|
|
203
320
|
i += delta;
|
|
204
321
|
}
|
|
205
322
|
},
|
|
206
|
-
[
|
|
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 =
|
|
335
|
+
const n = filteredOptions.length;
|
|
222
336
|
for (let off = 1; off <= n; off++) {
|
|
223
337
|
const idx = (start + off) % n;
|
|
224
|
-
const o =
|
|
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 =
|
|
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
|
-
[
|
|
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
|
|
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
|
});
|