import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; import { ChevronLeft, ChevronRight } from 'lucide-react'; import { cn } from '@/lib/utils'; // Horizontal scroller for a row of tabs/chips that overflows its width. When the // row overflows, a fade + a clickable chevron appears on the overflowing edge // (a row that fits looks unchanged); a vertical mouse wheel over the row is // translated into horizontal scroll. This is the one place the fade + arrow // affordance lives, so every tab strip (stack anatomy, the mobile page tabs) // reads and behaves the same. interface ScrollableTabRowProps { children: ReactNode; /** Surface the row sits on, so the edge fade blends into it. */ surface?: 'card' | 'background'; /** Applied to the scroll viewport (e.g. a border-b that should span full width). */ className?: string; /** Applied to the relative wrapper (e.g. `min-w-0 flex-1` inside a flex row). */ wrapperClassName?: string; } const FADE: Record<'card' | 'background', { left: string; right: string }> = { card: { left: 'from-card via-card/90 to-transparent', right: 'from-card via-card/90 to-transparent', }, background: { left: 'from-background via-background/90 to-transparent', right: 'from-background via-background/90 to-transparent', }, }; export function ScrollableTabRow({ children, surface = 'card', className, wrapperClassName }: ScrollableTabRowProps) { const scrollRef = useRef(null); const [edges, setEdges] = useState({ left: false, right: false }); const measure = useCallback((el: HTMLElement) => { const left = el.scrollLeft > 1; const right = Math.ceil(el.scrollLeft + el.clientWidth) < el.scrollWidth; // Bail when unchanged so the per-render measure effect cannot loop (a fresh // object every render would re-trigger the effect forever). setEdges(prev => (prev.left === left && prev.right === right ? prev : { left, right })); }, []); const scrollBy = useCallback((direction: -1 | 1) => { const el = scrollRef.current; if (el) el.scrollBy({ left: direction * Math.max(96, el.clientWidth * 0.7), behavior: 'smooth' }); }, []); // Re-measure after every render so a changed tab set (or width) updates the // arrows; cheap and avoids threading the row's contents through a dep array. useEffect(() => { const el = scrollRef.current; if (el) measure(el); }); useEffect(() => { const el = scrollRef.current; if (!el) return; // Non-passive so preventDefault works: turn a vertical wheel into horizontal // scroll only when the row overflows (trackpads already scroll horizontally). const onWheel = (e: WheelEvent) => { if (el.scrollWidth <= el.clientWidth || Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; el.scrollLeft += e.deltaY; e.preventDefault(); }; el.addEventListener('wheel', onWheel, { passive: false }); const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => measure(el)) : null; ro?.observe(el); return () => { el.removeEventListener('wheel', onWheel); ro?.disconnect(); }; }, [measure]); const fade = FADE[surface]; return (
measure(e.currentTarget)} className={cn('overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden', className)} > {children}
{edges.left && ( )} {edges.right && ( )}
); }