swarm-ui: sortable + filterable table columns

hyperhive#4020, mara. TableColumn gets two new optional extractors,
each one the whole signal for its capability (mara: 'why separate
selector and flag?' — dropped the sortable/filterable booleans that
would've said the same thing twice and could disagree with the
extractor's presence):

- sortBy: (row) => string | number — column is click-to-sort iff
  present. Table owns the sort state (one active column, header click
  cycles none -> ascending -> descending), since a rendered cell often
  isn't the sortable value itself (e.g. AgentsPage's status column
  renders a Badge, not a plain string).
- filterValue: (row) => string — column is filterable iff present. A
  text input row under the headers, substring match case-insensitive.

Client-side only, no backend change - every consuming page already
fetches its full row set. Wired into AgentsPage and HivesPage, the two
pages with a real per-row Table. Left IssueReportPage alone (already
has its own purpose-built sort + label/hide-blocked filters, predates
this and covers its own domain better than a generic per-column text
filter would) and JobsPage alone (renders an indented state tree via
JobqGraph, no column table at all despite what my original scoping
comment assumed).

Verified: typecheck + build clean, nix fmt clean, static render of the
actual built CSS confirms the new sort-header + filter-row markup
doesn't break table layout.
This commit is contained in:
iris 2026-09-03 00:43:00 +02:00 committed by mara
commit 247ff4498d
4 changed files with 225 additions and 13 deletions

View file

@ -35,3 +35,49 @@
white-space: normal;
word-break: break-word;
}
/* Sortable header the whole header cell is the click target, not a
separate icon-only button next to it, same "control lives on the
thing it affects" reasoning as `Badge`'s own click-to-toggle shape. */
.ui-table-sort-button {
display: inline-flex;
align-items: center;
gap: 0.35em;
background: none;
border: none;
padding: 0;
margin: 0;
font: inherit;
font-weight: 600;
color: inherit;
cursor: pointer;
}
.ui-table-sort-button:hover {
color: var(--fg);
}
.ui-table-sort-glyph {
opacity: 0.6;
font-size: 0.85em;
}
.ui-table-sort-button:hover .ui-table-sort-glyph {
opacity: 1;
}
/* The per-column filter inputs' row same cell padding as a header row,
no bottom border of its own (the header row above already has one). */
.ui-table-filter-row th {
padding-top: 0;
padding-bottom: 0.5em;
font-weight: 400;
}
.ui-table-filter-input {
width: 100%;
box-sizing: border-box;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.25em 0.5em;
font: inherit;
font-size: 0.9em;
}

View file

