import { useCallback, useEffect, useState, type ReactNode } from "react"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { useTranslation } from "react-i18next"; import clsx from "clsx"; // Shared horizontal rail — the one place the app's "row of cards you scroll sideways" lives, replacing // the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future // Plex-Discover rows. Gives every consumer the same affordances for free: edge fade, hover/focus scroll // buttons that appear only on the side that actually has more content, a native scrollbar hidden behind // `.no-scrollbar`, and reduced-motion-aware paging. Purely presentational — the caller owns the cards. // Prefer an instant jump when the viewer asked for reduced motion; smooth otherwise. Guarded for jsdom, // where matchMedia is often absent (the button paging is still exercised — it just uses "auto" there). function scrollBehavior(): ScrollBehavior { const reduce = typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; return reduce ? "auto" : "smooth"; } // Horizontal scroll-affordance: tracks whether the scroller is clipped on the left / right so the rail // can show a button + fade only where there's more to see. The X-axis twin of useScrollFade — same // node-state callback ref (re-runs on attach) and same watch-the-children discipline (a rail whose // cards load in later grows scrollWidth without ever firing a scroll event), and it also hands back the // element so the buttons can scroll it. function useHScroll(): { ref: (node: HTMLElement | null) => void; el: HTMLElement | null; left: boolean; right: boolean; } { const [el, setEl] = useState(null); const [edge, setEdge] = useState({ left: false, right: false }); useEffect(() => { if (!el) return; const update = () => { const left = el.scrollLeft > 1; const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1; setEdge((prev) => (prev.left === left && prev.right === right ? prev : { left, right })); }; update(); el.addEventListener("scroll", update, { passive: true }); const ro = new ResizeObserver(update); ro.observe(el); for (const child of Array.from(el.children)) ro.observe(child); const mo = new MutationObserver((records) => { for (const rec of records) { for (const node of Array.from(rec.addedNodes)) if (node instanceof Element) ro.observe(node); for (const node of Array.from(rec.removedNodes)) if (node instanceof Element) ro.unobserve(node); } update(); }); mo.observe(el, { childList: true }); return () => { el.removeEventListener("scroll", update); ro.disconnect(); mo.disconnect(); }; }, [el]); return { ref: setEl, el, left: edge.left, right: edge.right }; } const FADE_PX = 28; export function Carousel({ title, onSeeAll, seeAllLabel, ariaLabel, gap = "gap-3", className, children, }: { /** Optional built-in header (rails). Omit for a bare scroller inside a card that owns its own heading. */ title?: ReactNode; /** When set, a "see all" affordance appears in the header (needs `title`). */ onSeeAll?: () => void; seeAllLabel?: string; /** Names the scroll region for AT; falls back to the string `title` when that's a plain string. */ ariaLabel?: string; /** Tailwind gap class for the scroller (e.g. "gap-4"). */ gap?: string; className?: string; children: ReactNode; }) { const { t } = useTranslation(); const { ref, el, left, right } = useHScroll(); const page = useCallback( (dir: -1 | 1) => { if (!el) return; // ~one viewport-worth, but never a hair scroll on a narrow rail (or 0 in a zero-width jsdom box). const amount = Math.max(Math.round(el.clientWidth * 0.85), 200); el.scrollBy({ left: dir * amount, behavior: scrollBehavior() }); }, [el], ); // Fade only the clipped edge — no fade at a boundary, none when it all fits (matches useScrollFade). const mask = `linear-gradient(to right, transparent, #000 ${left ? FADE_PX : 0}px, #000 calc(100% - ${ right ? FADE_PX : 0 }px), transparent)`; const label = ariaLabel ?? (typeof title === "string" ? title : undefined); const arrowBtn = "absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " + "bg-black/55 text-white backdrop-blur-xs border border-white/15 shadow-lg transition " + "opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75"; return (
{title != null && (

{title}

{onSeeAll && ( )}
)}
{left && ( )}
{children}
{right && ( )}
); }