"use client"; import { ChevronRight, Ellipsis } from "lucide-react"; import { AnimatePresence, LayoutGroup, motion, useIsPresent, useReducedMotion, type HTMLMotionProps, } from "motion/react"; import { Children, forwardRef, useEffect, useLayoutEffect, useRef, useState, type ReactNode, useId, type ComponentPropsWithRef, type ReactElement, } from "react"; import { MorphPopover, MorphPopoverContent, MorphPopoverTrigger } from "@/components/motion/popover-morph"; import { useHoverCapable } from "@/lib/hooks/use-hover-capable"; import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type BreadcrumbProps = ComponentPropsWithRef<"nav">; /** A navigation landmark. Keep it mounted while the route changes. */ export function Breadcrumb({ className, children, ...props }: BreadcrumbProps) { const id = useId(); return ( ); } export type BreadcrumbListProps = ComponentPropsWithRef<"ol"> & { /** Maximum visible slots, including the ellipsis. Minimum 3; Infinity disables collapsing. */ maxItems?: number; /** Accessible label for the hidden ancestor disclosure. */ overflowLabel?: string; }; /** Pass keyed BreadcrumbItems directly so entering and leaving routes animate. */ export function BreadcrumbList({ className, children, maxItems = 4, overflowLabel = "Show hidden paths", ...props }: BreadcrumbListProps) { const items = Children.toArray(children); const limit = Number.isFinite(maxItems) ? Math.max(3, Math.floor(maxItems)) : 4; const collapse = maxItems !== Infinity && items.length > limit; const tailCount = limit - 2; const visible = collapse ? [ items[0], {items.slice(1, -tailCount)} , ...items.slice(-tailCount), ] : items; return (
    {visible}
); } export type BreadcrumbItemProps = HTMLMotionProps<"li">; /** Use a stable route key; put its optional separator inside this item. */ export const BreadcrumbItem = forwardRef( function BreadcrumbItem({ className, style, children, ...props }, ref) { const reduce = useReducedMotion(); const present = useIsPresent(); const itemRef = useRef(null); useLayoutEffect(() => { const item = itemRef.current; if (!item || !present) return; const measure = () => { // popLayout snapshots offsetWidth (integer pixels). Retain the exact // width so a fractional-pixel loss cannot wrap the final character. item.style.setProperty("--breadcrumb-exit-width", `${item.getBoundingClientRect().width}px`); }; measure(); const observer = new ResizeObserver(measure); observer.observe(item); return () => observer.disconnect(); }, [present]); const hidden = { opacity: 0, y: reduce ? 0 : 6 }; return ( { itemRef.current = node; if (typeof ref === "function") return ref(node); if (ref) ref.current = node; }} layout={reduce ? false : "position"} initial={hidden} animate={{ opacity: 1, y: 0 }} exit={hidden} transition={{ duration: 0.2, ease: EASE_OUT, layout: SPRING_LAYOUT }} {...props} inert={!present} aria-hidden={!present || undefined} style={{ ...style, minWidth: present ? style?.minWidth : "var(--breadcrumb-exit-width)", pointerEvents: present ? style?.pointerEvents : "none", }} className={cn("relative inline-flex min-w-0 max-w-full items-center gap-1", className)} > {children} ); }, ); export type BreadcrumbLinkProps = ComponentPropsWithRef<"a"> & { /** Render your router's Link, spreading these props onto it. */ render?: (props: ComponentPropsWithRef<"a">) => ReactElement; }; export function BreadcrumbLink({ className, render, ...props }: BreadcrumbLinkProps) { const linkProps = { ...props, className: cn( "inline-flex min-h-8 min-w-0 items-center gap-1.5 rounded-md px-2 font-medium text-muted-foreground transition-colors duration-150 hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 [&>svg]:size-3.5 [&>svg]:shrink-0", className, ), }; return render ? render(linkProps) : ; } export type BreadcrumbPageProps = ComponentPropsWithRef<"span">; export function BreadcrumbPage({ className, children, ...props }: BreadcrumbPageProps) { return ( svg]:size-3.5 [&>svg]:shrink-0", className, )} > {children} ); } export type BreadcrumbSeparatorProps = ComponentPropsWithRef<"span">; /** Decorative separator, placed inside the following BreadcrumbItem. */ export function BreadcrumbSeparator({ className, children, ...props }: BreadcrumbSeparatorProps) { return ( ); } export interface BreadcrumbEllipsisProps { /** Hidden BreadcrumbItems, in path order. */ children: ReactNode; className?: string; label?: string; } /** Hover disclosure with click/touch toggle and keyboard access to ancestor links. */ export function BreadcrumbEllipsis({ children, className, label = "Show hidden paths" }: BreadcrumbEllipsisProps) { const [open, setOpen] = useState(false); const [placement, setPlacement] = useState<{ align: "start" | "end"; side: "top" | "bottom"; width: number }>({ align: "start", side: "bottom", width: 224 }); const canHover = useHoverCapable(); const present = useIsPresent(); const trigger = useRef(null); const panel = useRef(null); const focusOnOpen = useRef(false); const closeTimer = useRef | null>(null); useLayoutEffect(() => { if (!open) return; const update = () => { const rect = trigger.current?.getBoundingClientRect(); if (!rect) return; const right = window.innerWidth - rect.left - 8; const left = rect.right - 8; const align = right < 224 && left > right ? "end" : "start"; const below = window.innerHeight - rect.bottom; setPlacement({ align, side: below < 280 && rect.top > below ? "top" : "bottom", width: Math.max(32, Math.min(224, align === "start" ? right : left)), }); }; update(); window.addEventListener("resize", update); return () => window.removeEventListener("resize", update); }, [open]); const cancelClose = () => { if (closeTimer.current !== null) clearTimeout(closeTimer.current); closeTimer.current = null; }; const leave = () => { cancelClose(); // Allow the pointer to cross the gap between the trigger and portal. closeTimer.current = setTimeout(() => { if (!panel.current?.contains(document.activeElement) && document.activeElement !== trigger.current) setOpen(false); }, 160); }; useEffect(() => () => { if (closeTimer.current !== null) clearTimeout(closeTimer.current); }, []); useEffect(() => { if (!open) return; const onFocus = (event: FocusEvent) => { if (event.target instanceof Node && event.target !== trigger.current && !panel.current?.contains(event.target)) setOpen(false); }; document.addEventListener("focusin", onFocus); return () => document.removeEventListener("focusin", onFocus); }, [open]); return ( { cancelClose(); if (canHover && event.pointerType === "mouse") { focusOnOpen.current = false; setOpen(true); } }} onPointerLeave={leave} > { cancelClose(); setOpen(true); }} onPointerLeave={leave} onClick={(event) => { if ((event.target as Element).closest("a[href]")) setOpen(false); }} > {children} ); } function BreadcrumbOverflowPaths({ focusOnOpen, ref, ...props }: ComponentPropsWithRef<"ol"> & { focusOnOpen: { current: boolean } }) { const localRef = useRef(null); useLayoutEffect(() => { if (!focusOnOpen.current) return; const focus = () => localRef.current?.querySelector("a[href],button")?.focus(); focus(); // The portal becomes visible after its parent's layout measurement. const frame = requestAnimationFrame(() => { focus(); focusOnOpen.current = false; }); return () => cancelAnimationFrame(frame); }, [focusOnOpen]); return (
    { localRef.current = node; if (typeof ref === "function") return ref(node); if (ref) ref.current = node; }} className="flex max-h-64 flex-col gap-0.5 overflow-y-auto [&>li]:w-full [&_a]:w-full [&_a]:py-1 [&_a]:[overflow-wrap:anywhere] [&_[data-breadcrumb-separator]]:hidden" /> ); }