import { useCallback, useEffect, useId, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type FocusEvent as ReactFocusEvent, type PointerEvent as ReactPointerEvent, } from "react"; import { Link } from "react-router"; import { isCurrentPath, type Direction } from "../LanguageSwitcher"; import { cx, FOCUS_RING, HOVER_TRANSITION } from "./styles"; import { isNavGroup, type NavItem, type NavigationStrings } from "./types"; /* The trigger row and the column row MUST use the same gap: the columns are width-matched to their triggers, so an identical gap is what makes every column line up under its own heading in both directions. */ const ROW_GAP = "gap-2xl"; /** Grace period before a pointer leaving the bar closes the mega menu. */ const CLOSE_DELAY_MS = 160; export interface DesktopNavProps { items: readonly NavItem[]; /** Current pathname, for aria-current="page". */ pathname: string; strings: NavigationStrings; /** Reading direction, so the arrow keys mirror in Arabic. */ direction: Direction; className?: string; } type ColumnPosition = "first" | "last"; /** * Desktop navigation with the Figma mega menu (317:6063): opening reveals every * group's children at once, each column sitting directly under its own trigger. * * Opens on hover and on keyboard focus, closes on Escape, on outside click and * when focus leaves the bar. Tab is deliberately untouched, so focus is never * trapped; the arrow keys add direct movement on top of it. */ export function DesktopNav({ items, pathname, strings, direction, className, }: DesktopNavProps) { const [open, setOpen] = useState(false); const navRef = useRef(null); const closeTimer = useRef | null>(null); const pendingFocus = useRef<{ groupId: string; position: ColumnPosition } | null>( null, ); const rawId = useId(); const baseId = `sharper-nav-${rawId.replace(/[^a-zA-Z0-9_-]/g, "")}`; const triggerId = (groupId: string) => `${baseId}-${groupId}-trigger`; const panelId = (groupId: string) => `${baseId}-${groupId}-panel`; const groupIds = items.filter(isNavGroup).map((item) => item.id); const clearCloseTimer = useCallback(() => { if (closeTimer.current !== null) { clearTimeout(closeTimer.current); closeTimer.current = null; } }, []); const openMega = useCallback(() => { clearCloseTimer(); setOpen(true); }, [clearCloseTimer]); const closeMega = useCallback(() => { clearCloseTimer(); pendingFocus.current = null; setOpen(false); }, [clearCloseTimer]); const scheduleClose = useCallback(() => { clearCloseTimer(); closeTimer.current = setTimeout(() => setOpen(false), CLOSE_DELAY_MS); }, [clearCloseTimer]); useEffect(() => clearCloseTimer, [clearCloseTimer]); /* Outside click. pointerdown rather than click so the menu is already gone by the time the click lands on whatever is underneath. */ useEffect(() => { if (!open) return; const handlePointerDown = (event: PointerEvent) => { if (!navRef.current?.contains(event.target as Node)) setOpen(false); }; document.addEventListener("pointerdown", handlePointerDown); return () => document.removeEventListener("pointerdown", handlePointerDown); }, [open]); /* A keyboard request to open and step into a column can only be honoured once the column has actually rendered. */ useEffect(() => { if (!open) return; const target = pendingFocus.current; pendingFocus.current = null; if (target) focusInColumn(target.groupId, target.position); }, [open]); const columnLinks = useCallback((groupId: string): HTMLElement[] => { const nav = navRef.current; if (!nav) return []; return Array.from( nav.querySelectorAll("[data-nav-sublink]"), ).filter( (link) => link.closest("[data-nav-column]")?.dataset.navColumn === groupId, ); }, []); const topLevelControls = useCallback((): HTMLElement[] => { const nav = navRef.current; if (!nav) return []; return Array.from(nav.querySelectorAll("[data-nav-top]")); }, []); const triggerFor = useCallback( (groupId: string): HTMLElement | undefined => topLevelControls().find((el) => el.dataset.navTrigger === groupId), [topLevelControls], ); const focusInColumn = useCallback( (groupId: string, position: ColumnPosition): boolean => { const links = columnLinks(groupId); const target = position === "first" ? links[0] : links[links.length - 1]; target?.focus(); return Boolean(target); }, [columnLinks], ); /** Open (if needed) and put focus on the first/last link of `groupId`. */ const enterColumn = useCallback( (groupId: string, position: ColumnPosition) => { if (open) { focusInColumn(groupId, position); return; } pendingFocus.current = { groupId, position }; openMega(); }, [focusInColumn, open, openMega], ); const forwardKey = direction === "rtl" ? "ArrowLeft" : "ArrowRight"; const backwardKey = direction === "rtl" ? "ArrowRight" : "ArrowLeft"; const moveTopLevel = (from: HTMLElement, delta: number) => { const controls = topLevelControls(); const index = controls.indexOf(from); if (index === -1 || controls.length === 0) return; const next = controls[(index + delta + controls.length) % controls.length]; next?.focus(); }; const handleTriggerKeyDown = ( event: ReactKeyboardEvent, groupId: string, ) => { switch (event.key) { case "Enter": case " ": case "Spacebar": event.preventDefault(); if (open) closeMega(); else enterColumn(groupId, "first"); break; case "ArrowDown": event.preventDefault(); enterColumn(groupId, "first"); break; case "ArrowUp": event.preventDefault(); enterColumn(groupId, "last"); break; case forwardKey: event.preventDefault(); moveTopLevel(event.currentTarget, 1); break; case backwardKey: event.preventDefault(); moveTopLevel(event.currentTarget, -1); break; case "Home": event.preventDefault(); topLevelControls()[0]?.focus(); break; case "End": { event.preventDefault(); const controls = topLevelControls(); controls[controls.length - 1]?.focus(); break; } default: break; } }; const handleSubLinkKeyDown = ( event: ReactKeyboardEvent, groupId: string, ) => { const links = columnLinks(groupId); const index = links.indexOf(event.currentTarget); const groupIndex = groupIds.indexOf(groupId); const step = (delta: number) => { if (links.length === 0) return; links[(index + delta + links.length) % links.length]?.focus(); }; const jumpColumn = (delta: number) => { if (groupIds.length === 0) return; const nextId = groupIds[(groupIndex + delta + groupIds.length) % groupIds.length]; if (nextId) focusInColumn(nextId, "first"); }; switch (event.key) { case "ArrowDown": event.preventDefault(); step(1); break; case "ArrowUp": event.preventDefault(); if (index === 0) triggerFor(groupId)?.focus(); else step(-1); break; case forwardKey: event.preventDefault(); jumpColumn(1); break; case backwardKey: event.preventDefault(); jumpColumn(-1); break; case "Home": event.preventDefault(); links[0]?.focus(); break; case "End": event.preventDefault(); links[links.length - 1]?.focus(); break; default: break; } }; /* Escape is handled once, on the bar, so it works from a trigger and from inside a column. Focus goes back to the trigger that owns the column. */ const handleNavKeyDown = (event: ReactKeyboardEvent) => { if (event.key !== "Escape" || !open) return; event.preventDefault(); const active = document.activeElement as HTMLElement | null; const groupId = active?.closest("[data-nav-column]")?.dataset.navColumn ?? active?.dataset.navTrigger; closeMega(); if (groupId) triggerFor(groupId)?.focus(); }; /* React's onBlur is focusout, so it fires for descendants too. */ const handleNavBlur = (event: ReactFocusEvent) => { const next = event.relatedTarget as Node | null; if (!next || !navRef.current?.contains(next)) closeMega(); }; const handleItemPointerEnter = ( event: ReactPointerEvent, isGroup: boolean, ) => { if (event.pointerType !== "mouse") return; if (isGroup) openMega(); else closeMega(); }; const handleNavPointerLeave = (event: ReactPointerEvent) => { if (event.pointerType !== "mouse") return; scheduleClose(); }; return ( ); }