import { useCallback, type ReactNode } from "react"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { useTranslation } from "react-i18next"; import clsx from "clsx"; import { useScrollEdges } from "../lib/useScrollEdges"; // 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: whether the scroller is clipped on the left / right, so the rail shows // a button + fade only where there's more to see. The X-axis face of the shared `useScrollEdges` core // (the same watch-the-scroller-and-its-children discipline as the vertical `useScrollFade`), plus the // element itself so the paging buttons can scroll it. function useHScroll(): { ref: (node: HTMLElement | null) => void; el: HTMLElement | null; left: boolean; right: boolean; } { const { ref, el, start, end } = useScrollEdges("x"); return { ref, el, left: start, right: end }; } 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); // Mouse-only: hidden until the carousel is hovered or the arrow is keyboard-focused; `reveal-hover` // removes it on touch, where the strip scrolls by swipe — so an invisible edge button never eats a tap. const arrowBtn = "reveal-hover 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 (