"use client"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type MouseEvent, type PointerEvent, type ReactNode, useCallback, useId, useRef, useState, } from "react"; import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease"; import { useDismiss } from "@/lib/hooks/use-dismiss"; import { useHoverGesture } from "@/lib/hooks/use-hover-gesture"; import { useTapGesture } from "@/lib/hooks/use-tap-gesture"; import { cn } from "@/lib/utils"; export interface PreviewRailItem { id: string; label: string; ariaLabel?: string; description?: ReactNode; href?: string; target?: "_blank" | "_self" | "_parent" | "_top"; rel?: string; } export interface PreviewRailProps { items: PreviewRailItem[]; label?: string; orientation?: "vertical" | "horizontal"; activeId?: string; defaultActiveId?: string; onActiveChange?: (id: string) => void; onItemSelect?: (item: PreviewRailItem) => void; renderPreview?: (item: PreviewRailItem) => ReactNode; showPreview?: boolean; previewSide?: "before" | "after"; highlightActive?: boolean; itemSize?: number; children?: ReactNode; className?: string; railClassName?: string; previewContainerClassName?: string; previewClassName?: string; } function DefaultPreview({ item }: { item: PreviewRailItem }) { return (

{item.label}

{item.description ? (
{item.description}
) : null}
); } export function PreviewRail({ items, label = "Section navigation", orientation = "vertical", activeId, defaultActiveId, onActiveChange, onItemSelect, renderPreview, showPreview = true, previewSide = "after", highlightActive = false, itemSize = 24, children, className, railClassName, previewContainerClassName, previewClassName, }: PreviewRailProps) { const uid = useId(); const reduce = useReducedMotion(); const rootRef = useRef(null); const [internalActiveId, setInternalActiveId] = useState( defaultActiveId ?? items[0]?.id ?? "", ); const [hoveredId, setHoveredId] = useState(null); // A finger cannot hover, so a tap lights the tick instead. Kept apart from // the hovered one: they end in different ways, and a stray mouse move must // not clear a tick the keyboard or a tap chose. const [pinnedId, setPinnedId] = useState(null); const [focusedId, setFocusedId] = useState(null); // A click carries no pointerType, so the pointerdown before it is what says // whether the activation was a tap. Keyboard activation has none at all. const tap = useTapGesture(); const hover = useHoverGesture(); const clearPinned = useCallback(() => setPinnedId(null), []); // The next tap outside the rail stands in for the pointer leaving it. The // card is a preview, so that tap passes through to whatever it landed on. useDismiss(pinnedId !== null, clearPinned, rootRef); const requestedActiveId = activeId ?? internalActiveId; const selectedId = items.some((item) => item.id === requestedActiveId) ? requestedActiveId : (items[0]?.id ?? ""); const displayedId = hoveredId ?? pinnedId ?? focusedId ?? ""; const highlightedId = displayedId || (highlightActive ? selectedId : ""); const displayedIndex = items.findIndex((item) => item.id === highlightedId); const rowTemplate = items.length ? `repeat(${items.length}, ${itemSize}px)` : undefined; const isHorizontal = orientation === "horizontal"; const selectItem = (id: string) => { if (activeId === undefined) setInternalActiveId(id); onActiveChange?.(id); }; return ( { // Both tick sources leave with the focus: a tap does not always land // focus, but when it does, tabbing away must not strand the card. if (!event.currentTarget.contains(event.relatedTarget)) { setFocusedId(null); setPinnedId(null); } }} className={cn( "isolate relative flex w-full overflow-visible", isHorizontal ? "min-h-64 flex-col items-center justify-center" : "min-h-80", className, )} > {showPreview ? ( ) : null} {children ? (
{children}
) : null}
); }