hyperhive/frontend/packages/swarm-ui/src/ui/table/Table.tsx
iris e234afa26a swarm-ui: table filters move to a per-header icon + popover
Replaces the permanent filter-row under Table's headers with a small
filter-icon button in each filterable column's own header cell. The
icon fades in on header hover/focus via a CSS opacity transition, or
stays visible outright once that column actually has a filter set
(mara: "instead of a filter row, add little filter icons on header
hover with fade in out animation ... when a filter is set, the filter
icon does not disappear"). Clicking it opens a small anchored popover
directly under the header holding the exact same filter control
filterMode already provides (text input or select) -- the underlying
filter mechanism from hyperhive#4088 is unchanged, only where the
control lives moved. Close-on-outside-click/Escape mirrors the
contract Dropdown already gives its own popover, adapted to a shared
listener across every column instead of a ref per column since only
one popover is ever open at a time.

New FilterIcon in @hive/shared's icons.tsx (a plain inline SVG funnel,
same Feather/lucide-style shape as the existing GearIcon/LinkIcon) --
found and reused that pattern rather than reaching for an emoji glyph,
matching the documented reason those two exist as SVG in the first
place (mara, on the old emoji icons: inconsistent size/weight across
platforms).
2026-09-08 12:14:56 +02:00

367 lines
15 KiB
TypeScript

