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