initial commit
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
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<HTMLElement>(null);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | 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<HTMLElement>("[data-nav-sublink]"),
|
||||
).filter(
|
||||
(link) =>
|
||||
link.closest<HTMLElement>("[data-nav-column]")?.dataset.navColumn ===
|
||||
groupId,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const topLevelControls = useCallback((): HTMLElement[] => {
|
||||
const nav = navRef.current;
|
||||
if (!nav) return [];
|
||||
return Array.from(nav.querySelectorAll<HTMLElement>("[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<HTMLButtonElement>,
|
||||
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<HTMLAnchorElement>,
|
||||
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<HTMLElement>) => {
|
||||
if (event.key !== "Escape" || !open) return;
|
||||
event.preventDefault();
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
const groupId =
|
||||
active?.closest<HTMLElement>("[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<HTMLElement>) => {
|
||||
const next = event.relatedTarget as Node | null;
|
||||
if (!next || !navRef.current?.contains(next)) closeMega();
|
||||
};
|
||||
|
||||
const handleItemPointerEnter = (
|
||||
event: ReactPointerEvent<HTMLLIElement>,
|
||||
isGroup: boolean,
|
||||
) => {
|
||||
if (event.pointerType !== "mouse") return;
|
||||
if (isGroup) openMega();
|
||||
else closeMega();
|
||||
};
|
||||
|
||||
const handleNavPointerLeave = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (event.pointerType !== "mouse") return;
|
||||
scheduleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
ref={navRef}
|
||||
aria-label={strings.primaryNavLabel}
|
||||
data-mega-open={open ? "true" : "false"}
|
||||
className={cx("relative", className)}
|
||||
onPointerEnter={clearCloseTimer}
|
||||
onPointerLeave={handleNavPointerLeave}
|
||||
onKeyDown={handleNavKeyDown}
|
||||
onBlur={handleNavBlur}
|
||||
>
|
||||
<ul className={cx("flex items-center", ROW_GAP)}>
|
||||
{items.map((item) => {
|
||||
const group = isNavGroup(item);
|
||||
const hasCurrentChild =
|
||||
group && item.children.some((child) => isCurrentPath(child.href, pathname));
|
||||
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
className="flex"
|
||||
onPointerEnter={(event) => handleItemPointerEnter(event, group)}
|
||||
>
|
||||
{group ? (
|
||||
<button
|
||||
type="button"
|
||||
id={triggerId(item.id)}
|
||||
data-nav-top=""
|
||||
data-nav-trigger={item.id}
|
||||
data-current={hasCurrentChild ? "true" : undefined}
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId(item.id)}
|
||||
onFocus={openMega}
|
||||
onClick={() => (open ? closeMega() : openMega())}
|
||||
onKeyDown={(event) => handleTriggerKeyDown(event, item.id)}
|
||||
className={cx(
|
||||
"body-md-bold cursor-pointer whitespace-nowrap pb-[2px]",
|
||||
"text-start text-[var(--text-primary)]",
|
||||
"hover:text-[var(--text-accent)]",
|
||||
"data-[current=true]:text-[var(--text-accent)]",
|
||||
HOVER_TRANSITION,
|
||||
FOCUS_RING,
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to={item.href}
|
||||
data-nav-top=""
|
||||
aria-current={
|
||||
isCurrentPath(item.href, pathname) ? "page" : undefined
|
||||
}
|
||||
onFocus={closeMega}
|
||||
className={cx(
|
||||
"body-md-bold whitespace-nowrap pb-[2px] no-underline",
|
||||
"text-[var(--text-primary)]",
|
||||
"hover:text-[var(--text-accent)]",
|
||||
"aria-[current=page]:text-[var(--text-accent)]",
|
||||
"aria-[current=page]:underline aria-[current=page]:decoration-2",
|
||||
"aria-[current=page]:underline-offset-4",
|
||||
HOVER_TRANSITION,
|
||||
FOCUS_RING,
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{/* Mega panel. Anchored to the bar's inline start so the columns share the
|
||||
trigger row's origin and gap; `start-0` mirrors on its own in Arabic. */}
|
||||
<div hidden={!open} className="absolute start-0 top-full w-full">
|
||||
{/* One full-bleed backdrop for all columns, so the surface and its
|
||||
bottom edge are a single unbroken rectangle. The header clips the
|
||||
horizontal bleed with overflow-x-clip, which leaves the vertical
|
||||
overflow visible. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
"absolute inset-y-0 start-[-50vw] end-[-50vw]",
|
||||
"border-b border-[var(--border-subtle)] bg-[var(--surface-page)]",
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className={cx("relative flex pb-lg", ROW_GAP)}>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
data-nav-column={item.id}
|
||||
className="flex flex-col"
|
||||
>
|
||||
{/* Width setter: an invisible copy of the trigger label in the
|
||||
same type role, so this column is exactly as wide as its
|
||||
trigger. It is h-0, so it adds no height. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="body-md-bold invisible block h-0 overflow-hidden whitespace-nowrap"
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
|
||||
{isNavGroup(item) && (
|
||||
/* w-0 keeps the list out of the column's max-content width;
|
||||
min-w-full then stretches it back, so long child labels wrap
|
||||
inside the trigger's width exactly as they do in Figma. */
|
||||
<ul
|
||||
id={panelId(item.id)}
|
||||
aria-labelledby={triggerId(item.id)}
|
||||
className="flex w-0 min-w-full flex-col gap-sm pt-md"
|
||||
>
|
||||
{item.children.map((child) => (
|
||||
<li key={child.id}>
|
||||
<Link
|
||||
to={child.href}
|
||||
data-nav-sublink=""
|
||||
aria-current={
|
||||
isCurrentPath(child.href, pathname) ? "page" : undefined
|
||||
}
|
||||
onKeyDown={(event) => handleSubLinkKeyDown(event, item.id)}
|
||||
className={cx(
|
||||
"body-md block no-underline",
|
||||
"text-[var(--text-secondary)]",
|
||||
"hover:text-[var(--text-primary)]",
|
||||
"aria-[current=page]:text-[var(--text-accent)]",
|
||||
HOVER_TRANSITION,
|
||||
FOCUS_RING,
|
||||
)}
|
||||
>
|
||||
{child.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user