@hilum/ui 3.3.4 → 3.5.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 (66) hide show
  1. package/README.md +4 -0
  2. package/dist/{chunk-MAHPAP65.js → chunk-RI6QJR2L.js} +6 -3
  3. package/dist/chunk-RI6QJR2L.js.map +1 -0
  4. package/dist/index.d.ts +800 -122
  5. package/dist/index.js +10458 -1119
  6. package/dist/index.js.map +1 -1
  7. package/dist/tokens.css +17 -0
  8. package/dist/tokens.d.ts +3 -0
  9. package/dist/tokens.js +1 -1
  10. package/llms.txt +18 -4
  11. package/package.json +19 -1
  12. package/src/components/accordion.tsx +746 -58
  13. package/src/components/alert-dialog.tsx +11 -5
  14. package/src/components/ask-user-questions.tsx +1366 -0
  15. package/src/components/badge.tsx +105 -24
  16. package/src/components/button.tsx +207 -38
  17. package/src/components/card.tsx +3 -2
  18. package/src/components/chat-message.tsx +116 -0
  19. package/src/components/checkbox-group.tsx +403 -0
  20. package/src/components/checkbox.tsx +6 -3
  21. package/src/components/color-input.tsx +3 -1
  22. package/src/components/color-picker.tsx +1992 -80
  23. package/src/components/dialog.tsx +141 -81
  24. package/src/components/dropdown-menu.tsx +21 -13
  25. package/src/components/dropdown.tsx +245 -0
  26. package/src/components/file-thumbnail.tsx +156 -0
  27. package/src/components/hover-card.tsx +2 -0
  28. package/src/components/input-copy.tsx +307 -0
  29. package/src/components/input-group.tsx +303 -108
  30. package/src/components/input-message.tsx +883 -0
  31. package/src/components/input-number.tsx +62 -21
  32. package/src/components/input.tsx +6 -3
  33. package/src/components/menu-item.tsx +156 -0
  34. package/src/components/mobile-drawer.tsx +50 -0
  35. package/src/components/nav-item.tsx +43 -0
  36. package/src/components/nav-menu.tsx +47 -0
  37. package/src/components/popover.tsx +3 -1
  38. package/src/components/radio-group.tsx +381 -35
  39. package/src/components/scroll-area.tsx +9 -2
  40. package/src/components/select.tsx +708 -121
  41. package/src/components/sheet.tsx +15 -6
  42. package/src/components/slider.tsx +1583 -20
  43. package/src/components/status-badge.tsx +2 -0
  44. package/src/components/switch.tsx +245 -28
  45. package/src/components/table.tsx +191 -77
  46. package/src/components/tabs-subtle.tsx +411 -0
  47. package/src/components/tabs.tsx +459 -49
  48. package/src/components/textarea.tsx +7 -3
  49. package/src/components/thinking-indicator.tsx +106 -0
  50. package/src/components/thinking-steps.tsx +382 -0
  51. package/src/components/tooltip.tsx +158 -33
  52. package/src/hooks/use-merge-split.tsx +359 -0
  53. package/src/hooks/use-proximity-hover.ts +208 -0
  54. package/src/index.ts +14 -0
  55. package/src/lib/elevated.tsx +52 -0
  56. package/src/lib/font-weight.ts +18 -0
  57. package/src/lib/icon-context.tsx +98 -0
  58. package/src/lib/icon-map.tsx +669 -0
  59. package/src/lib/interaction.ts +102 -0
  60. package/src/lib/scroll-fade.tsx +241 -0
  61. package/src/lib/shape-context.tsx +121 -0
  62. package/src/lib/springs.ts +30 -0
  63. package/src/lib/surface-classes.ts +43 -0
  64. package/src/lib/surface-context.tsx +17 -0
  65. package/src/tokens/tokens.ts +3 -0
  66. package/dist/chunk-MAHPAP65.js.map +0 -1
@@ -1,146 +1,733 @@
1
1
  "use client";
2
2
 
