@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.
- package/README.md +4 -0
- package/dist/{chunk-MAHPAP65.js → chunk-RI6QJR2L.js} +6 -3
- package/dist/chunk-RI6QJR2L.js.map +1 -0
- package/dist/index.d.ts +800 -122
- package/dist/index.js +10458 -1119
- package/dist/index.js.map +1 -1
- package/dist/tokens.css +17 -0
- package/dist/tokens.d.ts +3 -0
- package/dist/tokens.js +1 -1
- package/llms.txt +18 -4
- package/package.json +19 -1
- package/src/components/accordion.tsx +746 -58
- package/src/components/alert-dialog.tsx +11 -5
- package/src/components/ask-user-questions.tsx +1366 -0
- package/src/components/badge.tsx +105 -24
- package/src/components/button.tsx +207 -38
- package/src/components/card.tsx +3 -2
- package/src/components/chat-message.tsx +116 -0
- package/src/components/checkbox-group.tsx +403 -0
- package/src/components/checkbox.tsx +6 -3
- package/src/components/color-input.tsx +3 -1
- package/src/components/color-picker.tsx +1992 -80
- package/src/components/dialog.tsx +141 -81
- package/src/components/dropdown-menu.tsx +21 -13
- package/src/components/dropdown.tsx +245 -0
- package/src/components/file-thumbnail.tsx +156 -0
- package/src/components/hover-card.tsx +2 -0
- package/src/components/input-copy.tsx +307 -0
- package/src/components/input-group.tsx +303 -108
- package/src/components/input-message.tsx +883 -0
- package/src/components/input-number.tsx +62 -21
- package/src/components/input.tsx +6 -3
- package/src/components/menu-item.tsx +156 -0
- package/src/components/mobile-drawer.tsx +50 -0
- package/src/components/nav-item.tsx +43 -0
- package/src/components/nav-menu.tsx +47 -0
- package/src/components/popover.tsx +3 -1
- package/src/components/radio-group.tsx +381 -35
- package/src/components/scroll-area.tsx +9 -2
- package/src/components/select.tsx +708 -121
- package/src/components/sheet.tsx +15 -6
- package/src/components/slider.tsx +1583 -20
- package/src/components/status-badge.tsx +2 -0
- package/src/components/switch.tsx +245 -28
- package/src/components/table.tsx +191 -77
- package/src/components/tabs-subtle.tsx +411 -0
- package/src/components/tabs.tsx +459 -49
- package/src/components/textarea.tsx +7 -3
- package/src/components/thinking-indicator.tsx +106 -0
- package/src/components/thinking-steps.tsx +382 -0
- package/src/components/tooltip.tsx +158 -33
- package/src/hooks/use-merge-split.tsx +359 -0
- package/src/hooks/use-proximity-hover.ts +208 -0
- package/src/index.ts +14 -0
- package/src/lib/elevated.tsx +52 -0
- package/src/lib/font-weight.ts +18 -0
- package/src/lib/icon-context.tsx +98 -0
- package/src/lib/icon-map.tsx +669 -0
- package/src/lib/interaction.ts +102 -0
- package/src/lib/scroll-fade.tsx +241 -0
- package/src/lib/shape-context.tsx +121 -0
- package/src/lib/springs.ts +30 -0
- package/src/lib/surface-classes.ts +43 -0
- package/src/lib/surface-context.tsx +17 -0
- package/src/tokens/tokens.ts +3 -0
- 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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
"
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 =
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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 };
|