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

128 lines
4.0 KiB
TypeScript
Raw Normal View History

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>
);
}