"use client"; import { ChevronDown, Menu, X } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactElement, type ReactNode, } from "react"; import { SPRING_LAYOUT, SPRING_PANEL, SPRING_SWAP } from "@/lib/ease"; import { cn } from "@/lib/utils"; /* ------------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------------ */ export type NavigationMenuLinkRenderProps = { href: string; className: string; children: ReactNode; onClick?: () => void; }; type RenderLink = (props: NavigationMenuLinkRenderProps) => ReactElement; export type NavigationMenuLink = { label: ReactNode; href: string; description?: ReactNode; icon?: ReactNode; /** Route through your router: `(p) => `. */ render?: RenderLink; }; export type NavigationMenuSection = { title?: ReactNode; links: NavigationMenuLink[]; }; export type NavigationMenuItem = | { id: string; label: ReactNode; /** A plain top-level link. */ href: string; render?: RenderLink; } | { id: string; label: ReactNode; /** Link columns. Also feed the mobile accordion. */ sections: NavigationMenuSection[]; /** Extra column on desktop, e.g. a featured card. */ featured?: ReactNode; /** Replace the desktop panel body entirely (mobile still uses `sections`). */ content?: ReactNode; }; export interface NavigationMenuProps { items: NavigationMenuItem[]; /** Brand slot on the left of the bar. */ logo?: ReactNode; /** Right-hand slot (CTA buttons); hidden below `md`, repeated in the sheet. */ actions?: ReactNode; /** Open panel id, `""` when closed. */ value?: string; defaultValue?: string; onValueChange?: (value: string) => void; /** Hover-intent delay before the first panel opens (ms). */ openDelay?: number; /** Grace period after the pointer leaves before closing (ms). */ closeDelay?: number; "aria-label"?: string; className?: string; } type Size = { w: number; h: number }; type Geom = Size & { x: number }; const SLIDE = 28; const panelVariants = { enter: ({ dir, reduce }: { dir: number; reduce: boolean }) => ({ opacity: 0, x: reduce ? 0 : dir * SLIDE, }), center: { opacity: 1, x: 0 }, exit: ({ dir, reduce }: { dir: number; reduce: boolean }) => ({ opacity: 0, x: reduce ? 0 : dir * -SLIDE, transition: { duration: 0.14 }, }), }; const linkRowClass = "group/link flex items-start gap-3 rounded-xl p-2.5 outline-none transition-colors hover:bg-muted focus-visible:bg-muted"; function defaultLink({ href, className, children, onClick }: NavigationMenuLinkRenderProps) { return ( {children} ); } const sectionKey = (section: NavigationMenuSection) => section.links.map((l) => l.href).join("|"); function hasPanel( item: NavigationMenuItem, ): item is Extract { return "sections" in item; } /* ------------------------------------------------------------------------ */ /* Panel body */ /* ------------------------------------------------------------------------ */ function LinkRow({ link, onNavigate }: { link: NavigationMenuLink; onNavigate: () => void }) { return (link.render ?? defaultLink)({ href: link.href, className: linkRowClass, onClick: onNavigate, children: ( <> {link.icon ? ( {link.icon} ) : null} {link.label} {link.description ? ( {link.description} ) : null} ), }); } function PanelBody({ item, onNavigate, }: { item: Extract; onNavigate: () => void; }) { if (item.content) return <>{item.content}; return (
{item.sections.map((section) => (
{section.title ? (

{section.title}

) : null}
    {section.links.map((link) => (
  • ))}
))} {item.featured ?
{item.featured}
: null}
); } /** Reports its natural (untransformed) size so the viewport can morph to it. */ function Measured({ onSize, children }: { onSize: (size: Size) => void; children: ReactNode }) { const ref = useRef(null); useLayoutEffect(() => { const el = ref.current; if (!el) return; const report = () => onSize({ w: el.offsetWidth, h: el.offsetHeight }); report(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(report); observer.observe(el); return () => observer.disconnect(); }, [onSize]); return (
{children}
); } const focusables = (root: HTMLElement | null) => Array.from( root?.querySelectorAll("a[href],button:not([disabled]),[tabindex]:not([tabindex='-1'])") ?? [], ); /* ------------------------------------------------------------------------ */ /* NavigationMenu */ /* ------------------------------------------------------------------------ */ export function NavigationMenu({ items, logo, actions, value, defaultValue = "", onValueChange, openDelay = 150, closeDelay = 250, "aria-label": ariaLabel = "Main", className, }: NavigationMenuProps) { const reduce = useReducedMotion() ?? false; const uid = useId(); const [internal, setInternal] = useState(defaultValue); const controlled = value !== undefined; const open = controlled ? value : internal; const [hovered, setHovered] = useState(null); const [dir, setDir] = useState(1); const [geom, setGeom] = useState(null); const [mobileOpen, setMobileOpen] = useState(false); const navRef = useRef(null); const listRef = useRef(null); const viewportRef = useRef(null); const actionsRef = useRef(null); const triggerRefs = useRef>({}); const openRef = useRef(open); openRef.current = open; const sizeRef = useRef(null); const timer = useRef | null>(null); const openedBy = useRef<"hover" | "click">("click"); const focusPanelOnOpen = useRef(false); // The first frame after opening snaps to size; later switches morph. const snap = useRef(true); const panelIds = items.filter(hasPanel).map((i) => i.id); const panelIdsRef = useRef(panelIds); panelIdsRef.current = panelIds; const panelId = (id: string) => `${uid}-panel-${id}`; const setOpen = useCallback( (next: string) => { if (next === openRef.current) return; const from = panelIdsRef.current.indexOf(openRef.current); const to = panelIdsRef.current.indexOf(next); if (from >= 0 && to >= 0) setDir(to > from ? 1 : -1); if (!controlled) setInternal(next); onValueChange?.(next); }, [controlled, onValueChange], ); const clearTimer = () => { if (timer.current) clearTimeout(timer.current); timer.current = null; }; useEffect( () => () => { if (timer.current) clearTimeout(timer.current); }, [], ); const schedule = (fn: () => void, ms: number) => { clearTimer(); timer.current = setTimeout(fn, ms); }; /* -- geometry ---------------------------------------------------------- */ const place = useCallback((size: Size) => { const nav = navRef.current; const trigger = triggerRefs.current[openRef.current]; if (!nav || !trigger) return; const navRect = nav.getBoundingClientRect(); const tRect = trigger.getBoundingClientRect(); const max = Math.max(0, navRect.width - size.w); const x = Math.min(Math.max(0, tRect.left - navRect.left - 8), max); setGeom((g) => (g && g.x === x && g.w === size.w && g.h === size.h ? g : { x, ...size })); }, []); // Last measured size per panel. A panel revived mid-exit (A → B → A fast) // keeps its mounted node and never re-measures, so reuse its cached size. const sizes = useRef>({}); const onPanelSize = useCallback( (id: string) => (size: Size) => { sizes.current[id] = size; if (id !== openRef.current) return; // an exiting panel; ignore it sizeRef.current = size; place(size); }, [place], ); useLayoutEffect(() => { const cached = open ? sizes.current[open] : undefined; if (cached) { sizeRef.current = cached; place(cached); } }, [open, place]); // Stable per-id callbacks so Measured's effect does not re-subscribe. const sizeCallbacks = useRef void>>({}); const sizeCallback = (id: string) => { sizeCallbacks.current[id] ??= onPanelSize(id); return sizeCallbacks.current[id]; }; useEffect(() => { snap.current = geom === null; }, [geom]); useEffect(() => { if (!open) { setGeom(null); sizeRef.current = null; } }, [open]); useEffect(() => { if (!open) return; const onResize = () => sizeRef.current && place(sizeRef.current); window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, [open, place]); /* -- dismissal --------------------------------------------------------- */ useEffect(() => { if (!open && !mobileOpen) return; const onPointer = (event: globalThis.PointerEvent) => { if (navRef.current?.contains(event.target as Node)) return; setOpen(""); setMobileOpen(false); }; document.addEventListener("pointerdown", onPointer); return () => document.removeEventListener("pointerdown", onPointer); }, [open, mobileOpen, setOpen]); useEffect(() => { if (!open || !focusPanelOnOpen.current || !geom) return; focusPanelOnOpen.current = false; focusables(viewportRef.current)[0]?.focus(); }, [open, geom]); const close = (refocus: boolean) => { const current = openRef.current; clearTimer(); setOpen(""); if (refocus && current) triggerRefs.current[current]?.focus(); }; /* -- pointer intent ---------------------------------------------------- */ const onTriggerEnter = (event: PointerEvent, id: string) => { if (event.pointerType !== "mouse") return; setHovered(id); if (openRef.current) { clearTimer(); openedBy.current = "hover"; setOpen(id); // already open: switch instantly, no intent delay } else { schedule(() => { openedBy.current = "hover"; setOpen(id); }, openDelay); } }; const onLinkEnter = (event: PointerEvent, id: string) => { if (event.pointerType !== "mouse") return; setHovered(id); if (openRef.current) schedule(() => setOpen(""), closeDelay); else clearTimer(); }; const onRegionLeave = (event: PointerEvent) => { if (event.pointerType !== "mouse") return; if (openRef.current) schedule(() => setOpen(""), closeDelay); else clearTimer(); }; const onRegionEnter = (event: PointerEvent) => { if (event.pointerType !== "mouse") return; if (openRef.current) clearTimer(); }; const onTriggerClick = (id: string) => { clearTimer(); if (openRef.current === id) { // A hover-opened panel stays put on the click that usually follows. if (openedBy.current === "hover") openedBy.current = "click"; else setOpen(""); return; } openedBy.current = "click"; setOpen(id); }; /* -- keyboard ---------------------------------------------------------- */ // Top-level controls are each
  • 's own /
  • ); })}
    {actions}
    {/* Desktop viewport ------------------------------------------------ */} {openItem && hasPanel(openItem) ? ( // biome-ignore lint/a11y/noStaticElementInteractions: hover-intent region and key delegation for the panel's links
    close(false)} />
    ) : null}
    {/* Mobile sheet ----------------------------------------------------- */} {mobileOpen ? ( { setMobileOpen(false); if (refocus) navRef.current?.querySelector("[aria-label='Close menu']")?.focus(); }} /> ) : null} ); } /* ------------------------------------------------------------------------ */ /* Mobile */ /* ------------------------------------------------------------------------ */ function MobileSheet({ items, actions, reduce, onClose, }: { items: NavigationMenuItem[]; actions?: ReactNode; reduce: boolean; onClose: (refocus: boolean) => void; }) { const [section, setSection] = useState(null); const uid = useId(); return ( { if (e.key === "Escape") { e.preventDefault(); onClose(true); } }} className="absolute inset-x-0 top-full mt-2 max-h-[70vh] overflow-y-auto rounded-2xl border border-border bg-popover p-2 text-popover-foreground shadow-xl [scrollbar-width:none] md:hidden [&::-webkit-scrollbar]:hidden" >
      {items.map((item) => { if (!hasPanel(item)) { return (
    • {(item.render ?? defaultLink)({ href: item.href, onClick: () => onClose(false), className: "flex h-12 items-center rounded-xl px-3 text-sm font-medium text-foreground outline-none transition-colors hover:bg-muted focus-visible:bg-muted", children: item.label, })}
    • ); } const expanded = section === item.id; const regionId = `${uid}-${item.id}`; return (
    • {expanded ? (
      {item.sections.map((s) => (
      {s.title ? (

      {s.title}

      ) : null}
        {s.links.map((link) => (
      • onClose(false)} />
      • ))}
      ))}
      ) : null}
    • ); })}
    {actions ? (
    {actions}
    ) : null}
    ); }