3
- import * as React from "react";
4
- import { Select } from "radix-ui";
5
- import { Check, ChevronDown, ChevronUp } from "lucide-react";
6
- import { cn } from "../lib/utils";
7
3
  import {
8
- mobilePopperSheetMotionClassName,
9
- mobilePopperSheetPositionClassName,
10
- mobilePopperSheetStyle,
11
- mobilePopperSheetSurfaceClassName,
12
- } from "../lib/mobile-popper-sheet";
13
-
14
- const SelectRoot = Select.Root;
15
- const SelectGroup = Select.Group;
16
- const SelectValue = Select.Value;
17
-
18
- const SelectTrigger = React.forwardRef<
19
- React.ComponentRef<typeof Select.Trigger>,
20
- React.ComponentPropsWithoutRef<typeof Select.Trigger>
21
- >(({ className, children, ...props }, ref) => (
22
- <Select.Trigger
23
- ref={ref}
24
- className={cn(
25
- "flex h-10 w-full items-center justify-between rounded-xl bg-card px-3 py-2",
26
- "body text-foreground shadow-natural",
27
- "focus:outline-none focus:ring-2 focus:ring-ring/20",
28
- "disabled:opacity-50 disabled:cursor-not-allowed",
29
- "data-[placeholder]:text-muted-foreground",
30
- "[&>span]:line-clamp-1",
31
- className,
32
- )}
33
- {...props}
34
- >
35
- {children}
36
- <Select.Icon asChild>
37
- <ChevronDown size={15} className="text-muted-foreground shrink-0" />
38
- </Select.Icon>
39
- </Select.Trigger>
40
- ));
4
+ forwardRef,
5
+ useRef,
6
+ useEffect,
7
+ useState,
8
+ useCallback,
9
+ createContext,
10
+ useContext,
11
+ Children,
12
+ cloneElement,
13
+ isValidElement,
14
+ type ReactNode,
15
+ type HTMLAttributes,
16
+ } from "react";
17
+ import { createPortal } from "react-dom";
18
+ import { motion, AnimatePresence } from "framer-motion";
19
+ import { cva, type VariantProps } from "class-variance-authority";
20
+ import type { IconComponent } from "../lib/icon-context";
21
+ import { cn } from "../lib/utils";
22
+ import { spring } from "../lib/springs";
23
+ import { useProximityHover } from "../hooks/use-proximity-hover";
24
+ import { useShape } from "../lib/shape-context";
25
+ import { useScrollEdges, ScrollEdgeCue } from "../lib/scroll-fade";
26
+ import { Elevated } from "../lib/elevated";
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Select context
30
+ // ---------------------------------------------------------------------------
31
+
32
+ interface SelectContextValue {
33
+ value: string;
34
+ onChange: (value: string) => void;
35
+ open: boolean;
36
+ setOpen: (open: boolean) => void;
37
+ disabled: boolean;
38
+ triggerRef: React.RefObject<HTMLButtonElement | null>;
39
+ labelMap: React.MutableRefObject<Map<string, string>>;
40
+ }
41
+
42
+ const SelectContext = createContext<SelectContextValue | null>(null);
43
+
44
+ function useSelectContext() {
45
+ const ctx = useContext(SelectContext);
46
+ if (!ctx) throw new Error("Select compound components must be inside <Select>");
47
+ return ctx;
48
+ }
49
+
50
+ // Content context for proximity hover
51
+ interface SelectContentContextValue {
52
+ registerItem: (index: number, element: HTMLElement | null) => void;
53
+ activeIndex: number | null;
54
+ checkedIndex?: number;
55
+ }
56
+
57
+ const SelectContentContext = createContext<SelectContentContextValue | null>(null);
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Select (root)
61
+ // ---------------------------------------------------------------------------
62
+
63
+ interface SelectProps {
64
+ children: ReactNode;
65
+ value?: string;
66
+ defaultValue?: string;
67
+ onValueChange?: (value: string) => void;
68
+ disabled?: boolean;
69
+ name?: string;
70
+ required?: boolean;
71
+ }
72
+
73
+ function Select({
74
+ children,
75
+ value,
76
+ defaultValue,
77
+ onValueChange,
78
+ disabled = false,
79
+ name,
80
+ required,
81
+ }: SelectProps) {
82
+ const [internalValue, setInternalValue] = useState(defaultValue ?? "");
83
+ const [open, setOpen] = useState(false);
84
+ const currentValue = value !== undefined ? value : internalValue;
85
+ const triggerRef = useRef<HTMLButtonElement>(null);
86
+ const labelMap = useRef(new Map<string, string>());
87
+
88
+ const onChange = useCallback(
89
+ (v: string) => {
90
+ if (value === undefined) setInternalValue(v);
91
+ onValueChange?.(v);
92
+ setOpen(false);
93
+ requestAnimationFrame(() => triggerRef.current?.focus());
94
+ },
95
+ [value, onValueChange],
96
+ );
97
+
98
+ return (
99
+ <SelectContext.Provider
100
+ value={{
101
+ value: currentValue,
102
+ onChange,
103
+ open,
104
+ setOpen,
105
+ disabled,
106
+ triggerRef,
107
+ labelMap,
108
+ }}
109
+ >
110
+ {children}
111
+ {name && <input type="hidden" name={name} value={currentValue} required={required} />}
112
+ </SelectContext.Provider>
113
+ );
114
+ }
115
+
116
+ Select.displayName = "Select";
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // SelectTrigger
120
+ // ---------------------------------------------------------------------------
121
+
122
+ const triggerVariants = cva(
123
+ [
124
+ "group inline-flex items-center justify-between gap-2 outline-none cursor-pointer",
125
+ "text-[13px] h-9 px-3 min-w-[160px]",
126
+ "transition-all duration-80",
127
+ "disabled:opacity-50 disabled:pointer-events-none",
128
+ "focus-visible:ring-1 focus-visible:ring-[#6B97FF]",
129
+ ],
130
+ {
131
+ variants: {
132
+ variant: {
133
+ bordered: "border border-border bg-transparent text-foreground hover:bg-hover",
134
+ borderless: "border border-transparent bg-transparent text-foreground hover:bg-hover",
135
+ },
136
+ },
137
+ defaultVariants: {
138
+ variant: "bordered",
139
+ },
140
+ },
141
+ );
142
+
143
+ interface SelectTriggerProps
144
+ extends HTMLAttributes<HTMLButtonElement>, VariantProps<typeof triggerVariants> {
145
+ icon?: IconComponent;
146
+ placeholder?: string;
147
+ error?: string;
148
+ }
149
+
150
+ const SelectTrigger = forwardRef<HTMLButtonElement, SelectTriggerProps>(
151
+ ({ className, variant, icon: Icon, placeholder = "Select…", error, children, ...props }, ref) => {
152
+ const { value, open, setOpen, disabled, triggerRef, labelMap } = useSelectContext();
153
+ const shape = useShape();
154
+ const label = value ? (labelMap.current.get(value) ?? value) : undefined;
155
+
156
+ return (
157
+ <div className="flex flex-col gap-1">
158
+ <button
159
+ ref={(node) => {
160
+ (triggerRef as React.MutableRefObject<HTMLButtonElement | null>).current = node;
161
+ if (typeof ref === "function") ref(node);
162
+ else if (ref) (ref as React.MutableRefObject<HTMLButtonElement | null>).current = node;
163
+ }}
164
+ type="button"
165
+ role="combobox"
166
+ aria-expanded={open}
167
+ aria-haspopup="listbox"
168
+ disabled={disabled}
169
+ onClick={() => setOpen(!open)}
170
+ onKeyDown={(e) => {
171
+ if (
172
+ !open &&
173
+ (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter" || e.key === " ")
174
+ ) {
175
+ e.preventDefault();
176
+ setOpen(true);
177
+ }
178
+ }}
179
+ aria-invalid={!!error || undefined}
180
+ className={cn(
181
+ triggerVariants({ variant }),
182
+ shape.input,
183
+ error && "border-destructive/50 hover:border-destructive/50",
184
+ className,
185
+ )}
186
+ {...props}
187
+ >
188
+ <span className="flex items-center gap-2 min-w-0 flex-1">
189
+ {Icon && (
190
+ <Icon
191
+ size={16}
192
+ strokeWidth={1.5}
193
+ className="shrink-0 text-muted-foreground transition-[color,stroke-width] duration-80 group-hover:text-foreground group-hover:stroke-[2]"
194
+ />
195
+ )}
196
+ {children ?? (
197
+ <span className="min-w-0 flex-1 text-left truncate">
198
+ {label ?? <span className="text-muted-foreground">{placeholder}</span>}
199
+ </span>
200
+ )}
201
+ </span>
202
+
203
+ <svg
204
+ width={16}
205
+ height={16}
206
+ viewBox="0 0 24 24"
207
+ fill="none"
208
+ stroke="currentColor"
209
+ strokeWidth={2}
210
+ strokeLinecap="round"
211
+ strokeLinejoin="round"
212
+ className="shrink-0 text-muted-foreground transition-colors duration-80 group-hover:text-foreground"
213
+ >
214
+ <path d="M6 9l6 6 6-6" />
215
+ </svg>
216
+ </button>
217
+ {error && <span className="text-[12px] text-destructive pl-3">{error}</span>}
218
+ </div>
219
+ );
220
+ },
221
+ );
222
+
41
223
  SelectTrigger.displayName = "SelectTrigger";
42
224
 
43
- const SelectContent = React.forwardRef<
44
- React.ComponentRef<typeof Select.Content>,
45
- React.ComponentPropsWithoutRef<typeof Select.Content>
46
- >(({ className, children, position = "popper", ...props }, ref) => (
47
- <>
48
- <style>{mobilePopperSheetStyle}</style>
49
- <Select.Portal>
50
- <Select.Content
51
- ref={ref}
52
- data-hilum-mobile-sheet="true"
225
+ // ---------------------------------------------------------------------------
226
+ // SelectContent
227
+ // ---------------------------------------------------------------------------
228
+
229
+ interface SelectContentProps {
230
+ className?: string;
231
+ children: ReactNode;
232
+ /** Show a fade + chevron cue at the scroll edges when the list overflows its
233
+ * max-height, signalling there's more to scroll. Auto-activates on overflow;
234
+ * set to `false` to disable. Defaults to `true`. */
235
+ scrollFade?: boolean;
236
+ }
237
+
238
+ const SelectContent = forwardRef<HTMLDivElement, SelectContentProps>(
239
+ ({ className, children, scrollFade = true }, ref) => {
240
+ const { open, setOpen, value, triggerRef } = useSelectContext();
241
+ const shape = useShape();
242
+ const containerRef = useRef<HTMLDivElement>(null);
243
+ const [triggerRect, setTriggerRect] = useState<DOMRect | null>(null);
244
+
245
+ // Scroll-edge cues show only when there's more content above/below the
246
+ // visible area. `triggerRect !== null` gates attachment until the portal
247
+ // (and thus containerRef.current) has mounted.
248
+ const edges = useScrollEdges(containerRef, {
249
+ enabled: open && scrollFade && triggerRect !== null,
250
+ });
251
+
252
+ const {
253
+ activeIndex,
254
+ setActiveIndex,
255
+ itemRects,
256
+ sessionRef,
257
+ handlers,
258
+ registerItem,
259
+ measureItems,
260
+ } = useProximityHover(containerRef);
261
+
262
+ const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
263
+ const [checkedIndex, setCheckedIndex] = useState<number | undefined>(undefined);
264
+
265
+ // Capture trigger rect synchronously when opening
266
+ useEffect(() => {
267
+ if (open && triggerRef.current) {
268
+ setTriggerRect(triggerRef.current.getBoundingClientRect());
269
+ }
270
+ }, [open, triggerRef]);
271
+
272
+ // Measure items + detect checked AFTER the portal has mounted
273
+ // triggerRect being set means the portal will render on the next commit
274
+ useEffect(() => {
275
+ if (!open || !triggerRect) return;
276
+ // Double rAF: first waits for React commit, second for layout
277
+ let outer: number;
278
+ let inner: number;
279
+ outer = requestAnimationFrame(() => {
280
+ inner = requestAnimationFrame(() => {
281
+ measureItems();
282
+ const container = containerRef.current;
283
+ if (container) {
284
+ const items = Array.from(
285
+ container.querySelectorAll("[data-proximity-index]"),
286
+ ) as HTMLElement[];
287
+ const idx = items.findIndex((el) => el.getAttribute("data-value") === value);
288
+ if (idx !== -1) setCheckedIndex(idx);
289
+ else setCheckedIndex(undefined);
290
+
291
+ // Focus the container so keyboard events work;
292
+ // don't focus an item directly to avoid showing a focus ring
293
+ containerRef.current?.focus({ preventScroll: true });
294
+ }
295
+ });
296
+ });
297
+ return () => {
298
+ cancelAnimationFrame(outer);
299
+ cancelAnimationFrame(inner);
300
+ };
301
+ }, [open, triggerRect, measureItems, value]);
302
+
303
+ // Close on escape
304
+ useEffect(() => {
305
+ if (!open) return;
306
+ const onKey = (e: KeyboardEvent) => {
307
+ if (e.key === "Escape") {
308
+ setOpen(false);
309
+ triggerRef.current?.focus();
310
+ }
311
+ };
312
+ document.addEventListener("keydown", onKey);
313
+ return () => document.removeEventListener("keydown", onKey);
314
+ }, [open, setOpen, triggerRef]);
315
+
316
+ // Close on click outside
317
+ useEffect(() => {
318
+ if (!open) return;
319
+ const onPointer = (e: MouseEvent) => {
320
+ if (
321
+ !containerRef.current?.contains(e.target as Node) &&
322
+ !triggerRef.current?.contains(e.target as Node)
323
+ ) {
324
+ setOpen(false);
325
+ }
326
+ };
327
+ document.addEventListener("mousedown", onPointer);
328
+ return () => document.removeEventListener("mousedown", onPointer);
329
+ }, [open, setOpen, triggerRef]);
330
+
331
+ // Close on scroll (instead of locking body scroll, which causes layout shift)
332
+ useEffect(() => {
333
+ if (!open) return;
334
+ const onScroll = () => setOpen(false);
335
+ window.addEventListener("scroll", onScroll, { passive: true });
336
+ return () => window.removeEventListener("scroll", onScroll);
337
+ }, [open, setOpen]);
338
+
339
+ // Keyboard nav inside content
340
+ const handleKeyDown = useCallback(
341
+ (e: React.KeyboardEvent) => {
342
+ const items = Array.from(
343
+ containerRef.current?.querySelectorAll('[role="option"]:not([data-disabled])') ?? [],
344
+ ) as HTMLElement[];
345
+ const currentIdx = items.indexOf(e.target as HTMLElement);
346
+
347
+ if (["ArrowDown", "ArrowUp", "ArrowRight", "ArrowLeft"].includes(e.key)) {
348
+ e.preventDefault();
349
+ if (currentIdx === -1) {
350
+ // No item focused yet — focus checked or first item
351
+ const checked =
352
+ value !== "" ? items.find((item) => item.getAttribute("data-value") === value) : null;
353
+ (checked ?? items[0])?.focus();
354
+ } else {
355
+ const next = ["ArrowDown", "ArrowRight"].includes(e.key)
356
+ ? (currentIdx + 1) % items.length
357
+ : (currentIdx - 1 + items.length) % items.length;
358
+ items[next]?.focus();
359
+ }
360
+ } else if (e.key === "Home") {
361
+ e.preventDefault();
362
+ items[0]?.focus();
363
+ } else if (e.key === "End") {
364
+ e.preventDefault();
365
+ items[items.length - 1]?.focus();
366
+ }
367
+ },
368
+ [value],
369
+ );
370
+
371
+ const indexedChildren = Children.map(children, (child, index) => {
372
+ if (!isValidElement(child)) return child;
373
+ const childProps = child.props as { index?: number };
374
+ if (childProps.index !== undefined) return child;
375
+ return cloneElement(child, { index } as Record<string, unknown>);
376
+ });
377
+
378
+ // Render hidden when closed so items can register labels
379
+ if (!open) {
380
+ return (
381
+ <div hidden aria-hidden="true">
382
+ {indexedChildren}
383
+ </div>
384
+ );
385
+ }
386
+
387
+ if (!triggerRect) return null;
388
+
389
+ const activeRect = activeIndex !== null ? itemRects[activeIndex] : null;
390
+ const checkedRect = checkedIndex != null ? itemRects[checkedIndex] : null;
391
+ const focusRect = focusedIndex !== null ? itemRects[focusedIndex] : null;
392
+ const isHoveringOther = activeIndex !== null && activeIndex !== checkedIndex;
393
+
394
+ return createPortal(
395
+ <SelectContentContext.Provider
396
+ value={
397
+ checkedIndex === undefined
398
+ ? { registerItem, activeIndex }
399
+ : { registerItem, activeIndex, checkedIndex }
400
+ }
401
+ >
402
+ <div
403
+ style={{
404
+ position: "fixed",
405
+ top: triggerRect.bottom + 6,
406
+ left: triggerRect.left,
407
+ minWidth: triggerRect.width,
408
+ zIndex: 50,
409
+ }}
410
+ >
411
+ <motion.div
412
+ initial={{ opacity: 0, y: -4, scaleY: 0.96 }}
413
+ animate={{ opacity: 1, y: 0, scaleY: 1 }}
414
+ transition={spring.fast}
415
+ style={{ transformOrigin: "top center" }}
416
+ >
417
+ <Elevated
418
+ offset={2}
419
+ shadowLevel={3}
420
+ ref={(node) => {
421
+ (containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
422
+ if (typeof ref === "function") ref(node);
423
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
424
+ }}
425
+ role="listbox"
426
+ data-hilum-mobile-sheet="true"
427
+ tabIndex={-1}
428
+ onMouseEnter={() => {
429
+ handlers.onMouseEnter();
430
+ setFocusedIndex(null);
431
+ }}
432
+ onMouseMove={handlers.onMouseMove}
433
+ onMouseLeave={handlers.onMouseLeave}
434
+ onFocus={(e) => {
435
+ const indexAttr = (e.target as HTMLElement)
436
+ .closest("[data-proximity-index]")
437
+ ?.getAttribute("data-proximity-index");
438
+ if (indexAttr != null) {
439
+ const idx = Number(indexAttr);
440
+ setActiveIndex(idx);
441
+ setFocusedIndex((e.target as HTMLElement).matches(":focus-visible") ? idx : null);
442
+ }
443
+ }}
444
+ onBlur={(e) => {
445
+ if (containerRef.current?.contains(e.relatedTarget as Node)) return;
446
+ setFocusedIndex(null);
447
+ setActiveIndex(null);
448
+ }}
449
+ onKeyDown={handleKeyDown}
450
+ className={cn(
451
+ `relative flex flex-col gap-0.5 max-h-[300px] overflow-y-auto ${shape.container} p-1 select-none outline-none max-sm:!fixed max-sm:rounded-2xl max-sm:data-[state=open]:slide-in-from-bottom`,
452
+ className,
453
+ )}
454
+ >
455
+ {/* Selected background */}
456
+ <AnimatePresence>
457
+ {checkedRect && (
458
+ <motion.div
459
+ className={`absolute ${shape.bg} bg-active pointer-events-none`}
460
+ initial={false}
461
+ animate={{
462
+ top: checkedRect.top,
463
+ left: checkedRect.left,
464
+ width: checkedRect.width,
465
+ height: checkedRect.height,
466
+ opacity: isHoveringOther ? 0.8 : 1,
467
+ }}
468
+ exit={{ opacity: 0, transition: spring.moderate.exit }}
469
+ transition={{
470
+ ...spring.moderate,
471
+ opacity: { duration: 0.08 },
472
+ }}
473
+ />
474
+ )}
475
+ </AnimatePresence>
476
+
477
+ {/* Hover background */}
478
+ <AnimatePresence>
479
+ {activeRect && (
480
+ <motion.div
481
+ key={sessionRef.current}
482
+ className={`absolute ${shape.bg} bg-hover pointer-events-none`}
483
+ initial={{
484
+ opacity: 0,
485
+ top: checkedRect?.top ?? activeRect.top,
486
+ left: checkedRect?.left ?? activeRect.left,
487
+ width: checkedRect?.width ?? activeRect.width,
488
+ height: checkedRect?.height ?? activeRect.height,
489
+ }}
490
+ animate={{
491
+ opacity: 1,
492
+ top: activeRect.top,
493
+ left: activeRect.left,
494
+ width: activeRect.width,
495
+ height: activeRect.height,
496
+ }}
497
+ exit={{ opacity: 0, transition: spring.fast.exit }}
498
+ transition={{
499
+ ...spring.fast,
500
+ opacity: { duration: 0.08 },
501
+ }}
502
+ />
503
+ )}
504
+ </AnimatePresence>
505
+
506
+ {/* Focus ring */}
507
+ <AnimatePresence>
508
+ {focusRect && (
509
+ <motion.div
510
+ className={`absolute ${shape.focusRing} pointer-events-none z-20 border border-[#6B97FF]`}
511
+ initial={false}
512
+ animate={{
513
+ left: focusRect.left - 2,
514
+ top: focusRect.top - 2,
515
+ width: focusRect.width + 4,
516
+ height: focusRect.height + 4,
517
+ }}
518
+ exit={{ opacity: 0, transition: spring.fast.exit }}
519
+ transition={{
520
+ ...spring.fast,
521
+ opacity: { duration: 0.08 },
522
+ }}
523
+ />
524
+ )}
525
+ </AnimatePresence>
526
+
527
+ {/* Cues read the elevated surface level from Elevated's provider,
528
+ so the gradient matches the menu background at any depth. */}
529
+ {scrollFade && <ScrollEdgeCue edge="top" visible={edges.top} />}
530
+
531
+ {indexedChildren}
532
+
533
+ {scrollFade && <ScrollEdgeCue edge="bottom" visible={edges.bottom} />}
534
+ </Elevated>
535
+ </motion.div>
536
+ </div>
537
+ </SelectContentContext.Provider>,
538
+ document.body,
539
+ );
540
+ },
541
+ );
542
+
543
+ SelectContent.displayName = "SelectContent";
544
+
545
+ // ---------------------------------------------------------------------------
546
+ // SelectItem
547
+ // ---------------------------------------------------------------------------
548
+
549
+ interface SelectItemProps extends HTMLAttributes<HTMLDivElement> {
550
+ icon?: IconComponent;
551
+ index?: number;
552
+ value: string;
553
+ disabled?: boolean;
554
+ }
555
+
556
+ const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
557
+ ({ className, children, icon: Icon, value, index = 0, disabled = false, ...props }, ref) => {
558
+ const selectCtx = useSelectContext();
559
+ const contentCtx = useContext(SelectContentContext);
560
+ const internalRef = useRef<HTMLDivElement>(null);
561
+ const shape = useShape();
562
+ const hasMounted = useRef(false);
563
+
564
+ useEffect(() => {
565
+ hasMounted.current = true;
566
+ }, []);
567
+
568
+ // Register label with root context
569
+ useEffect(() => {
570
+ if (typeof children === "string") {
571
+ selectCtx.labelMap.current.set(value, children);
572
+ }
573
+ }, [value, children, selectCtx.labelMap]);
574
+
575
+ // Register with proximity hover (only when content context exists = open)
576
+ useEffect(() => {
577
+ contentCtx?.registerItem(index, internalRef.current);
578
+ return () => contentCtx?.registerItem(index, null);
579
+ }, [index, contentCtx]);
580
+
581
+ const isActive = contentCtx?.activeIndex === index;
582
+ const isChecked = selectCtx.value === value;
583
+ const skipAnimation = !hasMounted.current;
584
+
585
+ return (
586
+ <div
587
+ ref={(node) => {
588
+ (internalRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
589
+ if (typeof ref === "function") ref(node);
590
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
591
+ }}
592
+ data-proximity-index={index}
593
+ data-value={value}
594
+ data-disabled={disabled || undefined}
595
+ role="option"
596
+ aria-selected={isChecked}
597
+ aria-label={typeof children === "string" ? children : undefined}
598
+ tabIndex={isChecked ? 0 : index === (contentCtx?.checkedIndex ?? 0) ? 0 : -1}
599
+ onClick={() => {
600
+ if (!disabled) selectCtx.onChange(value);
601
+ }}
602
+ onKeyDown={(e) => {
603
+ if ((e.key === "Enter" || e.key === " ") && !disabled) {
604
+ e.preventDefault();
605
+ selectCtx.onChange(value);
606
+ }
607
+ }}
53
608
  className={cn(
54
- "relative z-50 max-h-96 min-w-[8rem] overflow-hidden",
55
- "bg-card rounded-xl shadow-natural p-1",
56
- mobilePopperSheetPositionClassName,
57
- mobilePopperSheetSurfaceClassName,
58
- "max-sm:px-2 max-sm:pb-2 max-sm:pt-5",
59
- "data-[state=open]:animate-in data-[state=closed]:animate-out",
60
- "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
61
- "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
62
- mobilePopperSheetMotionClassName,
63
- "data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2",
64
- position === "popper" &&
65
- "data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1",
609
+ `relative z-10 flex items-center gap-2 ${shape.item} px-2 py-2 text-[13px] cursor-pointer outline-none select-none`,
610
+ "transition-[color] duration-80",
611
+ isActive || isChecked ? "text-foreground" : "text-muted-foreground",
612
+ disabled && "opacity-50 pointer-events-none",
66
613
  className,
67
614
  )}
68
- position={position}
69
615
  {...props}
70
616
  >
71
- <Select.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
72
- <ChevronUp size={14} className="text-muted-foreground" />
73
- </Select.ScrollUpButton>
74
- <Select.Viewport
75
- className={cn(
76
- position === "popper" &&
77
- "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
78
- "max-sm:!h-auto max-sm:max-h-[calc(min(70dvh,28rem)-3rem)] max-sm:!w-full max-sm:!min-w-0",
617
+ {Icon && (
618
+ <Icon
619
+ size={16}
620
+ strokeWidth={isActive || isChecked ? 2 : 1.5}
621
+ className="shrink-0 transition-[color,stroke-width] duration-80"
622
+ />
623
+ )}
624
+
625
+ <span className="flex-1 min-w-0 truncate">{children}</span>
626
+
627
+ <AnimatePresence>
628
+ {isChecked && (
629
+ <motion.svg
630
+ key="check"
631
+ width={16}
632
+ height={16}
633
+ viewBox="0 0 24 24"
634
+ fill="none"
635
+ stroke="currentColor"
636
+ strokeWidth={2}
637
+ strokeLinecap="round"
638
+ strokeLinejoin="round"
639
+ className="shrink-0 text-foreground"
640
+ initial={{ opacity: 1 }}
641
+ animate={{ opacity: 1 }}
642
+ exit={{ opacity: 1 }}
643
+ >
644
+ <motion.path
645
+ d="M4 12L9 17L20 6"
646
+ initial={{ pathLength: skipAnimation ? 1 : 0 }}
647
+ animate={{
648
+ pathLength: 1,
649
+ transition: { duration: 0.08, ease: "easeOut" },
650
+ }}
651
+ exit={{
652
+ pathLength: 0,
653
+ transition: { duration: 0.04, ease: "easeIn" },
654
+ }}
655
+ />
656
+ </motion.svg>
79
657
  )}
80
- >
81
- {children}
82
- </Select.Viewport>
83
- <Select.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
84
- <ChevronDown size={14} className="text-muted-foreground" />
85
- </Select.ScrollDownButton>
86
- </Select.Content>
87
- </Select.Portal>
88
- </>
89
- ));
90
- SelectContent.displayName = "SelectContent";
658
+ </AnimatePresence>
659
+ </div>
660
+ );
661
+ },
662
+ );
91
663
 
92
- const SelectItem = React.forwardRef<
93
- React.ComponentRef<typeof Select.Item>,
94
- React.ComponentPropsWithoutRef<typeof Select.Item>
95
- >(({ className, children, ...props }, ref) => (
96
- <Select.Item
97
- ref={ref}
98
- className={cn(
99
- "relative flex min-h-10 w-full cursor-default select-none items-center rounded-md px-2.5 py-2",
100
- "body text-muted-foreground outline-none",
101
- "focus:bg-muted focus:text-foreground",
102
- "data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
103
- className,
104
- )}
105
- {...props}
106
- >
107
- <span className="absolute right-2 flex size-3.5 items-center justify-center">
108
- <Select.ItemIndicator>
109
- <Check size={13} className="text-foreground" />
110
- </Select.ItemIndicator>
111
- </span>
112
- <Select.ItemText>{children}</Select.ItemText>
113
- </Select.Item>
114
- ));
115
664
  SelectItem.displayName = "SelectItem";
116
665
 
117
- const SelectLabel = React.forwardRef<
118
- React.ComponentRef<typeof Select.Label>,
119
- React.ComponentPropsWithoutRef<typeof Select.Label>
120
- >(({ className, ...props }, ref) => (
121
- <Select.Label
122
- ref={ref}
123
- className={cn("px-2.5 py-1 label text-muted-foreground", className)}
124
- {...props}
125
- />
126
- ));
666
+ // ---------------------------------------------------------------------------
667
+ // SelectGroup + SelectLabel + SelectSeparator
668
+ // ---------------------------------------------------------------------------
669
+
670
+ function SelectGroup({ children, className, ...props }: HTMLAttributes<HTMLDivElement>) {
671
+ return (
672
+ <div role="group" className={className} {...props}>
673
+ {children}
674
+ </div>
675
+ );
676
+ }
677
+
678
+ SelectGroup.displayName = "SelectGroup";
679
+
680
+ const SelectLabel = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
681
+ ({ className, ...props }, ref) => (
682
+ <div
683
+ ref={ref}
684
+ className={cn("px-2 py-1.5 text-[11px] text-muted-foreground", className)}
685
+ {...props}
686
+ />
687
+ ),
688
+ );
689
+
127
690
  SelectLabel.displayName = "SelectLabel";
128
691
 
129
- const SelectSeparator = React.forwardRef<
130
- React.ComponentRef<typeof Select.Separator>,
131
- React.ComponentPropsWithoutRef<typeof Select.Separator>
132
- >(({ className, ...props }, ref) => (
133
- <Select.Separator ref={ref} className={cn("mx-1 my-1 h-px bg-muted", className)} {...props} />
134
- ));
692
+ const SelectSeparator = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
693
+ ({ className, ...props }, ref) => (
694
+ <div
695
+ ref={ref}
696
+ role="separator"
697
+ className={cn("my-1 -mx-1 h-px bg-border/60", className)}
698
+ {...props}
699
+ />
700
+ ),
701
+ );
702
+
135
703
  SelectSeparator.displayName = "SelectSeparator";
136
704
 
705
+ function SelectValue({ placeholder }: { placeholder?: string }) {
706
+ const { value, labelMap } = useSelectContext();
707
+ const label = value ? (labelMap.current.get(value) ?? value) : undefined;
708
+ return (
709
+ <span className="min-w-0 flex-1 text-left truncate">
710
+ {label ?? <span className="text-muted-foreground">{placeholder}</span>}
711
+ </span>
712
+ );
713
+ }
714
+
715
+ SelectValue.displayName = "SelectValue";
716
+
717
+ // ---------------------------------------------------------------------------
718
+ // Exports
719
+ // ---------------------------------------------------------------------------
720
+
137
721
  export {
138
- SelectRoot as Select,
139
- SelectGroup,
140
- SelectValue,
722
+ Select,
141
723
  SelectTrigger,
142
724
  SelectContent,
143
725
  SelectItem,
726
+ SelectValue,
727
+ SelectGroup,
144
728
  SelectLabel,
145
729
  SelectSeparator,
730
+ triggerVariants,
146
731
  };
732
+
733
+ export type { SelectProps, SelectTriggerProps, SelectContentProps, SelectItemProps };