527 lines
16 KiB
TypeScript
527 lines
16 KiB
TypeScript
/* ============================================================================
|
||
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";
|
||
|
||
import type { PaginationModel, SelectOption } from "./listing";
|
||
import { withParam } from "./listing";
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
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(
|
||
"flex flex-col items-start gap-md rounded-card border border-dashed",
|
||
"border-border-default bg-surface-sunken p-2xl",
|
||
)}
|
||
>
|
||
<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>
|
||
);
|
||
}
|