"use client"; import { ChevronDown } from "lucide-react"; import { type HTMLMotionProps, isValidMotionProp, motion, useReducedMotion } from "motion/react"; import { cloneElement, createContext, type ReactElement, type Ref, type RefObject, useCallback, useContext, useId, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type CollapsibleState = { open: boolean; disabled: boolean; setOpen: (open: boolean) => void; toggle: () => void; }; type CollapsibleContextValue = CollapsibleState & { reduce: boolean; contentId: string; triggerId: string; triggerRef: RefObject; }; const CollapsibleContext = createContext(null); function useCollapsibleContext(component: string) { const context = useContext(CollapsibleContext); if (!context) throw new Error(`${component} must be used within `); return context; } /** Shared state and actions for custom children inside a Collapsible root. */ export function useCollapsible(): CollapsibleState { return useCollapsibleContext("useCollapsible"); } function mergeRefs(...refs: Array | undefined>) { return (node: T | null) => { const cleanups = refs.map((ref) => { if (typeof ref === "function") return ref(node); if (ref) ref.current = node; return undefined; }); if (!node) return; return () => { refs.forEach((ref, index) => { const cleanup = cleanups[index]; if (typeof cleanup === "function") cleanup(); else if (typeof ref === "function") ref(null); else if (ref) ref.current = null; }); }; }; } export interface CollapsibleProps extends Omit, "layout"> { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; disabled?: boolean; /** Layout classes for the inner wrapper, separate from the animated surface. */ contentClassName?: string; } export function Collapsible({ open: controlledOpen, defaultOpen = false, onOpenChange, disabled = false, className, contentClassName, children, style, ...props }: CollapsibleProps) { const [internalOpen, setInternalOpen] = useState(defaultOpen); const open = controlledOpen ?? internalOpen; const reduce = useReducedMotion() ?? false; const id = useId(); const triggerRef = useRef(null); const stateRef = useRef({ open, controlled: controlledOpen !== undefined, disabled, onOpenChange }); useLayoutEffect(() => { stateRef.current = { open, controlled: controlledOpen !== undefined, disabled, onOpenChange }; }, [controlledOpen, disabled, onOpenChange, open]); const setOpen = useCallback((next: boolean) => { const state = stateRef.current; if (state.disabled || next === state.open) return; if (!state.controlled) { stateRef.current = { ...state, open: next }; setInternalOpen(next); } state.onOpenChange?.(next); }, []); const toggle = useCallback(() => setOpen(!stateRef.current.open), [setOpen]); const context = useMemo( () => ({ open, disabled, reduce, contentId: `${id}-content`, triggerId: `${id}-trigger`, triggerRef, setOpen, toggle }), [disabled, id, open, reduce, setOpen, toggle], ); return ( {children} ); } export interface CollapsibleTriggerProps extends Omit, "id" | "layout"> { /** Reuse a button element or a component that forwards its props and ref to a button. */ render?: ReactElement; } export function CollapsibleTrigger({ className, children, onClick, disabled, ref, render, style, ...props }: CollapsibleTriggerProps) { const context = useCollapsibleContext("CollapsibleTrigger"); const child = render as ReactElement> | undefined; const childRef = child?.props.ref; const mergedRef = useMemo( () => mergeRefs(context.triggerRef, ref, childRef), [context.triggerRef, ref, childRef], ); const unavailable = context.disabled || disabled || child?.props.disabled; const handleClick: NonNullable["onClick"]> = (event) => { child?.props.onClick?.(event); onClick?.(event); if (!event.defaultPrevented && !unavailable) context.toggle(); }; const triggerProps = { id: context.triggerId, type: "button" as const, disabled: unavailable, "aria-expanded": context.open, "aria-controls": context.contentId, "data-slot": "collapsible-trigger", "data-state": context.open ? "open" : "closed", "data-disabled": unavailable || undefined, onClick: handleClick, ref: mergedRef, }; if (child) { if (typeof child.type === "string" && child.type !== "button") { throw new Error("CollapsibleTrigger render must be a button or a component that renders a button."); } const forwarded = typeof child.type === "string" ? Object.fromEntries(Object.entries(props).filter(([key]) => !isValidMotionProp(key))) : props; return cloneElement(child, { ...forwarded, ...triggerProps, className: cn(className, child.props.className), style: { ...style, ...child.props.style }, children: children === undefined ? child.props.children : children, }); } return ( {children} ); } export interface CollapsibleContentProps extends Omit, "id" | "layout" | "animate" | "initial"> { /** Padding and layout classes for the measured content, inside the clipping wrapper. */ contentClassName?: string; } export function CollapsibleContent({ className, contentClassName, children, ref, style, ...props }: CollapsibleContentProps) { const context = useCollapsibleContext("CollapsibleContent"); const contentRef = useRef(null); const innerRef = useRef(null); const [height, setHeight] = useState(0); const mergedRef = useMemo(() => mergeRefs(contentRef, ref), [ref]); useLayoutEffect(() => { const node = innerRef.current; if (!node) return; const measure = () => setHeight(node.offsetHeight); measure(); const observer = new ResizeObserver(measure); observer.observe(node); return () => observer.disconnect(); }, []); useLayoutEffect(() => { if (!context.open && contentRef.current?.contains(document.activeElement)) { context.triggerRef.current?.focus({ preventScroll: true }); } }, [context.open, context.triggerRef]); return ( {children} ); } export interface CollapsibleIndicatorProps extends HTMLMotionProps<"span"> {} export function CollapsibleIndicator({ className, children, ...props }: CollapsibleIndicatorProps) { const { open, reduce } = useCollapsibleContext("CollapsibleIndicator"); return ( ); }