// <Table> — a generic column-defined table, the shape the hive roster
// (name / domain / status chip, one row per hive) needs. Columns own
// their own cell rendering rather than this component knowing about
// any particular row shape, so it stays reusable for the swarm-wide
// agent roster later without a rewrite.
//
// Wrapped in a scrollable container rather than letting a wide table
// force the whole page to overflow horizontally on a narrow viewport —
// 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 { useEffect, useMemo, useRef, useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
import { FilterIcon } from "@hive/shared/icons.js";
import "./Table.css";
export interface TableColumn<T> {
key: string;
// `ComponentChildren`, not `string` — a plain string still satisfies
// this and renders identically, but a caller that wants a clickable
// sortable header (the issue-report page's own column-header buttons)
// can pass real markup instead of forking a second table primitive.
header: ComponentChildren;
render: (row: T) => ComponentChildren;
/** Extra class on this column's `<td>` — e.g. `ui-table-prose` for a
* free-text cell that should cap its width and wrap instead of
* dragging the whole table wider. Omit for the default cell styling. */
cellClass?: string;
/**
* ARIA sort state for this column's `<th>` — `'ascending'` /
* `'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). 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 filter-icon button in its own header cell,
* opening a small popover with the actual control) when present —
* extracts the string to filter this column against. Same
* one-signal reasoning as `sortBy`: no separate `filterable` flag,
* the extractor's presence is the flag. How the extracted string is
* matched against the operator's input is `filterMode`. The icon
* fades in on header hover/focus, or stays visible outright once
* this column has an active filter (mara: "when a filter is set,
* the filter icon does not disappear") — see `Table.css`'s
* `.ui-table-filter-icon` for the actual rule.
*/
filterValue?: (row: T) => string;
/**
* `"text"` (default): free substring, case-insensitive `<input>` —
* today's only behavior, unaffected if you don't set this.
* `"select"`: a `<select>` populated from the *distinct* `filterValue`
* results across the currently-loaded rows (deduped, case-insensitive
* sort) plus a leading "any" option, matched by exact equality. Pick
* this for a column whose value only ever comes from a small
* bounded/closed set (an enum label, a hive name) — typing an exact
* substring is friction a picker removes, and a substring query can
* accidentally straddle two distinct values. Deriving options from
* live rows rather than a hardcoded enum means the list never offers
* a choice that would match zero rows. Ignored unless `filterValue`
* is also set.
*/
filterMode?: "text" | "select";
}
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>({
columns,
rows,
rowKey,
emptyMessage,
}: {
columns: TableColumn<T>[];
rows: T[];
rowKey: (row: T) => string;
// Rendered as a single full-width row when `rows` is empty. Omit to
// leave a bare empty `<tbody>` — a caller whose "no rows yet" is
// covered by its own loading/error state (rendered instead of the
// table entirely) has nothing useful to add here, so this stays
// 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>>({});
// Which column's filter popover is open, if any — at most one at a
// time (opening a second closes the first) so the header row never
// shows more than one panel at once.
const [openFilterKey, setOpenFilterKey] = useState<string | null>(null);
const activeFilters = Object.entries(filters).filter(([, v]) => v !== "");
// Close on outside click or Escape — same contract `Dropdown` gives
// its own popover (../.../shared/src/dropdown/Dropdown.tsx), but
// checked by CSS class rather than a ref: unlike `Dropdown`, there's
// one shared listener across every column's icon+popover pair here,
// not one component instance per trigger, so "is this click part of
// the open popover" is answered by `closest()` against the pair's
// classes instead of a per-column ref map.
useEffect(() => {
if (openFilterKey === null) return;
function handlePointerDown(e: PointerEvent) {
if (!(e.target instanceof Element)) return;
if (e.target.closest(".ui-table-filter-icon, .ui-table-filter-popover"))
return;
setOpenFilterKey(null);
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpenFilterKey(null);
}
document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("keydown", handleKeyDown);
};
}, [openFilterKey]);
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) ?? "";
if (col?.filterMode === "select") return value === query;
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";
}
// Distinct values currently present for a `"select"`-mode column, so
// the dropdown never offers an option that would match zero rows.
// Computed off `rows` (pre-filter) — every column's own option list
// stays stable while a sibling column's filter narrows `visibleRows`,
// matching how a spreadsheet's column filters don't hide each other's
// choices.
function selectOptionsFor(c: TableColumn<T>): string[] {
if (!c.filterValue) return [];
const values = new Set(rows.map(c.filterValue));
return Array.from(values).sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }),
);
}
// Only one popover is ever open at a time, so one ref (rather than a
// per-column ref map) is enough to focus whichever control just
// mounted — typing immediately after the click that opened it,
// without a separate click into the field first. A callback ref
// (not `useRef`'s object form attached directly to the element)
// because the element is one of two different concrete types
// depending on `filterMode` — `RefObject<HTMLInputElement |
// HTMLSelectElement>` doesn't structurally satisfy either element's
// own narrower `ref` prop type, but a plain callback taking the
// union does.
const popoverControlRef = useRef<HTMLElement | null>(null);
function setPopoverControlRef(el: HTMLElement | null) {
popoverControlRef.current = el;
}
useEffect(() => {
if (openFilterKey !== null) popoverControlRef.current?.focus();
}, [openFilterKey]);
function filterLabel(c: TableColumn<T>): string {
return `filter by ${typeof c.header === "string" ? c.header : c.key}`;
}
function renderFilterControl(c: TableColumn<T>) {
if (c.filterMode === "select") {
return (
<select
ref={setPopoverControlRef}
class="ui-table-filter-select"
aria-label={filterLabel(c)}
value={filters[c.key] ?? ""}
onChange={(e) => {
const v = (e.target as HTMLSelectElement).value;
setFilters((prev) => ({ ...prev, [c.key]: v }));
}}
>
<option value="">any</option>
{selectOptionsFor(c).map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
);
}
return (
<input
ref={setPopoverControlRef}
type="text"
class="ui-table-filter-input"
placeholder="filter…"
aria-label={filterLabel(c)}
value={filters[c.key] ?? ""}
onInput={(e) => {
const v = (e.target as HTMLInputElement).value;
setFilters((prev) => ({ ...prev, [c.key]: v }));
}}
/>
);
}
return (
<div class="ui-table-scroll">
<table class="ui-table">
<thead>
<tr>
{columns.map((c) => {
const hasFilterValue = (filters[c.key] ?? "") !== "";
const filterOpen = openFilterKey === c.key;
const headerContent = c.sortBy ? (
<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>
) : (
c.header
);
return (
<th
key={c.key}
aria-sort={ariaSortFor(c)}
class={c.filterValue ? "ui-table-th-filterable" : undefined}
>
{headerContent}
{c.filterValue ? (
<>
<button
type="button"
class={
"ui-table-filter-icon" +
(filterOpen || hasFilterValue
? " ui-table-filter-icon-active"
: "")
}
aria-label={filterLabel(c)}
aria-expanded={filterOpen}
onClick={() =>
setOpenFilterKey((k) => (k === c.key ? null : c.key))
}
>
<FilterIcon />
</button>
{filterOpen ? (
<div
class="ui-table-filter-popover"
role="dialog"
aria-label={filterLabel(c)}
>
{renderFilterControl(c)}
</div>
) : null}
</>
) : null}
</th>
);
})}
</tr>
</thead>
<tbody>
{rows.length === 0 && emptyMessage ? (
<tr>
<td class="ui-table-empty" colSpan={columns.length}>
{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>
) : (
visibleRows.map((row) => (
<tr key={rowKey(row)}>
{columns.map((c) => (
<td key={c.key} class={c.cellClass}>
{c.render(row)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
);
}