import { useCallback, useRef, type ReactNode } from "react"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { useTranslation } from "react-i18next"; import clsx from "clsx"; import { useScrollEdges } from "../../lib/useScrollEdges"; import { useListNav } from "../../lib/listNav"; import { Button } from "./Button"; import { IconButton } from "./IconButton"; import { REDUCED_MOTION } from "../../lib/useMediaQuery"; // 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(REDUCED_MOTION).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, keyboardNav = false, 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; /** The cards spread `navItemAttrs(i)` on their roots: `j`/`k`, ←→ and Enter walk the rail, and ↑↓ * leave it for the rail above or below (lib/listNav). */ keyboardNav?: boolean; children: ReactNode; }) { const { t } = useTranslation(); const { ref, el, left, right } = useHScroll(); // The scroller is the list the keyboard walks. useHScroll holds the node through a callback ref, // listNav reads a ref object — one node, both handed it. const navRef = useRef(null); const setScroller = useCallback( (node: HTMLDivElement | null) => { ref(node); navRef.current = node; }, [ref], ); useListNav(navRef, { enabled: keyboardNav }); 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. // Round, dark translucent: they sit over the pictures (A2). const arrowBtn = "reveal-hover absolute top-1/2 -translate-y-1/2 z-base backdrop-blur-xs border border-white/15 shadow-lg " + "opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100"; return (
{title != null && (

{title}

{onSeeAll && ( )}
)}
{left && ( page(-1)} label={t("common.scrollLeft")} className={clsx(arrowBtn, "left-1")} > )} {/* The px-1/pt-1 against -mx-1/-mt-1 keep the layout and give the keyboard ring room: a scroller clips its overflow, and the ring sits 4px outside the card it surrounds. */}
{children}
{right && ( page(1)} label={t("common.scrollRight")} className={clsx(arrowBtn, "right-1")} > )}
); }