2026-09-11 02:46:56 +02:00
|
|
|
|
/* ============================================================================
|
|
|
|
|
|
LISTING UI
|
|
|
|
|
|
----------------------------------------------------------------------------
|
|
|
|
|
|
The pieces the three listings share: page header, filter bar, result
|
|
|
|
|
|
summary, empty state, tabs, pagination, metadata list and detail-page
|
|
|
|
|
|
scaffolding.
|
|
|
|
|
|
|
|
|
|
|
|
None of this duplicates the component library - Button, Input and the Card
|
|
|
|
|
|
family come from ~/components/ui and are used as they are; what is here is
|
|
|
|
|
|
layout and the handful of controls (a native <select>, a tab strip, a
|
|
|
|
|
|
pagination list) the library does not ship.
|
|
|
|
|
|
|
|
|
|
|
|
RULES FOLLOWED THROUGHOUT
|
|
|
|
|
|
* colour, radius, spacing and motion are tier-2 semantic tokens only,
|
|
|
|
|
|
spent through the Tailwind bridge (bg-surface-raised, text-text-primary)
|
|
|
|
|
|
or as arbitrary values (shadow-[var(--elevation-card)]);
|
|
|
|
|
|
* every directional property is logical (ps-/pe-/ms-/me-/start-/end-,
|
|
|
|
|
|
border-s), so the Arabic build mirrors with no second stylesheet;
|
|
|
|
|
|
* type comes from the published roles - display-sm, heading-h2, body-md,
|
|
|
|
|
|
label-md - never a raw font-size.
|
|
|
|
|
|
========================================================================= */
|
|
|
|
|
|
|
|
|
|
|
|
import type { ReactNode } from "react";
|
|
|
|
|
|
import { Form, Link, useSubmit } from "react-router";
|
|
|
|
|
|
|
|
|
|
|
|
import { ArrowIcon, Button } from "~/components/ui/Button";
|
|
|
|
|
|
import { Input } from "~/components/ui/Input";
|
|
|
|
|
|
import type { Translator } from "~/i18n";
|
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
|
import { Container, PageHero, type HeroWash } from "../_page-kit/sections";
|
2026-09-11 02:46:56 +02:00
|
|
|
|
import type { PaginationModel, SelectOption } from "./listing";
|
|
|
|
|
|
import { withParam } from "./listing";
|
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
|
import "../_page-kit/motion.css";
|
|
|
|
|
|
|
2026-09-11 02:46:56 +02:00
|
|
|
|
function cx(...parts: Array<string | false | null | undefined>): string {
|
|
|
|
|
|
return parts.filter(Boolean).join(" ");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
|
PAGE SCAFFOLDING
|
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
|
|
/** The centred column every page in this section uses. */
|
|
|
|
|
|
export function PageShell({
|
|
|
|
|
|
children,
|
|
|
|
|
|
className,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
|
className?: string;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={cx(
|
|
|
|
|
|
"mx-auto flex w-full max-w-[var(--layout-content-max)] flex-col",
|
|
|
|
|
|
"gap-2xl px-[var(--layout-gutter)] py-3xl",
|
|
|
|
|
|
className,
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* The redesigned listing hero (2026 drafts): an accent eyebrow over the
|
|
|
|
|
|
* headline, the standfirst, the two slow washes drifting behind it, the
|
|
|
|
|
|
* staggered entrance, and a rule drawn on under the whole band.
|
|
|
|
|
|
*
|
|
|
|
|
|
* It is `PageHero` from the page kit, not a second hero: the same component
|
|
|
|
|
|
* the home page uses, with `ambient` and `reveal` turned on, so every page on
|
|
|
|
|
|
* the site arrives the same way and there is one hero to maintain.
|
|
|
|
|
|
*
|
|
|
|
|
|
* `children` is the slot under the headline row - the knowledge hub puts its
|
|
|
|
|
|
* search box there; `actions` is the page kit's own inline-end slot.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function ListingHero({
|
|
|
|
|
|
heading,
|
|
|
|
|
|
intro,
|
|
|
|
|
|
eyebrow,
|
|
|
|
|
|
actions,
|
|
|
|
|
|
ambient,
|
|
|
|
|
|
children,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
heading: string;
|
|
|
|
|
|
intro?: string;
|
|
|
|
|
|
eyebrow?: string;
|
|
|
|
|
|
actions?: ReactNode;
|
|
|
|
|
|
/** This page's own drifting wash - every draft positions its own. */
|
|
|
|
|
|
ambient?: readonly HeroWash[];
|
|
|
|
|
|
children?: ReactNode;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-col gap-2xl">
|
|
|
|
|
|
<PageHero
|
|
|
|
|
|
title={heading}
|
|
|
|
|
|
{...(eyebrow ? { eyebrow } : {})}
|
|
|
|
|
|
{...(intro ? { lead: intro } : {})}
|
|
|
|
|
|
{...(actions ? { actions } : {})}
|
|
|
|
|
|
{...(ambient ? { ambient } : {})}
|
|
|
|
|
|
reveal
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{children ? <Container>{children}</Container> : null}
|
|
|
|
|
|
|
|
|
|
|
|
{/* The draft closes the hero with a hairline. Drawn on from the inline
|
|
|
|
|
|
start rather than simply appearing - the `barGrow` of the drafts. */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
className="sh-bar-grow h-px w-full bg-[var(--border-subtle)]"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 02:46:56 +02:00
|
|
|
|
export function PageHeader({
|
|
|
|
|
|
heading,
|
|
|
|
|
|
intro,
|
|
|
|
|
|
eyebrow,
|
|
|
|
|
|
children,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
heading: string;
|
|
|
|
|
|
intro?: string;
|
|
|
|
|
|
eyebrow?: string;
|
|
|
|
|
|
children?: ReactNode;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<header className="flex max-w-[var(--layout-prose-max)] flex-col gap-md">
|
|
|
|
|
|
{eyebrow ? <p className="label-md uppercase text-text-accent">{eyebrow}</p> : null}
|
|
|
|
|
|
<h1 className="display-sm text-text-primary">{heading}</h1>
|
|
|
|
|
|
{intro ? <p className="body-lg text-text-secondary">{intro}</p> : null}
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</header>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
|
FILTERS
|
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* A native <select>. Native on purpose: it is keyboard- and screen-reader
|
|
|
|
|
|
* correct in every browser, it works before hydration, and `color-scheme` on
|
|
|
|
|
|
* the surface already makes the dropdown itself paint dark or light.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function FilterSelect({
|
|
|
|
|
|
name,
|
|
|
|
|
|
label,
|
|
|
|
|
|
value,
|
|
|
|
|
|
options,
|
|
|
|
|
|
allLabel,
|
|
|
|
|
|
disabled,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
value: string;
|
|
|
|
|
|
options: readonly SelectOption[];
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Label of the "no filter" option, e.g. "All". Omit it for a control that
|
|
|
|
|
|
* always has a value - a sort order has no "unset" state, and offering one
|
|
|
|
|
|
* would duplicate whichever option is the default.
|
|
|
|
|
|
*/
|
|
|
|
|
|
allLabel?: string;
|
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex min-w-0 flex-col gap-2xs">
|
|
|
|
|
|
<label className="label-md text-text-tertiary" htmlFor={`filter-${name}`}>
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
id={`filter-${name}`}
|
|
|
|
|
|
name={name}
|
|
|
|
|
|
defaultValue={value}
|
|
|
|
|
|
disabled={disabled || options.length === 0}
|
|
|
|
|
|
className={cx(
|
|
|
|
|
|
"body-md w-full rounded-[var(--radius-control)] border bg-field-bg",
|
|
|
|
|
|
"border-field-border px-md py-sm text-[var(--field-fg)]",
|
|
|
|
|
|
"transition-colors duration-[var(--motion-duration-fast)]",
|
|
|
|
|
|
"ease-[var(--motion-ease-standard)] hover:border-[var(--field-border-hover)]",
|
|
|
|
|
|
"disabled:cursor-not-allowed disabled:bg-[var(--field-bg-disabled)]",
|
|
|
|
|
|
"disabled:text-[var(--field-fg-disabled)]",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{allLabel === undefined ? null : <option value="">{allLabel}</option>}
|
|
|
|
|
|
{options.map((option) => (
|
|
|
|
|
|
<option key={option.value} value={option.value}>
|
|
|
|
|
|
{option.label}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The filter bar. A real `<form method="get">`, so every combination of
|
|
|
|
|
|
* filters is a URL: shareable, indexable, and usable with scripting off. When
|
|
|
|
|
|
* scripting is on, changing a control submits immediately; the Apply button
|
|
|
|
|
|
* stays for the case where it is not.
|
|
|
|
|
|
*
|
|
|
|
|
|
* `page` is deliberately NOT a field: submitting the form drops it, which
|
|
|
|
|
|
* returns the reader to page 1 of the new result set.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function FilterBar({
|
|
|
|
|
|
legend,
|
|
|
|
|
|
action,
|
|
|
|
|
|
applyLabel,
|
|
|
|
|
|
clearLabel,
|
|
|
|
|
|
clearHref,
|
|
|
|
|
|
showClear,
|
|
|
|
|
|
children,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
legend: string;
|
|
|
|
|
|
/** The listing's own pathname - keeps the GET target explicit. */
|
|
|
|
|
|
action: string;
|
|
|
|
|
|
applyLabel: string;
|
|
|
|
|
|
clearLabel: string;
|
|
|
|
|
|
clearHref: string;
|
|
|
|
|
|
showClear: boolean;
|
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const submit = useSubmit();
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Form
|
|
|
|
|
|
method="get"
|
|
|
|
|
|
action={action}
|
|
|
|
|
|
aria-label={legend}
|
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
// Progressive enhancement: with JS, changing a control applies at once.
|
|
|
|
|
|
if (event.target instanceof HTMLSelectElement) {
|
|
|
|
|
|
submit(event.currentTarget, { method: "get" });
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
className={cx(
|
|
|
|
|
|
"flex flex-col gap-md rounded-card border border-border-subtle",
|
|
|
|
|
|
"bg-surface-raised p-lg shadow-[var(--elevation-raised)]",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="grid gap-md sm:grid-cols-2 lg:grid-cols-4">{children}</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-wrap items-center gap-sm">
|
|
|
|
|
|
<Button type="submit" variant="secondary" size="sm">
|
|
|
|
|
|
{applyLabel}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
{showClear ? (
|
|
|
|
|
|
<Button as={Link} to={clearHref} variant="ghost" size="sm">
|
|
|
|
|
|
{clearLabel}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** The search box of the knowledge hub. Submits with the surrounding form. */
|
|
|
|
|
|
export function FilterSearch({
|
|
|
|
|
|
label,
|
|
|
|
|
|
placeholder,
|
|
|
|
|
|
defaultValue,
|
|
|
|
|
|
name = "q",
|
|
|
|
|
|
}: {
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
placeholder: string;
|
|
|
|
|
|
defaultValue: string;
|
|
|
|
|
|
name?: string;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="search"
|
|
|
|
|
|
name={name}
|
|
|
|
|
|
label={label}
|
|
|
|
|
|
placeholder={placeholder}
|
|
|
|
|
|
defaultValue={defaultValue}
|
|
|
|
|
|
className="min-w-0"
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
|
RESULTS
|
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
|
|
/** "Showing 1–12 of 34". Announced politely when a filter changes the count. */
|
|
|
|
|
|
export function ResultSummary({
|
|
|
|
|
|
pagination,
|
|
|
|
|
|
t,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
pagination: PaginationModel;
|
|
|
|
|
|
t: Translator;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<p className="body-sm text-text-tertiary" role="status">
|
|
|
|
|
|
{pagination.total === 0
|
|
|
|
|
|
? null
|
|
|
|
|
|
: t("pagination.showing", {
|
|
|
|
|
|
from: pagination.from,
|
|
|
|
|
|
to: pagination.to,
|
|
|
|
|
|
total: pagination.total,
|
|
|
|
|
|
})}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function EmptyState({
|
|
|
|
|
|
title,
|
|
|
|
|
|
body,
|
|
|
|
|
|
children,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
body: string;
|
|
|
|
|
|
children?: ReactNode;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={cx(
|
2026-09-16 20:32:56 +02:00
|
|
|
|
// The drafts draw the empty panel as a dashed outline on whatever band
|
|
|
|
|
|
// it sits on - no fill of its own, so it is correct on the #0B0B0A
|
|
|
|
|
|
// ground and inside a #1D1D1B band alike.
|
2026-09-11 02:46:56 +02:00
|
|
|
|
"flex flex-col items-start gap-md rounded-card border border-dashed",
|
2026-09-16 20:32:56 +02:00
|
|
|
|
"border-border-default p-2xl",
|
2026-09-11 02:46:56 +02:00
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<p className="heading-h3 text-text-primary">{title}</p>
|
|
|
|
|
|
<p className="body-md max-w-[var(--layout-prose-max)] text-text-secondary">{body}</p>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
|
TABS
|
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
|
|
export interface TabLink {
|
|
|
|
|
|
readonly id: string;
|
|
|
|
|
|
readonly label: string;
|
|
|
|
|
|
readonly href: string;
|
|
|
|
|
|
readonly isCurrent: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The News tab strip. Links rather than buttons, because each tab is its own
|
|
|
|
|
|
* URL - the reader can share "From the Network" and a crawler can reach it.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function TabLinks({ label, tabs }: { label: string; tabs: readonly TabLink[] }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<nav aria-label={label}>
|
|
|
|
|
|
<ul className="flex flex-wrap gap-xs border-b border-border-subtle">
|
|
|
|
|
|
{tabs.map((tab) => (
|
|
|
|
|
|
<li key={tab.id}>
|
|
|
|
|
|
<Link
|
|
|
|
|
|
to={tab.href}
|
|
|
|
|
|
aria-current={tab.isCurrent ? "page" : undefined}
|
|
|
|
|
|
className={cx(
|
|
|
|
|
|
"-mb-px inline-block border-b-2 px-md py-sm body-md-bold",
|
|
|
|
|
|
"transition-colors duration-[var(--motion-duration-fast)]",
|
|
|
|
|
|
"ease-[var(--motion-ease-standard)]",
|
|
|
|
|
|
tab.isCurrent
|
|
|
|
|
|
? "border-border-accent text-text-accent"
|
|
|
|
|
|
: "border-transparent text-text-secondary hover:text-text-primary",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{tab.label}
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
|
PAGINATION
|
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
|
|
const PAGE_LINK = cx(
|
|
|
|
|
|
"inline-flex min-w-[2.75rem] items-center justify-center rounded-[var(--radius-control)]",
|
|
|
|
|
|
"border px-sm py-2xs body-sm-bold transition-colors",
|
|
|
|
|
|
"duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]",
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Numbered pagination, rendered as ordinary links so the whole archive is
|
|
|
|
|
|
* crawlable. The arrows follow reading order: `ArrowIcon` mirrors itself in
|
|
|
|
|
|
* RTL, so "next" points left on /ar without a second component.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function Pagination({
|
|
|
|
|
|
model,
|
|
|
|
|
|
pathname,
|
|
|
|
|
|
params,
|
|
|
|
|
|
t,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
model: PaginationModel;
|
|
|
|
|
|
pathname: string;
|
|
|
|
|
|
params: URLSearchParams;
|
|
|
|
|
|
t: Translator;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
if (model.pageCount <= 1) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const href = (page: number) => `${pathname}${withParam(params, "page", page)}`;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<nav aria-label={t("pagination.label")} className="flex flex-col gap-md">
|
|
|
|
|
|
<ul className="flex flex-wrap items-center gap-xs">
|
|
|
|
|
|
<li>
|
|
|
|
|
|
{model.previous === undefined ? (
|
|
|
|
|
|
<Button variant="ghost" size="sm" disabled iconStart={<ArrowIcon direction="backward" />}>
|
|
|
|
|
|
{t("pagination.previous")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
as={Link}
|
|
|
|
|
|
to={href(model.previous)}
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
iconStart={<ArrowIcon direction="backward" />}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t("pagination.previous")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
|
|
|
|
|
|
{model.pages.map((entry, index) => {
|
|
|
|
|
|
const previousPage = model.pages[index - 1]?.page;
|
|
|
|
|
|
const gap = previousPage !== undefined && entry.page - previousPage > 1;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<li key={entry.page} className="flex items-center gap-xs">
|
|
|
|
|
|
{gap ? (
|
|
|
|
|
|
<span aria-hidden="true" className="body-sm text-text-tertiary">
|
|
|
|
|
|
…
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
<Link
|
|
|
|
|
|
to={href(entry.page)}
|
|
|
|
|
|
aria-current={entry.isCurrent ? "page" : undefined}
|
|
|
|
|
|
aria-label={t("pagination.goToPage", { page: entry.page })}
|
|
|
|
|
|
className={cx(
|
|
|
|
|
|
PAGE_LINK,
|
|
|
|
|
|
entry.isCurrent
|
|
|
|
|
|
? "border-border-accent bg-surface-accent-subtle text-text-accent"
|
|
|
|
|
|
: "border-border-subtle text-text-secondary hover:border-border-default hover:text-text-primary",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{entry.page}
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
|
|
<li>
|
|
|
|
|
|
{model.next === undefined ? (
|
|
|
|
|
|
<Button variant="ghost" size="sm" disabled iconEnd={<ArrowIcon />}>
|
|
|
|
|
|
{t("pagination.next")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
as={Link}
|
|
|
|
|
|
to={href(model.next)}
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
iconEnd={<ArrowIcon />}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t("pagination.next")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
|
|
|
|
|
|
<p className="body-sm text-text-tertiary">
|
|
|
|
|
|
{t("pagination.page", { page: model.page, pageCount: model.pageCount })}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
|
DETAIL PAGES
|
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
|
|
export interface MetaItem {
|
|
|
|
|
|
readonly label: string;
|
|
|
|
|
|
readonly value: ReactNode;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** The "Published / Author / Partner organisation" block of a detail page. */
|
|
|
|
|
|
export function MetaList({ heading, items }: { heading: string; items: readonly MetaItem[] }) {
|
|
|
|
|
|
if (items.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<section
|
|
|
|
|
|
aria-label={heading}
|
|
|
|
|
|
className={cx(
|
|
|
|
|
|
"flex flex-col gap-md rounded-card border border-border-subtle",
|
|
|
|
|
|
"bg-surface-raised p-lg",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<h2 className="label-md uppercase text-text-tertiary">{heading}</h2>
|
|
|
|
|
|
<dl className="flex flex-col gap-sm">
|
|
|
|
|
|
{items.map((item) => (
|
|
|
|
|
|
<div key={item.label} className="flex flex-col gap-2xs">
|
|
|
|
|
|
<dt className="body-sm text-text-tertiary">{item.label}</dt>
|
|
|
|
|
|
<dd className="body-md-bold text-text-primary">{item.value}</dd>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</dl>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Topic chips. Each one links back into the listing, pre-filtered. */
|
|
|
|
|
|
export function TopicChips({
|
|
|
|
|
|
heading,
|
|
|
|
|
|
topics,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
heading: string;
|
|
|
|
|
|
topics: readonly { label: string; href?: string }[];
|
|
|
|
|
|
}) {
|
|
|
|
|
|
if (topics.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<section aria-label={heading} className="flex flex-col gap-xs">
|
|
|
|
|
|
<h2 className="label-md uppercase text-text-tertiary">{heading}</h2>
|
|
|
|
|
|
<ul className="flex flex-wrap gap-xs">
|
|
|
|
|
|
{topics.map((topic) => (
|
|
|
|
|
|
<li key={topic.label}>
|
|
|
|
|
|
{topic.href ? (
|
|
|
|
|
|
<Link
|
|
|
|
|
|
to={topic.href}
|
|
|
|
|
|
className={cx(
|
|
|
|
|
|
"inline-block rounded-pill border border-border-subtle px-sm py-2xs",
|
|
|
|
|
|
"body-sm text-text-secondary transition-colors",
|
|
|
|
|
|
"duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]",
|
|
|
|
|
|
"hover:border-border-accent hover:text-text-accent",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{topic.label}
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="inline-block rounded-pill border border-border-subtle px-sm py-2xs body-sm text-text-secondary">
|
|
|
|
|
|
{topic.label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** The link out of a detail page, arrow pointing against reading order. */
|
|
|
|
|
|
export function BackLink({ to, label }: { to: string; label: string }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
as={Link}
|
|
|
|
|
|
to={to}
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
iconStart={<ArrowIcon direction="backward" />}
|
|
|
|
|
|
>
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** A short, dot-separated metadata line under a card or a detail title. */
|
|
|
|
|
|
export function MetaLine({ items }: { items: readonly string[] }) {
|
|
|
|
|
|
const present = items.filter(Boolean);
|
|
|
|
|
|
if (present.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<p className="body-sm text-text-tertiary">
|
|
|
|
|
|
{present.map((item, index) => (
|
|
|
|
|
|
<span key={`${item}-${index}`}>
|
|
|
|
|
|
{index > 0 ? <span aria-hidden="true"> · </span> : null}
|
|
|
|
|
|
{item}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-09-16 20:32:56 +02:00
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
|
PILL FILTERS
|
|
|
|
|
|
----------------------------------------------------------------------------
|
|
|
|
|
|
The 2026 drafts replace the underlined tab strip with a row of pills on the
|
|
|
|
|
|
calendar, the calls list and the activities index: a dot marks the pill in
|
|
|
|
|
|
force and the rest are outlined. They are LINKS, exactly as the tab strip
|
|
|
|
|
|
was - each combination of filters is its own URL, so it survives a share, a
|
|
|
|
|
|
bookmark, the back button and a crawler that only follows links.
|
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
|
|
export interface FilterPill {
|
|
|
|
|
|
readonly id: string;
|
|
|
|
|
|
readonly label: string;
|
|
|
|
|
|
readonly href: string;
|
|
|
|
|
|
readonly isCurrent: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function FilterPills({
|
|
|
|
|
|
label,
|
|
|
|
|
|
pills,
|
|
|
|
|
|
shape = "pill",
|
|
|
|
|
|
hideLabel = false,
|
|
|
|
|
|
children,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
pills: readonly FilterPill[];
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The drafts draw two shapes on the same row: the filters themselves are
|
|
|
|
|
|
* fully rounded, while the calendar's List / Month switch is a pair of
|
|
|
|
|
|
* square controls. Same markup, same behaviour, one radius token apart.
|
|
|
|
|
|
*/
|
|
|
|
|
|
shape?: "pill" | "control";
|
|
|
|
|
|
/** The view switch carries no visible caption in the draft. */
|
|
|
|
|
|
hideLabel?: boolean;
|
|
|
|
|
|
/** Anything that belongs on the same row - a region select, a view switch. */
|
|
|
|
|
|
children?: ReactNode;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const radius = shape === "pill" ? "rounded-pill" : "rounded-[var(--radius-control)]";
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<nav aria-label={label} className="flex flex-wrap items-center gap-sm">
|
|
|
|
|
|
<p
|
|
|
|
|
|
className={cx(
|
|
|
|
|
|
"label-md uppercase text-text-tertiary",
|
|
|
|
|
|
hideLabel && "sr-only",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<ul className="flex flex-wrap items-center gap-xs">
|
|
|
|
|
|
{pills.map((pill) => (
|
|
|
|
|
|
<li key={pill.id}>
|
|
|
|
|
|
<Link
|
|
|
|
|
|
to={pill.href}
|
|
|
|
|
|
prefetch="intent"
|
|
|
|
|
|
aria-current={pill.isCurrent ? "page" : undefined}
|
|
|
|
|
|
className={cx(
|
|
|
|
|
|
"inline-flex items-center gap-xs border px-md py-xs",
|
|
|
|
|
|
radius,
|
|
|
|
|
|
"body-sm-bold transition-colors",
|
|
|
|
|
|
"duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]",
|
|
|
|
|
|
pill.isCurrent
|
|
|
|
|
|
? "border-border-accent bg-surface-accent-subtle text-text-primary"
|
|
|
|
|
|
: "border-border-default text-text-secondary hover:border-border-accent hover:text-text-primary",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* Decorative: `aria-current` already says which pill is on. */}
|
|
|
|
|
|
{pill.isCurrent ? (
|
|
|
|
|
|
<span
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
className="size-[7px] shrink-0 rounded-pill bg-[var(--surface-accent)]"
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{pill.label}
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* A single `<select>` that navigates on change, styled as one of the pills
|
|
|
|
|
|
* beside it. A real `<form method="get">`, so it still applies with scripting
|
|
|
|
|
|
* off; `keep` carries the filters that are not this one, because a GET submit
|
|
|
|
|
|
* replaces the whole query string.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function PillSelect({
|
|
|
|
|
|
name,
|
|
|
|
|
|
label,
|
|
|
|
|
|
value,
|
|
|
|
|
|
options,
|
|
|
|
|
|
allLabel,
|
|
|
|
|
|
applyLabel,
|
|
|
|
|
|
action,
|
|
|
|
|
|
keep,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
value: string;
|
|
|
|
|
|
options: readonly SelectOption[];
|
|
|
|
|
|
allLabel: string;
|
|
|
|
|
|
/** Label of the fallback submit - shown because a <select> cannot submit. */
|
|
|
|
|
|
applyLabel: string;
|
|
|
|
|
|
action: string;
|
|
|
|
|
|
/** Other query parameters to preserve, as [name, value] pairs. */
|
|
|
|
|
|
keep?: readonly (readonly [string, string])[];
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const submit = useSubmit();
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Form
|
|
|
|
|
|
method="get"
|
|
|
|
|
|
action={action}
|
|
|
|
|
|
aria-label={label}
|
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
if (event.target instanceof HTMLSelectElement) {
|
|
|
|
|
|
submit(event.currentTarget, { method: "get" });
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="flex items-center gap-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
{(keep ?? []).map(([key, kept]) => (
|
|
|
|
|
|
<input key={key} type="hidden" name={key} value={kept} />
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
|
|
<label className="sr-only" htmlFor={`pill-${name}`}>
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
id={`pill-${name}`}
|
|
|
|
|
|
name={name}
|
|
|
|
|
|
defaultValue={value}
|
|
|
|
|
|
className={cx(
|
|
|
|
|
|
"body-sm-bold rounded-pill border border-border-default bg-surface-raised",
|
|
|
|
|
|
"px-md py-xs text-text-secondary transition-colors",
|
|
|
|
|
|
"duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]",
|
|
|
|
|
|
"hover:border-border-accent hover:text-text-primary",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">{allLabel}</option>
|
|
|
|
|
|
{options.map((option) => (
|
|
|
|
|
|
<option key={option.value} value={option.value}>
|
|
|
|
|
|
{option.label}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Kept for the case where scripting is off - the same bargain the
|
|
|
|
|
|
filter bar strikes. A <select> alone cannot submit its form. */}
|
|
|
|
|
|
<Button type="submit" variant="ghost" size="sm">
|
|
|
|
|
|
{applyLabel}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
|
FACET SIDEBAR - the resources listing
|
|
|
|
|
|
----------------------------------------------------------------------------
|
|
|
|
|
|
The draft draws the resources filters as checkbox groups in a sticky rail.
|
|
|
|
|
|
They are rendered as LINKS with a checkbox-shaped marker: one click sets the
|
|
|
|
|
|
facet, clicking the active option clears it, and the result is still one
|
|
|
|
|
|
URL per view. A real <input type="checkbox"> would promise multi-select,
|
|
|
|
|
|
which the Strapi query behind this listing does not offer.
|
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
|
|
export interface FacetOption {
|
|
|
|
|
|
readonly value: string;
|
|
|
|
|
|
readonly label: string;
|
|
|
|
|
|
readonly href: string;
|
|
|
|
|
|
readonly isCurrent: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function FacetGroup({
|
|
|
|
|
|
heading,
|
|
|
|
|
|
options,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
heading: string;
|
|
|
|
|
|
options: readonly FacetOption[];
|
|
|
|
|
|
}) {
|
|
|
|
|
|
if (options.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-col gap-sm border-t border-border-subtle pt-md">
|
|
|
|
|
|
<p className="label-md uppercase text-text-tertiary">{heading}</p>
|
|
|
|
|
|
<ul className="flex flex-col gap-2xs">
|
|
|
|
|
|
{options.map((option) => (
|
|
|
|
|
|
<li key={option.value}>
|
|
|
|
|
|
<Link
|
|
|
|
|
|
to={option.href}
|
|
|
|
|
|
prefetch="intent"
|
|
|
|
|
|
aria-current={option.isCurrent ? "true" : undefined}
|
|
|
|
|
|
className={cx(
|
|
|
|
|
|
"flex items-start gap-sm py-2xs text-start body-sm-bold",
|
|
|
|
|
|
"transition-colors duration-[var(--motion-duration-fast)]",
|
|
|
|
|
|
"ease-[var(--motion-ease-standard)]",
|
|
|
|
|
|
option.isCurrent
|
|
|
|
|
|
? "text-text-primary"
|
|
|
|
|
|
: "text-text-secondary hover:text-text-primary",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
className={cx(
|
|
|
|
|
|
"mt-2xs flex size-[0.875rem] shrink-0 items-center justify-center",
|
2026-09-20 11:14:15 +02:00
|
|
|
|
"rounded-chip border",
|
2026-09-16 20:32:56 +02:00
|
|
|
|
option.isCurrent
|
|
|
|
|
|
? "border-border-accent"
|
|
|
|
|
|
: "border-border-default",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{option.isCurrent ? (
|
|
|
|
|
|
<span className="size-[7px] bg-[var(--surface-accent)]" />
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span>{option.label}</span>
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** The rail the facet groups sit in - sticky from the large breakpoint up. */
|
|
|
|
|
|
export function FacetRail({
|
|
|
|
|
|
label,
|
|
|
|
|
|
children,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<aside
|
|
|
|
|
|
aria-label={label}
|
|
|
|
|
|
className={cx(
|
|
|
|
|
|
"flex min-w-0 flex-col gap-lg lg:sticky lg:top-[var(--layout-sticky-top)]",
|
|
|
|
|
|
"lg:max-w-[20rem] lg:self-start",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
|
SEARCH
|
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* A standalone search box - the hub's hero field and the resources rail both
|
|
|
|
|
|
* use it. `keep` preserves the filters that are not the query, so searching
|
|
|
|
|
|
* inside a filtered view does not silently clear the filters.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function SearchForm({
|
|
|
|
|
|
action,
|
|
|
|
|
|
label,
|
|
|
|
|
|
placeholder,
|
|
|
|
|
|
defaultValue,
|
|
|
|
|
|
submitLabel,
|
|
|
|
|
|
keep,
|
|
|
|
|
|
name = "q",
|
|
|
|
|
|
className,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
action: string;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
placeholder: string;
|
|
|
|
|
|
defaultValue: string;
|
|
|
|
|
|
submitLabel: string;
|
|
|
|
|
|
keep?: readonly (readonly [string, string])[];
|
|
|
|
|
|
name?: string;
|
|
|
|
|
|
className?: string;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Form
|
|
|
|
|
|
method="get"
|
|
|
|
|
|
action={action}
|
|
|
|
|
|
role="search"
|
|
|
|
|
|
aria-label={label}
|
|
|
|
|
|
className={cx("flex flex-wrap items-end gap-sm", className)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{(keep ?? []).map(([key, value]) => (
|
|
|
|
|
|
<input key={key} type="hidden" name={key} value={value} />
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="search"
|
|
|
|
|
|
name={name}
|
|
|
|
|
|
label={label}
|
|
|
|
|
|
placeholder={placeholder}
|
|
|
|
|
|
defaultValue={defaultValue}
|
|
|
|
|
|
className="min-w-0 flex-1"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<Button type="submit" variant="primary" size="md">
|
|
|
|
|
|
{submitLabel}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
|
CALLOUT BAND
|
|
|
|
|
|
----------------------------------------------------------------------------
|
|
|
|
|
|
The wide panel the drafts close a page with: a raised surface, an accent
|
|
|
|
|
|
rule down the inline-start edge, a headline and one action. Used by the
|
|
|
|
|
|
newsroom ("Are you a partner organisation?") and by the knowledge hub's
|
|
|
|
|
|
repository note.
|
|
|
|
|
|
|
|
|
|
|
|
It is NOT Card.CTA: that card is a filled green field for a primary action,
|
|
|
|
|
|
and the drafts draw this one as a quiet panel on the page surface.
|
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
|
|
export function CalloutBand({
|
|
|
|
|
|
eyebrow,
|
|
|
|
|
|
headline,
|
|
|
|
|
|
body,
|
|
|
|
|
|
action,
|
|
|
|
|
|
headlineAs = "h2",
|
|
|
|
|
|
}: {
|
|
|
|
|
|
headline: string;
|
|
|
|
|
|
action: { readonly to: string; readonly label: string };
|
|
|
|
|
|
eyebrow?: string;
|
|
|
|
|
|
body?: string;
|
|
|
|
|
|
headlineAs?: "h2" | "h3";
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const Headline = headlineAs;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<section
|
|
|
|
|
|
className={cx(
|
|
|
|
|
|
"sh-reveal flex flex-col gap-lg rounded-card border border-border-subtle",
|
|
|
|
|
|
"border-s-4 border-s-[var(--border-accent)] bg-surface-raised p-xl",
|
|
|
|
|
|
"md:flex-row md:items-center md:justify-between",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex max-w-[var(--layout-prose-max)] flex-col gap-xs">
|
|
|
|
|
|
{eyebrow ? (
|
|
|
|
|
|
<p className="label-md uppercase text-text-accent">{eyebrow}</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
<Headline className="heading-h3 text-text-primary">{headline}</Headline>
|
|
|
|
|
|
{body ? <p className="body-md text-text-secondary">{body}</p> : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
|
as={Link}
|
|
|
|
|
|
to={action.to}
|
|
|
|
|
|
variant="primary"
|
|
|
|
|
|
size="md"
|
|
|
|
|
|
iconEnd={<ArrowIcon />}
|
|
|
|
|
|
className="shrink-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
{action.label}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
|
FORM CONTROLS
|
|
|
|
|
|
----------------------------------------------------------------------------
|
|
|
|
|
|
`FilterSelect` above is a GET filter: it has no error state and its id is
|
|
|
|
|
|
namespaced for the filter bar. A select that is part of a POSTed form needs
|
|
|
|
|
|
both, so it gets its own control here rather than a second set of props on
|
|
|
|
|
|
the filter one.
|
|
|
|
|
|
|
|
|
|
|
|
It is native on purpose, for the same reasons `FilterSelect` is: correct in
|
|
|
|
|
|
every browser with a keyboard and a screen reader, and usable before - and
|
|
|
|
|
|
without - hydration. The visual treatment is the field treatment from
|
|
|
|
|
|
~/components/ui/Input, spent through the same tier-2 tokens.
|
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
|
|
export function FormSelect({
|
|
|
|
|
|
id,
|
|
|
|
|
|
name,
|
|
|
|
|
|
label,
|
|
|
|
|
|
hint,
|
|
|
|
|
|
error,
|
|
|
|
|
|
defaultValue,
|
|
|
|
|
|
options,
|
|
|
|
|
|
required = false,
|
|
|
|
|
|
className,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
options: readonly SelectOption[];
|
|
|
|
|
|
hint?: string;
|
|
|
|
|
|
error?: string;
|
|
|
|
|
|
defaultValue?: string;
|
|
|
|
|
|
required?: boolean;
|
|
|
|
|
|
className?: string;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const hintId = `${id}-hint`;
|
|
|
|
|
|
const errorId = `${id}-error`;
|
|
|
|
|
|
const describedBy =
|
|
|
|
|
|
[hint ? hintId : null, error ? errorId : null].filter(Boolean).join(" ") ||
|
|
|
|
|
|
undefined;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className={cx("sh-field flex min-w-0 flex-col gap-xs", className)}>
|
|
|
|
|
|
<label className="sh-field__label label-md" htmlFor={id}>
|
|
|
|
|
|
{label}
|
|
|
|
|
|
{required ? (
|
|
|
|
|
|
<span className="sh-field__required ms-2xs" aria-hidden="true">
|
|
|
|
|
|
*
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
|
|
<select
|
|
|
|
|
|
id={id}
|
|
|
|
|
|
name={name}
|
|
|
|
|
|
defaultValue={defaultValue}
|
|
|
|
|
|
required={required}
|
|
|
|
|
|
aria-invalid={error ? true : undefined}
|
|
|
|
|
|
aria-describedby={describedBy}
|
|
|
|
|
|
className={cx(
|
|
|
|
|
|
"body-md w-full rounded-[var(--radius-control)] border bg-field-bg",
|
|
|
|
|
|
"border-field-border px-md py-sm text-[var(--field-fg)]",
|
|
|
|
|
|
"transition-colors duration-[var(--motion-duration-fast)]",
|
|
|
|
|
|
"ease-[var(--motion-ease-standard)] hover:border-[var(--field-border-hover)]",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{options.map((option) => (
|
|
|
|
|
|
<option key={option.value} value={option.value}>
|
|
|
|
|
|
{option.label}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
|
|
|
|
|
|
{hint ? (
|
|
|
|
|
|
<p id={hintId} className="sh-field__hint body-sm">
|
|
|
|
|
|
{hint}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{error ? (
|
|
|
|
|
|
<p id={errorId} role="alert" className="sh-field__error body-sm">
|
|
|
|
|
|
{error}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|