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. */ /* The draft marks a group trigger with a chevron and each menu entry with a forward arrow. Both are decorative: the label beside them carries the name. */ function Chevron() { return ( ); } function MenuArrow() { return ( ); } const ROW_GAP = "gap-2xl"; /** Grace period before a pointer leaving the bar closes the open group. */ 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. Every group is a disclosure: hovering its trigger, or * opening it from the keyboard, reveals that group's links - and only that * group's - in a column directly under its own trigger. * * DEVIATION FROM FIGMA: the mega menu (317:6063) reveals every group's column * at once. The designer has since asked for only the hovered group to open, so * the panel keeps the Figma column geometry - each column under its trigger, on * the bar's own surface - but shows one column at a time, on a backdrop as wide * as the row of items rather than the whole window. * * Keyboard follows the disclosure-navigation pattern: a trigger does not open * on focus; Enter, Space and ArrowDown open it. The columns share one backdrop, * so they come after the whole trigger row in the DOM; Tab is bridged to match * what the reader sees - from an open trigger it steps into that column, and * from the column's last link it moves on to the next trigger. Escape, an * outside press and focus leaving the bar all close it. Focus is never trapped. */ export function DesktopNav({ items, pathname, strings, direction, className, }: DesktopNavProps) { /** The one group whose column is showing, or null when nothing is open. */ const [openGroup, setOpenGroup] = useState(null); const navRef = useRef(null); const closeTimer = useRef | null>(null); const pendingFocus = useRef<{ groupId: string; position: ColumnPosition } | null>( null, ); /* How the last press on a trigger began, so a mouse click does not close the column that hovering the same trigger has just opened. */ const lastPointerType = useRef(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 showGroup = useCallback( (groupId: string) => { clearCloseTimer(); setOpenGroup(groupId); }, [clearCloseTimer], ); const closeGroup = useCallback(() => { clearCloseTimer(); pendingFocus.current = null; setOpenGroup(null); }, [clearCloseTimer]); const scheduleClose = useCallback(() => { clearCloseTimer(); closeTimer.current = setTimeout(() => setOpenGroup(null), CLOSE_DELAY_MS); }, [clearCloseTimer]); useEffect(() => clearCloseTimer, [clearCloseTimer]); useEffect(() => closeGroup(), [pathname, closeGroup]); 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], ); /* Outside press. pointerdown rather than click so the column is already gone by the time the click lands on whatever is underneath. */ useEffect(() => { if (openGroup === null) return; const handlePointerDown = (event: PointerEvent) => { if (!navRef.current?.contains(event.target as Node)) setOpenGroup(null); }; document.addEventListener("pointerdown", handlePointerDown); return () => document.removeEventListener("pointerdown", handlePointerDown); }, [openGroup]); /* A keyboard request to open a group and step into its column can only be honoured once that column has actually rendered. */ useEffect(() => { const target = pendingFocus.current; if (openGroup === null || !target || target.groupId !== openGroup) return; pendingFocus.current = null; focusInColumn(target.groupId, target.position); }, [openGroup, focusInColumn]); /** Open `groupId` (if needed) and put focus on its first/last link. */ const enterColumn = useCallback( (groupId: string, position: ColumnPosition) => { if (openGroup === groupId) { focusInColumn(groupId, position); return; } pendingFocus.current = { groupId, position }; showGroup(groupId); }, [focusInColumn, openGroup, showGroup], ); 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 handleTriggerClick = (groupId: string) => { const pointer = lastPointerType.current; lastPointerType.current = null; /* A mouse has already opened the column by hovering; a click keeps it open. Touch and pen have no hover, so for them a press toggles. */ if (pointer === "mouse" || openGroup !== groupId) showGroup(groupId); else closeGroup(); }; const handleTriggerKeyDown = ( event: ReactKeyboardEvent, groupId: string, ) => { switch (event.key) { case "Enter": case " ": case "Spacebar": event.preventDefault(); if (openGroup === groupId) closeGroup(); 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; } case "Tab": { /* Into the open column, which the DOM places after the trigger row. */ if (event.shiftKey || openGroup !== groupId) break; const first = columnLinks(groupId)[0]; if (first) { event.preventDefault(); first.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) enterColumn(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; case "Tab": { if (event.shiftKey) { /* Back out of the column to the trigger that owns it. */ if (index === 0) { event.preventDefault(); triggerFor(groupId)?.focus(); } break; } /* On from the end of the column to the next top-level item, which closes this group. With no next item, Tab leaves the bar as usual. */ if (index !== links.length - 1) break; const controls = topLevelControls(); const trigger = triggerFor(groupId); const next = trigger ? controls[controls.indexOf(trigger) + 1] : undefined; if (next) { event.preventDefault(); next.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" || openGroup === null) return; event.preventDefault(); const groupId = openGroup; closeGroup(); 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)) closeGroup(); }; const handleItemPointerEnter = ( event: ReactPointerEvent, groupId: string | null, ) => { if (event.pointerType !== "mouse") return; if (groupId) showGroup(groupId); else closeGroup(); }; const handleNavPointerLeave = (event: ReactPointerEvent) => { if (event.pointerType !== "mouse") return; scheduleClose(); }; return ( ); }