"use client" import * as React from "react" import { composeRefs } from "../../lib/compose-refs" import { cn } from "../../lib/utils" import { Button } from "./button" import { ScrollRegion } from "./scroll-region" import { Tip } from "./tip" /** Default horizontal scroll distance per chevron click (px). */ export const HORIZONTAL_SCROLL_STEP_PX = 240 export interface HorizontalScrollAffordances { canScrollLeft: boolean canScrollRight: boolean overflowing: boolean scrollPrev: () => void scrollNext: () => void } /** * Tracks overflow + scroll position for a horizontally scrollable element. * Pair with {@link HorizontalScrollControls} or compose your own chrome. */ export function useHorizontalScrollAffordances( scrollRef: React.RefObject, stepPx = HORIZONTAL_SCROLL_STEP_PX, ): HorizontalScrollAffordances { const [canScrollLeft, setCanScrollLeft] = React.useState(false) const [canScrollRight, setCanScrollRight] = React.useState(false) const [overflowing, setOverflowing] = React.useState(false) const sync = React.useCallback(() => { const el = scrollRef.current if (!el) return const { scrollLeft, scrollWidth, clientWidth } = el const overflow = scrollWidth > clientWidth + 1 setOverflowing(overflow) setCanScrollLeft(overflow && scrollLeft > 1) setCanScrollRight(overflow && scrollLeft + clientWidth < scrollWidth - 1) }, [scrollRef]) const syncRef = React.useRef(sync) // Layout, not passive: the effect below reads this ref during the same // commit, and it only sees the current callback if the write lands first. React.useLayoutEffect(() => { syncRef.current = sync }) React.useLayoutEffect(() => { const runSync = () => syncRef.current() runSync() const el = scrollRef.current if (!el) return el.addEventListener("scroll", runSync, { passive: true }) const ro = new ResizeObserver(runSync) ro.observe(el) for (const child of el.children) { ro.observe(child) } return () => { el.removeEventListener("scroll", runSync) ro.disconnect() } }, [scrollRef]) const scrollBy = React.useCallback( (delta: number) => { scrollRef.current?.scrollBy({ left: delta, behavior: "smooth" }) }, [scrollRef], ) return { canScrollLeft, canScrollRight, overflowing, scrollPrev: () => scrollBy(-stepPx), scrollNext: () => scrollBy(stepPx), } } /** Pin scroll position to the trailing edge when content grows (breadcrumb trails). */ export function useHorizontalScrollAlignEnd( scrollRef: React.RefObject, enabled: boolean, deps: React.DependencyList = [], ) { React.useLayoutEffect(() => { if (!enabled) return const el = scrollRef.current if (!el) return const scrollToEnd = () => { el.scrollLeft = Math.max(0, el.scrollWidth - el.clientWidth) } scrollToEnd() const ro = new ResizeObserver(scrollToEnd) ro.observe(el) for (const child of el.children) { ro.observe(child) } return () => ro.disconnect() // eslint-disable-next-line react-hooks/exhaustive-deps -- caller supplies content deps }, [enabled, scrollRef, ...deps]) } /** * Keeps an active child visible inside a horizontal scroll viewport (wizards, tab strips). */ export function useHorizontalScrollItemIntoView( containerRef: React.RefObject, itemRef: React.RefObject, deps: React.DependencyList = [], ) { React.useLayoutEffect(() => { const container = containerRef.current const item = itemRef.current if (!container || !item) return const padding = 8 const containerRect = container.getBoundingClientRect() const itemRect = item.getBoundingClientRect() if (itemRect.left < containerRect.left + padding) { container.scrollTo({ left: container.scrollLeft + (itemRect.left - containerRect.left) - padding, behavior: "smooth", }) } else if (itemRect.right > containerRect.right - padding) { container.scrollTo({ left: container.scrollLeft + (itemRect.right - containerRect.right) + padding, behavior: "smooth", }) } // eslint-disable-next-line react-hooks/exhaustive-deps -- caller supplies deps }, deps) } export type HorizontalScrollControlsLayout = "group" | "split-prev" | "split-next" export interface HorizontalScrollControlsProps { /** Prefix for prev/next `aria-label`s (e.g. "Views", "Breadcrumb"). */ ariaLabel: string layout?: HorizontalScrollControlsLayout canScrollLeft: boolean canScrollRight: boolean onScrollPrev: () => void onScrollNext: () => void className?: string } /** * Shared prev/next chevron control for horizontally overflowed rows. * * - **`group`** — segmented `[← | →]` button (default for view tabs, breadcrumbs). * - **`split-prev` / `split-next`** — single chevron for flanking layouts. */ export function HorizontalScrollControls({ ariaLabel, layout = "group", canScrollLeft, canScrollRight, onScrollPrev, onScrollNext, className, }: HorizontalScrollControlsProps) { const prevButton = (