@ -9,6 +9,7 @@
// a `<table>` doesn't shrink below its content's natural width on its
// own, so without this the page itself would break, not just look
// cramped.
import { useMemo, useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
import "./Table.css";
@ -29,12 +30,44 @@ export interface TableColumn<T> {
* `'descending'` while this is the active sort column, `'none'` while
* sortable but not active, omitted entirely for a non-sortable column
* (no `aria-sort` attribute at all, the correct value for a column
* that can never be the active sort). A screen reader announces which
* column/direction is active from this attribute; the / glyph a
* sortable header renders is `aria-hidden` and carries no information
* on its own.
* that can never be the active sort). Only consulted for a column with
* no `sortBy` one with `sortBy` gets its `aria-sort` computed from
* `Table`'s own sort state instead. Kept for the issue-report page's
* pre-existing hand-rolled sortable headers (real `<button>` markup as
* `header`, its own click handling), which predate `sortBy` and don't
* need to be ported to it just because it now exists.
*/
ariaSort?: "ascending" | "descending" | "none";
/**
* Column is click-to-sort when present extracts the comparable
* value from a row (a rendered cell is often not that value itself,
* e.g. a status column rendering a `Badge`). `Table` owns the actual
* sort state (one active column, cycling none ascending
* descending on header click); the column only says how to compare
* its own rows. String values compare case-insensitively, numbers
* numerically deliberately no separate `sortable` flag alongside
* this: the extractor's presence already answers whether the column
* sorts, so there's no second flag that could disagree with it.
*/
sortBy?: (row: T) => string | number;
/**
* Column is filterable (a text input in its own row under the
* headers) when present extracts the substring to match a case-
* insensitive filter query against. Same one-signal reasoning as
* `sortBy`: no separate `filterable` flag, the extractor's presence
* is the flag.
*/
filterValue?: (row: T) => string;
}
type SortDir = "asc" | "desc";
function compareValues(a: string | number, b: string | number): number {
if (typeof a === "number" && typeof b === "number") return a - b;
return String(a).localeCompare(String(b), undefined, {
sensitivity: "base",
numeric: true,
});
}
export function Table<T>({
@ -53,17 +86,112 @@ export function Table<T>({
// opt-in rather than every table growing a mandatory default string.
emptyMessage?: ComponentChildren;
}) {
// One active sort column at a time, same as any spreadsheet — a
// multi-column sort is real complexity (tie-break order, a UI to
// express it) nothing here has asked for yet.
const [sort, setSort] = useState<{ key: string; dir: SortDir } | null>(null);
// Filter text per filterable column key, only populated for a column
// whose input the operator has actually typed into.
const [filters, setFilters] = useState<Record<string, string>>({});
const filterableColumns = columns.filter((c) => c.filterValue);
const activeFilters = Object.entries(filters).filter(([, v]) => v !== "");
const visibleRows = useMemo(() => {
let out = rows;
if (activeFilters.length > 0) {
out = out.filter((row) =>
activeFilters.every(([key, query]) => {
const col = columns.find((c) => c.key === key);
const value = col?.filterValue?.(row) ?? "";
return value.toLowerCase().includes(query.toLowerCase());
}),
);
}
if (sort) {
const col = columns.find((c) => c.key === sort.key);
const sortBy = col?.sortBy;
if (sortBy) {
out = [...out].sort((a, b) => {
const cmp = compareValues(sortBy(a), sortBy(b));
return sort.dir === "asc" ? cmp : -cmp;
});
}
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps -- `columns` is
// a fresh array every render (built inline by every caller); keying
// off its identity would recompute every render regardless, so this
// depends on the values that actually determine the output instead.
}, [rows, sort, JSON.stringify(activeFilters)]);
function toggleSort(key: string) {
setSort((prev) => {
if (prev?.key !== key) return { key, dir: "asc" };
if (prev.dir === "asc") return { key, dir: "desc" };
return null;
});
}
function ariaSortFor(
c: TableColumn<T>,
): "ascending" | "descending" | "none" | undefined {
if (!c.sortBy) return c.ariaSort;
if (sort?.key !== c.key) return "none";
return sort.dir === "asc" ? "ascending" : "descending";
}
return (
<div class="ui-table-scroll">
<table class="ui-table">
<thead>
<tr>
{columns.map((c) => (
<th key={c.key} aria-sort={c.ariaSort}>
{c.header}
</th>
))}
{columns.map((c) =>
c.sortBy ? (
<th key={c.key} aria-sort={ariaSortFor(c)}>
<button
type="button"
class="ui-table-sort-button"
onClick={() => toggleSort(c.key)}
>
{c.header}
<span class="ui-table-sort-glyph" aria-hidden="true">
{sort?.key === c.key
? sort.dir === "asc"
? "▲"
: "▼"
: "↕"}
</span>
</button>
</th>
) : (
<th key={c.key} aria-sort={ariaSortFor(c)}>
{c.header}
</th>
),
)}
</tr>
{filterableColumns.length > 0 ? (
<tr class="ui-table-filter-row">
{columns.map((c) => (
<th key={c.key}>
{c.filterValue ? (
<input
type="text"
class="ui-table-filter-input"
placeholder="filter…"
aria-label={`filter by ${typeof c.header === "string" ? c.header : c.key}`}
value={filters[c.key] ?? ""}
onInput={(e) => {
const v = (e.target as HTMLInputElement).value;
setFilters((prev) => ({ ...prev, [c.key]: v }));
}}
/>
) : null}
</th>
))}
</tr>
) : null}
</thead>
<tbody>
{rows.length === 0 && emptyMessage ? (
@ -72,8 +200,17 @@ export function Table<T>({
{emptyMessage}
</td>
</tr>
) : rows.length > 0 && visibleRows.length === 0 ? (
// Distinct from `emptyMessage` — real rows exist, the active
// filter(s) just matched none of them, not "there's nothing
// here at all".
<tr>
<td class="ui-table-empty" colSpan={columns.length}>
no rows match the current filter
</td>
</tr>
) : (
rows.map((row) => (
visibleRows.map((row) => (
<tr key={rowKey(row)}>
{columns.map((c) => (
<td key={c.key} class={c.cellClass}>