Files
siftlode/frontend/src/components/ui/ClampedText.tsx
T
peter 77638f7a0b fix(player): a link that opens the cut text is scrolled back into view
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.
2026-10-09 23:46:40 +02:00

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