hyperhive/frontend/packages/swarm-ui/src/ui/table/Table.tsx

562 lines
23 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 { createPortal } from "preact/compat";
import type { ComponentChildren } from "preact";
import { FilterIcon } from "@hive/shared/icons.js";
import { useLocalSetting } from "@hive/shared/settings-storage.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;
/**
* Like `filterValue`, but for a column whose row value is a *set*
* rather than one string (e.g. issue labels) — required for and only
* consulted by `filterMode: "multiselect"`. A row matches when it
* shares at least one value with the operator's current selection
* (OR, not AND — "has any of these labels", not "has all of them").
*/
filterValues?: (row: T) => string[];
/**
* `"text"` (default): free substring, case-insensitive `<input>` —
* today's only behavior, unaffected if you don't set this.
* `"multiselect"`: a checkbox list, populated from the *distinct*
* `filterValues` results across the currently-loaded rows (deduped,
* case-insensitive sort), matching on any overlap between the row's
* values and the operator's current selection. 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. Works the same whether a row naturally carries a
* *set* of values (e.g. issue labels — pass the real array) or just
* one (wrap it in a 1-element array). Deriving options from live rows
* rather than a hardcoded enum means the list never offers a choice
* that would match zero rows. Ignored unless `filterValues` is set.
* There's deliberately no separate single-value `"select"` mode —
* multiselect subsumes it (mara: "why have a non multi select? the
* user can always just choose 1"), so a single-choice picker never
* needs distinct handling from an N-choice one.
*/
filterMode?: "text" | "multiselect";
}
// One filter's full state: `value` for `"text"`/`"select"`, `values` for
// `"multiselect"` (the other is left at its default and ignored), plus a
// `negate` flag consulted regardless of mode (mara: "you should be able
// to change if the filter is negated or not — search for this text vs
// exclude it"). A column with no entry here behaves identically to the
// all-defaults entry below — `getFilter` returns this same shape either
// way, so callers never need an `undefined` branch.
interface ColumnFilterState {
value: string;
values: string[];
negate: boolean;
}
const EMPTY_FILTER: ColumnFilterState = {
value: "",
values: [],
negate: false,
};
function isFilterActive(f: ColumnFilterState | undefined): boolean {
return f !== undefined && (f.value !== "" || f.values.length > 0);
}
function isFilterable<T>(c: TableColumn<T>): boolean {
return c.filterValue !== undefined || c.filterValues !== undefined;
}
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,
storageKey,
}: {
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;
// Namespaces this table's filter state in `localStorage` (mara:
// "tables should remember their filters in general") — required, not
// optional, so every table gets persistence for free and no caller can
// forget it; a stray collision between two tables sharing a key is a
// caller bug worth a distinct, unique string, same as any other
// storage key in this codebase (see `IssueReportPage`'s own
// `swarm-ui:issue-report:…` keys). Sort intentionally stays
// `useState` below, not persisted — only asked for filters.
storageKey: string;
}) {
// 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 state per filterable column key, only populated for a column
// the operator has actually touched. Persisted (see `storageKey`
// above), same `useLocalSetting` plumbing `IssueReportPage` already
// used for its own now-folded-in label filter.
const [filters, setFilters] = useLocalSetting<
Record<string, ColumnFilterState>
>(storageKey, {});
// 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);
// Viewport coordinates for the open popover, or null while closed.
// Recomputed on open and on scroll/resize below — see the portal
// rendering further down for why this exists at all: `.ui-table-scroll`
// sets `overflow-x: auto`, and per the CSS overflow spec an axis left
// unset computes to `auto` too once the *other* axis isn't `visible`,
// so this box silently clips vertically as well. A popover positioned
// `absolute`/`top: 100%` under its header — the first shape this
// shipped with — gets cut off by that clip on any table whose height
// is shorter than header-plus-popover (argus's review: reproduced,
// not speculative, on a 1-row table). `position: fixed` computed from
// the anchor `<th>`'s own `getBoundingClientRect()`, rendered via a
// portal outside `.ui-table-scroll`'s subtree entirely, escapes that
// clip the same way any `position: fixed` element escapes an
// ancestor's `overflow` (unless that ancestor establishes a new
// containing block via `transform`/`filter`/`will-change` — neither
// `.ui-table-scroll` nor `.ui-table` do).
const [popoverPos, setPopoverPos] = useState<{
top: number;
left: number;
} | null>(null);
// One `<th>` ref per filterable column key, so the scroll/resize
// effect below can recompute the *currently open* column's position
// without needing the click that opened it to have happened again.
const thRefs = useRef<Map<string, HTMLTableCellElement>>(new Map());
function computePopoverPos(key: string) {
const th = thRefs.current.get(key);
if (!th) return;
const rect = th.getBoundingClientRect();
setPopoverPos({ top: rect.bottom + 4, left: rect.left });
}
// Keeps the popover visually anchored to its header while it's open,
// rather than only positioning it once at click time — a page/scroll
// container scroll or a viewport resize while the popover is open
// would otherwise leave it floating over the wrong spot. `scroll`
// doesn't bubble, so this listens on `window` with `capture: true`,
// which *does* see a scroll on `.ui-table-scroll` (or any other
// nested scroll container) during the capture phase — the standard
// technique for "detect scroll anywhere in the tree" without binding
// a listener to every individual scrollable ancestor by hand.
useEffect(() => {
if (openFilterKey === null) return;
function recompute() {
if (openFilterKey !== null) computePopoverPos(openFilterKey);
}
window.addEventListener("scroll", recompute, true);
window.addEventListener("resize", recompute);
return () => {
window.removeEventListener("scroll", recompute, true);
window.removeEventListener("resize", recompute);
};
}, [openFilterKey]);
const activeFilters = Object.entries(filters).filter(([, f]) =>
isFilterActive(f),
);
function getFilter(key: string): ColumnFilterState {
return filters[key] ?? EMPTY_FILTER;
}
function updateFilter(key: string, patch: Partial<ColumnFilterState>) {
setFilters({
...filters,
[key]: { ...getFilter(key), ...patch },
});
}
function toggleFilterValue(key: string, value: string) {
const current = getFilter(key).values;
updateFilter(key, {
values: current.includes(value)
? current.filter((v) => v !== value)
: [...current, value],
});
}
// mara: "have a small reset filters btn" — one button clears every
// column's filter at once rather than hunting down each popover
// individually.
function resetFilters() {
setFilters({});
}
// 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, f]) => {
const col = columns.find((c) => c.key === key);
let matches: boolean;
if (col?.filterMode === "multiselect") {
const rowValues = col.filterValues?.(row) ?? [];
matches = f.values.some((v) => rowValues.includes(v));
} else {
const value = col?.filterValue?.(row) ?? "";
matches = value.toLowerCase().includes(f.value.toLowerCase());
}
return f.negate ? !matches : matches;
}),
);
}
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 `"multiselect"`-mode column,
// so the checkbox list 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. A row contributes every one of its own
// values, not just one.
function multiselectOptionsFor(c: TableColumn<T>): string[] {
if (!c.filterValues) return [];
const values = new Set<string>();
for (const row of rows) for (const v of c.filterValues(row)) values.add(v);
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. (The multiselect
// checkbox list below doesn't use this — there's no single "the"
// control to focus in a list of several.)
const popoverControlRef = useRef<HTMLInputElement | null>(null);
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}`;
}
// mara: "you should be able to change if the filter is negated or not
// (eg search for this text vs exclude it)" — one toggle, consulted the
// same way regardless of which control above it produced the match.
function renderNegateToggle(c: TableColumn<T>) {
const f = getFilter(c.key);
return (
<label class="ui-table-filter-negate">
<input
type="checkbox"
checked={f.negate}
onChange={(e) =>
updateFilter(c.key, {
negate: (e.target as HTMLInputElement).checked,
})
}
/>
exclude
</label>
);
}
function renderFilterControl(c: TableColumn<T>) {
if (c.filterMode === "multiselect") {
const f = getFilter(c.key);
return (
<>
<div class="ui-table-filter-multiselect">
{multiselectOptionsFor(c).map((v) => (
<label key={v} class="ui-table-filter-checkbox">
<input
type="checkbox"
checked={f.values.includes(v)}
onChange={() => toggleFilterValue(c.key, v)}
/>
{v}
</label>
))}
</div>
{renderNegateToggle(c)}
</>
);
}
return (
<>
<input
ref={popoverControlRef}
type="text"
class="ui-table-filter-input"
placeholder="filter…"
aria-label={filterLabel(c)}
value={getFilter(c.key).value}
onInput={(e) => {
const v = (e.target as HTMLInputElement).value;
updateFilter(c.key, { value: v });
}}
/>
{renderNegateToggle(c)}
</>
);
}
const anyActiveFilter = activeFilters.length > 0;
return (
<>
{anyActiveFilter ? (
<button
type="button"
class="ui-table-reset-filters"
onClick={resetFilters}
>
reset filters
</button>
) : null}
<div class="ui-table-scroll">
<table class="ui-table">
<thead>
<tr>
{columns.map((c) => {
const hasFilterValue = isFilterActive(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={
isFilterable(c) ? "ui-table-th-filterable" : undefined
}
ref={(el) => {
if (!isFilterable(c)) return;
if (el) thRefs.current.set(c.key, el);
else thRefs.current.delete(c.key);
}}
>
{headerContent}
{isFilterable(c) ? (
<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) => {
const next = k === c.key ? null : c.key;
if (next !== null) computePopoverPos(next);
return next;
});
}}
>
<FilterIcon />
</button>
) : 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>
{openFilterKey !== null && popoverPos
? createPortal(
(() => {
const openColumn = columns.find((c) => c.key === openFilterKey);
if (!openColumn) return null;
return (
<div
class="ui-table-filter-popover"
role="dialog"
aria-label={filterLabel(openColumn)}
style={{
position: "fixed",
top: `${popoverPos.top}px`,
left: `${popoverPos.left}px`,
}}
>
{renderFilterControl(openColumn)}
</div>
);
})(),
document.body,
)
: null}
</>
);
}