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,62 @@
/* ============================================================================
ARROW ICON - figma `arrow-small-right` 84:4260 / `arrow-small-lift` 140:8078
----------------------------------------------------------------------------
The exported asset is at /assets/icons/arrow-small-right.svg. Its path is
filled with a hard-coded black, so it is applied as a mask over
`currentColor` instead of being dropped in an <img>: that is the only way the
arrow can turn white on a Primary button, dark grey on a Secondary one and
green on a Ghost one while remaining the exact exported bytes.
GEOMETRY, straight from Figma: a 24x24 clipped box (`--control-icon-size`)
with the 12.9996 x 11.414 leaf inset 20.8333% from the left, 25% from the
right and 26.22% top and bottom. The insets are physical on purpose - the
glyph is asymmetric, which app.css names as the one case that cannot be
expressed logically, so mirroring is done by flipping the whole box.
DIRECTION. Figma's left arrow is not a second drawing: node 140:8078 is this
same asset with `-scale-x-100`. So `forward` (the arrow that follows reading
order) is the exported right arrow in LTR and that same box mirrored in RTL,
which is exactly what the Arabic home 85:5029 shows in its hero CTA.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
.sh-arrow-icon {
position: relative;
display: inline-block;
flex: none;
inline-size: var(--control-icon-size);
block-size: var(--control-icon-size);
overflow: hidden;
}
.sh-arrow-icon__glyph {
position: absolute;
top: 26.22%;
bottom: 26.22%;
left: 20.8333%;
right: 25%;
background-color: currentColor;
-webkit-mask-image: url("/assets/icons/arrow-small-right.svg");
mask-image: url("/assets/icons/arrow-small-right.svg");
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
}
/* Mirror the box, not the glyph, so the 1px asymmetry of the inset mirrors
with it - this reproduces node 140:8078 rather than approximating it. */
.sh-arrow-icon[data-direction="backward"] {
transform: scaleX(-1);
}
[dir="rtl"] .sh-arrow-icon[data-direction="forward"] {
transform: scaleX(-1);
}
[dir="rtl"] .sh-arrow-icon[data-direction="backward"] {
transform: none;
}
}
@@ -0,0 +1,32 @@
import "./ArrowIcon.css";
export type ArrowIconDirection = "forward" | "backward";
export type ArrowIconProps = {
/**
* `forward` follows reading order - it points right in LTR and left in RTL,
* which is what the Arabic home hero does. `backward` is the opposite, for
* "previous" affordances such as pagination.
*/
direction?: ArrowIconDirection;
className?: string;
};
/**
* The arrow that rides along with the CTA label ("Discover SHARP-ER ->").
*
* Purely decorative: the label next to it carries the meaning, so the element
* is hidden from assistive technology. It paints in `currentColor`, so it takes
* the colour of whatever button or link it sits in with no props.
*/
export function ArrowIcon({ direction = "forward", className }: ArrowIconProps) {
return (
<span
aria-hidden="true"
data-direction={direction}
className={className ? `sh-arrow-icon ${className}` : "sh-arrow-icon"}
>
<span className="sh-arrow-icon__glyph" />
</span>
);
}
@@ -0,0 +1,247 @@
/* ============================================================================
BUTTON - figma frame 14:487 (Variant x Size x State)
----------------------------------------------------------------------------
Only TIER-2 semantic tokens are referenced here. The `--sh-btn-*` custom
properties are component-local plumbing: each variant block binds them to a
tier-2 token once, and the state rules below read them, so the whole 5x3x3
matrix is expressed as five small blocks instead of forty-five selectors.
Everything lives in @layer components so a Tailwind utility passed through
`className` (`w-full`, `mt-lg`, `text-text-secondary`, ...) still wins.
The layer statement is a defensive no-op when app.css has already declared
the same order, and makes this file correct on its own if it ever loads first.
HEIGHTS. Figma draws the stroke *inside* the frame, so a Secondary button is
the same height as a Primary one. Borders are therefore subtracted from the
padding, which lands every size on its designed box:
sm 6.5*2 + 20 + 1.5*2 = 36 md 10.5*2 + 24 + 1.5*2 = 48
lg 10.5*2 + 28 + 1.5*2 = 52
The 20/24/28 line-heights come from the type role the component puts on the
element (body-sm-bold / body-md-bold / body-lg-bold).
DEVIATIONS FROM FIGMA, deliberate:
* Focus-visible is not drawn in Figma at all. WCAG 2.4.7 requires it, so the
semantic focus ring is applied here; filled variants switch to the inverse
ring colour because a green ring on a green fill is invisible.
* "Ghost / Large / Default" is the one cell in the matrix with no padding
while its own Hover and Disabled cells have it - an authoring slip that
would make the button jump on hover. Padding is kept on all ghost states.
* Disabled uses the opaque `-bg-disabled` / `-fg-disabled` tokens rather than
Figma's 40% opacity, so the page never shows through a disabled control.
`--control-opacity-disabled` still holds the 0.4 if that is ever wanted.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
/* ---------------------------------------------------------------------
Base - shared by every variant and size.
A one-cell grid stacks the label and the loading spinner without either
transforms or absolute offsets, so it stays correct under dir="rtl".
------------------------------------------------------------------ */
.sh-btn {
display: inline-grid;
grid-template-areas: "sh-btn-stack";
place-items: center;
border-style: solid;
border-width: var(--control-border-width);
border-radius: var(--radius-control);
background-color: var(--sh-btn-bg);
color: var(--sh-btn-fg);
border-color: var(--sh-btn-border);
text-align: center;
text-decoration: none;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
transition:
background-color var(--motion-duration-fast) var(--motion-ease-standard),
border-color var(--motion-duration-fast) var(--motion-ease-standard),
color var(--motion-duration-fast) var(--motion-ease-standard);
}
.sh-btn--block {
display: grid;
inline-size: 100%;
}
.sh-btn__content {
grid-area: sh-btn-stack;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--control-gap);
transition: opacity var(--motion-duration-fast) var(--motion-ease-standard);
}
/* The 24px box the icon is drawn inside in Figma. Kept on the slot rather
than on the icon so a consumer's own glyph inherits the same geometry. */
.sh-btn__icon {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
inline-size: var(--control-icon-size);
block-size: var(--control-icon-size);
}
/* ---------------------------------------------------------------------
Sizes - Figma: sm 16/8 (space.md/space.xs), md + lg 24/12 (space.lg/sm)
------------------------------------------------------------------ */
.sh-btn[data-size="sm"] {
padding-inline: calc(var(--control-padding-inline-sm) - var(--control-border-width));
padding-block: calc(var(--control-padding-block-sm) - var(--control-border-width));
}
.sh-btn[data-size="md"] {
padding-inline: calc(var(--control-padding-inline-md) - var(--control-border-width));
padding-block: calc(var(--control-padding-block-md) - var(--control-border-width));
}
.sh-btn[data-size="lg"] {
padding-inline: calc(var(--control-padding-inline-lg) - var(--control-border-width));
padding-block: calc(var(--control-padding-block-lg) - var(--control-border-width));
}
/* ---------------------------------------------------------------------
Variants - the only place a token name appears twice.
------------------------------------------------------------------ */
.sh-btn[data-variant="primary"] {
--sh-btn-bg: var(--interactive-primary-bg);
--sh-btn-bg-hover: var(--interactive-primary-bg-hover);
--sh-btn-bg-active: var(--interactive-primary-bg-active);
--sh-btn-bg-disabled: var(--interactive-primary-bg-disabled);
--sh-btn-fg: var(--interactive-primary-fg);
--sh-btn-fg-hover: var(--interactive-primary-fg-hover);
--sh-btn-fg-disabled: var(--interactive-primary-fg-disabled);
--sh-btn-border: var(--interactive-primary-border);
--sh-btn-border-hover: var(--interactive-primary-border-hover);
--sh-btn-border-disabled: var(--interactive-primary-border-disabled);
}
.sh-btn[data-variant="secondary"] {
--sh-btn-bg: var(--interactive-secondary-bg);
--sh-btn-bg-hover: var(--interactive-secondary-bg-hover);
--sh-btn-bg-active: var(--interactive-secondary-bg-active);
--sh-btn-bg-disabled: var(--interactive-secondary-bg-disabled);
--sh-btn-fg: var(--interactive-secondary-fg);
--sh-btn-fg-hover: var(--interactive-secondary-fg-hover);
--sh-btn-fg-disabled: var(--interactive-secondary-fg-disabled);
--sh-btn-border: var(--interactive-secondary-border);
--sh-btn-border-hover: var(--interactive-secondary-border-hover);
--sh-btn-border-disabled: var(--interactive-secondary-border-disabled);
}
.sh-btn[data-variant="dark"] {
--sh-btn-bg: var(--interactive-dark-bg);
--sh-btn-bg-hover: var(--interactive-dark-bg-hover);
--sh-btn-bg-active: var(--interactive-dark-bg-active);
--sh-btn-bg-disabled: var(--interactive-dark-bg-disabled);
--sh-btn-fg: var(--interactive-dark-fg);
--sh-btn-fg-hover: var(--interactive-dark-fg-hover);
--sh-btn-fg-disabled: var(--interactive-dark-fg-disabled);
--sh-btn-border: var(--interactive-dark-border);
--sh-btn-border-hover: var(--interactive-dark-border-hover);
--sh-btn-border-disabled: var(--interactive-dark-border-disabled);
}
.sh-btn[data-variant="danger"] {
--sh-btn-bg: var(--interactive-danger-bg);
--sh-btn-bg-hover: var(--interactive-danger-bg-hover);
--sh-btn-bg-active: var(--interactive-danger-bg-active);
--sh-btn-bg-disabled: var(--interactive-danger-bg-disabled);
--sh-btn-fg: var(--interactive-danger-fg);
--sh-btn-fg-hover: var(--interactive-danger-fg-hover);
--sh-btn-fg-disabled: var(--interactive-danger-fg-disabled);
--sh-btn-border: var(--interactive-danger-border);
--sh-btn-border-hover: var(--interactive-danger-border-hover);
--sh-btn-border-disabled: var(--interactive-danger-border-disabled);
}
.sh-btn[data-variant="ghost"] {
--sh-btn-bg: var(--interactive-ghost-bg);
--sh-btn-bg-hover: var(--interactive-ghost-bg-hover);
--sh-btn-bg-active: var(--interactive-ghost-bg-active);
--sh-btn-bg-disabled: var(--interactive-ghost-bg-disabled);
--sh-btn-fg: var(--interactive-ghost-fg);
--sh-btn-fg-hover: var(--interactive-ghost-fg-hover);
--sh-btn-fg-disabled: var(--interactive-ghost-fg-disabled);
--sh-btn-border: var(--interactive-ghost-border);
--sh-btn-border-hover: var(--interactive-ghost-border-hover);
--sh-btn-border-disabled: var(--interactive-ghost-border-disabled);
}
/* Figma gives the ghost md/lg cells radius.md while every other cell uses
radius.sm - reproduced rather than normalised. */
.sh-btn[data-variant="ghost"][data-size="md"],
.sh-btn[data-variant="ghost"][data-size="lg"] {
border-radius: var(--radius-control-lg);
}
/* ---------------------------------------------------------------------
States
------------------------------------------------------------------ */
@media (hover: hover) {
.sh-btn:hover:not(:disabled, [aria-disabled="true"]) {
background-color: var(--sh-btn-bg-hover);
color: var(--sh-btn-fg-hover);
border-color: var(--sh-btn-border-hover);
}
}
/* No `-fg-active` / `-border-active` exists in the token contract, so the
pressed state deepens the fill only. */
.sh-btn:active:not(:disabled, [aria-disabled="true"]) {
background-color: var(--sh-btn-bg-active);
}
.sh-btn:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
/* A green ring on a green (or red, or near-black) fill reads as nothing. */
.sh-btn[data-variant="primary"]:focus-visible,
.sh-btn[data-variant="danger"]:focus-visible,
.sh-btn[data-variant="dark"]:focus-visible {
outline-color: var(--focus-ring-color-inverse);
}
.sh-btn:disabled,
.sh-btn[aria-disabled="true"] {
background-color: var(--sh-btn-bg-disabled);
color: var(--sh-btn-fg-disabled);
border-color: var(--sh-btn-border-disabled);
cursor: not-allowed;
}
/* ---------------------------------------------------------------------
Loading - the label keeps its box (no reflow) and stays in the
accessibility tree, which `visibility: hidden` would not allow.
------------------------------------------------------------------ */
.sh-btn[data-loading="true"] {
cursor: progress;
}
.sh-btn[data-loading="true"] .sh-btn__content {
opacity: 0;
}
.sh-btn__spinner {
grid-area: sh-btn-stack;
inline-size: 1em;
block-size: 1em;
border: var(--control-border-width) solid currentColor;
border-block-start-color: transparent;
border-radius: var(--radius-pill);
/* The contract publishes fast/base/slow only; two slows is the ~640ms a
spinner wants without inventing a duration token. */
animation: sh-btn-spin calc(var(--motion-duration-slow) * 2) linear infinite;
}
}
@keyframes sh-btn-spin {
to {
rotate: 1turn;
}
}
@@ -0,0 +1,151 @@
import type { ComponentPropsWithRef, ElementType, ReactNode, SyntheticEvent } from "react";
import "./Button.css";
export type ButtonVariant = "primary" | "secondary" | "dark" | "danger" | "ghost";
/** Figma calls these Small / Medium / Large; they resolve to 36 / 48 / 52px. */
export type ButtonSize = "sm" | "md" | "lg";
/**
* Each size owns a published type role rather than a raw font-size, and the
* role's line-height is what makes the button land on its designed height.
*/
const TYPE_ROLE: Record<ButtonSize, string> = {
sm: "body-sm-bold",
md: "body-md-bold",
lg: "body-lg-bold",
};
type ButtonOwnProps = {
variant?: ButtonVariant;
size?: ButtonSize;
/** Rendered in the inline-start 24px slot - the left in LTR, the right in RTL. */
iconStart?: ReactNode;
/** Rendered in the inline-end slot. Pass `<ArrowIcon />` for the CTA arrow. */
iconEnd?: ReactNode;
/** Keeps the label's box, overlays a spinner and sets `aria-busy`. */
isLoading?: boolean;
disabled?: boolean;
/** Stretch to the width of the parent (`display: grid` instead of inline). */
fullWidth?: boolean;
className?: string;
children?: ReactNode;
};
export type ButtonProps<E extends ElementType = "button"> = ButtonOwnProps & {
/**
* The element to render. Defaults to `button`; pass react-router's `Link`
* (plus its `to`) for navigation, or `"a"` for an external href. Props of the
* chosen element are type-checked, so `as={Link}` requires `to`.
*/
as?: E;
} & Omit<ComponentPropsWithRef<E>, keyof ButtonOwnProps | "as">;
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
function blockActivation(event: SyntheticEvent): void {
event.preventDefault();
event.stopPropagation();
}
/**
* The SHARP-ER button - figma frame 14:487.
*
* Polymorphic: renders a native `<button>` by default and anything else through
* `as`. `ref` is an ordinary prop in React 19, so it travels with the rest of
* the element props and lands on whichever element `as` selected.
*/
export function Button<E extends ElementType = "button">({
as,
variant = "primary",
size = "md",
iconStart,
iconEnd,
isLoading = false,
disabled = false,
fullWidth = false,
className,
children,
...rest
}: ButtonProps<E>) {
const Component = (as ?? "button") as ElementType;
const isNativeButton = Component === "button";
const isDisabled = disabled || isLoading;
const elementProps = rest as Record<string, unknown>;
/**
* A native button gets the real `disabled` attribute. Anything else (a Link,
* an anchor) has no such attribute, so it is taken out of the tab order and
* its activation is swallowed instead. Both forms carry `aria-disabled`.
*/
const stateProps: Record<string, unknown> = isNativeButton
? {
// Default to a plain button so a button inside a form never submits it
// by accident; pass type="submit" explicitly when that is the intent.
type: (elementProps.type as string | undefined) ?? "button",
disabled: isDisabled,
}
: isDisabled
? { tabIndex: -1, onClick: blockActivation }
: {};
return (
<Component
{...elementProps}
{...stateProps}
data-variant={variant}
data-size={size}
data-loading={isLoading ? "true" : undefined}
aria-busy={isLoading || undefined}
aria-disabled={isDisabled || undefined}
className={cx("sh-btn", TYPE_ROLE[size], fullWidth && "sh-btn--block", className)}
>
<span className="sh-btn__content">
{iconStart ? <span className="sh-btn__icon">{iconStart}</span> : null}
<span className="sh-btn__label">{children}</span>
{iconEnd ? <span className="sh-btn__icon">{iconEnd}</span> : null}
</span>
{isLoading ? <span className="sh-btn__spinner" aria-hidden="true" /> : null}
</Component>
);
}
/* ----------------------------------------------------------------------------
USAGE (kept as a comment - the project has no Storybook)
import { Link } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
// The home hero CTA, Home EN 253:4799 / Home AR 85:5029.
// The arrow sits inline-end, so RTL moves it to the left and mirrors it.
<Button as={Link} to="/en/about" size="lg" iconEnd={<ArrowIcon />}>
Discover SHARP-ER
</Button>
// Every variant, all three sizes.
<Button variant="primary">Apply</Button>
<Button variant="secondary" size="sm">Read more</Button>
<Button variant="dark" size="lg">Download the report</Button>
<Button variant="danger">Withdraw application</Button>
<Button variant="ghost" iconEnd={<ArrowIcon />}>All news</Button>
// Submitting a form: aria-busy is set and the label keeps its box.
<Button type="submit" isLoading={isSubmitting} fullWidth>Send message</Button>
// Disabled - aria-disabled on both the native and the link form.
<Button disabled>Applications closed</Button>
<Button as={Link} to="/en/events" disabled>Registration closed</Button>
// A "previous" control, mirrored the other way.
<Button variant="ghost" iconStart={<ArrowIcon direction="backward" />}>
Previous
</Button>
// An external link keeps anchor semantics and prop typing.
<Button as="a" href="https://europa.eu" target="_blank" rel="noreferrer">
European Union
</Button>
------------------------------------------------------------------------- */
@@ -0,0 +1,4 @@
export { Button } from "./Button";
export type { ButtonProps, ButtonSize, ButtonVariant } from "./Button";
export { ArrowIcon } from "./ArrowIcon";
export type { ArrowIconDirection, ArrowIconProps } from "./ArrowIcon";
@@ -0,0 +1,97 @@
/* ============================================================================
Card.CTA - Figma 25:802 (720 x 141)
----------------------------------------------------------------------------
A call to action on the accent fill: eyebrow, headline, supporting line and
one outlined action. The action is the only interactive element, so this
card is never stretched over a link.
The panel carries data-surface="dark" because it is a dark green field
whatever the page around it is doing: inside it the focus ring, the hover
veil and every text role resolve for a dark background, on a light page too.
USAGE
-----
<Card.CTA
eyebrow="Ready to join us?"
headline="Empower your local community"
body="Apply now to join the upcoming civil dialogue summit and help draft public policies."
action={{ href: "/en/opportunities", label: "Learn more" }}
/>
// Arabic
<Card.CTA
headline="مكّن مجتمعك المحلي"
action={{ href: "/ar/opportunities", label: "اعرف المزيد" }}
/>
// External application form
<Card.CTA
headline="Call for expression of interest"
action={{ href: "https://forms.example.org/eoi", label: "Apply" }}
/>
========================================================================= */
import { Button } from "../Button";
import { CardLink, cx } from "./CardShell";
import type { CardLinkTarget, CardTitleElement } from "./types";
export interface CardCTAAction extends CardLinkTarget {
label: string;
}
export interface CardCTAProps {
headline: string;
action: CardCTAAction;
/** Small line above the headline ("Ready to join us?"). */
eyebrow?: string;
body?: string;
headlineAs?: CardTitleElement;
className?: string;
}
export function CardCTA({
headline,
action,
eyebrow,
body,
headlineAs = "h2",
className,
}: CardCTAProps) {
const Headline = headlineAs;
const { label, ...destination } = action;
return (
<section
data-surface="dark"
className={cx(
"sh-card relative flex flex-col gap-lg rounded-surface p-10",
"bg-surface-accent text-text-on-accent shadow-[var(--elevation-popover)]",
"sm:flex-row sm:items-center",
className,
)}
>
<div className="flex flex-1 flex-col gap-xs">
{eyebrow ? <p className="label-md">{eyebrow}</p> : null}
<Headline className="heading-h3">{headline}</Headline>
{/* Figma dims this line to 90%; left at full strength because white on
the accent green is already close to the AA floor. */}
{body ? <p className="body-md">{body}</p> : null}
</div>
{/* The outlined action is the project Button, not a look-alike: inside
this dark subtree its secondary variant already resolves to the white
stroke and white label the design asks for. CardLink keeps internal
hrefs routing client-side and external ones a plain anchor. */}
<Button
as={CardLink}
variant="secondary"
className="sh-card-cta-action shrink-0"
{...destination}
>
{label}
</Button>
</section>
);
}
@@ -0,0 +1,114 @@
/* ============================================================================
Card.Content - Figma 25:752 (360 x 372)
----------------------------------------------------------------------------
The news / blog / programme card: image, category badge, eyebrow, title,
excerpt and a "Read more" affordance. The whole card is one link.
USAGE
-----
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
<Card.Content
as="li"
href="/en/news/civic-dialogue"
eyebrow="Programme"
badge="Rule of Law"
title="Strengthening Civic Dialogue"
excerpt="Supporting coordinated efforts among institutions, civil society,
and local actors to foster community resilience."
actionLabel="Read more"
image={{ src: "/media/dialogue.jpg", alt: "" }}
/>
</ul>
// Arabic - the copy is the only thing that changes, RTL mirrors itself.
<Card.Content
href="/ar/news/civic-dialogue"
title="تعزيز الحوار المدني"
actionLabel="اقرأ المزيد"
image={{ src: "/media/dialogue.jpg", alt: "" }}
/>
// Above the fold - skip lazy loading for the lead card only.
<Card.Content … image={{ src: "/media/lead.jpg", alt: "", priority: true }} />
========================================================================= */
import {
CardActionHint,
CardEyebrow,
CardLink,
CardMedia,
CardShell,
cx,
} from "./CardShell";
import type { CardElement, CardImage, CardTitleElement } from "./types";
export interface CardContentProps {
/** Where the whole card goes. */
href: string;
title: string;
/**
* Label of the "Read more" affordance. Required rather than defaulted so an
* Arabic page cannot silently ship the English string.
*/
actionLabel: string;
/** Accent kicker above the title, rendered uppercase. */
eyebrow?: string;
excerpt?: string;
/** Chip over the top-inline-start corner of the image ("Rule of Law"). */
badge?: string;
image?: CardImage;
as?: CardElement;
/** Heading level - match the surrounding page outline. */
titleAs?: CardTitleElement;
className?: string;
}
export function CardContent({
href,
title,
actionLabel,
eyebrow,
excerpt,
badge,
image,
as = "article",
titleAs = "h3",
className,
}: CardContentProps) {
const Title = titleAs;
return (
<CardShell as={as} linked className={cx("flex-col overflow-hidden", className)}>
{image ? (
<CardMedia image={image} className="aspect-[9/5] w-full">
{/* The chip sits on a scrim over the photo, so its label has to stay
light on both surfaces - text-on-accent is the only always-white
role, text-primary would go dark on the light surface. */}
{badge ? (
<span className="absolute start-md top-md rounded-control-lg bg-overlay-scrim px-xs py-2xs label-md text-text-on-accent backdrop-blur-[4px]">
{badge}
</span>
) : null}
</CardMedia>
) : null}
<div className="flex flex-col gap-md p-lg">
<div className="flex flex-col gap-xs">
{eyebrow ? <CardEyebrow>{eyebrow}</CardEyebrow> : null}
<Title className="sh-card-title body-lg-bold text-text-primary">
<CardLink href={href} className="sh-card-link">
{title}
</CardLink>
</Title>
{excerpt ? (
<p className="body-sm text-text-secondary line-clamp-3">{excerpt}</p>
) : null}
</div>
<CardActionHint label={actionLabel} />
</div>
</CardShell>
);
}
@@ -0,0 +1,104 @@
/* ============================================================================
Card.Horizontal - Figma 25:781 (720 x 200)
----------------------------------------------------------------------------
The listing row: a 240px image beside the text, image at the inline start so
it moves to the right in Arabic on its own. Stacks to a single column below
the sm breakpoint. Like Card.Content, the whole row is one link.
USAGE
-----
<ul className="flex flex-col gap-lg">
<Card.Horizontal
as="li"
href="/en/resources/governance-systems"
eyebrow="Dialogue report"
title="Strengthening Governance Systems"
excerpt="Our annual deep-dive reports focus on structural coordination,
bringing diverse multi-stakeholder views into policy discussions."
actionLabel="Read full report"
image={{ src: "/media/roundtable.jpg", alt: "" }}
/>
</ul>
// External resource - rendered as a plain anchor, not a client-side route.
<Card.Horizontal
href="https://example.org/report.pdf"
title="Annual review 2026"
actionLabel="Download"
/>
========================================================================= */
import {
CardActionHint,
CardEyebrow,
CardLink,
CardMedia,
CardShell,
cx,
} from "./CardShell";
import type { CardElement, CardImage, CardTitleElement } from "./types";
export interface CardHorizontalProps {
href: string;
title: string;
/** Label of the "Read full report" affordance - required, so Arabic cannot inherit English. */
actionLabel: string;
eyebrow?: string;
excerpt?: string;
image?: CardImage;
as?: CardElement;
titleAs?: CardTitleElement;
className?: string;
}
export function CardHorizontal({
href,
title,
actionLabel,
eyebrow,
excerpt,
image,
as = "article",
titleAs = "h3",
className,
}: CardHorizontalProps) {
const Title = titleAs;
return (
<CardShell
as={as}
linked
className={cx(
"flex-col overflow-hidden sm:flex-row sm:items-stretch",
className,
)}
>
{/* Stacked: the ratio reserves the height. Side by side: a fixed 240px
column that stretches to whatever the text column needs. */}
{image ? (
<CardMedia
image={image}
className="aspect-[6/5] w-full sm:aspect-auto sm:w-60 sm:shrink-0"
/>
) : null}
<div className="flex flex-1 flex-col justify-between gap-md p-lg">
<div className="flex flex-col gap-xs">
{eyebrow ? <CardEyebrow>{eyebrow}</CardEyebrow> : null}
<Title className="sh-card-title body-lg-bold text-text-primary">
<CardLink href={href} className="sh-card-link">
{title}
</CardLink>
</Title>
{excerpt ? (
<p className="body-sm text-text-secondary line-clamp-2">{excerpt}</p>
) : null}
</div>
<CardActionHint label={actionLabel} />
</div>
</CardShell>
);
}
@@ -0,0 +1,238 @@
/* ============================================================================
CARD - shared building blocks
----------------------------------------------------------------------------
Every card in this directory is assembled from the pieces below, so the
surface, radius, border, elevation and link behaviour are decided once.
STYLING RULE for this whole directory:
* colour and radius come from the tier-2 Tailwind bridge
(bg-surface-raised, text-text-accent, border-border-subtle,
rounded-surface, ...) - never a hex, never a tier-1 primitive;
* tokens without a bridge utility are spent as arbitrary values,
e.g. shadow-[var(--elevation-card)];
* spacing uses the named scale (p-lg = 24, gap-md = 16) so the Figma
numbers stay readable;
* everything directional uses logical utilities (ps-/pe-/start-/end-)
so the Arabic build mirrors without a second stylesheet.
Nothing here is surface-aware by hand: the same markup is correct on
[data-surface="dark"] and [data-surface="light"] because every colour is a
role the surface re-points.
========================================================================= */
import type { CSSProperties, ReactElement, ReactNode } from "react";
import { Link } from "react-router";
import "./card.css";
import type {
CardElement,
CardIconName,
CardImage,
CardLinkTarget,
} from "./types";
/* ---------------------------------------------------------------------------
LINK
------------------------------------------------------------------------ */
/** Anything with a scheme, a protocol-relative host or a fragment is not a route. */
const NON_ROUTE_HREF = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
/**
* React Router sits in the hoisted root of this workspace, where it resolves
* its React types from the @types/react 18 that Strapi pulls in, so its props
* are declared against a ReactNode that is not ours (no bigint, and a
* ReactPortal that still requires children). Re-declaring the component once
* here keeps every call site below honest instead of casting at each of them.
* Delete this alias when the workspace resolves to a single @types/react.
*/
const RouterLink = Link as unknown as (props: {
to: string;
className?: string;
target?: string;
rel?: string;
children: ReactNode;
}) => ReactElement;
export interface CardLinkProps extends CardLinkTarget {
className?: string;
children: ReactNode;
}
/**
* Routes internal hrefs through React Router so navigation stays client-side,
* and falls back to a plain anchor for external links, mailto: and fragments.
*/
export function CardLink({ href, className, children, ...rest }: CardLinkProps) {
if (NON_ROUTE_HREF.test(href)) {
return (
<a href={href} className={className} {...rest}>
{children}
</a>
);
}
return (
<RouterLink to={href} className={className} {...rest}>
{children}
</RouterLink>
);
}
/* ---------------------------------------------------------------------------
ICON
------------------------------------------------------------------------ */
/** The assets exported from the Figma icon frame, served from public/. */
const CARD_ICON_SRC: Record<CardIconName, string> = {
"arrow-right": "url('/assets/icons/arrow-right.svg')",
"arrow-up": "url('/assets/icons/arrow-up.svg')",
linkedin: "url('/assets/icons/linkedin.svg')",
mail: "url('/assets/icons/mail.svg')",
globe: "url('/assets/icons/globe.svg')",
};
export interface CardIconProps {
name: CardIconName;
/** Designed box in px - 14 for the 14x14 icons, 10 for the trend arrow. */
size: number;
/** Mirror in RTL. True for the horizontal arrow, false for the vertical one. */
directional?: boolean;
className?: string;
}
/**
* Draws an exported SVG as a mask over a currentColor fill, so the icon keeps
* the geometry Figma exported while taking its colour from the token in force.
* Always decorative - the adjacent text carries the meaning.
*/
export function CardIcon({ name, size, directional, className }: CardIconProps) {
return (
<span
aria-hidden="true"
className={cx(
"sh-card-icon",
directional && "sh-card-icon--directional",
className,
)}
style={
{
"--sh-card-icon-src": CARD_ICON_SRC[name],
width: size,
height: size,
} as CSSProperties
}
/>
);
}
/* ---------------------------------------------------------------------------
SHELL
------------------------------------------------------------------------ */
export interface CardShellProps {
/** `li` inside a list, `section` for a landmark, `article` by default. */
as?: CardElement;
/**
* The card contains a stretched link. Adds the hover lift and lets the
* ::after overlay in card.css position against the card.
*/
linked?: boolean;
className?: string;
children: ReactNode;
}
/**
* The surface every card shares: raised fill, hairline border, 12px corner
* (Figma radius/lg) and the card elevation.
*/
export function CardShell({
as = "article",
linked = false,
className,
children,
}: CardShellProps) {
const Tag = as;
return (
<Tag
className={cx(
"sh-card",
linked && "sh-card--linked",
"relative flex bg-surface-raised text-text-primary",
"rounded-surface border border-border-subtle shadow-[var(--elevation-card)]",
className,
)}
>
{children}
</Tag>
);
}
/* ---------------------------------------------------------------------------
MEDIA
------------------------------------------------------------------------ */
export interface CardMediaProps {
image: CardImage;
/** Sets the box: an aspect-ratio utility, a fixed size, or both per breakpoint. */
className?: string;
/** Overlays that sit on the image, e.g. the category badge. */
children?: ReactNode;
}
/**
* A media box whose height is decided by the wrapper, never by the file, so
* the layout is stable before the image loads.
*/
export function CardMedia({ image, className, children }: CardMediaProps) {
return (
<div className={cx("sh-card-media relative overflow-hidden", className)}>
<img
src={image.src}
srcSet={image.srcSet}
sizes={image.sizes}
alt={image.alt}
loading={image.priority ? "eager" : "lazy"}
fetchPriority={image.priority ? "high" : undefined}
decoding="async"
className="absolute inset-0 block size-full object-cover"
/>
{children}
</div>
);
}
/* ---------------------------------------------------------------------------
TEXT ATOMS
------------------------------------------------------------------------ */
/** The small accent kicker above a card title ("PROGRAMME", "DIALOGUE REPORT"). */
export function CardEyebrow({ children }: { children: ReactNode }) {
return <p className="label-md uppercase text-text-accent">{children}</p>;
}
/**
* The "Read more ->" affordance. Decorative: the stretched title link already
* carries the destination, so announcing this again would just be noise.
*/
export function CardActionHint({ label }: { label: string }) {
return (
<p
aria-hidden="true"
className="inline-flex items-center gap-xs body-sm-bold text-text-accent"
>
{label}
<CardIcon name="arrow-right" size={14} directional />
</p>
);
}
/* ---------------------------------------------------------------------------
UTIL
------------------------------------------------------------------------ */
/** Joins class names, dropping anything falsy. */
export function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
@@ -0,0 +1,106 @@
/* ============================================================================
Card.Stat - Figma 25:792 (260 x 166)
----------------------------------------------------------------------------
The figure card used by the home page stats strip: a large accent number,
an optional trend chip, the label it counts, a rule and a caption. Never a
link - a number is not a destination.
USAGE
-----
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-4">
<Card.Stat
as="li"
value="2,450"
label="Participants reached"
caption="Cumulative regional outreach, updated each quarter."
trend={{ label: "12%", direction: "up", description: "up 12% on last quarter" }}
/>
<Card.Stat as="li" value="18" label="Municipalities engaged" />
</ul>
// Arabic - Eastern Arabic numerals belong in the value string itself.
<Card.Stat value="٢٬٤٥٠" label="عدد المشاركين" />
========================================================================= */
import { CardIcon, CardShell, cx } from "./CardShell";
import type { CardElement } from "./types";
export interface CardStatTrend {
/** Visible text, e.g. "12%". */
label: string;
/** Up is the accent fill, down the danger fill. Defaults to up. */
direction?: "up" | "down";
/**
* Spoken instead of the bare figure, e.g. "up 12% on last quarter". The
* arrow is decorative, so without this a screen reader hears only "12%".
*/
description?: string;
}
export interface CardStatProps {
/** Pre-formatted for the locale - the card never formats a number itself. */
value: string;
label: string;
caption?: string;
trend?: CardStatTrend;
as?: CardElement;
className?: string;
}
export function CardStat({
value,
label,
caption,
trend,
as = "article",
className,
}: CardStatProps) {
const isDown = trend?.direction === "down";
return (
<CardShell
as={as}
className={cx("flex-col items-start gap-md p-lg", className)}
>
<div className="flex w-full items-center justify-between gap-sm">
<p className="display-sm tabular-nums text-text-accent">{value}</p>
{trend ? (
/* Figma paints the chip label in text/accent on a background/accent
fill - green on green, invisible. Kept as the accent fill with the
always-white on-accent label, and the danger fill when the figure
is falling. */
<span
className={cx(
"inline-flex shrink-0 items-center gap-2xs rounded-control-lg px-xs py-2xs",
"label-md text-text-on-accent",
isDown ? "bg-surface-danger" : "bg-surface-accent",
)}
>
<CardIcon
name="arrow-up"
size={10}
className={isDown ? "rotate-180" : undefined}
/>
{trend.description ? (
<>
<span aria-hidden="true">{trend.label}</span>
<span className="sr-only">{trend.description}</span>
</>
) : (
trend.label
)}
</span>
) : null}
</div>
<p className="body-md-bold text-text-primary">{label}</p>
<div aria-hidden="true" className="w-full border-t border-border-subtle" />
{caption ? (
<p className="body-sm text-text-secondary">{caption}</p>
) : null}
</CardShell>
);
}
@@ -0,0 +1,140 @@
/* ============================================================================
Card.Team - Figma 25:765 (280 x 295)
----------------------------------------------------------------------------
Portrait in an accent frame, name, role, affiliation and up to three social
links. The social links are real links, so the card itself is only stretched
over a link when `href` is given - and the links are lifted above the
overlay so they stay clickable.
USAGE
-----
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-4">
<Card.Team
as="li"
name="Dr. Helen Vance"
role="Chief Programme Director"
affiliation="SHARP-ER Governance Initiative"
portrait={{ src: "/media/team/helen-vance.jpg", alt: "" }}
socials={[
{ kind: "linkedin", href: "https://www.linkedin.com/in/example" },
{ kind: "mail", href: "mailto:helen.vance@example.org", label: "Email" },
{ kind: "website", href: "https://example.org", label: "Website" },
]}
/>
</ul>
// Optional bio page - the whole card becomes clickable, socials still work.
<Card.Team href="/en/about/helen-vance" name="Dr. Helen Vance" … />
========================================================================= */
import { CardLink, CardMedia, CardShell, CardIcon, cx } from "./CardShell";
import type { CardElement, CardIconName, CardImage, CardTitleElement } from "./types";
export type CardTeamSocialKind = "linkedin" | "mail" | "website";
export interface CardTeamSocial {
kind: CardTeamSocialKind;
href: string;
/**
* Accessible name of the link. Defaults to the platform name; pass a
* translated string on Arabic pages ("البريد الإلكتروني", "الموقع").
*/
label?: string;
}
export interface CardTeamProps {
name: string;
role?: string;
/** Team, unit or initiative the person belongs to. */
affiliation?: string;
portrait?: CardImage;
socials?: CardTeamSocial[];
/** Optional bio page. Given, the whole card links to it. */
href?: string;
as?: CardElement;
titleAs?: CardTitleElement;
className?: string;
}
const SOCIAL_ICON: Record<CardTeamSocialKind, CardIconName> = {
linkedin: "linkedin",
mail: "mail",
website: "globe",
};
const SOCIAL_LABEL: Record<CardTeamSocialKind, string> = {
linkedin: "LinkedIn",
mail: "Email",
website: "Website",
};
export function CardTeam({
name,
role,
affiliation,
portrait,
socials,
href,
as = "article",
titleAs = "h3",
className,
}: CardTeamProps) {
const Title = titleAs;
return (
<CardShell
as={as}
linked={Boolean(href)}
className={cx("flex-col items-center gap-5 p-xl text-center", className)}
>
{/* 96px portrait inside the 4px accent frame, exactly as drawn. */}
{portrait ? (
<CardMedia
image={portrait}
className="size-24 shrink-0 rounded-surface border-4 border-border-accent"
/>
) : null}
<div className="flex w-full flex-col gap-xs">
<Title className="sh-card-title body-md-bold text-text-primary">
{href ? (
<CardLink href={href} className="sh-card-link">
{name}
</CardLink>
) : (
name
)}
</Title>
{role ? <p className="body-sm text-text-secondary">{role}</p> : null}
{affiliation ? (
<p className="body-sm text-text-accent">{affiliation}</p>
) : null}
</div>
{socials && socials.length > 0 ? (
/* sh-card-above keeps these clickable through the stretched-link
overlay when the card as a whole is a link. */
<ul className="sh-card-above flex items-start justify-center gap-sm">
{socials.map((social) => (
<li key={`${social.kind}-${social.href}`}>
{/* Figma strokes these icons in text/accent on a background/accent
chip - green on green, invisible. Painted with the always-white
on-accent role instead. */}
<CardLink
href={social.href}
className="flex size-8 items-center justify-center rounded-surface bg-surface-accent text-text-on-accent"
>
<CardIcon name={SOCIAL_ICON[social.kind]} size={14} />
<span className="sr-only">
{`${social.label ?? SOCIAL_LABEL[social.kind]} - ${name}`}
</span>
</CardLink>
</li>
))}
</ul>
) : null}
</CardShell>
);
}
+129
View File
@@ -0,0 +1,129 @@
/* ============================================================================
CARD - mechanics that cannot be expressed as a utility
----------------------------------------------------------------------------
Everything token-driven lives in the TSX as a tier-2 utility
(bg-surface-raised, text-text-accent, rounded-surface, ...). This file only
holds the three things a utility cannot do:
1. paint an exported SVG asset in the current token colour (mask),
2. the whole-card link overlay + its focus ring,
3. the hover transitions, which need a duration/easing token pair.
Class names are prefixed `sh-card` because this file is imported by the
component rather than by app/app.css, so it is not scoped by anything else.
Everything lives in @layer components so a Tailwind utility passed through
`className` (`rounded-surface`, `text-text-secondary`, ...) still wins.
The layer statement is a defensive no-op when app.css has already declared
the same order, and makes this file correct on its own if it ever loads first.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
/* ---------------------------------------------------------------------------
ICONS
The SVGs exported from Figma (public/assets/icons/*.svg) have the brand
green baked in as stroke="#008764", which would be wrong on the dark surface
and inside the accent chips. Drawing the asset as a mask over a currentColor
fill keeps the exact exported geometry while the colour comes from whichever
semantic token the parent set. Size is set per instance so each icon keeps
its designed box (14px, or 10px for the stat trend arrow).
------------------------------------------------------------------------ */
.sh-card-icon {
display: block;
flex: none;
background-color: currentColor;
-webkit-mask-image: var(--sh-card-icon-src);
mask-image: var(--sh-card-icon-src);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-size: contain;
mask-size: contain;
}
/* Arrows that mean "forward" point the other way in Arabic. Only the
horizontal arrow mirrors - the stat trend arrow is vertical. */
[dir="rtl"] .sh-card-icon--directional {
transform: scaleX(-1);
}
/* ---------------------------------------------------------------------------
WHOLE-CARD LINK
The anchor around the title stretches over the card with ::after, so the
entire card is clickable while the title stays the accessible name and no
interactive element is nested inside another.
z-index 1 / 2 are structural, not design values: 1 is the overlay, 2 lifts
anything that has to stay clickable above it (the team card's social links).
------------------------------------------------------------------------ */
.sh-card-link::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
}
.sh-card-above {
position: relative;
z-index: 2;
}
/* The ring belongs around the card, not around the inline title text. Where
:has() is unavailable the anchor keeps the global :focus-visible ring from
app.css, so focus is never invisible. */
@supports selector(:has(*)) {
.sh-card-link:focus-visible {
outline: none;
}
.sh-card:has(.sh-card-link:focus-visible) {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
}
/* ---------------------------------------------------------------------------
HOVER / FOCUS AFFORDANCES
------------------------------------------------------------------------ */
.sh-card--linked {
transition:
box-shadow var(--motion-duration-base) var(--motion-ease-standard),
border-color var(--motion-duration-base) var(--motion-ease-standard);
}
.sh-card--linked:hover {
border-color: var(--border-default);
box-shadow: var(--elevation-popover);
}
.sh-card--linked:hover .sh-card-title,
.sh-card--linked:has(.sh-card-link:focus-visible) .sh-card-title {
text-decoration: underline;
text-underline-offset: 2px;
}
.sh-card-media img {
transition: transform var(--motion-duration-slow) var(--motion-ease-standard);
}
.sh-card--linked:hover .sh-card-media img {
transform: scale(1.04);
}
/* app.css already neutralises transition durations under reduced motion, but a
transform still lands instantly - drop it entirely. */
@media (prefers-reduced-motion: reduce) {
.sh-card--linked:hover .sh-card-media img {
transform: none;
}
}
/* The CTA sits on the accent fill, where the green focus ring has almost no
contrast. Inside its data-surface="dark" subtree the inverse ring is white. */
.sh-card-cta-action:focus-visible {
outline-color: var(--focus-ring-color-inverse);
}
}
+71
View File
@@ -0,0 +1,71 @@
/* ============================================================================
CARD - Figma frame 25:809
----------------------------------------------------------------------------
Five card types that share one surface, one link mechanism and one icon
strategy. Import the namespace for readable markup:
import { Card } from "~/components/ui/Card";
<Card.Content … /> <Card.Team … /> <Card.Horizontal … />
<Card.Stat … /> <Card.CTA … />
or the components directly when you want the narrowest import:
import { CardStat } from "~/components/ui/Card";
Every card is correct on both surfaces without a prop: colour comes only
from tier-2 roles, which [data-surface="dark"] and [data-surface="light"]
re-point. See CardShell.tsx for the styling rules this directory follows.
========================================================================= */
export { CardContent } from "./CardContent";
export { CardTeam } from "./CardTeam";
export { CardHorizontal } from "./CardHorizontal";
export { CardStat } from "./CardStat";
export { CardCTA } from "./CardCTA";
export {
CardShell,
CardMedia,
CardIcon,
CardLink,
CardEyebrow,
CardActionHint,
} from "./CardShell";
export type { CardContentProps } from "./CardContent";
export type {
CardTeamProps,
CardTeamSocial,
CardTeamSocialKind,
} from "./CardTeam";
export type { CardHorizontalProps } from "./CardHorizontal";
export type { CardStatProps, CardStatTrend } from "./CardStat";
export type { CardCTAProps, CardCTAAction } from "./CardCTA";
export type {
CardShellProps,
CardMediaProps,
CardIconProps,
CardLinkProps,
} from "./CardShell";
export type {
CardElement,
CardIconName,
CardImage,
CardLinkTarget,
CardTitleElement,
} from "./types";
import { CardContent } from "./CardContent";
import { CardTeam } from "./CardTeam";
import { CardHorizontal } from "./CardHorizontal";
import { CardStat } from "./CardStat";
import { CardCTA } from "./CardCTA";
/** The card family, namespaced: Card.Content, Card.Team, Card.Horizontal, Card.Stat, Card.CTA. */
export const Card = {
Content: CardContent,
Team: CardTeam,
Horizontal: CardHorizontal,
Stat: CardStat,
CTA: CardCTA,
} as const;
+42
View File
@@ -0,0 +1,42 @@
/* ============================================================================
CARD - shared prop types
========================================================================= */
/** Elements a card shell is allowed to render as. `li` for grids and lists. */
export type CardElement = "article" | "div" | "li" | "section";
/** Heading level for a card title - pick the one that fits the page outline. */
export type CardTitleElement = "h2" | "h3" | "h4";
/**
* An image in a card.
*
* The card fixes the aspect ratio of every media box, so the space is reserved
* before the file arrives and nothing shifts. `priority` is for the rare card
* that is above the fold; everything else stays lazy.
*/
export interface CardImage {
src: string;
/** Pass "" for a purely decorative image so it is skipped by screen readers. */
alt: string;
srcSet?: string;
sizes?: string;
/** Above the fold: load eagerly at high priority instead of lazily. */
priority?: boolean;
}
/** A destination. Internal paths route client-side, everything else is a plain anchor. */
export interface CardLinkTarget {
/** `/en/news/launch` routes client-side; `https://…`, `mailto:…`, `#…` do not. */
href: string;
target?: string;
rel?: string;
}
/** The five icons the card family uses, each an asset exported from Figma. */
export type CardIconName =
| "arrow-right"
| "arrow-up"
| "linkedin"
| "mail"
| "globe";
@@ -0,0 +1,340 @@
/* ============================================================================
Footer - figma frame 140:7813
----------------------------------------------------------------------------
ONE component for all four published variants:
light EN 140:7812 | dark EN 140:7814 | light AR 140:8713 | dark AR 140:8685
`surface` picks the palette and the logo cut; direction comes from `<html dir>`
and every offset is a logical property, so the Arabic variants are the same
markup mirrored - there is no RTL branch anywhere below.
STYLING: Tailwind utilities for layout and spacing, tier-2 semantic tokens for
everything that carries meaning (`text-[var(--text-primary)]`). No raw hex, no
tier-1 primitive, no component stylesheet.
TWO DELIBERATE DEVIATIONS FROM THE PUBLISHED FRAMES, both noted inline:
1. the two EN frames disagree on the sitemap (140:7812 carries the real IA
but no column headings, 140:7814 carries headings but stale labels) -
this takes the newer IA and the headings;
2. `text/tertiary` (#949492) on white is ~2.9:1, so the small labels use
`--text-secondary` instead. Revert the two lines marked (a11y) for
pixel-exact parity.
========================================================================= */
import { Link } from "react-router";
import type {
FooterCopy,
FooterLink,
FooterPartner,
FooterProps,
FooterSocialLink,
} from "./types";
import {
DEFAULT_FOOTER_COPY,
DEFAULT_FOOTER_PARTNERS,
DEFAULT_FOOTER_SOCIAL_LINKS,
FOOTER_ASSETS,
FOOTER_EU_EMBLEM_SIZE,
FOOTER_WORDMARK_SIZE,
defaultFooterLinkGroups,
} from "./content";
import { FooterSocialIcon } from "./FooterSocialIcon";
const cx = (...parts: readonly (string | false | undefined)[]): string =>
parts.filter(Boolean).join(" ");
function definedOnly<T extends object>(source: Partial<T> | undefined): Partial<T> {
if (!source) return {};
return Object.fromEntries(
Object.entries(source).filter(([, value]) => value !== undefined),
) as Partial<T>;
}
/** Visible focus ring on every interactive element, straight off the focus tokens. */
const FOCUS_RING = cx(
"rounded-[var(--radius-control)]",
"focus-visible:[outline:var(--focus-ring-width)_solid_var(--focus-ring-color)]",
"focus-visible:[outline-offset:var(--focus-ring-offset)]",
);
/** Only `color` - `transition-colors` would also fade `outline-color`, which
makes the focus ring arrive late. */
const TRANSITION =
"transition-[color] duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]";
/** Body/Medium Bold links, as in figma 380:12385. */
const LINK_CLASS = cx(
FOCUS_RING,
TRANSITION,
"body-md-bold inline-block text-[var(--text-primary)]",
"hover:text-[var(--text-link)] hover:underline underline-offset-4",
);
/** Label/Medium, used for the column headings and the "implemented by" label. */
const LABEL_CLASS = "label-md uppercase";
/**
* Internal links go through the router so the footer navigates client-side;
* anything flagged `external` stays a plain anchor and opens in a new tab.
*/
function FooterNavLink({ link, className }: { link: FooterLink; className: string }) {
if (link.external) {
return (
<a href={link.href} className={className} target="_blank" rel="noopener noreferrer">
{link.label}
</a>
);
}
return (
<Link to={link.href} className={className} prefetch="intent">
{link.label}
</Link>
);
}
export function Footer({
surface = "dark",
locale = "en",
dir,
linkGroups,
socialLinks = DEFAULT_FOOTER_SOCIAL_LINKS,
partners = DEFAULT_FOOTER_PARTNERS,
copy: copyOverrides,
homeHref,
className,
id,
}: FooterProps) {
const isDark = surface === "dark";
// A CMS field that has not been filled in arrives as undefined; spreading it
// would blank the default, so only defined overrides are applied.
const copy: FooterCopy = { ...DEFAULT_FOOTER_COPY[locale], ...definedOnly(copyOverrides) };
const groups = linkGroups ?? defaultFooterLinkGroups(locale);
const home = homeHref ?? `/${locale}`;
return (
<footer
id={id}
dir={dir}
data-surface={surface}
className={cx(
// data-surface paints background-color and color itself (app.css), so
// the footer needs no colour utility of its own.
"w-full px-lg pb-2xl pt-[var(--layout-section-gap)] md:px-xl",
className,
)}
>
<div className="mx-auto flex w-full max-w-[var(--layout-content-max)] flex-col gap-2xl">
{/* ---- brand + sitemap (figma 140:7684 / 140:8686) ---------------- */}
<div className="flex flex-col gap-2xl lg:flex-row lg:gap-3xl">
{/* --- brand column (figma 140:7685 / 140:8687) ----------------- */}
<div
className={cx(
"flex flex-col gap-md",
// the divider sits on the inline-end of the brand block, which is
// the left edge once <html dir="rtl"> mirrors the row.
"lg:w-[345px] lg:shrink-0 lg:border-e lg:border-[var(--border-subtle)] lg:pe-2xl",
)}
>
<Link to={home} aria-label={copy.homeLabel} className={cx(FOCUS_RING, "w-fit")}>
<img
src={isDark ? FOOTER_ASSETS.wordmarkInverse : FOOTER_ASSETS.wordmark}
alt="SHARP-ER"
width={FOOTER_WORDMARK_SIZE.width}
height={FOOTER_WORDMARK_SIZE.height}
className="block h-[27px] w-[196px]"
loading="lazy"
decoding="async"
/>
</Link>
<p className="body-sm text-[var(--text-secondary)]">{copy.tagline}</p>
<p className="body-sm max-w-[46ch] text-[var(--text-primary)]">
{copy.description}
</p>
{/* --- EU visibility (figma 140:7688 / 238:2325) -------------- */}
<div className="flex items-center gap-2xs pt-2xs">
<img
src={isDark ? FOOTER_ASSETS.euEmblemInverse : FOOTER_ASSETS.euEmblem}
alt={copy.euEmblemAlt}
width={FOOTER_EU_EMBLEM_SIZE.width}
height={FOOTER_EU_EMBLEM_SIZE.height}
className="block h-[32px] w-[48px] shrink-0"
loading="lazy"
decoding="async"
/>
<p className="body-sm-bold max-w-[24ch] text-[var(--text-primary)]">
{copy.euStatement}
</p>
</div>
{socialLinks.length > 0 && <SocialRow copy={copy} links={socialLinks} />}
</div>
{/* --- sitemap columns (figma 380:12402) ------------------------ */}
<nav aria-label={copy.navLabel} className="flex flex-1 flex-col gap-2xl sm:flex-row sm:gap-0">
{groups.map((group, index) => (
<div
key={group.id}
className={cx(
"flex min-w-0 flex-col gap-md sm:flex-1",
// the rule sits on the inline-start of every column but the
// first, so it mirrors with the row under dir="rtl".
index > 0 && "sm:border-s sm:border-[var(--border-subtle)] sm:ps-xl",
index < groups.length - 1 && "sm:pe-xl",
)}
>
{/* (a11y) figma uses text/tertiary; see the header note. */}
<h2 className={cx(LABEL_CLASS, "text-[var(--text-secondary)]")}>
{group.heading}
</h2>
<ul className="flex flex-col gap-sm">
{group.links.map((link: FooterLink, linkIndex: number) => (
<li key={`${link.href}-${linkIndex}`}>
<FooterNavLink link={link} className={LINK_CLASS} />
</li>
))}
</ul>
</div>
))}
</nav>
</div>
{/* ---- implemented-by bar (figma 228:3062 / 140:8710) ------------- */}
{partners.length > 0 && (
<div className="flex flex-col gap-lg border-t border-[var(--border-default)] pt-lg sm:flex-row sm:items-center sm:justify-between">
{/* Figma paints this label background/accent on dark and
text/secondary on light; --text-accent is the readable green. */}
<p
className={cx(
LABEL_CLASS,
"text-[var(--text-secondary)] surface-dark:text-[var(--text-accent)]",
)}
>
{copy.implementedByLabel}
</p>
<ul className="flex flex-wrap items-center gap-lg">
{partners.map((partner) => (
<li key={partner.id} className="flex items-center">
<PartnerMark isDark={isDark} partner={partner} />
</li>
))}
</ul>
</div>
)}
</div>
</footer>
);
}
/* ---------------------------------------------------------------------------
Consortium logo. The three marks are shown together and never on their own
(communication guideline), so this renders one mark of that row - each keeps
its own designed box rather than a shared height rule.
------------------------------------------------------------------------ */
function PartnerMark({ isDark, partner }: { isDark: boolean; partner: FooterPartner }) {
const image = (
<img
src={isDark ? partner.srcInverse : partner.src}
alt={partner.name}
width={partner.width}
height={partner.height}
style={{ width: `${partner.width}px`, height: `${partner.height}px` }}
className="block"
loading="lazy"
decoding="async"
/>
);
if (!partner.href) return image;
return (
<a
href={partner.href}
target="_blank"
rel="noopener noreferrer"
className={cx(FOCUS_RING, "inline-flex")}
>
{image}
</a>
);
}
/* ---------------------------------------------------------------------------
Social row. Not in the published frames - the draft copy asks for it, so it
closes the brand column. The negative inline-start margin cancels the icon
padding so the glyphs optically align with the text above them.
------------------------------------------------------------------------ */
function SocialRow({
copy,
links,
}: {
copy: FooterCopy;
links: readonly FooterSocialLink[];
}) {
return (
<div className="pt-2xs">
<h2 className="sr-only">{copy.socialHeading}</h2>
<ul className="-ms-2xs flex flex-wrap items-center gap-2xs">
{links.map((link) => (
<li key={link.platform}>
<a
href={link.href}
aria-label={link.label}
target="_blank"
rel="noopener noreferrer"
className={cx(
FOCUS_RING,
TRANSITION,
"inline-flex items-center justify-center p-2xs",
"text-[var(--text-secondary)] hover:text-[var(--text-link)]",
)}
>
<FooterSocialIcon
platform={link.platform}
className="h-[var(--control-icon-size)] w-[var(--control-icon-size)]"
/>
</a>
</li>
))}
</ul>
</div>
);
}
export default Footer;
/* ============================================================================
Footer.stories-ish - usage, as a comment only (no Storybook in this project)
----------------------------------------------------------------------------
// 1. Dark English footer - the default, matches figma 140:7814.
// <html lang="en" dir="ltr"> already carries the direction.
<Footer locale="en" />
// 2. Light English footer - figma 140:7812.
<Footer surface="light" locale="en" />
// 3. Arabic, dark. Same markup; the RTL mirroring comes from <html dir="rtl">,
// so nothing is passed for direction inside the real app.
<Footer locale="ar" />
// 4. Light Arabic - figma 140:8713. `dir` is only needed when the footer is
// previewed inside a document that is not in that locale.
<Footer surface="light" locale="ar" dir="rtl" />
// 5. Strapi-driven: every list is a prop, so the CMS payload maps 1:1.
<Footer
surface="dark"
locale="en"
linkGroups={cms.footer.groups}
socialLinks={cms.footer.social}
partners={cms.footer.partners}
copy={{ description: cms.footer.blurb }}
/>
// 6. Sitemap-only footer, no consortium row and no social row.
<Footer partners={[]} socialLinks={[]} />
========================================================================= */
@@ -0,0 +1,43 @@
/* ============================================================================
Footer - social glyphs
----------------------------------------------------------------------------
The Figma icon set (frame 84:4485) only ships the arrow pair, so the brand
marks below are inlined rather than exported. Inline is also what the surface
model needs: the glyph paints with `currentColor`, so one file works on both
the light and the dark footer without a second asset.
========================================================================= */
import type { FooterSocialPlatform } from "./types";
/** Single-path 24x24 brand marks, viewBox `0 0 24 24`, filled with currentColor. */
const GLYPHS: Readonly<Record<FooterSocialPlatform, string>> = {
facebook:
"M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z",
linkedin:
"M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286ZM5.337 7.433a2.062 2.062 0 1 1 0-4.124 2.062 2.062 0 0 1 0 4.124Zm1.782 13.019H3.555V9h3.564v11.452ZM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003Z",
youtube:
"M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814ZM9.545 15.568V8.432L15.818 12l-6.273 3.568Z",
instagram:
"M12 2.163c3.204 0 3.584.012 4.85.07 1.17.053 1.805.249 2.227.413.56.218.96.478 1.382.9.421.42.681.82.898 1.38.164.423.36 1.058.413 2.228.058 1.266.07 1.646.07 4.85 0 3.204-.012 3.584-.07 4.85-.053 1.17-.249 1.805-.413 2.227a3.72 3.72 0 0 1-.898 1.382c-.422.421-.822.681-1.382.898-.422.164-1.057.36-2.227.413-1.265.058-1.645.07-4.85.07-3.204 0-3.584-.012-4.85-.07-1.17-.053-1.805-.249-2.227-.413a3.72 3.72 0 0 1-1.382-.898 3.72 3.72 0 0 1-.898-1.382c-.164-.422-.36-1.057-.413-2.227-.058-1.266-.07-1.646-.07-4.85 0-3.204.012-3.584.07-4.85.053-1.17.249-1.805.413-2.227a3.72 3.72 0 0 1 .898-1.382 3.72 3.72 0 0 1 1.382-.898c.422-.164 1.057-.36 2.227-.413 1.266-.058 1.646-.07 4.85-.07ZM12 0C8.741 0 8.332.014 7.052.072 5.775.13 4.902.333 4.14.63a5.88 5.88 0 0 0-2.126 1.384A5.88 5.88 0 0 0 .63 4.14C.333 4.902.13 5.775.072 7.052.014 8.332 0 8.741 0 12s.014 3.668.072 4.948c.058 1.277.261 2.15.558 2.912a5.88 5.88 0 0 0 1.384 2.126A5.88 5.88 0 0 0 4.14 23.37c.762.297 1.635.5 2.912.558C8.332 23.986 8.741 24 12 24s3.668-.014 4.948-.072c1.277-.058 2.15-.261 2.912-.558a5.88 5.88 0 0 0 2.126-1.384 5.88 5.88 0 0 0 1.384-2.126c.297-.762.5-1.635.558-2.912.058-1.28.072-1.689.072-4.948s-.014-3.668-.072-4.948c-.058-1.277-.261-2.15-.558-2.912a5.88 5.88 0 0 0-1.384-2.126A5.88 5.88 0 0 0 19.86.63c-.762-.297-1.635-.5-2.912-.558C15.668.014 15.259 0 12 0Zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324ZM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm7.846-10.405a1.44 1.44 0 1 1-2.88 0 1.44 1.44 0 0 1 2.88 0Z",
x: "M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z",
};
export interface FooterSocialIconProps {
readonly platform: FooterSocialPlatform;
readonly className?: string;
}
/** Decorative glyph - the accessible name lives on the wrapping link. */
export function FooterSocialIcon({ platform, className }: FooterSocialIconProps) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
focusable="false"
className={className}
>
<path d={GLYPHS[platform]} />
</svg>
);
}
@@ -0,0 +1,202 @@
/* ============================================================================
Footer - typed defaults
----------------------------------------------------------------------------
Draft copy is taken from docs/SHARPER_Website_Draft_Section_EN_AR.docx and the
communication guideline; the section names are the ten-section IA. Everything
here is a default only - Strapi will supply the same shapes later, so nothing
below may be imported by anything except <Footer />'s own prop defaults.
========================================================================= */
import { ROUTE_SLUGS } from "~/lib/route-slugs";
import type {
FooterCopy,
FooterLinkGroup,
FooterLocale,
FooterPartner,
FooterSocialLink,
} from "./types";
/* ---------------------------------------------------------------------------
Assets - exported from Figma (frames 312:4012, 84:4483, 228:2984) and
committed under apps/web/public/assets/. Two cuts of every piece of baked
ink: the plain one for a light surface, `*-inverse` for a dark one.
------------------------------------------------------------------------ */
export const FOOTER_ASSETS = {
/** SHARP-ER wordmark, designed 334x46 (figma 317:5571 / 317:7812). */
wordmark: "/assets/sharp-er-wordmark.png",
wordmarkInverse: "/assets/sharp-er-wordmark-inverse.png",
/** EU emblem, designed 48x32 (figma 22:5358 / 238:2323). */
euEmblem: "/assets/eu-emblem.png",
euEmblemInverse: "/assets/eu-emblem-inverse.png",
} as const;
/** Designed CSS pixels of the wordmark inside the footer brand column. */
export const FOOTER_WORDMARK_SIZE = { width: 196, height: 27 } as const;
/** Designed CSS pixels of the EU emblem (figma "european union", 48x32). */
export const FOOTER_EU_EMBLEM_SIZE = { width: 48, height: 32 } as const;
/* ---------------------------------------------------------------------------
Route slugs
The footer only ever builds `/{locale}/{slug}`, and the slugs come from the
ONE source of truth the route config itself is built from - redeclaring them
here is how the footer ended up linking at five pages that do not exist.
Re-exported under the old name so the barrel keeps its public surface.
------------------------------------------------------------------------ */
export const FOOTER_ROUTE_SLUGS = ROUTE_SLUGS;
const path = (locale: FooterLocale, slug: string): string => `/${locale}/${slug}`;
/* ---------------------------------------------------------------------------
Copy
------------------------------------------------------------------------ */
export const DEFAULT_FOOTER_COPY: Readonly<Record<FooterLocale, FooterCopy>> = {
en: {
tagline: "Shared Action on Rule of Law Progress – Exit Route",
description:
"A nationwide initiative supporting Libya’s civil society, local institutions and communities in advancing justice, accountability and good governance. Running from December 2025 to December 2027.",
euStatement: "This project is funded by the European Union",
euEmblemAlt: "Emblem of the European Union",
implementedByLabel: "Implemented by",
socialHeading: "Follow SHARP-ER",
homeLabel: "SHARP-ER — home",
navLabel: "Site sections",
},
ar: {
tagline: "العمل المشترك لتعزيز سيادة القانون – مسار الخروج",
description:
"مبادرة وطنية تدعم المجتمع المدني والمؤسسات المحلية والمجتمعات الليبية في تعزيز العدالة والمساءلة والحوكمة الرشيدة. يمتد المشروع من ديسمبر 2025 إلى ديسمبر 2027.",
euStatement: "هذا المشروع ممول من الاتحاد الأوروبي",
euEmblemAlt: "شعار الاتحاد الأوروبي",
implementedByLabel: "الجهات المنفّذة",
socialHeading: "تابعوا SHARP-ER",
homeLabel: "SHARP-ER — الصفحة الرئيسية",
navLabel: "أقسام الموقع",
},
};
/* ---------------------------------------------------------------------------
Sitemap columns - the three IA groups. "Home" is not repeated as a link
because the wordmark above the columns already points at it.
------------------------------------------------------------------------ */
const LINK_GROUPS: Readonly<
Record<FooterLocale, readonly FooterLinkGroup[]>
> = {
en: [
{
id: "about-and-strategy",
heading: "About and Strategy",
links: [
{ label: "About", href: path("en", FOOTER_ROUTE_SLUGS.about) },
{ label: "What We Do", href: path("en", FOOTER_ROUTE_SLUGS.whatWeDo) },
{
label: "Activities and Initiatives",
href: path("en", FOOTER_ROUTE_SLUGS.activities),
},
],
},
{
id: "engagement-and-opportunities",
heading: "Engagement and Opportunities",
links: [
{ label: "Events Calendar", href: path("en", FOOTER_ROUTE_SLUGS.events) },
{
label: "Applications and Opportunities",
href: path("en", FOOTER_ROUTE_SLUGS.applications),
},
{ label: "News and Updates", href: path("en", FOOTER_ROUTE_SLUGS.news) },
{ label: "Contact", href: path("en", FOOTER_ROUTE_SLUGS.contact) },
],
},
{
id: "knowledge-and-impact",
heading: "Knowledge and Impact",
links: [
{
label: "Resources and Publications",
href: path("en", FOOTER_ROUTE_SLUGS.resources),
},
{ label: "Knowledge Hub", href: path("en", FOOTER_ROUTE_SLUGS.knowledgeHub) },
],
},
],
ar: [
{
id: "about-and-strategy",
heading: "عن المشروع والاستراتيجية",
links: [
{ label: "عن المشروع", href: path("ar", FOOTER_ROUTE_SLUGS.about) },
{ label: "ماذا نفعل", href: path("ar", FOOTER_ROUTE_SLUGS.whatWeDo) },
{ label: "الأنشطة والمبادرات", href: path("ar", FOOTER_ROUTE_SLUGS.activities) },
],
},
{
id: "engagement-and-opportunities",
heading: "المشاركة والفرص",
links: [
{ label: "التقويم الزمني للفعاليات", href: path("ar", FOOTER_ROUTE_SLUGS.events) },
{ label: "طلبات وفرص المشاركة", href: path("ar", FOOTER_ROUTE_SLUGS.applications) },
{ label: "الأخبار والمستجدات", href: path("ar", FOOTER_ROUTE_SLUGS.news) },
{ label: "تواصل معنا", href: path("ar", FOOTER_ROUTE_SLUGS.contact) },
],
},
{
id: "knowledge-and-impact",
heading: "المعرفة والأثر",
links: [
{ label: "الموارد والمنشورات", href: path("ar", FOOTER_ROUTE_SLUGS.resources) },
{ label: "مركز المعرفة", href: path("ar", FOOTER_ROUTE_SLUGS.knowledgeHub) },
],
},
],
};
/** The three IA sitemap columns for a locale. */
export function defaultFooterLinkGroups(
locale: FooterLocale,
): readonly FooterLinkGroup[] {
return LINK_GROUPS[locale];
}
/* ---------------------------------------------------------------------------
Consortium logos - shown together, never standalone (communication guideline).
Sizes are the designed boxes from figma frame 228:2984.
------------------------------------------------------------------------ */
export const DEFAULT_FOOTER_PARTNERS: readonly FooterPartner[] = [
{
id: "acted",
name: "Acted",
src: "/assets/partner-acted.png",
srcInverse: "/assets/partner-acted-inverse.png",
width: 86,
height: 24,
},
{
id: "uec",
name: "United Efforts Consultancy (UEC)",
src: "/assets/partner-uec.png",
srcInverse: "/assets/partner-uec-inverse.png",
width: 24,
height: 24,
},
{
id: "sbe",
name: "Stichting Bureau Ensemble (SBE)",
src: "/assets/partner-sbe.png",
srcInverse: "/assets/partner-sbe-inverse.png",
width: 43,
height: 24,
},
];
/* ---------------------------------------------------------------------------
Social profiles
DELIBERATELY EMPTY. The draft copy names Facebook as the only confirmed
channel and leaves its URL a placeholder, so there is no handle here that
the project is known to own - and shipping a guessed one from an EU-funded
site would point readers at an account somebody else may control.
Real profiles arrive through the `socialLinks` prop (Strapi); the footer's
`links.length > 0` guard drops the whole row until they do.
------------------------------------------------------------------------ */
export const DEFAULT_FOOTER_SOCIAL_LINKS: readonly FooterSocialLink[] = [];
@@ -0,0 +1,31 @@
/* ============================================================================
Footer - public surface
========================================================================= */
export { Footer, default } from "./Footer";
export { FooterSocialIcon } from "./FooterSocialIcon";
export type { FooterSocialIconProps } from "./FooterSocialIcon";
export {
DEFAULT_FOOTER_COPY,
DEFAULT_FOOTER_PARTNERS,
DEFAULT_FOOTER_SOCIAL_LINKS,
FOOTER_ASSETS,
FOOTER_EU_EMBLEM_SIZE,
FOOTER_ROUTE_SLUGS,
FOOTER_WORDMARK_SIZE,
defaultFooterLinkGroups,
} from "./content";
export type {
FooterCopy,
FooterDirection,
FooterLink,
FooterLinkGroup,
FooterLocale,
FooterPartner,
FooterProps,
FooterSocialLink,
FooterSocialPlatform,
FooterSurface,
} from "./types";
+116
View File
@@ -0,0 +1,116 @@
/* ============================================================================
Footer - public types
----------------------------------------------------------------------------
Everything the footer renders that is not chrome comes in through props, so
the same component can later be driven straight from Strapi. The defaults in
./content.ts are the fallback, not the contract.
========================================================================= */
/** Which tier-2 surface the footer paints itself with. `:root` is the dark one. */
export type FooterSurface = "light" | "dark";
/** The two locales the site is served in. URLs are prefixed on both. */
export type FooterLocale = "en" | "ar";
/** Writing direction. Normally inherited from `<html dir>` - see FooterProps.dir. */
export type FooterDirection = "ltr" | "rtl";
/** Social channels the footer can render a glyph for. */
export type FooterSocialPlatform =
| "facebook"
| "linkedin"
| "youtube"
| "instagram"
| "x";
/** A single sitemap or utility link. */
export interface FooterLink {
/** Visible label, already in the target locale. */
readonly label: string;
/** Absolute path including the locale prefix, e.g. `/en/about`. */
readonly href: string;
/** Opens in a new tab with `rel="noopener noreferrer"` when true. */
readonly external?: boolean;
}
/** One sitemap column: a heading plus its links. */
export interface FooterLinkGroup {
/** Stable key, locale independent (e.g. `about-and-strategy`). */
readonly id: string;
/** Visible column heading, already in the target locale. */
readonly heading: string;
readonly links: readonly FooterLink[];
}
/** A social profile. `label` is the accessible name of the icon link. */
export interface FooterSocialLink {
readonly platform: FooterSocialPlatform;
readonly label: string;
readonly href: string;
}
/**
* A consortium logo in the "implemented by" row. Two sources, because the
* artwork is baked ink: `src` is the dark-ink cut for a light surface and
* `srcInverse` the white cut for a dark surface. `width`/`height` are the
* designed CSS pixels, kept explicit so the row never reflows on load.
*/
export interface FooterPartner {
readonly id: string;
readonly name: string;
readonly src: string;
readonly srcInverse: string;
readonly width: number;
readonly height: number;
readonly href?: string;
}
/** Every string the footer renders that is not a link label. */
export interface FooterCopy {
/** "Shared Action on Rule of Law Progress - Exit Route" */
readonly tagline: string;
/** Short programme description under the wordmark. */
readonly description: string;
/** EU funding statement rendered next to the emblem. */
readonly euStatement: string;
/** Alt text for the EU emblem image. */
readonly euEmblemAlt: string;
/** Label above the consortium logos. */
readonly implementedByLabel: string;
/** Visually hidden heading for the social row. */
readonly socialHeading: string;
/** Accessible name of the wordmark link. */
readonly homeLabel: string;
/** Accessible name of the sitemap `<nav>` and the `<footer>` landmark. */
readonly navLabel: string;
}
export interface FooterProps {
/**
* Which surface to paint. Sets `data-surface`, so the subtree re-resolves
* every tier-2 token and the correct logo cut is chosen. Defaults to `dark`,
* matching the home design.
*/
readonly surface?: FooterSurface;
/** Selects the default copy and link set. Defaults to `en`. */
readonly locale?: FooterLocale;
/**
* Writing direction. Leave unset in the real app - `<html dir>` owns it and
* the footer lays out with logical properties, so it mirrors for free. Pass
* it only when previewing the footer outside a document of that locale.
*/
readonly dir?: FooterDirection;
/** Sitemap columns. Defaults to the ten-section IA for `locale`. */
readonly linkGroups?: readonly FooterLinkGroup[];
/** Social profiles. Pass `[]` to hide the row. */
readonly socialLinks?: readonly FooterSocialLink[];
/** Consortium logos. Pass `[]` to hide the row. */
readonly partners?: readonly FooterPartner[];
/** Overrides for individual strings; anything omitted falls back to `locale`. */
readonly copy?: Partial<FooterCopy>;
/** Target of the wordmark link. Defaults to `/{locale}`. */
readonly homeHref?: string;
/** Extra classes on the `<footer>` element. */
readonly className?: string;
readonly id?: string;
}
+141
View File
@@ -0,0 +1,141 @@
/* ============================================================================
INPUT / TEXTAREA - figma frame 18:212 (Type x State)
----------------------------------------------------------------------------
Only TIER-2 semantic tokens. The label, hint and error colours are driven off
two data attributes on the group (`data-invalid`, `data-disabled`) plus
`:focus-within`, so the whole 2x5 matrix is a handful of rules.
Layout (the column and its gaps) is plain Tailwind spacing utilities in
Input.tsx, and they name a step of the spacing scale (`gap-xs` = 8, `ms-2xs`
= 4) rather than the raw `gap-1.5` / `ms-0.5` multiples of --spacing, which
are tier-1. Everything token-driven lives here.
BOX. As with the button, Figma strokes inside the frame, so the 1.5px border
is subtracted from the 16/12 padding and the outer edge stays where it was
drawn.
DEVIATIONS FROM FIGMA, deliberate:
* Height. Figma hand-sets 44px, which does not agree with its own 12px
padding and 24px Body/Medium line-height. Following the tokens gives 48px,
which is also exactly the Medium button, so a field and a button sitting on
one row line up. Textarea keeps Figma's 120px, reached honestly as
rows={4}: 4*24 + 10.5*2 + 1.5*2.
* Focus. Figma paints a 2px grey (`stroke/hover`) border, which both moves
the box by half a pixel and is not a WCAG-visible focus indicator. The
border instead turns `--field-border-focus` at an unchanged 1.5px and the
semantic focus ring is drawn outside it. The green label on focus is kept.
* Disabled. Figma dims the whole group to 50%. The `--field-*-disabled`
tokens already encode that reading and stay opaque, so no group opacity is
applied and text on a translucent dark surface cannot smear.
* The error caption is Figma's "Caption" role (Regular 12), which the type
scale does not publish. It uses `body-sm` (14/20), the smallest published
body role - more legible for a message a user has to act on.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
/* ---------------------------------------------------------------------
Label, hint, error
------------------------------------------------------------------ */
.sh-field__label {
color: var(--text-primary);
transition: color var(--motion-duration-fast) var(--motion-ease-standard);
}
.sh-field:focus-within .sh-field__label {
color: var(--text-accent);
}
.sh-field[data-invalid="true"] .sh-field__label,
.sh-field[data-invalid="true"]:focus-within .sh-field__label {
color: var(--text-danger);
}
.sh-field[data-disabled="true"] .sh-field__label {
color: var(--text-disabled);
}
.sh-field__required {
color: var(--text-danger);
}
.sh-field__hint {
color: var(--text-secondary);
}
.sh-field[data-disabled="true"] .sh-field__hint {
color: var(--text-disabled);
}
.sh-field__error {
color: var(--text-danger);
}
/* ---------------------------------------------------------------------
The control itself
------------------------------------------------------------------ */
.sh-field__control {
display: block;
inline-size: 100%;
appearance: none;
background-color: var(--field-bg);
color: var(--field-fg);
border: var(--control-border-width) solid var(--field-border);
border-radius: var(--radius-control-lg);
padding-inline: calc(var(--control-padding-inline-md) - var(--control-border-width));
padding-block: calc(var(--control-padding-block-md) - var(--control-border-width));
text-align: start;
transition:
background-color var(--motion-duration-fast) var(--motion-ease-standard),
border-color var(--motion-duration-fast) var(--motion-ease-standard),
color var(--motion-duration-fast) var(--motion-ease-standard);
}
.sh-field__control::placeholder {
color: var(--field-placeholder);
opacity: 1;
}
@media (hover: hover) {
.sh-field__control:hover:not(:disabled) {
border-color: var(--field-border-hover);
}
}
/* Matched to the hover rule's specificity and placed after it, so focusing
a field the pointer happens to be over still shows the focus border. */
.sh-field .sh-field__control:focus-visible {
border-color: var(--field-border-focus);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
.sh-field[data-invalid="true"] .sh-field__control {
border-color: var(--field-border-error);
}
.sh-field[data-invalid="true"] .sh-field__control:focus-visible {
border-color: var(--field-border-error);
outline-color: var(--field-border-error);
}
/* Last, and specific enough to win over the invalid border - a disabled
field reads as disabled even when it is still holding an error. */
.sh-field .sh-field__control:disabled {
background-color: var(--field-bg-disabled);
color: var(--field-fg-disabled);
border-color: var(--border-subtle);
cursor: not-allowed;
}
.sh-field .sh-field__control:disabled::placeholder {
color: var(--field-fg-disabled);
}
/* Only the block axis resizes - growing a textarea sideways would break the
column it sits in. */
.sh-field__control--textarea {
resize: vertical;
}
}
+246
View File
@@ -0,0 +1,246 @@
import { useId } from "react";
import type { ComponentPropsWithRef, ReactNode } from "react";
import "./Input.css";
/** Everything the label / hint / error scaffolding needs, shared by both controls. */
type FieldShellProps = {
/** Visible, always-present label. Never omit it - a placeholder is not a label. */
label: ReactNode;
/** Helper text under the control. Announced through `aria-describedby`. */
hint?: ReactNode;
/** Validation message. Turns the field red and is announced via `role="alert"`. */
error?: ReactNode;
required?: boolean;
/** Auto-generated from `useId()` when omitted, so SSR and hydration agree. */
id?: string;
/** Applied to the field group (label + control + messages). */
className?: string;
/** Applied to the `<input>` / `<textarea>` itself. */
inputClassName?: string;
};
type NativeInputProps = Omit<
ComponentPropsWithRef<"input">,
keyof FieldShellProps | "aria-invalid"
>;
type NativeTextareaProps = Omit<
ComponentPropsWithRef<"textarea">,
keyof FieldShellProps | "aria-invalid"
>;
export type InputProps = FieldShellProps & NativeInputProps;
export type TextareaProps = FieldShellProps & NativeTextareaProps;
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
type FieldWiring = {
fieldId: string;
hintId: string;
errorId: string;
describedBy: string | undefined;
};
/**
* Stable ids for the control and its two messages, plus the `aria-describedby`
* that points at whichever of them are actually rendered. Any `aria-describedby`
* the caller supplied is preserved and appended rather than overwritten.
*/
function useFieldWiring(
id: string | undefined,
hasHint: boolean,
hasError: boolean,
callerDescribedBy: string | undefined,
): FieldWiring {
const generatedId = useId();
const fieldId = id ?? `sh-field-${generatedId}`;
const hintId = `${fieldId}-hint`;
const errorId = `${fieldId}-error`;
const describedBy =
[hasHint ? hintId : null, hasError ? errorId : null, callerDescribedBy ?? null]
.filter(Boolean)
.join(" ") || undefined;
return { fieldId, hintId, errorId, describedBy };
}
type FieldShellRenderProps = FieldShellProps & {
wiring: FieldWiring;
disabled?: boolean;
children: ReactNode;
};
function FieldShell({
label,
hint,
error,
required = false,
className,
wiring,
disabled,
children,
}: FieldShellRenderProps) {
return (
<div
className={cx("sh-field flex flex-col gap-xs", className)}
data-invalid={error ? "true" : undefined}
data-disabled={disabled ? "true" : undefined}
>
<label className="sh-field__label label-md" htmlFor={wiring.fieldId}>
{label}
{required ? (
<span className="sh-field__required ms-2xs" aria-hidden="true">
*
</span>
) : null}
</label>
{children}
{hint ? (
<p id={wiring.hintId} className="sh-field__hint body-sm">
{hint}
</p>
) : null}
{/* role="alert" so a message that appears after a failed submit is spoken. */}
{error ? (
<p id={wiring.errorId} role="alert" className="sh-field__error body-sm">
{error}
</p>
) : null}
</div>
);
}
/**
* Single-line text field - figma frame 18:212, Type=Text.
*
* The Default / Focused / Filled / Error / Disabled states of the design are all
* driven by the control's own state, so there is no `state` prop to keep in
* sync: pass `error` to get the error state, `disabled` to get the disabled one.
*/
export function Input({
label,
hint,
error,
required = false,
id,
className,
inputClassName,
disabled,
...rest
}: InputProps) {
const wiring = useFieldWiring(id, Boolean(hint), Boolean(error), rest["aria-describedby"]);
return (
<FieldShell
label={label}
hint={hint}
error={error}
required={required}
className={className}
wiring={wiring}
disabled={disabled}
>
<input
{...rest}
id={wiring.fieldId}
disabled={disabled}
required={required}
aria-invalid={error ? true : undefined}
aria-describedby={wiring.describedBy}
className={cx("sh-field__control body-md", inputClassName)}
/>
</FieldShell>
);
}
/**
* Multi-line text field - figma frame 18:212, Type=Textarea.
*
* `rows` defaults to 4, which is what makes the box land on the 120px Figma
* drew: 4 lines of 24 + 2 x 10.5 padding + 2 x 1.5 border.
*/
export function Textarea({
label,
hint,
error,
required = false,
id,
className,
inputClassName,
disabled,
rows = 4,
...rest
}: TextareaProps) {
const wiring = useFieldWiring(id, Boolean(hint), Boolean(error), rest["aria-describedby"]);
return (
<FieldShell
label={label}
hint={hint}
error={error}
required={required}
className={className}
wiring={wiring}
disabled={disabled}
>
<textarea
{...rest}
id={wiring.fieldId}
rows={rows}
disabled={disabled}
required={required}
aria-invalid={error ? true : undefined}
aria-describedby={wiring.describedBy}
className={cx(
"sh-field__control sh-field__control--textarea body-md",
inputClassName,
)}
/>
</FieldShell>
);
}
/* ----------------------------------------------------------------------------
USAGE (kept as a comment - the project has no Storybook)
import { Input, Textarea } from "~/components/ui/Input";
// Default. The id is generated, the label is wired to it with htmlFor.
<Input label="Full name" placeholder="Enter value..." />
// Required: a red asterisk for sighted users, the native `required`
// attribute (which is what actually gets announced) for everyone else.
<Input label="Email address" type="email" required autoComplete="email" />
// Hint and error together - aria-describedby points at both, in order.
<Input
label="Organisation"
hint="The legal name as it appears on your registration."
error="Enter the organisation's legal name."
defaultValue="United Efforts"
/>
// Disabled.
<Input label="Reference number" defaultValue="SHARP-ER/2026/014" disabled />
// Textarea - rows={4} is the 120px box from the design.
<Textarea label="Message" placeholder="Enter value..." />
<Textarea label="Message" rows={8} error="Tell us how we can help." />
// Contact form, controlled, with a submit button from the Button barrel.
const [email, setEmail] = useState("");
<Input
label="Email address"
type="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
error={touched && !email ? "Enter your email address." : undefined}
/>
------------------------------------------------------------------------- */
@@ -0,0 +1,2 @@
export { Input, Textarea } from "./Input";
export type { InputProps, TextareaProps } from "./Input";
@@ -0,0 +1,121 @@
import { Link } from "react-router";
import {
getCounterpartLocale,
getCounterpartPath,
getDirection,
LOCALE_ENDONYM,
LOCALE_SHORT_LABEL,
type Locale,
} from "./locale-path";
/* Reinforces the global :focus-visible rule in app.css with the same tier-2
focus tokens, so the ring is visible even if a caller overrides `outline`. */
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)]";
/** Padding presets. Kept as a prop rather than an overridable class, because
two padding utilities on one element are resolved by the order Tailwind
emits them in, not by the order they appear in the class attribute. */
const SIZE_CLASSES = {
sm: "px-sm py-2xs",
md: "min-h-[44px] px-md py-xs",
} as const;
export type LanguageSwitcherSize = keyof typeof SIZE_CLASSES;
export interface LanguageSwitcherProps {
/** The locale the reader is currently on. */
locale: Locale;
/** Current pathname, e.g. "/en/news-and-updates". */
pathname: string;
/** Query string to carry across the switch. Usually `location.search`. */
search?: string;
/** Fragment to carry across the switch. Usually `location.hash`. */
hash?: string;
/**
* Navigate with a full document request instead of a client transition.
* Off by default: the root route re-renders <html lang>/<html dir> on a
* client navigation, so a soft transition already lands on a correct
* document. Turn it on if a page needs the server to re-render first.
*/
reloadDocument?: boolean;
/** "sm" for the desktop bar, "md" for a 44px touch target in the drawer. */
size?: LanguageSwitcherSize;
className?: string;
}
/**
* Compact language toggle: shows the two-letter code of the OTHER language
* ("AR" while reading English, "EN" while reading Arabic) and links to the
* same page under that locale, preserving the rest of the path.
*
* The Figma navigation has no language switcher - a gap recorded in
* docs/SHARP-ER_Website Recommendations.docx - so this is designed to sit in
* the existing bar: label-md on a pill outline, matching the EU badge height.
*/
export function LanguageSwitcher({
locale,
pathname,
search,
hash,
reloadDocument = false,
size = "sm",
className = "",
}: LanguageSwitcherProps) {
const target = getCounterpartLocale(locale);
const href = getCounterpartPath(pathname, locale, { search, hash });
return (
<Link
to={href}
reloadDocument={reloadDocument}
hrefLang={target}
lang={target}
dir={getDirection(target)}
aria-label={LOCALE_ENDONYM[target]}
title={LOCALE_ENDONYM[target]}
data-language-switcher={target}
className={[
"label-md inline-flex shrink-0 items-center justify-center rounded-pill",
"border border-[var(--border-default)]",
SIZE_CLASSES[size],
"text-[var(--text-primary)] no-underline",
"transition-colors duration-[var(--motion-duration-fast)]",
"ease-[var(--motion-ease-standard)]",
"hover:border-[var(--border-strong)] hover:bg-[var(--surface-muted)]",
FOCUS_RING,
className,
]
.filter(Boolean)
.join(" ")}
>
<span aria-hidden="true">{LOCALE_SHORT_LABEL[target]}</span>
</Link>
);
}
export default LanguageSwitcher;
/* ---------------------------------------------------------------------------
USAGE
---------------------------------------------------------------------------
// Reading English at /en/news-and-updates?page=2 -> links to
// /ar/news-and-updates?page=2 and renders the pill "AR".
<LanguageSwitcher locale="en" pathname="/en/news-and-updates" search="?page=2" />
// Reading Arabic -> renders "EN", links to /en/...
<LanguageSwitcher locale="ar" pathname="/ar/contact" />
// Inside the mobile drawer, where a 44px touch target is wanted:
<LanguageSwitcher locale={locale} pathname={pathname} size="md" />
// Force a document navigation (server re-renders <html lang>/<html dir>):
<LanguageSwitcher locale={locale} pathname={pathname} reloadDocument />
// Wired from a route, using the router's own location:
const { pathname, search, hash } = useLocation();
<LanguageSwitcher locale={locale} pathname={pathname} search={search} hash={hash} />
------------------------------------------------------------------------ */
@@ -0,0 +1,26 @@
export { LanguageSwitcher, default } from "./LanguageSwitcher";
export type {
LanguageSwitcherProps,
LanguageSwitcherSize,
} from "./LanguageSwitcher";
export {
buildLocalizedHref,
DEFAULT_LOCALE,
getCounterpartLocale,
getCounterpartPath,
getDirection,
getLocaleFromPathname,
isCurrentPath,
isLocale,
LOCALE_ENDONYM,
LOCALE_SHORT_LABEL,
LOCALES,
localizePath,
stripLocale,
} from "./locale-path";
export type {
CounterpartPathOptions,
Direction,
Locale,
} from "./locale-path";
@@ -0,0 +1,140 @@
/* ============================================================================
LOCALE + PATH HELPERS
----------------------------------------------------------------------------
SHARP-ER is served with an EXPLICIT locale prefix on both languages:
/en/... and /ar/.... Bare "/" is redirected by the server on Accept-Language;
there is no ?lang= query parameter anywhere in the product.
These are pure functions on purpose - they run identically during SSR and in
the browser, and they are the single source of truth for "what is the other
language's URL for the page I am on".
========================================================================= */
export const LOCALES = ["en", "ar"] as const;
export type Locale = (typeof LOCALES)[number];
export type Direction = "ltr" | "rtl";
export const DEFAULT_LOCALE: Locale = "en";
/** Locale -> the value that belongs in <html dir>. */
const DIRECTION_BY_LOCALE: Record<Locale, Direction> = {
en: "ltr",
ar: "rtl",
};
/** Locale -> its endonym, used as the accessible name of the switcher link. */
export const LOCALE_ENDONYM: Record<Locale, string> = {
en: "English",
ar: "العربية",
};
/** Locale -> the two-letter badge the switcher shows. */
export const LOCALE_SHORT_LABEL: Record<Locale, string> = {
en: "EN",
ar: "AR",
};
export function isLocale(value: unknown): value is Locale {
return (
typeof value === "string" && (LOCALES as readonly string[]).includes(value)
);
}
export function getDirection(locale: Locale): Direction {
return DIRECTION_BY_LOCALE[locale];
}
/** The language a reader on `locale` can switch to. Two locales, so it flips. */
export function getCounterpartLocale(locale: Locale): Locale {
return locale === "en" ? "ar" : "en";
}
/**
* The locale prefix of a pathname, or null when there is none.
* "/ar/contact" -> "ar" "/about" -> null "/" -> null
*/
export function getLocaleFromPathname(pathname: string): Locale | null {
const first = pathname.replace(/^\/+/, "").split("/")[0]?.toLowerCase();
return isLocale(first) ? first : null;
}
/**
* The pathname with its locale prefix removed, always leading-slashed.
* "/ar/news/2026" -> "/news/2026" "/en" -> "/" "/news" -> "/news"
*/
export function stripLocale(pathname: string): string {
const locale = getLocaleFromPathname(pathname);
const withoutLeadingSlashes = pathname.replace(/^\/+/, "");
const rest = locale
? withoutLeadingSlashes.slice(locale.length)
: withoutLeadingSlashes;
if (rest === "" || rest === "/") return "/";
return rest.startsWith("/") ? rest : `/${rest}`;
}
/**
* The same page under `locale`. Safe to call with an already-prefixed path, an
* unprefixed path, or a bare "/" - the locale prefix is normalised either way.
* ("/en/contact", "ar") -> "/ar/contact"
* ("/contact", "ar") -> "/ar/contact"
* ("/", "en") -> "/en"
*/
export function localizePath(pathname: string, locale: Locale): string {
const rest = stripLocale(pathname);
return rest === "/" ? `/${locale}` : `/${locale}${rest}`;
}
export interface CounterpartPathOptions {
/** Query string to carry over, with or without the leading "?". */
search?: string;
/** Fragment to carry over, with or without the leading "#". */
hash?: string;
}
/**
* The counterpart-language URL for the current location: same page, other
* locale, query string and fragment preserved.
*/
export function getCounterpartPath(
pathname: string,
locale: Locale,
options: CounterpartPathOptions = {},
): string {
return buildLocalizedHref(
pathname,
getCounterpartLocale(locale),
options,
);
}
/** localizePath() plus the preserved search/hash. */
export function buildLocalizedHref(
pathname: string,
locale: Locale,
{ search = "", hash = "" }: CounterpartPathOptions = {},
): string {
const normalisedSearch =
search && search !== "?" ? (search.startsWith("?") ? search : `?${search}`) : "";
const normalisedHash =
hash && hash !== "#" ? (hash.startsWith("#") ? hash : `#${hash}`) : "";
return `${localizePath(pathname, locale)}${normalisedSearch}${normalisedHash}`;
}
/**
* True when `href` addresses the page currently at `pathname`. Trailing
* slashes and an absent locale prefix on either side are tolerated so a link
* built as "/en/about" still matches a location of "/en/about/".
*/
export function isCurrentPath(href: string, pathname: string): boolean {
return normalizeForCompare(href) === normalizeForCompare(pathname);
}
function normalizeForCompare(value: string): string {
const pathOnly = value.split(/[?#]/)[0] ?? value;
const withoutTrailingSlash = pathOnly.replace(/\/+$/, "");
return withoutTrailingSlash === "" ? "/" : withoutTrailingSlash.toLowerCase();
}
@@ -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>;