"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 / (links may come from a // render prop, so they are found structurally instead of by attribute). const topItems = () => Array.from( listRef.current?.querySelectorAll(":scope > li > a, :scope > li > button") ?? [], ); const idOf = (el: Element | null | undefined) => el?.closest("li[data-nav-id]")?.dataset.navId; const onListKeyDown = (event: KeyboardEvent) => { const tops = topItems(); const target = event.target as HTMLElement; const at = tops.indexOf(target); if (at < 0) return; const item = items.find((i) => i.id === idOf(target)); if (!item) return; if (event.key === "ArrowRight" || event.key === "ArrowLeft") { event.preventDefault(); const delta = event.key === "ArrowRight" ? 1 : -1; const next = tops[(at + delta + tops.length) % tops.length]; next?.focus(); // Keep an open menu following focus across panel triggers. const nextId = idOf(next); if (openRef.current && nextId) setOpen(panelIds.includes(nextId) ? nextId : ""); } else if (event.key === "Home" || event.key === "End") { event.preventDefault(); tops[event.key === "Home" ? 0 : tops.length - 1]?.focus(); } else if (hasPanel(item) && event.key === "ArrowDown") { event.preventDefault(); if (openRef.current === item.id) focusables(viewportRef.current)[0]?.focus(); else { focusPanelOnOpen.current = true; openedBy.current = "click"; setOpen(item.id); } } else if (hasPanel(item) && event.key === "Tab" && !event.shiftKey && openRef.current === item.id) { // The panel lives after the bar in the DOM; walk Tab into it. const first = focusables(viewportRef.current)[0]; if (first) { event.preventDefault(); first.focus(); } } else if (event.key === "Escape" && openRef.current) { event.preventDefault(); close(true); } }; const onViewportKeyDown = (event: KeyboardEvent) => { const links = focusables(viewportRef.current); const at = links.indexOf(document.activeElement as HTMLElement); const trigger = triggerRefs.current[openRef.current]; if (event.key === "Escape") { event.preventDefault(); close(true); } else if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); const delta = event.key === "ArrowDown" ? 1 : -1; links[(at + delta + links.length) % links.length]?.focus(); } else if (event.key === "Tab" && event.shiftKey && at === 0 && trigger) { event.preventDefault(); trigger.focus(); } else if (event.key === "Tab" && !event.shiftKey && at === links.length - 1 && trigger) { // Leaving the panel forward: continue to the item after its trigger. const tops = topItems(); const after = tops[tops.indexOf(trigger) + 1] ?? focusables(actionsRef.current)[0]; close(false); if (after) { event.preventDefault(); after.focus(); } } }; /* -- render ------------------------------------------------------------ */ const openItem = items.find((i) => i.id === open); const indicator = hovered ?? (open || null); const morph = reduce || snap.current ? { duration: 0 } : SPRING_LAYOUT; return ( { const next = event.relatedTarget as Node | null; if (next && !navRef.current?.contains(next)) { setOpen(""); setMobileOpen(false); } }} > {logo ? {logo} : null} {/* layoutRoot: the hover pill's shared layout stays local to the bar. */} { setHovered(null); onRegionLeave(e); }} > {items.map((item) => { const isOpen = open === item.id; const pill = indicator === item.id ? ( ) : null; const topClass = cn( "relative isolate inline-flex h-9 items-center gap-1 rounded-lg px-3 text-sm font-medium outline-none transition-colors", "focus-visible:ring-2 focus-visible:ring-ring", isOpen || indicator === item.id ? "text-foreground" : "text-muted-foreground hover:text-foreground", ); if (!hasPanel(item)) { return ( onLinkEnter(e, item.id)} > {pill} {(item.render ?? defaultLink)({ href: item.href, className: topClass, children: item.label, })} ); } return ( onTriggerEnter(e, item.id)} > {pill} { triggerRefs.current[item.id] = el; }} type="button" aria-expanded={isOpen} aria-controls={isOpen ? panelId(item.id) : undefined} onClick={() => onTriggerClick(item.id)} className={topClass} > {item.label} ); })} {actions} setMobileOpen((o) => !o)} onKeyDown={(e) => { if (e.key === "Escape") setMobileOpen(false); }} className="relative ml-auto inline-flex size-9 items-center justify-center rounded-lg text-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring md:hidden" > {mobileOpen ? : } {/* 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 ( setSection(expanded ? null : item.id)} className="flex h-12 w-full items-center justify-between rounded-xl px-3 text-sm font-medium text-foreground outline-none transition-colors hover:bg-muted focus-visible:bg-muted" > {item.label} {expanded ? ( {item.sections.map((s) => ( {s.title ? ( {s.title} ) : null} {s.links.map((link) => ( onClose(false)} /> ))} ))} ) : null} ); })} {actions ? ( {actions} ) : null} ); }
{section.title}
{s.title}