363 lines
11 KiB
TypeScript
363 lines
11 KiB
TypeScript
/* ============================================================================
|
|
PAGE KIT - section primitives
|
|
----------------------------------------------------------------------------
|
|
The handful of layout pieces every page module repeats: the centred content
|
|
column, a vertical rhythm for sections, a section header, the page hero and
|
|
the empty state that renders while the CMS has no entry yet.
|
|
|
|
RULES THIS FILE FOLLOWS
|
|
-----------------------
|
|
* tier-2 semantic tokens only - no hex, no tier-1 primitive, no raw type
|
|
size (the type role utilities carry family/size/leading/tracking/weight)
|
|
* logical properties only (ps-/pe-/ms-/me-/start-/end-, text-start) so the
|
|
Arabic pages mirror without a single `rtl:` override
|
|
* no component here duplicates Button / Input / Card - those are imported
|
|
from ~/components/ui by the pages themselves
|
|
========================================================================= */
|
|
|
|
import type { ReactNode } from "react";
|
|
|
|
export function cx(...parts: Array<string | false | null | undefined>): string {
|
|
return parts.filter(Boolean).join(" ");
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
LAYOUT
|
|
------------------------------------------------------------------------ */
|
|
|
|
export interface ContainerProps {
|
|
children: ReactNode;
|
|
className?: string;
|
|
}
|
|
|
|
/** The centred content column every section shares. */
|
|
export function Container({ children, className }: ContainerProps) {
|
|
return (
|
|
<div
|
|
className={cx(
|
|
"mx-auto w-full max-w-[var(--layout-content-max)] px-[var(--layout-gutter)] lg:px-[var(--layout-gutter-lg)]",
|
|
className,
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface PageSectionProps {
|
|
children: ReactNode;
|
|
id?: string;
|
|
/** Flip this section (and only this section) to the other surface. */
|
|
surface?: "dark" | "light";
|
|
className?: string;
|
|
/** Content is placed directly in the section, without the centred column. */
|
|
bleed?: boolean;
|
|
"aria-labelledby"?: string;
|
|
}
|
|
|
|
/**
|
|
* One band of the page. `surface` re-points the whole tier-2 palette for the
|
|
* subtree, so a light band needs no colour classes of its own.
|
|
*/
|
|
export function PageSection({
|
|
children,
|
|
id,
|
|
surface,
|
|
className,
|
|
bleed = false,
|
|
...rest
|
|
}: PageSectionProps) {
|
|
return (
|
|
<section
|
|
id={id}
|
|
{...(surface ? { "data-surface": surface } : {})}
|
|
className={cx("py-3xl md:py-4xl", className)}
|
|
{...rest}
|
|
>
|
|
{bleed ? children : <Container>{children}</Container>}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/** A readable measure for long-form prose. */
|
|
export function Prose({ children, className }: ContainerProps) {
|
|
return (
|
|
<div className={cx("flex max-w-[var(--layout-prose-max)] flex-col gap-md", className)}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
HEADINGS
|
|
------------------------------------------------------------------------ */
|
|
|
|
export interface SectionHeaderProps {
|
|
title: string;
|
|
id?: string;
|
|
eyebrow?: string;
|
|
lead?: string;
|
|
titleAs?: "h1" | "h2" | "h3";
|
|
/** Figma centres the home page's section headers; inner pages read start. */
|
|
align?: "start" | "center";
|
|
className?: string;
|
|
}
|
|
|
|
export function SectionHeader({
|
|
title,
|
|
id,
|
|
eyebrow,
|
|
lead,
|
|
titleAs = "h2",
|
|
align = "start",
|
|
className,
|
|
}: SectionHeaderProps) {
|
|
const Title = titleAs;
|
|
const centred = align === "center";
|
|
|
|
return (
|
|
<header
|
|
className={cx(
|
|
"flex flex-col gap-sm",
|
|
centred ? "items-center text-center" : "items-start text-start",
|
|
className,
|
|
)}
|
|
>
|
|
{eyebrow ? (
|
|
<p className="label-md uppercase text-[var(--text-accent)]">{eyebrow}</p>
|
|
) : null}
|
|
|
|
<Title
|
|
id={id}
|
|
className={cx(
|
|
"text-[var(--text-primary)]",
|
|
titleAs === "h1" ? "heading-h1 md:display-sm" : "heading-h2",
|
|
)}
|
|
>
|
|
{title}
|
|
</Title>
|
|
|
|
{lead ? (
|
|
<p className="body-lg max-w-[var(--layout-prose-max)] text-[var(--text-secondary)]">
|
|
{lead}
|
|
</p>
|
|
) : null}
|
|
</header>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
HERO
|
|
------------------------------------------------------------------------ */
|
|
|
|
export interface PageHeroProps {
|
|
title: string;
|
|
eyebrow?: string;
|
|
lead?: string;
|
|
/** Buttons, rendered inline-end of the headline on wide viewports. */
|
|
actions?: ReactNode;
|
|
/** Wide picture below the headline row, as in Figma 253:4800. */
|
|
media?: { src: string; alt: string; width?: number; height?: number };
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* The page hero - Figma "Hero header" (253:4800): headline on the inline-start
|
|
* side, the call-to-action group on the inline-end side, and a full-width
|
|
* picture underneath. The picture is only rendered when the CMS supplies one;
|
|
* an empty media box would otherwise ship as a grey rectangle.
|
|
*/
|
|
export function PageHero({
|
|
title,
|
|
eyebrow,
|
|
lead,
|
|
actions,
|
|
media,
|
|
className,
|
|
}: PageHeroProps) {
|
|
return (
|
|
<section className={cx("pt-2xl pb-3xl md:pt-3xl md:pb-4xl", className)}>
|
|
<Container className="flex flex-col gap-2xl">
|
|
<div className="flex flex-col gap-lg lg:flex-row lg:items-end lg:justify-between">
|
|
<div className="flex max-w-[46rem] flex-col gap-sm">
|
|
{eyebrow ? (
|
|
<p className="label-md uppercase text-[var(--text-accent)]">{eyebrow}</p>
|
|
) : null}
|
|
|
|
<h1 className="heading-h1 text-[var(--text-primary)] md:display-sm">
|
|
{title}
|
|
</h1>
|
|
|
|
{lead ? (
|
|
<p className="body-lg max-w-[var(--layout-prose-max)] text-[var(--text-secondary)]">
|
|
{lead}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
{actions ? (
|
|
<div className="flex flex-wrap items-center gap-md lg:justify-end">
|
|
{actions}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{media ? (
|
|
<img
|
|
src={media.src}
|
|
alt={media.alt}
|
|
{...(media.width ? { width: media.width } : {})}
|
|
{...(media.height ? { height: media.height } : {})}
|
|
fetchPriority="high"
|
|
className="aspect-[16/9] w-full max-w-full rounded-surface object-cover"
|
|
/>
|
|
) : null}
|
|
</Container>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
PANELS
|
|
------------------------------------------------------------------------ */
|
|
|
|
export interface InfoPanelProps {
|
|
title: string;
|
|
children?: ReactNode;
|
|
/** Small line above the title - a number, a partner name, a role. */
|
|
eyebrow?: string;
|
|
titleAs?: "h2" | "h3" | "h4";
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* A titled block of text with no destination - a pillar, a workstream, a
|
|
* principle. Deliberately NOT a Card: every card in the design system is a
|
|
* link, and these are not.
|
|
*/
|
|
export function InfoPanel({
|
|
title,
|
|
children,
|
|
eyebrow,
|
|
titleAs = "h3",
|
|
className,
|
|
}: InfoPanelProps) {
|
|
const Title = titleAs;
|
|
|
|
return (
|
|
<div
|
|
className={cx(
|
|
"flex h-full flex-col gap-xs rounded-card border border-[var(--border-subtle)] bg-[var(--surface-raised)] p-lg",
|
|
className,
|
|
)}
|
|
>
|
|
{eyebrow ? (
|
|
<p className="label-md uppercase text-[var(--text-accent)]">{eyebrow}</p>
|
|
) : null}
|
|
<Title className="body-lg-bold text-[var(--text-primary)]">{title}</Title>
|
|
{children ? (
|
|
<div className="body-md text-[var(--text-secondary)]">{children}</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface EmptyStateProps {
|
|
/** Localised - never an English default. */
|
|
message: string;
|
|
title?: string;
|
|
className?: string;
|
|
children?: ReactNode;
|
|
}
|
|
|
|
/**
|
|
* What renders where the CMS has nothing yet. It states plainly that the
|
|
* content is still to come rather than showing an empty grid, and it is always
|
|
* passed a localised string by the caller.
|
|
*/
|
|
export function EmptyState({ message, title, className, children }: EmptyStateProps) {
|
|
return (
|
|
<div
|
|
className={cx(
|
|
"flex flex-col items-start gap-sm rounded-card border border-dashed border-[var(--border-default)] bg-[var(--surface-sunken)] p-lg text-start",
|
|
className,
|
|
)}
|
|
>
|
|
{title ? (
|
|
<p className="body-md-bold text-[var(--text-primary)]">{title}</p>
|
|
) : null}
|
|
<p className="body-md text-[var(--text-secondary)]">{message}</p>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
SMALL PIECES
|
|
------------------------------------------------------------------------ */
|
|
|
|
export interface ValueChipsProps {
|
|
values: readonly string[];
|
|
label?: string;
|
|
className?: string;
|
|
}
|
|
|
|
/** The "What guides us" row: Dialogue · Community Leadership · Learning · … */
|
|
export function ValueChips({ values, label, className }: ValueChipsProps) {
|
|
if (values.length === 0) return null;
|
|
|
|
return (
|
|
<div className={cx("flex flex-col gap-sm", className)}>
|
|
{label ? (
|
|
<p className="label-md uppercase text-[var(--text-tertiary)]">{label}</p>
|
|
) : null}
|
|
<ul className="flex flex-wrap gap-sm">
|
|
{values.map((value) => (
|
|
<li
|
|
key={value}
|
|
className="rounded-pill border border-[var(--border-accent)] bg-[var(--surface-accent-subtle)] px-md py-2xs body-sm-bold text-[var(--text-primary)]"
|
|
>
|
|
{value}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface StatStripProps {
|
|
stats: readonly { value: string; label: string }[];
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* The figures band. `value` is authored in the CMS as a string, already
|
|
* formatted for its locale, because the site must never invent or re-derive a
|
|
* project figure - see the note the pages render when this list is empty.
|
|
*/
|
|
export function StatStrip({ stats, className }: StatStripProps) {
|
|
if (stats.length === 0) return null;
|
|
|
|
return (
|
|
<dl
|
|
className={cx(
|
|
"grid grid-cols-2 gap-lg border-y border-[var(--border-subtle)] py-xl md:grid-cols-4",
|
|
className,
|
|
)}
|
|
>
|
|
{stats.map((stat) => (
|
|
/* Source order is term-then-description, as the spec requires; the
|
|
reversed column paints the figure above its label without repeating
|
|
the label for a screen reader. */
|
|
<div
|
|
key={`${stat.value}-${stat.label}`}
|
|
className="flex flex-col-reverse gap-2xs text-start"
|
|
>
|
|
<dt className="body-sm text-[var(--text-secondary)]">{stat.label}</dt>
|
|
<dd className="heading-h1 text-[var(--text-accent)]">{stat.value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
);
|
|
}
|