@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,68 +1,756 @@
1
1
  "use client";
2
2
 
3
- import * as React from "react";
4
- import { Accordion } from "radix-ui";
5
- import { ChevronDown } from "lucide-react";
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
- const AccordionRoot = React.forwardRef<
9
- React.ComponentRef<typeof Accordion.Root>,
10
- React.ComponentPropsWithoutRef<typeof Accordion.Root>
11
- >(({ className, ...props }, ref) => (
12
- <Accordion.Root ref={ref} className={cn("divide-y divide-border", className)} {...props} />
13
- ));
14
- AccordionRoot.displayName = "Accordion";
15
-
16
- const AccordionItem = React.forwardRef<
17
- React.ComponentRef<typeof Accordion.Item>,
18
- React.ComponentPropsWithoutRef<typeof Accordion.Item>
19
- >(({ className, ...props }, ref) => (
20
- <Accordion.Item ref={ref} className={cn("", className)} {...props} />
21
- ));
22
- AccordionItem.displayName = "AccordionItem";
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 AccordionTrigger = React.forwardRef<
25
- React.ComponentRef<typeof Accordion.Trigger>,
26
- React.ComponentPropsWithoutRef<typeof Accordion.Trigger>
27
- >(({ className, children, ...props }, ref) => (
28
- <Accordion.Header className="flex">
29
- <Accordion.Trigger
30
- ref={ref}
31
- className={cn(
32
- "flex w-full items-center justify-between py-4",
33
- "body font-medium text-foreground transition-colors hover:text-muted-foreground",
34
- "[&[data-state=open]>svg]:rotate-180",
35
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/20",
36
- className,
37
- )}
38
- {...props}
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
- {children}
41
- <ChevronDown
42
- size={16}
43
- className="text-muted-foreground transition-transform duration-200 shrink-0"
44
- />
45
- </Accordion.Trigger>
46
- </Accordion.Header>
47
- ));
48
- AccordionTrigger.displayName = "AccordionTrigger";
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 AccordionContent = React.forwardRef<
51
- React.ComponentRef<typeof Accordion.Content>,
52
- React.ComponentPropsWithoutRef<typeof Accordion.Content>
53
- >(({ className, children, ...props }, ref) => (
54
- <Accordion.Content
55
- ref={ref}
56
- className={cn(
57
- "overflow-hidden body text-muted-foreground",
58
- "data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
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
- {...props}
62
- >
63
- <div className="pb-4">{children}</div>
64
- </Accordion.Content>
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
- export { AccordionRoot as Accordion, AccordionItem, AccordionTrigger, AccordionContent };
753
+ // ─── Exports ─────────────────────────────────────────────────────────────────
754
+
755
+ export { Accordion, AccordionGroup, AccordionItem, AccordionTrigger, AccordionContent };
756
+ export default Accordion;