Focus had scrolled the clamped box itself; opened, the link dropped by that much, out of a scrolling panel's sight. DESIGN states where Close sits in the stacked layout; a stale test comment goes.
90 lines
3.4 KiB
TypeScript
90 lines
3.4 KiB
TypeScript
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<HTMLDivElement>(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<Element | null>(null);
|
|
const revealFocused = (e: FocusEvent<HTMLDivElement>) => {
|
|
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 (
|
|
<div className={className}>
|
|
<div
|
|
ref={ref}
|
|
className={`${textClassName} ${expanded ? "" : CLAMP[lines]}`}
|
|
onFocus={revealFocused}
|
|
>
|
|
{children ?? text}
|
|
</div>
|
|
{(clipped || expanded) && (
|
|
<Button
|
|
variant="link"
|
|
aria-expanded={expanded}
|
|
onClick={() => setExpanded((v) => !v)}
|
|
className="mt-1 text-xs"
|
|
>
|
|
{expanded ? lessLabel : moreLabel}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|