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