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 (
);
}