Files
Arkie-Library-Frontend/src/components/messageStream/CollapsibleText.tsx
TerryM d7e2e56cde ui: home carousel height lock + bubble polish
- Home: lock category carousel height to the tallest page so the
  Official Recommendations section below does not jump up when
  swiping to a page with fewer categories.
- CollapsibleText: raise default threshold to 25 lines and tighten
  the spacing between the expand-all button and the timestamp
  (drop the fixed h-8 and use mt-1 instead of mt-1.5).
- formatTime: always render dates as yyyy/m/d HH:mm regardless of
  locale, matching the requested timestamp format.
2026-05-30 00:43:54 +08:00

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 = 25,
}: {
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 inline-flex items-center gap-1 text-[13px] font-medium leading-5 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>
);
}