initial commit
This commit is contained in:
@@ -0,0 +1,526 @@
|
||||
/* ============================================================================
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user