import { useLayoutEffect, useRef, useState, type FocusEvent, type ReactNode } from "react"; import { Button } from "./Button"; // Static class names, so Tailwind sees them. const CLAMP = { 3: "line-clamp-3", 5: "line-clamp-5", 8: "line-clamp-8" } as const; /** A long text (a biography, a summary) clamped to a few lines, with a More / Less toggle offered only * when the text is actually cut — measured, so a short text carries no dead button. Re-measured on a * resize: the window and the text-size slider both move where the cut falls. Another text starts * collapsed: a view reused for the next item (a show page opening another show) must not open its * text already expanded. `children` draws the text in a richer form (a description's links and * timestamps); `text` still names it, so another text is still another text. A link the cut hides * stays in the tab order (it is still text), so reaching it opens the text: focus never sits on * something out of sight. */ export function ClampedText({ text, lines, moreLabel, lessLabel, className, textClassName = "measure whitespace-pre-line text-sm leading-relaxed text-muted", children, }: { text: string; lines: keyof typeof CLAMP; moreLabel: string; lessLabel: string; className?: string; /** The text box's own dress (the default: a summary's muted paragraph). */ textClassName?: string; children?: ReactNode; }) { const [expanded, setExpanded] = useState(false); const [clipped, setClipped] = useState(false); const [shown, setShown] = useState(text); if (shown !== text) { setShown(text); setExpanded(false); } const ref = useRef(null); useLayoutEffect(() => { const el = ref.current; if (!el || expanded) return; const measure = () => setClipped(el.scrollHeight > el.clientHeight + 1); measure(); const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, [text, expanded]); // Measured from the text's top, not against the box on screen: focus scrolls the clamped box itself // to bring the link into view (overflow:hidden still scrolls), so on screen it looked "in sight". const revealing = useRef(null); const revealFocused = (e: FocusEvent) => { const box = e.currentTarget; if (expanded || e.target === box) return; const top = box.getBoundingClientRect().top - box.scrollTop; if (e.target.getBoundingClientRect().bottom - top <= box.clientHeight + 1) return; revealing.current = e.target; setExpanded(true); }; // That scroll was the box's own: opened, the link drops back to its place in the text, as far below // as the box had scrolled, and out of a scrolling panel's sight. Brought back into view. useLayoutEffect(() => { const el = revealing.current; revealing.current = null; if (expanded && el?.isConnected) el.scrollIntoView({ block: "nearest" }); }, [expanded]); return (
{children ?? text}
{(clipped || expanded) && ( )}
); }