556 lines
19 KiB
TypeScript
556 lines
19 KiB
TypeScript
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 (
|
|
<svg
|
|
width="11"
|
|
height="11"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="3"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
className="shrink-0"
|
|
>
|
|
<path d="M5 9l7 7 7-7" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function MenuArrow() {
|
|
return (
|
|
<svg
|
|
width="15"
|
|
height="15"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
/* Mirrors with the page: the arrow points at the reading edge. It only
|
|
appears when the reader hovers or focus-es the link, so a row of
|
|
always-visible arrows does not announce every entry at once. */
|
|
className={cx(
|
|
"shrink-0 rtl:-scale-x-100 opacity-0",
|
|
"transition-opacity duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]",
|
|
"group-hover:opacity-100 group-focus-visible:opacity-100",
|
|
)}
|
|
>
|
|
<path d="M5 12h13M12.5 6.5L18 12l-5.5 5.5" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
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<string | null>(null);
|
|
const navRef = useRef<HTMLElement>(null);
|
|
const closeTimer = useRef<ReturnType<typeof setTimeout> | 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<string | 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 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<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],
|
|
);
|
|
|
|
/* 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<HTMLButtonElement>,
|
|
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<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) 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<HTMLElement>) => {
|
|
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<HTMLElement>) => {
|
|
const next = event.relatedTarget as Node | null;
|
|
if (!next || !navRef.current?.contains(next)) closeGroup();
|
|
};
|
|
|
|
const handleItemPointerEnter = (
|
|
event: ReactPointerEvent<HTMLLIElement>,
|
|
groupId: string | null,
|
|
) => {
|
|
if (event.pointerType !== "mouse") return;
|
|
if (groupId) showGroup(groupId);
|
|
else closeGroup();
|
|
};
|
|
|
|
const handleNavPointerLeave = (event: ReactPointerEvent<HTMLElement>) => {
|
|
if (event.pointerType !== "mouse") return;
|
|
scheduleClose();
|
|
};
|
|
|
|
return (
|
|
<nav
|
|
ref={navRef}
|
|
aria-label={strings.primaryNavLabel}
|
|
data-mega-open={openGroup !== null ? "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="relative flex"
|
|
onPointerEnter={(event) =>
|
|
handleItemPointerEnter(event, group ? item.id : null)
|
|
}
|
|
>
|
|
{group ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
id={triggerId(item.id)}
|
|
data-nav-top=""
|
|
data-nav-trigger={item.id}
|
|
data-current={hasCurrentChild ? "true" : undefined}
|
|
aria-expanded={openGroup === item.id}
|
|
aria-controls={panelId(item.id)}
|
|
onFocus={() => {
|
|
/* Moving to another trigger closes the one left behind. */
|
|
if (openGroup !== null && openGroup !== item.id) closeGroup();
|
|
}}
|
|
onPointerDown={(event) => {
|
|
lastPointerType.current = event.pointerType;
|
|
}}
|
|
onClick={() => handleTriggerClick(item.id)}
|
|
onKeyDown={(event) => handleTriggerKeyDown(event, item.id)}
|
|
className={cx(
|
|
"body-sm-bold flex cursor-pointer items-center gap-2xs",
|
|
"whitespace-nowrap pb-[2px] text-start",
|
|
/* The drafts rest a nav label in text/secondary and
|
|
brighten it to primary under the pointer; the section
|
|
the reader is in stays primary and is marked by the
|
|
accent rule below, not by green text. */
|
|
"text-[var(--text-secondary)]",
|
|
"hover:text-[var(--text-primary)]",
|
|
"aria-expanded:text-[var(--text-primary)]",
|
|
"data-[current=true]:text-[var(--text-primary)]",
|
|
HOVER_TRANSITION,
|
|
FOCUS_RING,
|
|
)}
|
|
>
|
|
<span>{item.label}</span>
|
|
<Chevron />
|
|
</button>
|
|
|
|
{/* The section the reader is inside keeps a 2px accent rule
|
|
under its trigger, as the draft draws it. Decorative -
|
|
`data-current` on the trigger carries the state. */}
|
|
{hasCurrentChild ? (
|
|
<span
|
|
aria-hidden="true"
|
|
className="absolute inset-x-0 bottom-0 h-[2px] bg-[var(--surface-accent)]"
|
|
/>
|
|
) : null}
|
|
|
|
{/* The draft's dropdown is a compact card hanging from its
|
|
own trigger, not a full-width mega bar. `start-[-1rem]`
|
|
pulls the card back by its own padding so a child label
|
|
lines up with the trigger label above it; being a logical
|
|
inset, it mirrors in Arabic on its own. */}
|
|
<ul
|
|
id={panelId(item.id)}
|
|
aria-labelledby={triggerId(item.id)}
|
|
hidden={openGroup !== item.id}
|
|
className={cx(
|
|
"sh-nav-menu absolute top-full start-[-1rem] z-[var(--layer-dropdown)]",
|
|
"mt-sm flex min-w-[17.875rem] flex-col",
|
|
"rounded-control border border-[var(--border-subtle)]",
|
|
"bg-[var(--surface-raised)] p-xs",
|
|
"shadow-[var(--elevation-popover)]",
|
|
)}
|
|
>
|
|
{item.children.map((child) => (
|
|
<li key={child.id}>
|
|
<Link
|
|
to={child.href}
|
|
data-nav-sublink=""
|
|
onClick={closeGroup}
|
|
aria-current={
|
|
isCurrentPath(child.href, pathname) ? "page" : undefined
|
|
}
|
|
onKeyDown={(event) => handleSubLinkKeyDown(event, item.id)}
|
|
className={cx(
|
|
"group body-sm-bold flex items-center justify-between gap-md",
|
|
"rounded-control px-sm py-xs no-underline",
|
|
"text-[var(--text-primary)]",
|
|
"hover:bg-[var(--surface-page)] hover:text-[var(--text-accent)]",
|
|
"aria-[current=page]:text-[var(--text-accent)]",
|
|
HOVER_TRANSITION,
|
|
FOCUS_RING,
|
|
)}
|
|
>
|
|
<span>{child.label}</span>
|
|
<MenuArrow />
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Link
|
|
to={item.href}
|
|
data-nav-top=""
|
|
aria-current={
|
|
isCurrentPath(item.href, pathname) ? "page" : undefined
|
|
}
|
|
onFocus={closeGroup}
|
|
onClick={closeGroup}
|
|
className={cx(
|
|
"body-sm-bold whitespace-nowrap pb-[2px] no-underline",
|
|
/* Same rest/hover/current treatment as a group trigger. */
|
|
"text-[var(--text-secondary)]",
|
|
"hover:text-[var(--text-primary)]",
|
|
"aria-[current=page]:text-[var(--text-primary)]",
|
|
HOVER_TRANSITION,
|
|
FOCUS_RING,
|
|
)}
|
|
>
|
|
{item.label}
|
|
</Link>
|
|
|
|
{isCurrentPath(item.href, pathname) ? (
|
|
<span
|
|
aria-hidden="true"
|
|
className="absolute inset-x-0 bottom-0 h-[2px] bg-[var(--surface-accent)]"
|
|
/>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
|
|
</nav>
|
|
);
|
|
}
|