@hilum/ui 3.4.0 → 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 +789 -115
- package/dist/index.js +10404 -1099
- 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.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,68 +1,756 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
useEffect,
|
|
7
|
+
useCallback,
|
|
8
|
+
createContext,
|
|
9
|
+
useContext,
|
|
10
|
+
forwardRef,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
type HTMLAttributes,
|
|
13
|
+
} from "react";
|
|
14
|
+
import { motion, AnimatePresence } from "framer-motion";
|
|
15
|
+
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
|
6
16
|
import { cn } from "../lib/utils";
|
|
17
|
+
import { useIcon } from "../lib/icon-context";
|
|
18
|
+
import { spring } from "../lib/springs";
|
|
19
|
+
import { fontWeights } from "../lib/font-weight";
|
|
20
|
+
import { useProximityHover } from "../hooks/use-proximity-hover";
|
|
21
|
+
import { useShape } from "../lib/shape-context";
|
|
7
22
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
// ─── Contexts ────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
interface ItemRect {
|
|
26
|
+
top: number;
|
|
27
|
+
left: number;
|
|
28
|
+
width: number;
|
|
29
|
+
height: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface AccordionGroupContextValue {
|
|
33
|
+
registerItem: (index: number, element: HTMLElement | null) => void;
|
|
34
|
+
registerFullItem: (index: number, element: HTMLElement | null) => void;
|
|
35
|
+
activeIndex: number | null;
|
|
36
|
+
grouped: true;
|
|
37
|
+
remeasure: () => void;
|
|
38
|
+
openValues: Set<string>;
|
|
39
|
+
openItemRects: Map<number, ItemRect>;
|
|
40
|
+
toggleValue: (value: string) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const AccordionGroupContext = createContext<AccordionGroupContextValue | null>(null);
|
|
44
|
+
|
|
45
|
+
function useAccordionGroup() {
|
|
46
|
+
return useContext(AccordionGroupContext);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface AccordionItemContextValue {
|
|
50
|
+
index?: number;
|
|
51
|
+
value: string;
|
|
52
|
+
isOpen: boolean;
|
|
53
|
+
onToggle: () => void;
|
|
54
|
+
triggerRef: React.MutableRefObject<HTMLDivElement | null>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const AccordionItemContext = createContext<AccordionItemContextValue | null>(null);
|
|
58
|
+
|
|
59
|
+
function useAccordionItemContext() {
|
|
60
|
+
const ctx = useContext(AccordionItemContext);
|
|
61
|
+
if (!ctx)
|
|
62
|
+
throw new Error("AccordionTrigger/AccordionContent must be used within an AccordionItem");
|
|
63
|
+
return ctx;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── AccordionGroup ──────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
type AccordionGroupSingleProps = {
|
|
69
|
+
type?: "single";
|
|
70
|
+
value?: string;
|
|
71
|
+
defaultValue?: string;
|
|
72
|
+
onValueChange?: (value: string) => void;
|
|
73
|
+
collapsible?: boolean;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
type AccordionGroupMultipleProps = {
|
|
77
|
+
type: "multiple";
|
|
78
|
+
value?: string[];
|
|
79
|
+
defaultValue?: string[];
|
|
80
|
+
onValueChange?: (value: string[]) => void;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
type AccordionGroupProps = HTMLAttributes<HTMLDivElement> & {
|
|
84
|
+
children: ReactNode;
|
|
85
|
+
} & (AccordionGroupSingleProps | AccordionGroupMultipleProps);
|
|
86
|
+
|
|
87
|
+
const AccordionGroup = forwardRef<HTMLDivElement, AccordionGroupProps>((props, ref) => {
|
|
88
|
+
const { children, type = "single", className, ...rest } = props;
|
|
89
|
+
|
|
90
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
91
|
+
const fullItemElementsRef = useRef<Map<number, HTMLElement>>(new Map());
|
|
92
|
+
const [openItemRects, setOpenItemRects] = useState<Map<number, ItemRect>>(new Map());
|
|
93
|
+
|
|
94
|
+
const {
|
|
95
|
+
activeIndex,
|
|
96
|
+
setActiveIndex,
|
|
97
|
+
itemRects,
|
|
98
|
+
sessionRef,
|
|
99
|
+
handlers,
|
|
100
|
+
registerItem,
|
|
101
|
+
measureItems,
|
|
102
|
+
} = useProximityHover(containerRef);
|
|
103
|
+
|
|
104
|
+
const registerFullItem = useCallback((index: number, element: HTMLElement | null) => {
|
|
105
|
+
if (element) {
|
|
106
|
+
fullItemElementsRef.current.set(index, element);
|
|
107
|
+
} else {
|
|
108
|
+
fullItemElementsRef.current.delete(index);
|
|
109
|
+
}
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
const measureFullItems = useCallback(() => {
|
|
113
|
+
if (!containerRef.current) return;
|
|
114
|
+
const next = new Map<number, ItemRect>();
|
|
115
|
+
// Use offset* (layout coords) to match the proximity hook's items.
|
|
116
|
+
// getBoundingClientRect would return visual coords already scaled by
|
|
117
|
+
// any ancestor transform; once applied as CSS inside the same scaled
|
|
118
|
+
// container, the overlay would scale a second time.
|
|
119
|
+
fullItemElementsRef.current.forEach((el, idx) => {
|
|
120
|
+
next.set(idx, {
|
|
121
|
+
top: el.offsetTop,
|
|
122
|
+
left: el.offsetLeft,
|
|
123
|
+
width: el.offsetWidth,
|
|
124
|
+
height: el.offsetHeight,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
setOpenItemRects(next);
|
|
128
|
+
}, []);
|
|
129
|
+
|
|
130
|
+
// Track open values for context
|
|
131
|
+
const [internalSingleValue, setInternalSingleValue] = useState<string>(() => {
|
|
132
|
+
if (type === "single") {
|
|
133
|
+
const sp = props as AccordionGroupSingleProps;
|
|
134
|
+
return sp.defaultValue ?? "";
|
|
135
|
+
}
|
|
136
|
+
return "";
|
|
137
|
+
});
|
|
138
|
+
const [internalMultipleValue, setInternalMultipleValue] = useState<string[]>(() => {
|
|
139
|
+
if (type === "multiple") {
|
|
140
|
+
const mp = props as AccordionGroupMultipleProps;
|
|
141
|
+
return mp.defaultValue ?? [];
|
|
142
|
+
}
|
|
143
|
+
return [];
|
|
144
|
+
});
|
|
145
|
+
const singleOnValueChange = (props as AccordionGroupSingleProps).onValueChange;
|
|
146
|
+
const multipleOnValueChange = (props as AccordionGroupMultipleProps).onValueChange;
|
|
147
|
+
const controlledMultipleValue = (props as AccordionGroupMultipleProps).value;
|
|
23
148
|
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
)
|
|
38
|
-
|
|
149
|
+
const openValues = new Set<string>(
|
|
150
|
+
type === "multiple"
|
|
151
|
+
? ((props as AccordionGroupMultipleProps).value ?? internalMultipleValue)
|
|
152
|
+
: (() => {
|
|
153
|
+
const v = (props as AccordionGroupSingleProps).value ?? internalSingleValue;
|
|
154
|
+
return v ? [v] : [];
|
|
155
|
+
})(),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const handleSingleValueChange = useCallback(
|
|
159
|
+
(value: string) => {
|
|
160
|
+
const sp = props as AccordionGroupSingleProps;
|
|
161
|
+
if (sp.onValueChange) sp.onValueChange(value);
|
|
162
|
+
else setInternalSingleValue(value);
|
|
163
|
+
},
|
|
164
|
+
[singleOnValueChange],
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const handleMultipleValueChange = useCallback(
|
|
168
|
+
(value: string[]) => {
|
|
169
|
+
const mp = props as AccordionGroupMultipleProps;
|
|
170
|
+
if (mp.onValueChange) mp.onValueChange(value);
|
|
171
|
+
else setInternalMultipleValue(value);
|
|
172
|
+
},
|
|
173
|
+
[multipleOnValueChange],
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const toggleValue = useCallback(
|
|
177
|
+
(val: string) => {
|
|
178
|
+
if (type === "multiple") {
|
|
179
|
+
const current = (props as AccordionGroupMultipleProps).value ?? internalMultipleValue;
|
|
180
|
+
handleMultipleValueChange(current.filter((v) => v !== val));
|
|
181
|
+
} else {
|
|
182
|
+
handleSingleValueChange("");
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
[
|
|
186
|
+
type,
|
|
187
|
+
handleSingleValueChange,
|
|
188
|
+
handleMultipleValueChange,
|
|
189
|
+
internalMultipleValue,
|
|
190
|
+
controlledMultipleValue,
|
|
191
|
+
],
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
measureItems();
|
|
196
|
+
measureFullItems();
|
|
197
|
+
}, [measureItems, measureFullItems, children]);
|
|
198
|
+
|
|
199
|
+
// Remeasure synchronously when open values change so the first
|
|
200
|
+
// paint already reflects shifted trigger positions.
|
|
201
|
+
const openValuesKey = [...openValues].join(",");
|
|
202
|
+
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
measureItems();
|
|
205
|
+
measureFullItems();
|
|
206
|
+
}, [measureItems, measureFullItems, openValuesKey]);
|
|
207
|
+
|
|
208
|
+
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
|
209
|
+
|
|
210
|
+
const activeRect = activeIndex !== null ? itemRects[activeIndex] : null;
|
|
211
|
+
const focusRect = focusedIndex !== null ? itemRects[focusedIndex] : null;
|
|
212
|
+
// Dimming: reduce expanded BG opacity when hovering a non-expanded trigger
|
|
213
|
+
const isHoveringNonOpen = activeIndex !== null && !openItemRects.has(activeIndex);
|
|
214
|
+
const shape = useShape();
|
|
215
|
+
|
|
216
|
+
// Strip non-HTML props before spreading
|
|
217
|
+
const {
|
|
218
|
+
value: _value,
|
|
219
|
+
defaultValue: _defaultValue,
|
|
220
|
+
onValueChange: _onValueChange,
|
|
221
|
+
collapsible: _collapsible,
|
|
222
|
+
type: _type,
|
|
223
|
+
...htmlProps
|
|
224
|
+
} = rest as Record<string, unknown>;
|
|
225
|
+
|
|
226
|
+
// Build Radix root props
|
|
227
|
+
const radixProps =
|
|
228
|
+
type === "multiple"
|
|
229
|
+
? {
|
|
230
|
+
type: "multiple" as const,
|
|
231
|
+
value: (props as AccordionGroupMultipleProps).value ?? internalMultipleValue,
|
|
232
|
+
onValueChange: handleMultipleValueChange,
|
|
233
|
+
}
|
|
234
|
+
: {
|
|
235
|
+
type: "single" as const,
|
|
236
|
+
collapsible: (props as AccordionGroupSingleProps).collapsible ?? true,
|
|
237
|
+
value: (props as AccordionGroupSingleProps).value ?? internalSingleValue,
|
|
238
|
+
onValueChange: handleSingleValueChange,
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<AccordionGroupContext.Provider
|
|
243
|
+
value={{
|
|
244
|
+
registerItem,
|
|
245
|
+
registerFullItem,
|
|
246
|
+
activeIndex,
|
|
247
|
+
grouped: true,
|
|
248
|
+
remeasure: () => {
|
|
249
|
+
measureItems();
|
|
250
|
+
measureFullItems();
|
|
251
|
+
},
|
|
252
|
+
openValues,
|
|
253
|
+
openItemRects,
|
|
254
|
+
toggleValue,
|
|
255
|
+
}}
|
|
39
256
|
>
|
|
40
|
-
{
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
257
|
+
<AccordionPrimitive.Root {...radixProps} asChild>
|
|
258
|
+
<div
|
|
259
|
+
ref={(node) => {
|
|
260
|
+
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
261
|
+
if (typeof ref === "function") ref(node);
|
|
262
|
+
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
263
|
+
}}
|
|
264
|
+
onMouseEnter={handlers.onMouseEnter}
|
|
265
|
+
onMouseMove={(e) => {
|
|
266
|
+
// Suppress proximity hover when cursor is over an expanded
|
|
267
|
+
// content area (below the item's trigger). This keeps trigger
|
|
268
|
+
// hover scoped to the trigger row only.
|
|
269
|
+
const container = containerRef.current;
|
|
270
|
+
if (container) {
|
|
271
|
+
const cRect = container.getBoundingClientRect();
|
|
272
|
+
const layoutH = container.offsetHeight;
|
|
273
|
+
const visualH = cRect.height;
|
|
274
|
+
const scale = layoutH > 0 ? visualH / layoutH : 1;
|
|
275
|
+
const localY = (e.clientY - cRect.top) / scale + container.scrollTop;
|
|
276
|
+
for (const [idx, full] of openItemRects) {
|
|
277
|
+
const trigger = itemRects[idx];
|
|
278
|
+
if (!trigger) continue;
|
|
279
|
+
const contentTop = trigger.top + trigger.height;
|
|
280
|
+
const contentBottom = full.top + full.height;
|
|
281
|
+
if (localY >= contentTop && localY <= contentBottom) {
|
|
282
|
+
setActiveIndex(null);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
handlers.onMouseMove(e);
|
|
288
|
+
}}
|
|
289
|
+
onMouseLeave={handlers.onMouseLeave}
|
|
290
|
+
onFocus={(e) => {
|
|
291
|
+
const indexAttr = (e.target as HTMLElement)
|
|
292
|
+
.closest("[data-proximity-index]")
|
|
293
|
+
?.getAttribute("data-proximity-index");
|
|
294
|
+
if (indexAttr != null) {
|
|
295
|
+
const idx = Number(indexAttr);
|
|
296
|
+
setActiveIndex(idx);
|
|
297
|
+
setFocusedIndex((e.target as HTMLElement).matches(":focus-visible") ? idx : null);
|
|
298
|
+
}
|
|
299
|
+
}}
|
|
300
|
+
onBlur={(e) => {
|
|
301
|
+
if (containerRef.current?.contains(e.relatedTarget as Node)) return;
|
|
302
|
+
setFocusedIndex(null);
|
|
303
|
+
setActiveIndex(null);
|
|
304
|
+
}}
|
|
305
|
+
className={cn("relative flex flex-col gap-0.5 w-72 max-w-full", className)}
|
|
306
|
+
{...(htmlProps as HTMLAttributes<HTMLDivElement>)}
|
|
307
|
+
>
|
|
308
|
+
{/* Expanded item backgrounds */}
|
|
309
|
+
<AnimatePresence>
|
|
310
|
+
{[...openItemRects.entries()].map(([idx, rect]) => (
|
|
311
|
+
<motion.div
|
|
312
|
+
key={`expanded-${idx}`}
|
|
313
|
+
className={`absolute ${shape.bg} bg-accent/20 dark:bg-accent/12 pointer-events-none`}
|
|
314
|
+
initial={false}
|
|
315
|
+
animate={{
|
|
316
|
+
top: rect.top,
|
|
317
|
+
left: rect.left,
|
|
318
|
+
width: rect.width,
|
|
319
|
+
height: rect.height,
|
|
320
|
+
opacity: isHoveringNonOpen ? 0.7 : 1,
|
|
321
|
+
}}
|
|
322
|
+
exit={{ opacity: 0, transition: spring.moderate.exit }}
|
|
323
|
+
transition={{
|
|
324
|
+
top: { duration: 0 },
|
|
325
|
+
left: { duration: 0 },
|
|
326
|
+
width: { duration: 0 },
|
|
327
|
+
height: { duration: 0 },
|
|
328
|
+
opacity: { duration: 0.08 },
|
|
329
|
+
}}
|
|
330
|
+
/>
|
|
331
|
+
))}
|
|
332
|
+
</AnimatePresence>
|
|
333
|
+
|
|
334
|
+
{/* Hover background */}
|
|
335
|
+
<AnimatePresence>
|
|
336
|
+
{activeRect && (
|
|
337
|
+
<motion.div
|
|
338
|
+
key={sessionRef.current}
|
|
339
|
+
className={`absolute ${shape.bg} bg-hover pointer-events-none`}
|
|
340
|
+
initial={{
|
|
341
|
+
opacity: 0,
|
|
342
|
+
top: activeRect.top,
|
|
343
|
+
left: activeRect.left,
|
|
344
|
+
width: activeRect.width,
|
|
345
|
+
height: activeRect.height,
|
|
346
|
+
}}
|
|
347
|
+
animate={{
|
|
348
|
+
opacity: 1,
|
|
349
|
+
top: activeRect.top,
|
|
350
|
+
left: activeRect.left,
|
|
351
|
+
width: activeRect.width,
|
|
352
|
+
height: activeRect.height,
|
|
353
|
+
}}
|
|
354
|
+
exit={{ opacity: 0, transition: spring.fast.exit }}
|
|
355
|
+
transition={{
|
|
356
|
+
...spring.fast,
|
|
357
|
+
opacity: { duration: 0.08 },
|
|
358
|
+
}}
|
|
359
|
+
/>
|
|
360
|
+
)}
|
|
361
|
+
</AnimatePresence>
|
|
362
|
+
|
|
363
|
+
{/* Focus ring */}
|
|
364
|
+
<AnimatePresence>
|
|
365
|
+
{focusRect && (
|
|
366
|
+
<motion.div
|
|
367
|
+
className={`absolute ${shape.focusRing} pointer-events-none z-20 border border-[#6B97FF]`}
|
|
368
|
+
initial={false}
|
|
369
|
+
animate={{
|
|
370
|
+
left: focusRect.left - 2,
|
|
371
|
+
top: focusRect.top - 2,
|
|
372
|
+
width: focusRect.width + 4,
|
|
373
|
+
height: focusRect.height + 4,
|
|
374
|
+
}}
|
|
375
|
+
exit={{ opacity: 0, transition: spring.fast.exit }}
|
|
376
|
+
transition={{
|
|
377
|
+
...spring.fast,
|
|
378
|
+
opacity: { duration: 0.08 },
|
|
379
|
+
}}
|
|
380
|
+
/>
|
|
381
|
+
)}
|
|
382
|
+
</AnimatePresence>
|
|
383
|
+
|
|
384
|
+
{children}
|
|
385
|
+
</div>
|
|
386
|
+
</AccordionPrimitive.Root>
|
|
387
|
+
</AccordionGroupContext.Provider>
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
AccordionGroup.displayName = "AccordionGroup";
|
|
392
|
+
|
|
393
|
+
// ─── Accordion (Standalone) ──────────────────────────────────────────────────
|
|
394
|
+
|
|
395
|
+
interface AccordionProps extends HTMLAttributes<HTMLDivElement> {
|
|
396
|
+
children: ReactNode;
|
|
397
|
+
type?: "single" | "multiple";
|
|
398
|
+
collapsible?: boolean;
|
|
399
|
+
defaultValue?: string | string[];
|
|
400
|
+
value?: string | string[];
|
|
401
|
+
onValueChange?: ((value: string) => void) | ((value: string[]) => void);
|
|
402
|
+
}
|
|
49
403
|
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
404
|
+
const Accordion = forwardRef<HTMLDivElement, AccordionProps>(
|
|
405
|
+
(
|
|
406
|
+
{
|
|
407
|
+
children,
|
|
408
|
+
type = "single",
|
|
409
|
+
collapsible = true,
|
|
410
|
+
defaultValue,
|
|
411
|
+
value,
|
|
412
|
+
onValueChange,
|
|
59
413
|
className,
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
)
|
|
414
|
+
...props
|
|
415
|
+
},
|
|
416
|
+
ref,
|
|
417
|
+
) => {
|
|
418
|
+
// Track open values for AccordionItemContext
|
|
419
|
+
const [internalSingleValue, setInternalSingleValue] = useState<string>(() => {
|
|
420
|
+
if (type === "single") {
|
|
421
|
+
return (defaultValue as string) ?? "";
|
|
422
|
+
}
|
|
423
|
+
return "";
|
|
424
|
+
});
|
|
425
|
+
const [internalMultipleValue, setInternalMultipleValue] = useState<string[]>(() => {
|
|
426
|
+
if (type === "multiple") {
|
|
427
|
+
return (defaultValue as string[]) ?? [];
|
|
428
|
+
}
|
|
429
|
+
return [];
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const openValues = new Set<string>(
|
|
433
|
+
type === "multiple"
|
|
434
|
+
? ((value as string[] | undefined) ?? internalMultipleValue)
|
|
435
|
+
: (() => {
|
|
436
|
+
const v = (value as string | undefined) ?? internalSingleValue;
|
|
437
|
+
return v ? [v] : [];
|
|
438
|
+
})(),
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
const handleSingleChange = useCallback(
|
|
442
|
+
(v: string) => {
|
|
443
|
+
if (onValueChange) (onValueChange as (v: string) => void)(v);
|
|
444
|
+
else setInternalSingleValue(v);
|
|
445
|
+
},
|
|
446
|
+
[onValueChange],
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
const handleMultipleChange = useCallback(
|
|
450
|
+
(v: string[]) => {
|
|
451
|
+
if (onValueChange) (onValueChange as (v: string[]) => void)(v);
|
|
452
|
+
else setInternalMultipleValue(v);
|
|
453
|
+
},
|
|
454
|
+
[onValueChange],
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
const standaloneToggle = useCallback(
|
|
458
|
+
(val: string) => {
|
|
459
|
+
if (type === "multiple") {
|
|
460
|
+
const current = (value as string[] | undefined) ?? internalMultipleValue;
|
|
461
|
+
handleMultipleChange(current.filter((v) => v !== val));
|
|
462
|
+
} else {
|
|
463
|
+
handleSingleChange("");
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
[type, value, internalMultipleValue, handleSingleChange, handleMultipleChange],
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
const radixProps =
|
|
470
|
+
type === "multiple"
|
|
471
|
+
? {
|
|
472
|
+
type: "multiple" as const,
|
|
473
|
+
value: (value as string[] | undefined) ?? internalMultipleValue,
|
|
474
|
+
onValueChange: handleMultipleChange,
|
|
475
|
+
...((defaultValue as string[] | undefined) !== undefined
|
|
476
|
+
? { defaultValue: defaultValue as string[] }
|
|
477
|
+
: {}),
|
|
478
|
+
}
|
|
479
|
+
: {
|
|
480
|
+
type: "single" as const,
|
|
481
|
+
collapsible,
|
|
482
|
+
value: (value as string | undefined) ?? internalSingleValue,
|
|
483
|
+
onValueChange: handleSingleChange,
|
|
484
|
+
...((defaultValue as string | undefined) !== undefined
|
|
485
|
+
? { defaultValue: defaultValue as string }
|
|
486
|
+
: {}),
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
return (
|
|
490
|
+
<AccordionPrimitive.Root {...radixProps} asChild>
|
|
491
|
+
<div
|
|
492
|
+
ref={ref}
|
|
493
|
+
className={cn("w-72 max-w-full flex flex-col gap-0.5", className)}
|
|
494
|
+
{...props}
|
|
495
|
+
>
|
|
496
|
+
<StandaloneOpenContext.Provider value={openValues}>
|
|
497
|
+
<StandaloneToggleContext.Provider value={standaloneToggle}>
|
|
498
|
+
{children}
|
|
499
|
+
</StandaloneToggleContext.Provider>
|
|
500
|
+
</StandaloneOpenContext.Provider>
|
|
501
|
+
</div>
|
|
502
|
+
</AccordionPrimitive.Root>
|
|
503
|
+
);
|
|
504
|
+
},
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
Accordion.displayName = "Accordion";
|
|
508
|
+
|
|
509
|
+
// Standalone contexts to provide open values and toggle without AccordionGroup
|
|
510
|
+
const StandaloneOpenContext = createContext<Set<string>>(new Set());
|
|
511
|
+
const StandaloneToggleContext = createContext<(value: string) => void>(() => {});
|
|
512
|
+
|
|
513
|
+
// ─── AccordionItem ───────────────────────────────────────────────────────────
|
|
514
|
+
|
|
515
|
+
interface AccordionItemProps extends HTMLAttributes<HTMLDivElement> {
|
|
516
|
+
value: string;
|
|
517
|
+
index?: number;
|
|
518
|
+
disabled?: boolean;
|
|
519
|
+
children: ReactNode;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const AccordionItem = forwardRef<HTMLDivElement, AccordionItemProps>(
|
|
523
|
+
({ value, index, disabled, children, className, ...props }, ref) => {
|
|
524
|
+
const internalRef = useRef<HTMLDivElement>(null);
|
|
525
|
+
const groupCtx = useAccordionGroup();
|
|
526
|
+
const standaloneOpen = useContext(StandaloneOpenContext);
|
|
527
|
+
const standaloneToggle = useContext(StandaloneToggleContext);
|
|
528
|
+
const shape = useShape();
|
|
529
|
+
|
|
530
|
+
const isOpen = groupCtx?.grouped ? groupCtx.openValues.has(value) : standaloneOpen.has(value);
|
|
531
|
+
|
|
532
|
+
const triggerRef = useRef<HTMLDivElement>(null);
|
|
533
|
+
|
|
534
|
+
const onToggle = useCallback(() => {
|
|
535
|
+
if (groupCtx?.grouped) {
|
|
536
|
+
groupCtx.toggleValue(value);
|
|
537
|
+
} else {
|
|
538
|
+
standaloneToggle(value);
|
|
539
|
+
}
|
|
540
|
+
}, [groupCtx, standaloneToggle, value]);
|
|
541
|
+
|
|
542
|
+
// Register trigger element (not full item) for proximity hover
|
|
543
|
+
useEffect(() => {
|
|
544
|
+
if (groupCtx?.grouped && index !== undefined) {
|
|
545
|
+
groupCtx.registerItem(index, triggerRef.current);
|
|
546
|
+
return () => groupCtx.registerItem(index, null);
|
|
547
|
+
}
|
|
548
|
+
}, [index, groupCtx]);
|
|
549
|
+
|
|
550
|
+
// Register full item element for expanded background measurement
|
|
551
|
+
useEffect(() => {
|
|
552
|
+
if (groupCtx?.grouped && index !== undefined) {
|
|
553
|
+
if (isOpen) {
|
|
554
|
+
groupCtx.registerFullItem(index, internalRef.current);
|
|
555
|
+
} else {
|
|
556
|
+
groupCtx.registerFullItem(index, null);
|
|
557
|
+
}
|
|
558
|
+
return () => groupCtx.registerFullItem(index, null);
|
|
559
|
+
}
|
|
560
|
+
}, [index, groupCtx, isOpen]);
|
|
561
|
+
|
|
562
|
+
return (
|
|
563
|
+
<AccordionItemContext.Provider
|
|
564
|
+
value={
|
|
565
|
+
index === undefined
|
|
566
|
+
? { value, isOpen, onToggle, triggerRef }
|
|
567
|
+
: { index, value, isOpen, onToggle, triggerRef }
|
|
568
|
+
}
|
|
569
|
+
>
|
|
570
|
+
<AccordionPrimitive.Item
|
|
571
|
+
ref={(node) => {
|
|
572
|
+
(internalRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
573
|
+
if (typeof ref === "function") ref(node);
|
|
574
|
+
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
575
|
+
}}
|
|
576
|
+
value={value}
|
|
577
|
+
{...(disabled !== undefined ? { disabled } : {})}
|
|
578
|
+
data-proximity-index={index}
|
|
579
|
+
className={cn(!groupCtx?.grouped && "relative", className)}
|
|
580
|
+
{...props}
|
|
581
|
+
>
|
|
582
|
+
{/* Standalone expanded background */}
|
|
583
|
+
{!groupCtx?.grouped && (
|
|
584
|
+
<AnimatePresence>
|
|
585
|
+
{isOpen && (
|
|
586
|
+
<motion.div
|
|
587
|
+
className={`absolute inset-0 ${shape.bg} bg-accent/20 dark:bg-accent/12 pointer-events-none`}
|
|
588
|
+
initial={{ opacity: 0 }}
|
|
589
|
+
animate={{ opacity: 1 }}
|
|
590
|
+
exit={{ opacity: 0, transition: spring.fast.exit }}
|
|
591
|
+
transition={{ duration: 0.08 }}
|
|
592
|
+
/>
|
|
593
|
+
)}
|
|
594
|
+
</AnimatePresence>
|
|
595
|
+
)}
|
|
596
|
+
{children}
|
|
597
|
+
</AccordionPrimitive.Item>
|
|
598
|
+
</AccordionItemContext.Provider>
|
|
599
|
+
);
|
|
600
|
+
},
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
AccordionItem.displayName = "AccordionItem";
|
|
604
|
+
|
|
605
|
+
// ─── AccordionTrigger ────────────────────────────────────────────────────────
|
|
606
|
+
|
|
607
|
+
interface AccordionTriggerProps extends HTMLAttributes<HTMLButtonElement> {
|
|
608
|
+
children: ReactNode;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const AccordionTrigger = forwardRef<HTMLButtonElement, AccordionTriggerProps>(
|
|
612
|
+
({ children, className, ...props }, ref) => {
|
|
613
|
+
const ChevronRight = useIcon("chevron-right");
|
|
614
|
+
const groupCtx = useAccordionGroup();
|
|
615
|
+
const { index, isOpen, triggerRef } = useAccordionItemContext();
|
|
616
|
+
const shape = useShape();
|
|
617
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
618
|
+
|
|
619
|
+
const isActive = groupCtx?.grouped ? groupCtx.activeIndex === index : isHovered;
|
|
620
|
+
|
|
621
|
+
const triggerContent = (
|
|
622
|
+
<AccordionPrimitive.Header asChild>
|
|
623
|
+
<div>
|
|
624
|
+
<AccordionPrimitive.Trigger
|
|
625
|
+
ref={ref}
|
|
626
|
+
className={cn(
|
|
627
|
+
`relative z-10 flex items-center gap-2.5 ${shape.item} px-3 py-2 w-full cursor-pointer outline-none select-none`,
|
|
628
|
+
!groupCtx?.grouped &&
|
|
629
|
+
"focus-visible:ring-1 focus-visible:ring-[#6B97FF] focus-visible:ring-offset-0",
|
|
630
|
+
className,
|
|
631
|
+
)}
|
|
632
|
+
{...(props as React.ComponentProps<typeof AccordionPrimitive.Trigger>)}
|
|
633
|
+
>
|
|
634
|
+
{/* Label with dual-layer text */}
|
|
635
|
+
<span className="inline-grid text-[13px] flex-1 text-left">
|
|
636
|
+
<span
|
|
637
|
+
className="col-start-1 row-start-1 invisible"
|
|
638
|
+
style={{ fontVariationSettings: fontWeights.semibold }}
|
|
639
|
+
aria-hidden="true"
|
|
640
|
+
>
|
|
641
|
+
{"\u00a0"}
|
|
642
|
+
</span>
|
|
643
|
+
<span
|
|
644
|
+
className={cn(
|
|
645
|
+
"col-start-1 row-start-1 transition-[color,font-variation-settings] duration-80",
|
|
646
|
+
isOpen || isActive ? "text-foreground" : "text-muted-foreground",
|
|
647
|
+
)}
|
|
648
|
+
style={{
|
|
649
|
+
fontVariationSettings: isOpen ? fontWeights.semibold : fontWeights.normal,
|
|
650
|
+
}}
|
|
651
|
+
>
|
|
652
|
+
{children}
|
|
653
|
+
</span>
|
|
654
|
+
</span>
|
|
655
|
+
|
|
656
|
+
{/* Chevron — right when collapsed, rotates 90° down when expanded */}
|
|
657
|
+
<motion.span
|
|
658
|
+
className="shrink-0 inline-flex items-center justify-center"
|
|
659
|
+
animate={{ rotate: isOpen ? 90 : 0 }}
|
|
660
|
+
transition={spring.fast}
|
|
661
|
+
>
|
|
662
|
+
<ChevronRight
|
|
663
|
+
size={16}
|
|
664
|
+
strokeWidth={isOpen || isActive ? 2 : 1.5}
|
|
665
|
+
className={cn(
|
|
666
|
+
"transition-[color,stroke-width] duration-80",
|
|
667
|
+
isOpen || isActive ? "text-foreground" : "text-muted-foreground",
|
|
668
|
+
)}
|
|
669
|
+
/>
|
|
670
|
+
</motion.span>
|
|
671
|
+
</AccordionPrimitive.Trigger>
|
|
672
|
+
</div>
|
|
673
|
+
</AccordionPrimitive.Header>
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
// In grouped mode, wrap in a div for proximity hover registration
|
|
677
|
+
if (groupCtx?.grouped) {
|
|
678
|
+
return <div ref={triggerRef}>{triggerContent}</div>;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Standalone mode: local hover with animated BG
|
|
682
|
+
return (
|
|
683
|
+
<div
|
|
684
|
+
className="relative"
|
|
685
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
686
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
687
|
+
>
|
|
688
|
+
<AnimatePresence>
|
|
689
|
+
{isHovered && (
|
|
690
|
+
<motion.div
|
|
691
|
+
className={`absolute inset-0 ${shape.bg} bg-hover pointer-events-none`}
|
|
692
|
+
initial={{ opacity: 0 }}
|
|
693
|
+
animate={{ opacity: 1 }}
|
|
694
|
+
exit={{ opacity: 0, transition: spring.fast.exit }}
|
|
695
|
+
transition={{ duration: 0.08 }}
|
|
696
|
+
/>
|
|
697
|
+
)}
|
|
698
|
+
</AnimatePresence>
|
|
699
|
+
{triggerContent}
|
|
700
|
+
</div>
|
|
701
|
+
);
|
|
702
|
+
},
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
AccordionTrigger.displayName = "AccordionTrigger";
|
|
706
|
+
|
|
707
|
+
// ─── AccordionContent ────────────────────────────────────────────────────────
|
|
708
|
+
|
|
709
|
+
interface AccordionContentProps extends HTMLAttributes<HTMLDivElement> {
|
|
710
|
+
children: ReactNode;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const AccordionContent = forwardRef<HTMLDivElement, AccordionContentProps>(
|
|
714
|
+
({ children, className, ...props }, ref) => {
|
|
715
|
+
const groupCtx = useAccordionGroup();
|
|
716
|
+
const { isOpen } = useAccordionItemContext();
|
|
717
|
+
|
|
718
|
+
return (
|
|
719
|
+
<AnimatePresence initial={false}>
|
|
720
|
+
{isOpen && (
|
|
721
|
+
<AccordionPrimitive.Content forceMount asChild {...props}>
|
|
722
|
+
<motion.div
|
|
723
|
+
ref={ref}
|
|
724
|
+
className={cn("overflow-hidden", className)}
|
|
725
|
+
initial={{ height: 0 }}
|
|
726
|
+
animate={{ height: "auto" }}
|
|
727
|
+
exit={{ height: 0 }}
|
|
728
|
+
// bounce: 0 — a critically damped spring on body height.
|
|
729
|
+
// spring.moderate has bounce 0.15 which overshoots the
|
|
730
|
+
// "auto" target by a few px; under an ancestor transform:
|
|
731
|
+
// scale (e.g. /demo's 1.7x card), that overshoot becomes a
|
|
732
|
+
// visible pop. Pure height has no aesthetic value in
|
|
733
|
+
// bouncing, so a smooth approach reads better.
|
|
734
|
+
transition={{ ...spring.moderate, bounce: 0 }}
|
|
735
|
+
onUpdate={() => {
|
|
736
|
+
groupCtx?.remeasure();
|
|
737
|
+
}}
|
|
738
|
+
onAnimationComplete={() => {
|
|
739
|
+
groupCtx?.remeasure();
|
|
740
|
+
}}
|
|
741
|
+
>
|
|
742
|
+
<div className="px-3 pb-3 pt-1 text-[13px] text-muted-foreground">{children}</div>
|
|
743
|
+
</motion.div>
|
|
744
|
+
</AccordionPrimitive.Content>
|
|
745
|
+
)}
|
|
746
|
+
</AnimatePresence>
|
|
747
|
+
);
|
|
748
|
+
},
|
|
749
|
+
);
|
|
750
|
+
|
|
66
751
|
AccordionContent.displayName = "AccordionContent";
|
|
67
752
|
|
|
68
|
-
|
|
753
|
+
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
754
|
+
|
|
755
|
+
export { Accordion, AccordionGroup, AccordionItem, AccordionTrigger, AccordionContent };
|
|
756
|
+
export default Accordion;
|