import { AnimatePresence, m } from "framer-motion"; import { ChevronDown } from "lucide-react"; import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, } from "react"; import { useI18n } from "../../i18n"; import { EASE_OUT } from "../../motion"; /** * Collapsible long-text container. * * When the rendered text overflows `collapsedLines`: * 1. The text is clipped to a clean line boundary. * 2. A soft gradient fade hints that more content is below. * 3. A centered pill button with a chevron icon offers a clear tap target. * * The clipped height is `collapsedLines × computed line-height` so the cut * always lands at a line boundary — no half-cut characters. * * Motion uses the shared `EASE_OUT` curve and respects MotionProvider's * global `reducedMotion="user"`. */ export function CollapsibleText({ children, className = "", wrapperClassName = "", collapsedLines = 8, }: { children: ReactNode; /** Typography classes applied to the text container. */ className?: string; /** Outer wrapper classes (padding, etc.). */ wrapperClassName?: string; collapsedLines?: number; }) { const { t } = useI18n(); const innerRef = useRef(null); const [clampedHeight, setClampedHeight] = useState(0); const [fullHeight, setFullHeight] = useState(0); const [needsToggle, setNeedsToggle] = useState(false); const [expanded, setExpanded] = useState(false); const measure = () => { const el = innerRef.current; if (!el) return; const lh = parseFloat(getComputedStyle(el).lineHeight) || 24; const ch = Math.round(lh * collapsedLines); const fh = el.scrollHeight; setClampedHeight(ch); setFullHeight(fh); setNeedsToggle(fh > ch + 4); }; useLayoutEffect(() => { measure(); if (typeof ResizeObserver === "undefined") return; const ro = new ResizeObserver(measure); if (innerRef.current) ro.observe(innerRef.current); return () => ro.disconnect(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [children, collapsedLines]); useEffect(() => { const id = window.requestAnimationFrame(measure); return () => window.cancelAnimationFrame(id); // eslint-disable-next-line react-hooks/exhaustive-deps }, [children, collapsedLines]); const isClamped = needsToggle && !expanded; const targetHeight = needsToggle ? expanded ? fullHeight : clampedHeight : undefined; return (
{children}
{/* Soft fade hints "more content below" when clamped. */} {isClamped ? ( ) : null}
{needsToggle ? ( ) : null}
); }