Files
sharper.ly/apps/web/app/routes/_shared/listing-ui.tsx
T
2026-09-11 02:46:56 +02:00

527 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================================
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>
);
}