"use client"; /** * — THE horizontal card-strip engine (single source of truth). * * Extracted verbatim from so every strip surface shares ONE * marquee/chevron/seam/measure implementation ("unify means delete * variations"). Two input modes, one engine: * * MODE A — render-prop (`items` + `itemKey` + `renderCard`): full control. * The card receives the strip context (controlled activation, clone flag, * shared player-mount gate) and owns its own clone a11y. Used by * . The engine adds NO cell wrapper in this mode — the * card's root is the track's direct child (`scrollByCard` measures * `track.firstElementChild.offsetWidth`, and the `flex items-stretch gap-4` * sizing contract depends on it). * * MODE B — children (organic): ANY card components as plain JSX children. * Each child is auto-wrapped in a managed cell (fixed width, hover/keyboard * marquee-pause, clone `aria-hidden` + focus-suppressed but still CLICKABLE, * row-height stretch). * Adding a new card type requires ZERO strip code. Contract: * (a) pass an ARRAY of children (`{rows.map(...)}`) — `Children.toArray` * does NOT flatten through a single child (it would become * one giant cell; a dev-only warning fires); * (b) when `autoScroll` is on, children must be ref-free / unique-DOM-id- * free / mount-effect-free — the clone copy mounts the same element a * second time (double-fired effects, duplicate ids, last-mounted ref). * * Activation invariant: hover/focus activation is wired in exactly ONE place — * the managed cell (children mode) OR the card itself (render-prop mode, * e.g. ) — never both. * * Marquee state model (explicit — do not "simplify" into one timer): * the rAF advances only when the pause-reason set {cardHovered, offViewport, * tabHidden, reducedMotion} is empty AND `now > max(chevronSuppressUntil, * userScrollSuppressUntil)`. cardHovered means the pointer is over a CARD * (incl. its overlay) — leaving the card resumes the marquee immediately. * Chevron clicks and manual wheel/touch each set their own suppress-until * timestamp; any card-level leave-grace is a separate concern that never * touches marquee state. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { cn } from '../../utils/cn'; import { NEAR_VIEWPORT_ROOT_MARGIN } from '../../hooks/use-near-viewport'; import { useMarqueeEngine } from '../../hooks/ui/use-marquee-engine'; import { useResetOnPageHidden } from '../../hooks/ui/use-reset-on-page-hidden'; import { useSuppressCloneFocus } from '../../hooks/ui/use-suppress-clone-focus'; import { Button } from '../ui/button'; import { SECTION_HEADING_CLASS } from '../layout/page-heading'; import { Chevron02LeftIcon } from '../icons-v2-generated/arrows/chevron-02-left-icon'; import { Chevron02RightIcon } from '../icons-v2-generated/arrows/chevron-02-right-icon'; // ============================================================================= // Types // ============================================================================= /** Per-card strip context passed to `renderCard` (and used internally by the * children-mode managed cell). */ export interface CardStripRenderCtx { /** Item index (copy-independent — the clone copy repeats the same indices). */ index: number; /** `${itemKey}__${copyIndex}__${index}` — unique per rendered card, even for * duplicate items (the trailing index disambiguates). */ cardKey: string; isClone: boolean; isTouch: boolean; /** Engine's (max-width: 767px) media query. */ isMobile: boolean; /** activeKey === cardKey (controlled hover/focus activation). */ active: boolean; onActivate: (key: string) => void; onDeactivate: (key: string) => void; /** Shared-by-index 500px near-viewport gate (opt-in — only meaningful when * the card registers via `rootRef`). */ mounted: boolean; /** Registers the card root with the mount-gate IntersectionObserver. * May return a cleanup (React 19 ref contract). */ rootRef: (el: HTMLDivElement | null) => (() => void) | undefined; } interface CardsStripEngineProps { /** Section heading. Omit `title` (or set `showTitle={false}`) to hide. */ title?: string; showTitle?: boolean; /** Custom node rendered between the heading and the strip. */ headerSlot?: React.ReactNode; /** Marquee auto-scroll. Auto-disabled on no-overflow and * prefers-reduced-motion. Pass `autoScroll={false}` for chevron-style * entity strips — the default stays `true` for bites-contract parity. */ autoScroll?: boolean; /** Marquee speed in px/s. Default 60 — marquee libraries (e.g. GSAP marquee) * default around 100px/s; 60 keeps drift lively while cards stay easy to * hover-target. 60px/s ≈ 1px per 60Hz frame, the smoothest integer step. */ autoScrollSpeed?: number; /** Pause the marquee while a CARD is hovered (resumes as soon as the * pointer leaves the card — strip whitespace/heading never pauses). */ pauseOnHover?: boolean; /** Floating prev/next buttons. Hidden automatically when nothing overflows. */ showChevrons?: boolean; /** Chevron aria-labels. */ prevLabel?: string; nextLabel?: string; /** Merged into the engine's wrapper ref (e.g. `useVideoWarmup().ref`). */ rootRef?: (el: HTMLDivElement | null) => void; className?: string; } // The two mode interfaces hand-mirror each other's field names as `never` — // INVARIANT: every field added to one mode gets a `never` twin in the other, // or the XOR discrimination silently weakens (mixed-mode props become // assignable). Same manual pattern as social-icon-row.tsx / chart.tsx. /** MODE A — render-prop (advanced): full ctx control, no cell wrapper. */ interface RenderCardMode { items: ReadonlyArray; itemKey: (item: T, index: number) => string; renderCard: (item: T, ctx: CardStripRenderCtx) => React.ReactNode; children?: never; cardWidthDesktop?: never; cardWidthMobile?: never; } /** MODE B — children (organic): ANY card components, zero registration. */ interface ChildrenMode { children: React.ReactNode; /** Managed cell width in px per breakpoint (Figma: 400 desktop). */ cardWidthDesktop?: number; cardWidthMobile?: number; items?: never; itemKey?: never; renderCard?: never; } export type CardsStripProps = CardsStripEngineProps & (RenderCardMode | ChildrenMode); // ============================================================================= // Constants // ============================================================================= // Gap between cards (px) — keep in sync with the track's `gap-4`. const TRACK_GAP_PX = 16; // Marquee suppression windows after manual interaction. const CHEVRON_SUPPRESS_MS = 4000; const USER_SCROLL_SUPPRESS_MS = 3000; /** Single source for the strip cell width cap (shared with ). */ export const STRIP_CELL_MAX_WIDTH = '90vw'; const EMPTY_CHILDREN: ReadonlyArray = []; // ============================================================================= // Children-mode managed cell // ============================================================================= // (Clone focus suppression lives in hooks/ui/use-suppress-clone-focus — shared // with , which renders the same 2-copy endless-loop structure.) /** * Children-mode cell: fixed width + hover/keyboard activation (wired here, never * on the card — the single-place activation invariant). The OUTER cell is never * inert/aria-hidden — an inert cell would retarget its pointer events to the * track and defeat the clone hover-pause; the INNER wrapper carries the clone * a11y treatment. */ function ManagedCell({ item, ctx, cardWidthMobile, cardWidthDesktop, }: { item: unknown; ctx: CardStripRenderCtx; cardWidthMobile: number; cardWidthDesktop: number; }) { // Per-child DESKTOP cell-width hint: heterogeneous card designs need // heterogeneous cells (wide horizontal cards vs 400px vertical cards — // variable widths are native to the engine, exactly like bite ratios). Read // from the child ELEMENT's `data-strip-cell-width` prop; mobile keeps the // strip-level width (wide cards stack vertically below md anyway). const hintedWidth = React.isValidElement(item) ? (item.props as Record)['data-strip-cell-width'] : undefined; const desktopWidth = typeof hintedWidth === 'number' ? hintedWidth : cardWidthDesktop; const cloneRef = useSuppressCloneFocus(ctx.isClone); return (
ctx.onActivate(ctx.cardKey)} onPointerLeave={ctx.isTouch ? undefined : () => ctx.onDeactivate(ctx.cardKey)} // Keyboard pause parity (focus bubbles from the card's anchor; WCAG 2.2.2). onFocus={() => ctx.onActivate(ctx.cardKey)} onBlur={e => { // Intra-cell focus moves must not blip the pause off. if (e.currentTarget.contains(e.relatedTarget as Node | null)) return; ctx.onDeactivate(ctx.cardKey); }} > {/* INNER: clone a11y — `aria-hidden` + focusable descendants forced out of the tab order (useSuppressCloneFocus), but pointer events preserved so a visible clone card stays clickable. Cards untouched. */}
{item as React.ReactNode}
); } // ============================================================================= // Component // ============================================================================= export function CardsStrip(props: CardsStripProps): React.ReactElement | null { const { title, showTitle = true, headerSlot, autoScroll = true, autoScrollSpeed = 60, pauseOnHover = true, showChevrons = true, prevLabel = 'Previous items', nextLabel = 'Next items', rootRef, className, } = props; // ---- input-mode normalization --------------------------------------------- // Children mode feeds the SAME engine path as render-prop mode — one // track/clone/measure/scroll implementation. Engine internals operate on the // erased item type (`unknown`); the only assertions live at this seam. const childrenMode = props.renderCard === undefined; const childItems = useMemo( () => (childrenMode ? React.Children.toArray(props.children) : EMPTY_CHILDREN), [childrenMode, props.children], ); const items: ReadonlyArray = childrenMode ? childItems : props.items; const cardWidthDesktop = childrenMode ? (props.cardWidthDesktop ?? 400) : 0; const cardWidthMobile = childrenMode ? (props.cardWidthMobile ?? 320) : 0; useEffect(() => { if (process.env.NODE_ENV === 'production' || !childrenMode) return; const only = childItems.length === 1 ? childItems[0] : null; if (React.isValidElement(only) && only.type === React.Fragment) { console.warn( '[CardsStrip] A single child becomes ONE giant cell — pass an array of card elements ({rows.map(...)}) instead.', ); } }, [childrenMode, childItems]); const getItemKey = (item: unknown, index: number): string => { if (!childrenMode) { // Seam assertion: T is erased inside the engine; the props union // guarantees itemKey matches the items array. return (props.itemKey as (item: unknown, index: number) => string)(item, index); } // Children.toArray always assigns element keys ('.0' / '.$slug' — stable // across renders); explicit null-check because `&&`/`??` chaining yields // the string "false" for non-element children (same isValidElement guard // as steps.tsx). return React.isValidElement(item) && item.key != null ? String(item.key) : String(index); }; const wrapperRef = useRef(null); const scrollerRef = useRef(null); const trackRef = useRef(null); // ---- environment ---------------------------------------------------------- const [reducedMotion, setReducedMotion] = useState(false); const [isTouch, setIsTouch] = useState(false); const [isMobile, setIsMobile] = useState(false); useEffect(() => { const subs: Array<() => void> = []; const watch = (query: string, set: (v: boolean) => void) => { const mq = window.matchMedia(query); set(mq.matches); const onChange = (e: MediaQueryListEvent) => set(e.matches); mq.addEventListener('change', onChange); subs.push(() => mq.removeEventListener('change', onChange)); }; watch('(prefers-reduced-motion: reduce)', setReducedMotion); watch('(hover: none)', setIsTouch); watch('(max-width: 767px)', setIsMobile); return () => subs.forEach(fn => fn()); }, []); // ---- overflow measurement --------------------------------------------------- // TWO overflow states on purpose: `overflows` (any real overflow) gates // chevrons — a strip clipping even a few px must stay navigable — // while `marqueeEligible` (overflow beyond the joining gap) additionally // gates cloning + rAF: the cloned track's physical maxScroll is // 2·copy − gap − viewport, so a copy overflowing by less than the gap // makes even the zero-buffer wrap range [0, copy) unreachable — the // engine would clamp at the track end and freeze. Razor-thin overflow // (≤16px) stays a static, chevron-navigable row instead. const [overflows, setOverflows] = useState(false); const [marqueeEligible, setMarqueeEligible] = useState(false); const singleCopyWidthRef = useRef(0); const marqueeActive = autoScroll && !reducedMotion && marqueeEligible && items.length > 0; const measure = useCallback(() => { const scroller = scrollerRef.current; const track = trackRef.current; if (!scroller || !track) return; // When clones are rendered the track is 2× one copy (+ one joining gap). const copies = track.dataset.copies === '2' ? 2 : 1; const singleCopy = copies === 2 ? (track.scrollWidth - TRACK_GAP_PX) / 2 + TRACK_GAP_PX : track.scrollWidth; singleCopyWidthRef.current = singleCopy; setOverflows(singleCopy > scroller.clientWidth + 1); setMarqueeEligible(singleCopy > scroller.clientWidth + TRACK_GAP_PX); }, []); useEffect(() => { measure(); const scroller = scrollerRef.current; const track = trackRef.current; if (!scroller || !track || typeof ResizeObserver === 'undefined') return; const ro = new ResizeObserver(measure); ro.observe(scroller); ro.observe(track); return () => ro.disconnect(); }, [measure, items.length, marqueeActive, isMobile]); // ---- pause-reason set + suppress timestamps --------------------------------- // (Card hover — the only hover-based pause reason — lives in activeKeyRef below.) const chevronSuppressUntilRef = useRef(0); const userScrollSuppressUntilRef = useRef(0); // Two-way viewport gate (unlike the fire-once `useNearViewport`): STATE, // not a pause reason — it gates the engine's `active` so the rAF fully // stops while the strip is far off-screen (a paused engine would keep // scheduling frames forever), and because the velocity envelope persists // across engine restarts, scrolling back resumes at cruise speed instantly. const [nearViewport, setNearViewport] = useState(true); useEffect(() => { const el = wrapperRef.current; if (!el || typeof IntersectionObserver === 'undefined') return; const io = new IntersectionObserver( entries => setNearViewport(entries[0]?.isIntersecting ?? true), { rootMargin: '200px' }, ); io.observe(el); return () => io.disconnect(); }, []); // ---- hover / overlay state ----------------------------------------------------- // activeKey identifies the hovered/tap-activated CARD (copy-aware so hovering // a clone near the wrap seam works too). It only drives per-card `active` // state — any heavier behavior (players, overlays) is the card's concern. const [activeKey, setActiveKey] = useState(null); // Ref mirror for the rAF loop: the marquee pauses ONLY while a CARD is // hovered (activeKey set) — hovering strip whitespace/heading does not // pause, and leaving a card resumes immediately. const activeKeyRef = useRef(null); const activate = useCallback((key: string) => { activeKeyRef.current = key; setActiveKey(key); }, []); const deactivate = useCallback((key: string) => { // Ref write hoisted OUT of the state updater: React re-invokes queued // updaters at render time, so an impure updater could re-null the ref // AFTER a same-batch activate(B) already set it (leave(A)+enter(B) in one // dispatch when the pointer jumps directly between cells) — stranding the // rAF pause check while a card is visibly hovered. if (activeKeyRef.current === key) activeKeyRef.current = null; setActiveKey(current => (current === key ? null : current)); }, []); // ---- container-level hover pause + pointer-tracked activation sync --------- // TWO separate concerns, deliberately decoupled (matches how production // marquees — react-fast-marquee, GSAP — behave): // // 1. MARQUEE PAUSE is container-level: `hoverPointerRef.inside` is true // whenever a hover-capable pointer is over the SCROLLER (the cards row). // The rAF freezes on it, so content NEVER auto-moves under a pointing // cursor — which is what made per-card boundary pausing feel flaky // (cards sliding under/away from a stationary pointer don't emit // pointerenter/leave, gaps caused stutter-steps, seam warps swapped the // hovered card's identity mid-playback). // // 2. CARD ACTIVATION (overlay + video playback) stays per-card: the card/ // cell pointer handlers are the zero-latency path, and the // elementFromPoint sync below re-resolves the card under the pointer // whenever the track moves WHILE the pointer is inside (user wheel // scroll, chevron glide, seam warp) — scoped to THIS strip's scroller so // stacked strips can never activate/pause each other. const hoverPointerRef = useRef<{ x: number; y: number; inside: boolean }>({ x: 0, y: 0, inside: false }); const lastHoverSyncScrollRef = useRef(-1); const syncHoverToPointer = useCallback(() => { const p = hoverPointerRef.current; if (!p.inside) return; const el = typeof document !== 'undefined' ? document.elementFromPoint(p.x, p.y) : null; const cardEl = el?.closest?.('[data-strip-card-key]') as HTMLElement | null; // Strip-scoped: a card from ANOTHER CardsStrip under the tracked point // must read as "no card here" (stacked strips share the viewport). const scoped = cardEl && scrollerRef.current?.contains(cardEl) ? cardEl : null; const key = scoped?.getAttribute('data-strip-card-key') ?? null; const current = activeKeyRef.current; if (key === current) return; if (current !== null) deactivate(current); if (key !== null) activate(key); }, [activate, deactivate]); // Hover-capable pointers only (mouse/pen) — touch drags must not fake hover. const onHoverPointerEnter = useCallback((e: React.PointerEvent) => { if (e.pointerType === 'touch') return; hoverPointerRef.current = { x: e.clientX, y: e.clientY, inside: true }; }, []); const onHoverPointerMove = useCallback((e: React.PointerEvent) => { if (e.pointerType === 'touch') return; hoverPointerRef.current = { x: e.clientX, y: e.clientY, inside: true }; }, []); const onHoverPointerLeave = useCallback((e: React.PointerEvent) => { if (e.pointerType === 'touch') return; hoverPointerRef.current.inside = false; // The pointer left the whole scroller — no card can be hovered anymore // (covers leave events swallowed by DOM churn under the cursor). const current = activeKeyRef.current; if (current !== null) deactivate(current); }, [deactivate]); // Unconditional hover-clear (any pointer type) — the self-heal for a MISSED // `pointerleave`. A leave can be swallowed by a tab blur, an interrupted // pointer (pointercancel), or an overlay mounting under the cursor, leaving // `inside` stuck true so the marquee freezes FOREVER ("gets stuck every once // in a while"). Reset it on pointercancel + whenever the tab hides / the // window loses focus; the next real hover re-arms it. Can only un-stick. const clearHover = useCallback(() => { hoverPointerRef.current.inside = false; const current = activeKeyRef.current; if (current !== null) deactivate(current); }, [deactivate]); // Shared SSOT self-heal: un-stick the hover flag whenever the tab hides / the // window loses focus (a `pointerleave`/`pointercancel` can be swallowed). useResetOnPageHidden(clearHover); /** Re-sync hover only when the track actually moved under the pointer. * KNOWN HOT-PATH COST (intentional): while the marquee runs with the * pointer parked over strip whitespace (no card → not paused), scrollLeft * changes every frame, so this invokes `document.elementFromPoint` ~60×/s * — a synchronous hit-test. It's inherent to detecting a card sliding * under a stationary cursor; any throttle trades hover latency for it. * Revisit only if profiling flags it on pages with many stacked strips. */ const syncHoverIfScrolled = useCallback(() => { const scroller = scrollerRef.current; if (!scroller) return; if (scroller.scrollLeft === lastHoverSyncScrollRef.current) return; lastHoverSyncScrollRef.current = scroller.scrollLeft; syncHoverToPointer(); }, [syncHoverToPointer]); // ---- marquee rAF engine (shared core) --------------------------------------- // Position/envelope/wrap/glide live in `useMarqueeEngine` — THE marquee // animation, shared with (quick-action walls, chip marquees). // CardsStrip contributes the scroller driver (apply/readBack on scrollLeft: // the user can also drag this surface) and its pause-reason set: container- // level hover (industry-standard — content must never move under a pointing // cursor; per-card activeKey still pauses for keyboard focus), hidden tab, // and the chevron / user-scroll suppress windows. Chevron GLIDE // runs through the engine too: browser `scrollBy({behavior:'smooth'})` // animates toward an ABSOLUTE target, so a seam warp mid-animation made it // lunge a full copy-width to catch up, warp again, and oscillate. // Seam-warp BUFFER: the re-center zones sit this far inside the physical // track edges. An exact-edge check (`scrollLeft <= 0`) is racy against the // engine — the rAF nudges the scroller off 0 before the scroll handler // samples it, so a user scrolling left "sticks" 1–2px from the edge and // the loop never wraps. The engine's wrap range is aligned to // [buffer, buffer + copy) so engine-written positions can never enter a // zone and ping-pong with the handler. The buffer is ALSO capped by the // physical slack (copy − viewport − gap): the range's upper end // buffer + copy must stay reachable below maxScroll = 2·copy − gap − // viewport, or a barely-overflowing strip clamps at the track end and the // wrap threshold is never crossed — a permanently frozen marquee. const seamBuffer = useCallback(() => { const half = singleCopyWidthRef.current; const viewport = scrollerRef.current?.clientWidth ?? 0; return Math.max(0, Math.min(200, half / 4, half - viewport - TRACK_GAP_PX - 1)); }, []); const { posRef: marqueePosRef, glideBy } = useMarqueeEngine({ // Viewport gates `active` (the rAF fully stops off-screen — a paused // engine would keep scheduling frames forever), same treatment as // ; the envelope persists, so re-entry resumes at cruise. active: marqueeActive && nearViewport, speed: autoScrollSpeed, isPaused: now => (pauseOnHover && (hoverPointerRef.current.inside || activeKeyRef.current !== null)) || document.visibilityState === 'hidden' || now < Math.max(chevronSuppressUntilRef.current, userScrollSuppressUntilRef.current), getWrapSize: () => singleCopyWidthRef.current, getWrapMin: seamBuffer, apply: pos => { const scroller = scrollerRef.current; if (scroller) scroller.scrollLeft = pos; }, readBack: () => scrollerRef.current?.scrollLeft ?? 0, onAfterFrame: syncHoverIfScrolled, }); // ---- shared card-mount gate (by ITEM index, across both copies) ------------- // A card and its clone show identical content half a copy-width apart. If // each card gated its own expensive content (e.g. video players), a seam warp // instantly swapped the visible copy for cards whose content was NOT mounted // yet — every wrap crossing flashed placeholders. Mounting by INDEX (near in // EITHER copy → both copies mount) makes the warp pixel-identical. Opt-in: // cards that never register via ctx.rootRef incur no per-card observation. const [mountedIdx, setMountedIdx] = useState>(() => new Set()); const cardObserverRef = useRef(null); const cardElIdxRef = useRef(new Map()); const cardElNearRef = useRef(new Map()); useEffect(() => { if (typeof IntersectionObserver === 'undefined') return; const io = new IntersectionObserver(entries => { for (const e of entries) cardElNearRef.current.set(e.target, e.isIntersecting); const next = new Set(); cardElNearRef.current.forEach((near, el) => { if (!near) return; const idx = cardElIdxRef.current.get(el); if (idx !== undefined) next.add(idx); }); setMountedIdx(prev => { if (prev.size === next.size) { let same = true; next.forEach(i => { if (!prev.has(i)) same = false; }); if (same) return prev; } return next; }); }, { rootMargin: NEAR_VIEWPORT_ROOT_MARGIN }); cardObserverRef.current = io; cardElIdxRef.current.forEach((_idx, el) => io.observe(el)); return () => { io.disconnect(); cardObserverRef.current = null; }; }, []); // Stable per-index ref callbacks (a fresh closure per render would detach/ // re-attach the observer every render). React 19 ref-cleanup unregisters. const cardRefFnsRef = useRef(new Map (() => void) | undefined>()); const getCardRef = useCallback((idx: number) => { let fn = cardRefFnsRef.current.get(idx); if (!fn) { fn = (el: HTMLDivElement | null) => { if (!el) return undefined; cardElIdxRef.current.set(el, idx); cardObserverRef.current?.observe(el); return () => { cardElIdxRef.current.delete(el); cardElNearRef.current.delete(el); cardObserverRef.current?.unobserve(el); }; }; cardRefFnsRef.current.set(idx, fn); } return fn; }, []); // ---- manual navigation ----------------------------------------------------------- const scrollByCard = useCallback((dir: 1 | -1) => { const scroller = scrollerRef.current; const track = trackRef.current; if (!scroller || !track) return; const firstCard = track.firstElementChild as HTMLElement | null; const step = (firstCard?.offsetWidth ?? 320) + TRACK_GAP_PX; chevronSuppressUntilRef.current = performance.now() + CHEVRON_SUPPRESS_MS; if (marqueeActiveRef.current) { // Wrap-aware glide via the rAF engine (see engine comment) — clicks // accumulate distance instead of racing browser smooth-scroll targets. glideBy(dir * step); } else { // No clones/seam without the marquee — native smooth scroll is safe. scroller.scrollBy({ left: dir * step, behavior: 'smooth' }); } }, [glideBy]); const onUserScrollIntent = useCallback(() => { userScrollSuppressUntilRef.current = performance.now() + USER_SCROLL_SUPPRESS_MS; }, []); // Suppress ONLY on genuine horizontal-scroll intent. Vertical page-scroll // wheel events over the strip and plain clicks (pointerdown) used to arm the // 3s suppression — the "marquee resumes only after a delay" bug. const onWheelIntent = useCallback((e: React.WheelEvent) => { if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) onUserScrollIntent(); }, [onUserScrollIntent]); const onPointerDownIntent = useCallback((e: React.PointerEvent) => { if (e.pointerType === 'touch') onUserScrollIntent(); }, [onUserScrollIntent]); // Never-ending strip: re-center across the clone seam on EVERY scroll // (manual wheel/drag/chevron included — the rAF only wraps while the // marquee runs, so without this a user scrolling during a pause/suppress // window hits the physical track edges). Both copies render identical // content, so a ±half jump is pixel-invisible. ZONES, not exact edges: // below `buffer` warp forward (+half, lands under half+buffer — outside // the other zone, so the pair is hysteresis-stable), at/above // half+buffer warp back (−half, lands at ≥buffer). The user can never // reach a physical edge in either direction, and the racy "observe // exactly 0" check that stuck leftward scrolling is gone. Initial rest at // 0 warps once to `half` — pixel-identical, and it buys leftward headroom // immediately. const marqueeActiveRef = useRef(false); marqueeActiveRef.current = marqueeActive; const onSeamWarp = useCallback(() => { const scroller = scrollerRef.current; if (!scroller) return; // Every scroll (native wheel/drag, smooth chevron scroll, marquee) moves // cards under a possibly-stationary pointer — re-resolve hover regardless // of marquee state (pointerenter/leave never fire for track motion). syncHoverIfScrolled(); if (!marqueeActiveRef.current) return; const half = singleCopyWidthRef.current; if (half <= 0) return; const buffer = seamBuffer(); let sl = scroller.scrollLeft; if (sl >= half + buffer) { sl -= half; scroller.scrollLeft = sl; marqueePosRef.current = sl; } else if (sl < buffer) { sl += half; scroller.scrollLeft = sl; marqueePosRef.current = sl; } }, [syncHoverIfScrolled, seamBuffer]); // ---- render ---------------------------------------------------------------- const renderItem = (item: unknown, ctx: CardStripRenderCtx): React.ReactNode => { if (!childrenMode) { // Seam assertion (see normalization comment above). return (props.renderCard as (item: unknown, ctx: CardStripRenderCtx) => React.ReactNode)(item, ctx); } // Managed cell — the ONLY place cell width / activation / clone a11y are // encoded for children mode (extracted to a component so the clone // focus-suppression hook can run per cell; see ManagedCell). return ( ); }; if (items.length === 0) return null; const copies = marqueeActive ? 2 : 1; return (
{ wrapperRef.current = node; rootRef?.(node); }} className={cn('flex flex-col gap-6 w-full min-w-0', className)} > {showTitle && title && (

{title}

)} {headerSlot}
{Array.from({ length: copies }, (_, copyIndex) => items.map((item, i) => { const key = `${getItemKey(item, i)}__${copyIndex}__${i}`; const isClone = copyIndex === 1; return ( // Clone copy a11y (aria-hidden + no focusable descendants — // WCAG) is the card's job in render-prop mode (via // ctx.isClone) and the managed cell's job in children mode. // The engine itself adds no wrapper here in render-prop mode // so the card root stays the track's direct child. {renderItem(item, { index: i, cardKey: key, isClone, isTouch, isMobile, active: activeKey === key, onActivate: activate, onDeactivate: deactivate, mounted: mountedIdx.has(i), rootRef: getCardRef(i), })} ); }), )}
{showChevrons && overflows && ( <> {/* Shared design-system Button: outline surface (ods-card + border, the Figma button-icon look) + the square `icon` size, which owns the centered 24px glyph. Icon-only → aria-label required. */}
); }