initial commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user