Files
Arkie-Library-Frontend/src/components/messageStream/CollapsibleText.tsx

128 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<HTMLDivElement>(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 (
<div className={wrapperClassName}>
<m.div
className="relative overflow-hidden"
animate={{ height: targetHeight }}
transition={{ duration: 0.3, ease: EASE_OUT }}
style={needsToggle ? undefined : { height: "auto" }}
>
<div ref={innerRef} className={className}>
{children}
</div>
{/* Soft fade hints "more content below" when clamped. */}
<AnimatePresence>
{isClamped ? (
<m.div
key="fade"
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: EASE_OUT }}
className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-[#272632] via-[#272632]/85 to-transparent"
/>
) : null}
</AnimatePresence>
</m.div>
{needsToggle ? (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
className="group mt-1.5 inline-flex h-8 items-center gap-1 text-[13px] font-medium text-ark-gold transition-colors duration-200 hover:text-ark-gold2"
>
<span>{expanded ? t("showLess") : t("showMore")}</span>
<m.span
className="inline-flex"
animate={{ rotate: expanded ? 180 : 0 }}
transition={{ duration: 0.25, ease: EASE_OUT }}
>
<ChevronDown className="h-4 w-4" strokeWidth={2.2} />
</m.span>
</button>
) : null}
</div>
);
}