562 lines
23 KiB
TypeScript
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}
|
|
</>
|
|
);
|
|
}
|