Files
sharper.ly/apps/web/app/components/ui/Navigation/DesktopNav.tsx
T

546 lines
19 KiB
TypeScript
Raw Normal View History

2026-09-11 02:46:56 +02:00
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. */
className="shrink-0 rtl:-scale-x-100"
>
<path d="M5 12h13M12.5 6.5L18 12l-5.5 5.5" />
</svg>
);
}
2026-09-11 02:46:56 +02:00
const ROW_GAP = "gap-2xl";
/** Grace period before a pointer leaving the bar closes the open group. */
2026-09-11 02:46:56 +02:00
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.
2026-09-11 02:46:56 +02:00
*
* 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.
2026-09-11 02:46:56 +02:00
*/
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);
2026-09-11 02:46:56 +02:00
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);
2026-09-11 02:46:56 +02:00
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],
);
2026-09-11 02:46:56 +02:00
const closeGroup = useCallback(() => {
2026-09-11 02:46:56 +02:00
clearCloseTimer();
pendingFocus.current = null;
setOpenGroup(null);
2026-09-11 02:46:56 +02:00
}, [clearCloseTimer]);
const scheduleClose = useCallback(() => {
clearCloseTimer();
closeTimer.current = setTimeout(() => setOpenGroup(null), CLOSE_DELAY_MS);
2026-09-11 02:46:56 +02:00
}, [clearCloseTimer]);
useEffect(() => clearCloseTimer, [clearCloseTimer]);
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. */
2026-09-11 02:46:56 +02:00
const enterColumn = useCallback(
(groupId: string, position: ColumnPosition) => {
if (openGroup === groupId) {
2026-09-11 02:46:56 +02:00
focusInColumn(groupId, position);
return;
}
pendingFocus.current = { groupId, position };
showGroup(groupId);
2026-09-11 02:46:56 +02:00
},
[focusInColumn, openGroup, showGroup],
2026-09-11 02:46:56 +02:00
);
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();
};
2026-09-11 02:46:56 +02:00
const handleTriggerKeyDown = (
event: ReactKeyboardEvent<HTMLButtonElement>,
groupId: string,
) => {
switch (event.key) {
case "Enter":
case " ":
case "Spacebar":
event.preventDefault();
if (openGroup === groupId) closeGroup();
2026-09-11 02:46:56 +02:00
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;
}
2026-09-11 02:46:56 +02:00
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");
2026-09-11 02:46:56 +02:00
};
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;
}
2026-09-11 02:46:56 +02:00
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;
2026-09-11 02:46:56 +02:00
event.preventDefault();
const groupId = openGroup;
closeGroup();
triggerFor(groupId)?.focus();
2026-09-11 02:46:56 +02:00
};
/* 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();
2026-09-11 02:46:56 +02:00
};
const handleItemPointerEnter = (
event: ReactPointerEvent<HTMLLIElement>,
groupId: string | null,
2026-09-11 02:46:56 +02:00
) => {
if (event.pointerType !== "mouse") return;
if (groupId) showGroup(groupId);
else closeGroup();
2026-09-11 02:46:56 +02:00
};
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"}
2026-09-11 02:46:56 +02:00
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)
}
2026-09-11 02:46:56 +02:00
>
{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=""
aria-current={
isCurrentPath(child.href, pathname) ? "page" : undefined
}
onKeyDown={(event) => handleSubLinkKeyDown(event, item.id)}
className={cx(
"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>
</>
2026-09-11 02:46:56 +02:00
) : (
<>
<Link
to={item.href}
data-nav-top=""
aria-current={
isCurrentPath(item.href, pathname) ? "page" : undefined
}
onFocus={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}
</>
2026-09-11 02:46:56 +02:00
)}
</li>
);
})}
</ul>
</nav>
);
}