/* ============================================================================ 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 . 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 (
); } /** * The filter bar. A real `
`, 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 ( { // 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)]", )} >
{children}
{showClear ? ( ) : null}
); } /** 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 ( ); } /* --------------------------------------------------------------------------- RESULTS ------------------------------------------------------------------------ */ /** "Showing 1–12 of 34". Announced politely when a filter changes the count. */ export function ResultSummary({ pagination, t, }: { pagination: PaginationModel; t: Translator; }) { return (

{pagination.total === 0 ? null : t("pagination.showing", { from: pagination.from, to: pagination.to, total: pagination.total, })}

); } export function EmptyState({ title, body, children, }: { title: string; body: string; children?: ReactNode; }) { return (

{title}

{body}

{children}
); } /* --------------------------------------------------------------------------- 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 ( ); } /* --------------------------------------------------------------------------- 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 ( ); } /* --------------------------------------------------------------------------- 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 (

{heading}

{items.map((item) => (
{item.label}
{item.value}
))}
); } /** 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 (

{heading}

); } /** The link out of a detail page, arrow pointing against reading order. */ export function BackLink({ to, label }: { to: string; label: string }) { return ( ); } /** 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 (

{present.map((item, index) => ( {index > 0 ? : null} {item} ))}

); } /* --------------------------------------------------------------------------- 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 ( ); } /** * A single ` cannot submit. */ applyLabel: string; action: string; /** Other query parameters to preserve, as [name, value] pairs. */ keep?: readonly (readonly [string, string])[]; }) { const submit = useSubmit(); return (
{ if (event.target instanceof HTMLSelectElement) { submit(event.currentTarget, { method: "get" }); } }} className="flex items-center gap-xs" > {(keep ?? []).map(([key, kept]) => ( ))} {/* Kept for the case where scripting is off - the same bargain the filter bar strikes. A 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 (

{heading}

); } /** The rail the facet groups sit in - sticky from the large breakpoint up. */ export function FacetRail({ label, children, }: { label: string; children: ReactNode; }) { return ( ); } /* --------------------------------------------------------------------------- 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 ( {(keep ?? []).map(([key, value]) => ( ))}
); } /* --------------------------------------------------------------------------- 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 (
{eyebrow ? (

{eyebrow}

) : null} {headline} {body ?

{body}

: null}
); } /* --------------------------------------------------------------------------- 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 (
{hint ? (

{hint}

) : null} {error ? ( ) : null}
); }