initial commit

This commit is contained in:
2026-09-11 02:46:56 +02:00
commit 20b384039c
288 changed files with 46557 additions and 0 deletions
@@ -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>;