/** * Carousel - a horizontal image/slide slider with swipe, arrows and dot dots. * * Slides come either as `items` (image objects {src, alt}) or as arbitrary * `slides` (ReactNode[]). One slide is shown at a time; the track snaps between * them. Interaction mirrors DetailSheet's proven pointer mechanics but on the X * axis: during a drag the track transform is mutated imperatively with the * transition off (no re-render per move), and on release a threshold decides * whether to advance. Prev/next arrows and clickable dots also move it, and when * the strip is focused the left/right arrow keys step through. * * Accessibility: role="group" aria-roledescription="carousel", dots are labelled * buttons with aria-current. Colours from the theme, radii from tokens; both * themes. */ import { useRef, useState } from "react"; import type { CSSProperties, KeyboardEvent, PointerEvent, ReactNode } from "react"; import { R, SP } from "@rukin/tokens"; import { useTheme } from "./theme"; import type { Theme } from "./theme"; import { Icon } from "./Icon"; export type CarouselImage = { src: string; alt?: string }; export type CarouselProps = { /** Image slides (convenience). Ignored when `slides` is provided. */ items?: CarouselImage[]; /** Arbitrary slides; takes precedence over `items`. */ slides?: ReactNode[]; /** Controlled active index. */ index?: number; onIndexChange?: (index: number) => void; /** Uncontrolled initial index. */ defaultIndex?: number; /** Slide viewport height (default 260). */ height?: number | string; style?: CSSProperties; }; const SWIPE_THRESHOLD = 60; // px dragged past which release advances a slide export function Carousel({ items, slides, index, onIndexChange, defaultIndex = 0, height = 260, style, }: CarouselProps) { const { C } = useTheme(); const resolved: ReactNode[] = slides ?? (items ?? []).map((im, i) => ( )); const count = resolved.length; const [internal, setInternal] = useState(clamp(defaultIndex, count)); const controlled = index !== undefined; const active = clamp(controlled ? index! : internal, count); const viewportRef = useRef(null); const trackRef = useRef(null); const startX = useRef(null); const dragging = useRef(false); const go = (i: number) => { const next = clamp(i, count); if (next === active) return; if (controlled) onIndexChange?.(next); else setInternal(next); }; const onPointerDown = (e: PointerEvent) => { if (count <= 1) return; startX.current = e.clientX; dragging.current = false; const track = trackRef.current; if (track) track.style.transition = "none"; (e.currentTarget as HTMLDivElement).setPointerCapture?.(e.pointerId); }; const onPointerMove = (e: PointerEvent) => { const track = trackRef.current; const vw = viewportRef.current?.clientWidth ?? 1; if (track === null || startX.current === null) return; const dx = e.clientX - startX.current; if (Math.abs(dx) > 4) dragging.current = true; // resist dragging past the first/last edge const resist = (active === 0 && dx > 0) || (active === count - 1 && dx < 0) ? 0.35 : 1; track.style.transform = `translateX(${-active * vw + dx * resist}px)`; }; const endDrag = (e: PointerEvent) => { const track = trackRef.current; if (track) track.style.transition = ""; if (startX.current !== null && dragging.current) { const dx = e.clientX - startX.current; if (dx <= -SWIPE_THRESHOLD) go(active + 1); else if (dx >= SWIPE_THRESHOLD) go(active - 1); } // drop the inline px transform so the % state transform takes back over if (track) track.style.transform = ""; startX.current = null; dragging.current = false; }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "ArrowRight") { e.preventDefault(); go(active + 1); } else if (e.key === "ArrowLeft") { e.preventDefault(); go(active - 1); } }; if (count === 0) return null; return ( {resolved.map((s, i) => ( {s} ))} {count > 1 && ( <> go(active - 1)} /> go(active + 1)} /> {resolved.map((_, i) => ( go(i)} style={{ width: i === active ? 20 : 8, height: 8, padding: 0, border: "none", borderRadius: R.pill, background: i === active ? "#fff" : "rgba(255,255,255,.55)", boxShadow: "0 1px 3px rgba(0,0,0,.3)", cursor: "pointer", transition: "width .2s, background .2s", }} /> ))} > )} ); } function clamp(i: number, count: number): number { if (count <= 0) return 0; return Math.max(0, Math.min(count - 1, i)); } function NavArrow({ C, dir, disabled, onClick, }: { C: Theme["C"]; dir: "prev" | "next"; disabled: boolean; onClick: () => void; }) { const [hover, setHover] = useState(false); if (disabled) return null; return ( setHover(true)} onPointerLeave={() => setHover(false)} style={{ position: "absolute", top: "50%", [dir === "prev" ? "left" : "right"]: SP[3], transform: "translateY(-50%)", width: 36, height: 36, display: "inline-flex", alignItems: "center", justifyContent: "center", border: "none", borderRadius: R.pill, background: hover ? "rgba(0,0,0,.62)" : "rgba(0,0,0,.42)", color: "#fff", cursor: "pointer", transition: "background .15s", zIndex: 2, }} > ); } const dotsRow: CSSProperties = { position: "absolute", left: 0, right: 0, bottom: 12, display: "flex", justifyContent: "center", alignItems: "center", gap: 6, zIndex: 2, };