initial commit
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { cx, FOCUS_RING } from "./styles";
|
||||
import type { NavSurface } from "./types";
|
||||
|
||||
/* Exported from Figma logo/sharp-er (317:5571) and logo/sharp-er-dark
|
||||
(317:7812). Intrinsic 334x46; the navigation draws it at 100px wide, which
|
||||
is the designed size in every navigation frame (desktop and mobile). */
|
||||
const LOGO_SRC: Record<NavSurface, string> = {
|
||||
light: "/assets/sharp-er-logo.png",
|
||||
dark: "/assets/sharp-er-logo-dark.png",
|
||||
};
|
||||
|
||||
const LOGO_INTRINSIC_WIDTH = 334;
|
||||
const LOGO_INTRINSIC_HEIGHT = 46;
|
||||
|
||||
export interface BrandProps {
|
||||
/** Locale-prefixed home path, e.g. "/en". */
|
||||
href: string;
|
||||
/** Accessible name of the link; the image itself is decorative. */
|
||||
label: string;
|
||||
surface: NavSurface;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** The SHARP-ER wordmark, linking home. Sits at the inline start of the bar. */
|
||||
export function Brand({ href, label, surface, className }: BrandProps) {
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
aria-label={label}
|
||||
className={cx(
|
||||
"inline-flex shrink-0 items-center rounded-control",
|
||||
FOCUS_RING,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={LOGO_SRC[surface]}
|
||||
alt=""
|
||||
width={LOGO_INTRINSIC_WIDTH}
|
||||
height={LOGO_INTRINSIC_HEIGHT}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
className="block h-auto w-[100px]"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { getDirection, type Locale } from "../LanguageSwitcher";
|
||||
|
||||
import { cx } from "./styles";
|
||||
import type { NavSurface } from "./types";
|
||||
|
||||
/* Exported from Figma assets/eu-en (224:11275) and assets/eu-en - darkBg
|
||||
(238:2325). The emblem is drawn at the prescribed 48x32 on both surfaces;
|
||||
the source files are downscaled copies of the Figma exports. */
|
||||
const EMBLEM_SRC: Record<NavSurface, string> = {
|
||||
light: "/assets/eu-flag.png",
|
||||
dark: "/assets/eu-flag-dark.png",
|
||||
};
|
||||
|
||||
const EMBLEM_INTRINSIC_WIDTH = 192;
|
||||
const EMBLEM_INTRINSIC_HEIGHT = 128;
|
||||
|
||||
export interface EuBadgeProps {
|
||||
/** "Funded by the European Union", already in the reader's language. */
|
||||
statement: string;
|
||||
/** Alt text for the emblem. Empty when the statement is rendered beside it. */
|
||||
emblemAlt?: string;
|
||||
/** Language of `statement` - drives the font switch and the text direction. */
|
||||
locale: Locale;
|
||||
surface: NavSurface;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mandatory EU visibility lockup: emblem + funding statement.
|
||||
*
|
||||
* The lockup itself is pinned to `dir="ltr"` so the emblem always leads, which
|
||||
* is how the Arabic navigation frame (334:526) draws it; only the statement
|
||||
* carries the reader's own direction. The bar's own mirroring still moves the
|
||||
* whole badge from the top right (English) to the top left (Arabic).
|
||||
*
|
||||
* DEVIATION FROM FIGMA: the design sets the statement in EU reflex blue
|
||||
* (hex 0f2a8e). A raw hex is not permitted in a component here, and there is
|
||||
* no semantic token for a third-party brand colour, so the statement uses
|
||||
* --text-primary. It stays legible on both surfaces and the emblem still
|
||||
* carries the EU colours. Add a tier-2 token if the blue has to come back.
|
||||
*/
|
||||
export function EuBadge({
|
||||
statement,
|
||||
emblemAlt = "",
|
||||
locale,
|
||||
surface,
|
||||
className,
|
||||
}: EuBadgeProps) {
|
||||
return (
|
||||
<div
|
||||
dir="ltr"
|
||||
className={cx("flex shrink-0 items-center gap-2xs", className)}
|
||||
>
|
||||
<img
|
||||
src={EMBLEM_SRC[surface]}
|
||||
alt={emblemAlt}
|
||||
width={EMBLEM_INTRINSIC_WIDTH}
|
||||
height={EMBLEM_INTRINSIC_HEIGHT}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
className="block h-[32px] w-[48px] shrink-0"
|
||||
/>
|
||||
<span
|
||||
lang={locale}
|
||||
dir={getDirection(locale)}
|
||||
className="label-md block w-[96px] text-[var(--text-primary)]"
|
||||
>
|
||||
{statement}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { cx } from "./styles";
|
||||
|
||||
/* The Figma menu icon (20:224) is three 20x2 rectangles in a 24x24 box with a
|
||||
5px gap - not an exported asset, so it is drawn with the same geometry here.
|
||||
`open` folds the outer two into a cross and fades the middle one, which is
|
||||
what the drawer's close control needs. */
|
||||
const BAR = "block h-[2px] w-[20px] rounded-pill bg-[var(--text-primary)]";
|
||||
|
||||
const BAR_MOTION =
|
||||
"transition-[transform,opacity] duration-[var(--motion-duration-fast)] " +
|
||||
"ease-[var(--motion-ease-standard)]";
|
||||
|
||||
export interface MenuBarsProps {
|
||||
/** true renders the cross (close), false the hamburger. */
|
||||
open?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MenuBars({ open = false, className }: MenuBarsProps) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
"flex size-lg flex-col items-center justify-center gap-[5px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className={cx(BAR, BAR_MOTION, open && "translate-y-[7px] rotate-45")} />
|
||||
<span className={cx(BAR, BAR_MOTION, open && "opacity-0")} />
|
||||
<span className={cx(BAR, BAR_MOTION, open && "-translate-y-[7px] -rotate-45")} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
} from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { LanguageSwitcher, isCurrentPath, type Locale } from "../LanguageSwitcher";
|
||||
|
||||
import { Brand } from "./Brand";
|
||||
import { EuBadge } from "./EuBadge";
|
||||
import { MenuBars } from "./MenuBars";
|
||||
import { cx, FOCUS_RING, HOVER_TRANSITION } from "./styles";
|
||||
import { isNavGroup, type NavItem, type NavigationStrings, type NavSurface } from "./types";
|
||||
|
||||
/** Must stay in step with the `xl:` variants below (Tailwind xl = 80rem). */
|
||||
const DESKTOP_MEDIA_QUERY = "(min-width: 80rem)";
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(", ");
|
||||
|
||||
function focusablesIn(root: HTMLElement): HTMLElement[] {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||
(element) => !element.hasAttribute("hidden") && element.getClientRects().length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
export interface MobileNavProps {
|
||||
items: readonly NavItem[];
|
||||
pathname: string;
|
||||
search?: string;
|
||||
hash?: string;
|
||||
locale: Locale;
|
||||
strings: NavigationStrings;
|
||||
surface: NavSurface;
|
||||
/** Locale-prefixed home path for the logo inside the drawer. */
|
||||
homeHref: string;
|
||||
showLanguageSwitcher: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hamburger trigger plus the full-screen drawer it opens.
|
||||
*
|
||||
* While the drawer is open, focus is trapped inside it, everything behind it is
|
||||
* `inert` (so assistive technology cannot browse the page under an aria-modal
|
||||
* dialog either), the page behind cannot scroll, Escape closes it, and focus
|
||||
* returns to the hamburger on close.
|
||||
*/
|
||||
export function MobileNav({
|
||||
items,
|
||||
pathname,
|
||||
search,
|
||||
hash,
|
||||
locale,
|
||||
strings,
|
||||
surface,
|
||||
homeHref,
|
||||
showLanguageSwitcher,
|
||||
className,
|
||||
}: MobileNavProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
const drawerRef = useRef<HTMLDivElement>(null);
|
||||
const wasOpen = useRef(false);
|
||||
|
||||
const rawId = useId();
|
||||
const baseId = `sharper-drawer-${rawId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
||||
const drawerId = `${baseId}-panel`;
|
||||
const headingId = (groupId: string) => `${baseId}-${groupId}-heading`;
|
||||
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
/* Focus in on open, and back to the hamburger on close. */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
wasOpen.current = true;
|
||||
closeRef.current?.focus();
|
||||
} else if (wasOpen.current) {
|
||||
wasOpen.current = false;
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
/* The drawer is aria-modal, so the rest of the page has to be inert as well -
|
||||
otherwise a screen reader still browses the header, main and footer behind
|
||||
it. Rather than naming those landmarks (the drawer is itself rendered
|
||||
inside the header, so inerting the header would inert the drawer), every
|
||||
ancestor up to <body> has its OTHER children inerted. `inert` is set as an
|
||||
attribute so no DOM-lib typing is needed, and an element that was already
|
||||
inert is skipped so the restore never clears someone else's flag.
|
||||
|
||||
This runs after the focus effect above on purpose: focus is already inside
|
||||
the drawer by the time the element it came from goes inert. */
|
||||
useEffect(() => {
|
||||
const drawer = drawerRef.current;
|
||||
if (!open || !drawer) return;
|
||||
|
||||
const inerted: Element[] = [];
|
||||
let node: Element | null = drawer;
|
||||
|
||||
while (node !== null) {
|
||||
const parent: HTMLElement | null = node.parentElement;
|
||||
if (parent === null) break;
|
||||
|
||||
const siblings: Element[] = Array.from(parent.children);
|
||||
for (const sibling of siblings) {
|
||||
if (sibling === node || sibling.hasAttribute("inert")) continue;
|
||||
sibling.setAttribute("inert", "");
|
||||
inerted.push(sibling);
|
||||
}
|
||||
|
||||
node = parent === document.body ? null : parent;
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const element of inerted) element.removeAttribute("inert");
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
/* Body scroll lock. The scrollbar's width is handed back as inline-end
|
||||
padding so the page behind does not jump sideways when it disappears. */
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const { body, documentElement } = document;
|
||||
const previousOverflow = body.style.overflow;
|
||||
const previousPadding = body.style.paddingInlineEnd;
|
||||
const scrollbar = window.innerWidth - documentElement.clientWidth;
|
||||
|
||||
body.style.overflow = "hidden";
|
||||
if (scrollbar > 0) body.style.paddingInlineEnd = `${scrollbar}px`;
|
||||
|
||||
return () => {
|
||||
body.style.overflow = previousOverflow;
|
||||
body.style.paddingInlineEnd = previousPadding;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
/* Keeps focus inside the drawer even when something moves it programmatically
|
||||
(the Tab handler below covers ordinary keyboard travel). */
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleFocusIn = (event: FocusEvent) => {
|
||||
const drawer = drawerRef.current;
|
||||
if (drawer && !drawer.contains(event.target as Node)) {
|
||||
focusablesIn(drawer)[0]?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener("focusin", handleFocusIn);
|
||||
return () => document.removeEventListener("focusin", handleFocusIn);
|
||||
}, [open]);
|
||||
|
||||
/* The drawer is desktop-hidden; close it rather than stranding a locked body
|
||||
if the viewport grows past the breakpoint while it is open. */
|
||||
useEffect(() => {
|
||||
if (!open || typeof window.matchMedia !== "function") return;
|
||||
const query = window.matchMedia(DESKTOP_MEDIA_QUERY);
|
||||
if (query.matches) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
if (event.matches) setOpen(false);
|
||||
};
|
||||
query.addEventListener("change", handleChange);
|
||||
return () => query.removeEventListener("change", handleChange);
|
||||
}, [open]);
|
||||
|
||||
const handleDrawerKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
|
||||
const drawer = drawerRef.current;
|
||||
if (!drawer) return;
|
||||
|
||||
const stops = focusablesIn(drawer);
|
||||
if (stops.length === 0) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const first = stops[0];
|
||||
const last = stops[stops.length - 1];
|
||||
const active = document.activeElement;
|
||||
const inside = drawer.contains(active);
|
||||
|
||||
if (event.shiftKey && (active === first || !inside)) {
|
||||
event.preventDefault();
|
||||
last?.focus();
|
||||
} else if (!event.shiftKey && (active === last || !inside)) {
|
||||
event.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const linkClasses = cx(
|
||||
"block rounded-control py-2xs no-underline",
|
||||
"hover:text-[var(--text-accent)]",
|
||||
"aria-[current=page]:text-[var(--text-accent)]",
|
||||
HOVER_TRANSITION,
|
||||
FOCUS_RING,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={drawerId}
|
||||
aria-label={open ? strings.closeMenu : strings.openMenu}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className={cx(
|
||||
"inline-flex shrink-0 cursor-pointer items-center justify-center rounded-control xl:hidden",
|
||||
FOCUS_RING,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<MenuBars />
|
||||
</button>
|
||||
|
||||
<div
|
||||
id={drawerId}
|
||||
ref={drawerRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={strings.primaryNavLabel}
|
||||
hidden={!open}
|
||||
data-surface={surface}
|
||||
onKeyDown={handleDrawerKeyDown}
|
||||
className={cx(
|
||||
"fixed inset-0 z-[var(--layer-modal)] flex flex-col",
|
||||
"bg-[var(--surface-page)] xl:hidden",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-h-[64px] shrink-0 items-center justify-between gap-md px-[var(--layout-gutter)]">
|
||||
<Brand href={homeHref} label={strings.homeLinkLabel} surface={surface} />
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
aria-label={strings.closeMenu}
|
||||
onClick={close}
|
||||
className={cx(
|
||||
"inline-flex shrink-0 cursor-pointer items-center justify-center rounded-control",
|
||||
FOCUS_RING,
|
||||
)}
|
||||
>
|
||||
<MenuBars open />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
aria-label={strings.primaryNavLabel}
|
||||
className="flex-1 overflow-y-auto px-[var(--layout-gutter)] pt-md pb-2xl"
|
||||
>
|
||||
<ul className="flex flex-col gap-xl">
|
||||
{items.map((item) =>
|
||||
isNavGroup(item) ? (
|
||||
<li key={item.id}>
|
||||
<h2
|
||||
id={headingId(item.id)}
|
||||
className="body-md-bold text-[var(--text-primary)]"
|
||||
>
|
||||
{item.label}
|
||||
</h2>
|
||||
<ul
|
||||
aria-labelledby={headingId(item.id)}
|
||||
className="mt-sm flex flex-col gap-2xs border-s border-[var(--border-subtle)] ps-md"
|
||||
>
|
||||
{item.children.map((child) => (
|
||||
<li key={child.id}>
|
||||
<Link
|
||||
to={child.href}
|
||||
onClick={close}
|
||||
aria-current={
|
||||
isCurrentPath(child.href, pathname) ? "page" : undefined
|
||||
}
|
||||
className={cx(
|
||||
linkClasses,
|
||||
"body-md text-[var(--text-secondary)]",
|
||||
)}
|
||||
>
|
||||
{child.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.id}>
|
||||
<Link
|
||||
to={item.href}
|
||||
onClick={close}
|
||||
aria-current={
|
||||
isCurrentPath(item.href, pathname) ? "page" : undefined
|
||||
}
|
||||
className={cx(
|
||||
linkClasses,
|
||||
"body-md-bold text-[var(--text-primary)]",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-md border-t border-[var(--border-subtle)] px-[var(--layout-gutter)] py-lg">
|
||||
<EuBadge
|
||||
statement={strings.euStatement}
|
||||
emblemAlt={strings.euEmblemAlt}
|
||||
locale={locale}
|
||||
surface={surface}
|
||||
/>
|
||||
{showLanguageSwitcher && (
|
||||
<LanguageSwitcher
|
||||
locale={locale}
|
||||
pathname={pathname}
|
||||
search={search}
|
||||
hash={hash}
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
buildLocalizedHref,
|
||||
getDirection,
|
||||
LanguageSwitcher,
|
||||
type Locale,
|
||||
} from "../LanguageSwitcher";
|
||||
|
||||
import { Brand } from "./Brand";
|
||||
import { DesktopNav } from "./DesktopNav";
|
||||
import { EuBadge } from "./EuBadge";
|
||||
import { MobileNav } from "./MobileNav";
|
||||
import { getDefaultNavItems, getNavigationStrings } from "./nav-items";
|
||||
import { cx } from "./styles";
|
||||
import type { NavItem, NavigationStrings, NavSurface } from "./types";
|
||||
|
||||
export interface NavigationProps {
|
||||
/** Reading locale. Drives the copy, the default hrefs and the arrow keys. */
|
||||
locale?: Locale;
|
||||
/** Current pathname, e.g. "/ar/contact". Used for aria-current="page". */
|
||||
pathname: string;
|
||||
/** Query string, carried across a language switch. */
|
||||
search?: string;
|
||||
/** Fragment, carried across a language switch. */
|
||||
hash?: string;
|
||||
/**
|
||||
* The navigation tree. The app shell feeds this from the Strapi
|
||||
* site-settings single type; omitting it renders the typed default IA.
|
||||
*/
|
||||
items?: readonly NavItem[];
|
||||
/** Which tier-2 surface the bar paints. `:root` is dark, so dark is default. */
|
||||
surface?: NavSurface;
|
||||
/** Override any of the navigation's own strings (e.g. from the CMS). */
|
||||
strings?: Partial<NavigationStrings>;
|
||||
/** Where the logo links. Defaults to the locale home, "/en" or "/ar". */
|
||||
homeHref?: string;
|
||||
showLanguageSwitcher?: boolean;
|
||||
/**
|
||||
* Show the EU badge in the compact bar below xl as well. ON by default:
|
||||
* donor visibility is non-negotiable, and the Figma mobile frame (20:220),
|
||||
* which is logo + hamburger only, would leave the badge out of the header on
|
||||
* every phone, tablet and 1280px-narrow laptop. Below xl the lockup renders
|
||||
* at a reduced size rather than being dropped. Pass `false` only for a
|
||||
* surface where the funding statement is guaranteed elsewhere on screen.
|
||||
*/
|
||||
showEuBadgeOnMobileBar?: boolean;
|
||||
/** Stick the bar to the top of the viewport. */
|
||||
sticky?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SHARP-ER site navigation - Figma frame 20:236.
|
||||
*
|
||||
* Logo at the inline start, the mandatory "Funded by the European Union" badge
|
||||
* at the inline end, and the mega-menu groups between them. Everything is laid
|
||||
* out with logical properties, so the Arabic bar mirrors (logo right, EU badge
|
||||
* left) from `<html dir="rtl">` alone - there is no separate Arabic component.
|
||||
*
|
||||
* Below the xl breakpoint the bar collapses to logo + hamburger and the whole
|
||||
* tree moves into a full-screen drawer.
|
||||
*/
|
||||
export function Navigation({
|
||||
locale = "en",
|
||||
pathname,
|
||||
search,
|
||||
hash,
|
||||
items,
|
||||
surface = "dark",
|
||||
strings: stringOverrides,
|
||||
homeHref,
|
||||
showLanguageSwitcher = true,
|
||||
showEuBadgeOnMobileBar = true,
|
||||
sticky = true,
|
||||
className,
|
||||
}: NavigationProps) {
|
||||
const navItems = useMemo(
|
||||
() => items ?? getDefaultNavItems(locale),
|
||||
[items, locale],
|
||||
);
|
||||
const strings = useMemo(
|
||||
() => getNavigationStrings(locale, stringOverrides),
|
||||
[locale, stringOverrides],
|
||||
);
|
||||
|
||||
const resolvedHomeHref = homeHref ?? buildLocalizedHref("/", locale);
|
||||
const direction = getDirection(locale);
|
||||
|
||||
return (
|
||||
<header
|
||||
data-surface={surface}
|
||||
/* overflow-x-clip contains the mega panel's full-bleed backdrop without
|
||||
clipping the panel itself, which overflows vertically. */
|
||||
className={cx(
|
||||
"w-full overflow-x-clip border-b border-[var(--border-subtle)]",
|
||||
sticky && "sticky top-0 z-[var(--layer-header)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
"mx-auto flex w-full max-w-[var(--layout-content-max)] items-center justify-between",
|
||||
/* The compact bar carries wordmark + EU lockup + hamburger on a
|
||||
320px screen, so it runs a tighter gap than the desktop bar. */
|
||||
"min-h-[64px] gap-xs px-[var(--layout-gutter)]",
|
||||
"xl:min-h-[72px] xl:gap-lg xl:px-2xl",
|
||||
)}
|
||||
>
|
||||
<Brand
|
||||
href={resolvedHomeHref}
|
||||
label={strings.homeLinkLabel}
|
||||
surface={surface}
|
||||
/>
|
||||
|
||||
<DesktopNav
|
||||
items={navItems}
|
||||
pathname={pathname}
|
||||
strings={strings}
|
||||
direction={direction}
|
||||
className="hidden xl:block"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-xs xl:gap-md">
|
||||
{/* Desktop-only cluster. Visibility lives on this wrapper rather than
|
||||
on the children: two display utilities on one element are resolved
|
||||
by the order Tailwind emits them, not by class-attribute order. */}
|
||||
<div className="hidden items-center gap-md xl:flex">
|
||||
{showLanguageSwitcher && (
|
||||
<LanguageSwitcher
|
||||
locale={locale}
|
||||
pathname={pathname}
|
||||
search={search}
|
||||
hash={hash}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showEuBadgeOnMobileBar && (
|
||||
<EuBadge
|
||||
statement={strings.euStatement}
|
||||
emblemAlt={strings.euEmblemAlt}
|
||||
locale={locale}
|
||||
surface={surface}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showEuBadgeOnMobileBar && (
|
||||
<EuBadge
|
||||
statement={strings.euStatement}
|
||||
emblemAlt={strings.euEmblemAlt}
|
||||
locale={locale}
|
||||
surface={surface}
|
||||
/* Below xl the lockup is scaled down - emblem 36x24 instead of
|
||||
48x32, statement wrapped in a narrower box - so it still fits
|
||||
beside the wordmark and the hamburger at 320px. It is scaled,
|
||||
never hidden: the statement stays at Label/Medium, i.e. the
|
||||
same size it is everywhere else, so it remains legible. */
|
||||
className="max-xl:[&>img]:h-[24px] max-xl:[&>img]:w-[36px] max-xl:[&>span]:w-[80px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
<MobileNav
|
||||
items={navItems}
|
||||
pathname={pathname}
|
||||
search={search}
|
||||
hash={hash}
|
||||
locale={locale}
|
||||
strings={strings}
|
||||
surface={surface}
|
||||
homeHref={resolvedHomeHref}
|
||||
showLanguageSwitcher={showLanguageSwitcher}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Navigation;
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
USAGE
|
||||
---------------------------------------------------------------------------
|
||||
// Dark bar on the dark home page, default IA, English:
|
||||
<Navigation locale="en" pathname="/en" />
|
||||
|
||||
// Arabic. The bar mirrors from <html dir="rtl"> - no extra props, no
|
||||
// separate component - and the arrow keys mirror with it.
|
||||
<Navigation locale="ar" pathname="/ar/contact" />
|
||||
|
||||
// Light bar on an interior page:
|
||||
<Navigation locale="en" pathname="/en/about" surface="light" />
|
||||
|
||||
// Fed from Strapi site-settings, with the CMS owning the labels and hrefs:
|
||||
<Navigation
|
||||
locale={locale}
|
||||
pathname={pathname}
|
||||
items={siteSettings.navigation}
|
||||
strings={{ euStatement: siteSettings.euFundingStatement }}
|
||||
/>
|
||||
|
||||
// Wired from a route, using the router's own location so a language switch
|
||||
// keeps the query string and fragment:
|
||||
const { pathname, search, hash } = useLocation();
|
||||
<Navigation locale={locale} pathname={pathname} search={search} hash={hash} />
|
||||
|
||||
// The EU badge is in the compact bar by default. Opt out only where the
|
||||
// funding statement is guaranteed to be visible elsewhere on the screen:
|
||||
<Navigation locale="en" pathname="/en" showEuBadgeOnMobileBar={false} />
|
||||
------------------------------------------------------------------------ */
|
||||
@@ -0,0 +1,31 @@
|
||||
export { Navigation, default } from "./Navigation";
|
||||
export type { NavigationProps } from "./Navigation";
|
||||
|
||||
export { Brand } from "./Brand";
|
||||
export type { BrandProps } from "./Brand";
|
||||
export { DesktopNav } from "./DesktopNav";
|
||||
export type { DesktopNavProps } from "./DesktopNav";
|
||||
export { EuBadge } from "./EuBadge";
|
||||
export type { EuBadgeProps } from "./EuBadge";
|
||||
export { MenuBars } from "./MenuBars";
|
||||
export type { MenuBarsProps } from "./MenuBars";
|
||||
export { MobileNav } from "./MobileNav";
|
||||
export type { MobileNavProps } from "./MobileNav";
|
||||
|
||||
export {
|
||||
getDefaultNavItems,
|
||||
getNavigationStrings,
|
||||
NAV_PATHS,
|
||||
NAVIGATION_STRINGS,
|
||||
} from "./nav-items";
|
||||
export type { NavPathKey } from "./nav-items";
|
||||
|
||||
export { isNavGroup } from "./types";
|
||||
export type {
|
||||
NavGroupItem,
|
||||
NavigationStrings,
|
||||
NavigationStringsByLocale,
|
||||
NavItem,
|
||||
NavLinkItem,
|
||||
NavSurface,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,153 @@
|
||||
/* ============================================================================
|
||||
DEFAULT INFORMATION ARCHITECTURE
|
||||
----------------------------------------------------------------------------
|
||||
The ten top-level destinations from the Figma navigation (frame 20:236) and
|
||||
ToR 3.2. The app shell normally feeds `items` to <Navigation /> from the
|
||||
Strapi site-settings single type; this module is the typed fallback that
|
||||
renders when Strapi has not been wired yet.
|
||||
|
||||
It does NOT own the slugs: ~/lib/route-slugs is the one table the route
|
||||
config is built from, and `NAV_PATHS` below is constrained to it.
|
||||
|
||||
Arabic copy is taken verbatim from the Arabic mega-menu frame (334:639).
|
||||
========================================================================= */
|
||||
|
||||
import type { RouteSlugKey, RouteSlugs } from "~/lib/route-slugs";
|
||||
|
||||
import { buildLocalizedHref, type Locale } from "../LanguageSwitcher";
|
||||
|
||||
import type { NavItem, NavigationStrings, NavigationStringsByLocale } from "./types";
|
||||
|
||||
/**
|
||||
* Unprefixed route paths. Both locales share the Latin slugs; the locale lives
|
||||
* in the prefix (/en/contact, /ar/contact), never in the slug.
|
||||
*
|
||||
* The `satisfies` clause pins every entry to the slug `ROUTE_SLUGS` registers
|
||||
* the route under - home's empty slug yielding "/" - so this table cannot
|
||||
* drift away from the URLs the app actually serves, and cannot gain or lose a
|
||||
* section without ~/lib/route-slugs gaining or losing one first.
|
||||
*/
|
||||
export const NAV_PATHS = {
|
||||
home: "/",
|
||||
about: "/about",
|
||||
whatWeDo: "/what-we-do",
|
||||
activities: "/activities-and-initiatives",
|
||||
events: "/events-calendar",
|
||||
applications: "/applications-and-opportunities",
|
||||
news: "/news-and-updates",
|
||||
contact: "/contact",
|
||||
resources: "/resources-and-publications",
|
||||
knowledgeHub: "/knowledge-hub",
|
||||
} as const satisfies { readonly [K in RouteSlugKey]: `/${RouteSlugs[K]}` };
|
||||
|
||||
export type NavPathKey = keyof typeof NAV_PATHS;
|
||||
|
||||
type LabelKey =
|
||||
| NavPathKey
|
||||
| "aboutAndStrategy"
|
||||
| "engagementAndOpportunities"
|
||||
| "knowledgeAndImpact";
|
||||
|
||||
const LABELS: Record<Locale, Record<LabelKey, string>> = {
|
||||
en: {
|
||||
home: "Home",
|
||||
aboutAndStrategy: "About and Strategy",
|
||||
about: "About",
|
||||
whatWeDo: "What We Do",
|
||||
activities: "Activities and Initiatives",
|
||||
engagementAndOpportunities: "Engagement and Opportunities",
|
||||
events: "Events Calendar",
|
||||
applications: "Applications and Opportunities",
|
||||
news: "News and Updates",
|
||||
contact: "Contact",
|
||||
knowledgeAndImpact: "Knowledge and Impact",
|
||||
resources: "Resources and Publications",
|
||||
knowledgeHub: "Knowledge Hub",
|
||||
},
|
||||
ar: {
|
||||
home: "الرئيسية",
|
||||
aboutAndStrategy: "عن المشروع والاستراتيجية",
|
||||
about: "عن المشروع",
|
||||
whatWeDo: "ماذا نفعل",
|
||||
activities: "الأنشطة والمبادرات",
|
||||
engagementAndOpportunities: "المشاركة والفرص",
|
||||
events: "تقويم الفعاليات",
|
||||
applications: "التطبيقات والفرص",
|
||||
news: "الأخبار والمستجدات",
|
||||
contact: "اتصل بنا",
|
||||
knowledgeAndImpact: "المعرفة والتأثير",
|
||||
resources: "الموارد والمنشورات",
|
||||
knowledgeHub: "مركز المعرفة",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The default navigation for a locale, with every href already prefixed.
|
||||
* Returns a fresh array so a caller may safely sort or splice it.
|
||||
*/
|
||||
export function getDefaultNavItems(locale: Locale): NavItem[] {
|
||||
const label = LABELS[locale];
|
||||
const href = (key: NavPathKey) => buildLocalizedHref(NAV_PATHS[key], locale);
|
||||
|
||||
return [
|
||||
{ id: "home", label: label.home, href: href("home") },
|
||||
{
|
||||
id: "about-and-strategy",
|
||||
label: label.aboutAndStrategy,
|
||||
children: [
|
||||
{ id: "about", label: label.about, href: href("about") },
|
||||
{ id: "what-we-do", label: label.whatWeDo, href: href("whatWeDo") },
|
||||
{ id: "activities", label: label.activities, href: href("activities") },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "engagement-and-opportunities",
|
||||
label: label.engagementAndOpportunities,
|
||||
children: [
|
||||
{ id: "events", label: label.events, href: href("events") },
|
||||
{ id: "applications", label: label.applications, href: href("applications") },
|
||||
{ id: "news", label: label.news, href: href("news") },
|
||||
{ id: "contact", label: label.contact, href: href("contact") },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "knowledge-and-impact",
|
||||
label: label.knowledgeAndImpact,
|
||||
children: [
|
||||
{ id: "resources", label: label.resources, href: href("resources") },
|
||||
{ id: "knowledge-hub", label: label.knowledgeHub, href: href("knowledgeHub") },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* EU visibility is mandatory on every page. "Funded by the European Union" is
|
||||
* the prescribed wording; the Figma text layer reads "European nation", which
|
||||
* is a typo in the design file and is corrected here.
|
||||
*/
|
||||
export const NAVIGATION_STRINGS: NavigationStringsByLocale = {
|
||||
en: {
|
||||
primaryNavLabel: "Main navigation",
|
||||
openMenu: "Open menu",
|
||||
closeMenu: "Close menu",
|
||||
homeLinkLabel: "SHARP-ER home",
|
||||
euStatement: "Funded by the European Union",
|
||||
euEmblemAlt: "",
|
||||
},
|
||||
ar: {
|
||||
primaryNavLabel: "التنقل الرئيسي",
|
||||
openMenu: "فتح القائمة",
|
||||
closeMenu: "إغلاق القائمة",
|
||||
homeLinkLabel: "الصفحة الرئيسية لمشروع SHARP-ER",
|
||||
euStatement: "بتمويل من الاتحاد الأوروبي",
|
||||
euEmblemAlt: "",
|
||||
},
|
||||
};
|
||||
|
||||
export function getNavigationStrings(
|
||||
locale: Locale,
|
||||
overrides?: Partial<NavigationStrings>,
|
||||
): NavigationStrings {
|
||||
return { ...NAVIGATION_STRINGS[locale], ...overrides };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Shared class fragments for the navigation.
|
||||
*
|
||||
* app.css already paints a token-driven ring on every `:focus-visible`; these
|
||||
* classes restate it with the same tier-2 tokens so the ring survives any local
|
||||
* `outline` override and is visible in the component source.
|
||||
*/
|
||||
export const FOCUS_RING =
|
||||
"focus-visible:outline-[color:var(--focus-ring-color)] " +
|
||||
"focus-visible:outline-[length:var(--focus-ring-width)] " +
|
||||
"focus-visible:outline-offset-[var(--focus-ring-offset)]";
|
||||
|
||||
/** Colour/opacity transitions, driven by the motion tokens. */
|
||||
export const HOVER_TRANSITION =
|
||||
"transition-colors duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]";
|
||||
|
||||
export function cx(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Locale } from "../LanguageSwitcher";
|
||||
|
||||
/** A leaf destination in the navigation. `href` is already locale-prefixed. */
|
||||
export interface NavLinkItem {
|
||||
/** Stable identifier - React key and the base of the panel's DOM id. */
|
||||
id: string;
|
||||
label: string;
|
||||
/** Locale-prefixed path, e.g. "/en/contact". */
|
||||
href: string;
|
||||
children?: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A mega-menu group. Groups are disclosures rather than destinations: the
|
||||
* Figma navigation draws the group title as plain text with no landing page,
|
||||
* so the trigger is a <button> and only the children navigate.
|
||||
*/
|
||||
export interface NavGroupItem {
|
||||
id: string;
|
||||
label: string;
|
||||
children: readonly NavLinkItem[];
|
||||
}
|
||||
|
||||
export type NavItem = NavLinkItem | NavGroupItem;
|
||||
|
||||
export function isNavGroup(item: NavItem): item is NavGroupItem {
|
||||
const children = (item as NavGroupItem).children;
|
||||
return Array.isArray(children) && children.length > 0;
|
||||
}
|
||||
|
||||
/** Which tier-2 surface the bar paints itself with. `:root` is the dark one. */
|
||||
export type NavSurface = "dark" | "light";
|
||||
|
||||
/** Every non-link string the navigation renders or announces. */
|
||||
export interface NavigationStrings {
|
||||
/** Accessible name of the <nav> landmark and of the mobile dialog. */
|
||||
primaryNavLabel: string;
|
||||
/** Accessible name of the hamburger button. */
|
||||
openMenu: string;
|
||||
/** Accessible name of the drawer's close button. */
|
||||
closeMenu: string;
|
||||
/** Accessible name of the logo link. */
|
||||
homeLinkLabel: string;
|
||||
/** The mandatory EU funding statement. */
|
||||
euStatement: string;
|
||||
/**
|
||||
* Alt text for the EU emblem. Empty by default: the statement sits next to
|
||||
* the emblem as real text, so describing the image would double it up.
|
||||
*/
|
||||
euEmblemAlt: string;
|
||||
}
|
||||
|
||||
export type NavigationStringsByLocale = Record<Locale, NavigationStrings>;
|
||||
Reference in New Issue
Block a user