"use client" import * as React from "react" import { cn } from "@/lib/utils" interface CollapsibleContextValue { open: boolean setOpen: (open: boolean) => void contentId: string } const CollapsibleContext = React.createContext(null) function useCollapsible() { const context = React.useContext(CollapsibleContext) if (!context) { throw new Error("useCollapsible must be used within a Collapsible") } return context } interface CollapsibleProps extends React.HTMLAttributes { open?: boolean defaultOpen?: boolean onOpenChange?: (open: boolean) => void disabled?: boolean } function Collapsible({ children, open: controlledOpen, defaultOpen = false, onOpenChange, disabled, className, ...props }: CollapsibleProps) { const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen) const contentId = React.useId() const isControlled = controlledOpen !== undefined const open = isControlled ? controlledOpen : uncontrolledOpen const setOpen = React.useCallback( (value: boolean) => { if (disabled) return if (!isControlled) { setUncontrolledOpen(value) } onOpenChange?.(value) }, [disabled, isControlled, onOpenChange] ) return (
{children}
) } interface CollapsibleTriggerProps extends React.ButtonHTMLAttributes { asChild?: boolean } function CollapsibleTrigger({ children, asChild, className, ...props }: CollapsibleTriggerProps) { const { open, setOpen, contentId } = useCollapsible() const handleClick = (e: React.MouseEvent) => { props.onClick?.(e) if (!e.defaultPrevented) { setOpen(!open) } } if (asChild && React.isValidElement(children)) { return React.cloneElement(children as React.ReactElement, { onClick: handleClick, "aria-expanded": open, "aria-controls": contentId, "data-state": open ? "open" : "closed", "data-slot": "collapsible-trigger", }) } return ( ) } interface CollapsibleContentProps extends React.HTMLAttributes { forceMount?: boolean } function CollapsibleContent({ children, className, forceMount, ...props }: CollapsibleContentProps) { const { open, contentId } = useCollapsible() const contentRef = React.useRef(null) const [height, setHeight] = React.useState(undefined) const [isAnimating, setIsAnimating] = React.useState(false) React.useLayoutEffect(() => { const content = contentRef.current if (!content) return if (open) { // Opening: measure and animate setIsAnimating(true) const contentHeight = content.scrollHeight setHeight(contentHeight) const timer = setTimeout(() => { setIsAnimating(false) setHeight(undefined) }, 200) // Match animation duration return () => clearTimeout(timer) } else { // Closing: set current height first, then animate to 0 const contentHeight = content.scrollHeight setHeight(contentHeight) setIsAnimating(true) // Force reflow then set to 0 requestAnimationFrame(() => { requestAnimationFrame(() => { setHeight(0) }) }) const timer = setTimeout(() => { setIsAnimating(false) }, 200) return () => clearTimeout(timer) } }, [open]) if (!open && !isAnimating && !forceMount) { return null } return ( ) } export { Collapsible, CollapsibleTrigger, CollapsibleContent }