swarm-ui: persist table filters, add reset button, multi-select, negate

Per mara's issue: tables should remember their filters (with a reset
button), the labels filter should be multi-select, and any filter should
support negation (search vs exclude).

All in the shared Table component (ui/table/Table.tsx), used by
AgentsPage/HivesPage/IssueReportPage:

- filters now persist via the same useLocalSetting hook IssueReportPage
  already used for its own state, keyed by a new required storageKey
  prop (required, not optional, so no caller can forget it and every
  table gets persistence for free)
- a small 'reset filters' button clears every column's filter at once,
  shown only when at least one is active
- new filterMode: "multiselect" (+ a filterValues extractor, alongside
  the existing single-value filterValue) renders a checkbox list and
  matches on any overlap - IssueReportPage's own bespoke label-checkbox
  sidebar is folded into this instead of staying a second, separate
  filter mechanism
- a negate toggle ('exclude') sits under every filter mode's control,
  applying uniformly to text/select/multiselect

Verified: tsc --noEmit and the esbuild bundle both clean.
This commit is contained in:
iris 2026-09-09 19:03:03 +02:00 committed by mara
commit 915c6c6f92
7 changed files with 386 additions and 197 deletions

View file

@ -482,6 +482,7 @@ export function AgentsPage() {
rows={rows} rows={rows}
rowKey={(a) => a.name} rowKey={(a) => a.name}
emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive" emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive"
storageKey="swarm-ui:agents:table-filters"
/> />
) : null} ) : null}
<Dialog <Dialog

View file

@ -223,10 +223,16 @@ export function ComponentsPage() {
columns={TABLE_COLUMNS} columns={TABLE_COLUMNS}
rows={TABLE_ROWS} rows={TABLE_ROWS}
rowKey={(r) => r.name} rowKey={(r) => r.name}
storageKey="swarm-ui:components-demo:table-filters"
/> />
</Sample> </Sample>
<Sample label="empty"> <Sample label="empty">
<Table columns={TABLE_COLUMNS} rows={[]} rowKey={(r) => r.name} /> <Table
columns={TABLE_COLUMNS}
rows={[]}
rowKey={(r) => r.name}
storageKey="swarm-ui:components-demo:table-filters-empty"
/>
</Sample> </Sample>
</Section> </Section>

View file

@ -144,7 +144,12 @@ export function HivesPage() {
) : null} ) : null}
{!error && hives === null ? <p>loading</p> : null} {!error && hives === null ? <p>loading</p> : null}
{hives ? ( {hives ? (
<Table columns={COLUMNS} rows={hives} rowKey={(h) => h.name} /> <Table
columns={COLUMNS}
rows={hives}
rowKey={(h) => h.name}
storageKey="swarm-ui:hives:table-filters"
/>
) : null} ) : null}
</Panel> </Panel>
); );

View file

@ -1,11 +1,13 @@
/* <IssueReportPage> the repo-picker + hide-blocked toggle sit in one /* <IssueReportPage> the repo-picker + hide-blocked toggle sit in one
row (mirrors CreateAgentForm's row/wrap pattern: flex-wrap so narrow row (mirrors CreateAgentForm's row/wrap pattern: flex-wrap so narrow
viewports stack instead of overflowing), the label chips get their viewports stack instead of overflowing). The label filter used to
own row below since the set is open-ended and shouldn't fight the have its own chip row here; folded into the "labels" column's own
controls row for width. Sort buttons live inside the table's own `Table` filter (`ui-table-filter-multiselect` in Table.css) instead,
`<th>` cells (Table.tsx's `header` now accepts real markup for so there's nothing page-specific left to style for it. Sort buttons
exactly this) so they inherit the table's header styling for free live inside the table's own `<th>` cells (Table.tsx's `header` now
only the pointer cursor + no-underline reset is this page's to own. */ accepts real markup for exactly this) so they inherit the table's
header styling for free only the pointer cursor + no-underline
reset is this page's to own. */
.issue-report-controls { .issue-report-controls {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -19,19 +21,6 @@
gap: 0.4em; gap: 0.4em;
cursor: pointer; cursor: pointer;
} }
.issue-report-labels {
display: flex;
flex-wrap: wrap;
gap: 0.6em;
margin-bottom: 1em;
}
.issue-report-label-chip {
display: flex;
align-items: center;
gap: 0.35em;
cursor: pointer;
color: var(--muted);
}
.issue-report-sort-btn { .issue-report-sort-btn {
background: none; background: none;
border: none; border: none;

View file

@ -9,8 +9,8 @@
// `transitively_blocks_count` all arrive pre-resolved per row, so // `transitively_blocks_count` all arrive pre-resolved per row, so
// nothing here does its own dependency-graph walk. // nothing here does its own dependency-graph walk.
// //
// Sorting and the label/hide-blocked filters are client-side over // Sorting and the hide-blocked/label/etc. filters are all client-side
// whatever's currently loaded — only the repo selection re-fetches. // over whatever's currently loaded — only the repo selection re-fetches.
// Default sort is `depended_on_by_count` descending: mara's own framing // Default sort is `depended_on_by_count` descending: mara's own framing
// ("i would also like to rank them by how many issues depend on them") // ("i would also like to rank them by how many issues depend on them")
// reads as the report's headline ordering, not just one more column. // reads as the report's headline ordering, not just one more column.
@ -21,11 +21,12 @@
// be instant), so it fetches once per repo-selection change instead of // be instant), so it fetches once per repo-selection change instead of
// polling. // polling.
// //
// Sort/filter state persists via the shared `useLocalSetting` hook (same // Sort state persists via the shared `useLocalSetting` hook (same
// plumbing the theme/motion overrides use) — mara: "local storage" over // plumbing the theme/motion overrides use) — mara: "local storage" over
// URL params. `labelFilter` is `string[]`, not `Set<string>`: a `Set` // URL params. The label filter used to be this page's own bespoke
// serializes to `"{}"` through `JSON.stringify` and silently loses its // checkbox row — folded into the "labels" column's own `Table` filter
// contents, which is exactly what this hook round-trips through. // (`filterMode: "multiselect"`) per mara's "fold filters into table"
// request, one mechanism instead of two.
import { useEffect, useMemo, useState } from "preact/hooks"; import { useEffect, useMemo, useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
@ -71,9 +72,12 @@ const ALL_REPOS = "";
// page — collides with these by accident. // page — collides with these by accident.
const REPO_FILTER_KEY = "swarm-ui:issue-report:repo-filter"; const REPO_FILTER_KEY = "swarm-ui:issue-report:repo-filter";
const HIDE_BLOCKED_KEY = "swarm-ui:issue-report:hide-blocked"; const HIDE_BLOCKED_KEY = "swarm-ui:issue-report:hide-blocked";
const LABEL_FILTER_KEY = "swarm-ui:issue-report:label-filter";
const SORT_KEY_KEY = "swarm-ui:issue-report:sort-key"; const SORT_KEY_KEY = "swarm-ui:issue-report:sort-key";
const SORT_DIR_KEY = "swarm-ui:issue-report:sort-dir"; const SORT_DIR_KEY = "swarm-ui:issue-report:sort-dir";
// `Table`'s own filter state (repo/labels/etc. are handled separately
// above/below — this key is just for the generic per-column filters,
// e.g. title/assignees/blocked).
const TABLE_FILTERS_KEY = "swarm-ui:issue-report:table-filters";
function splitRepo(repo: string): { org: string; name: string } | null { function splitRepo(repo: string): { org: string; name: string } | null {
const i = repo.indexOf("/"); const i = repo.indexOf("/");
@ -145,10 +149,6 @@ export function IssueReportPage() {
HIDE_BLOCKED_KEY, HIDE_BLOCKED_KEY,
false, false,
); );
const [labelFilter, setLabelFilter] = useLocalSetting<string[]>(
LABEL_FILTER_KEY,
[],
);
const [sortKey, setSortKey] = useLocalSetting<SortKey>( const [sortKey, setSortKey] = useLocalSetting<SortKey>(
SORT_KEY_KEY, SORT_KEY_KEY,
"depended_on_by_count", "depended_on_by_count",
@ -180,7 +180,8 @@ export function IssueReportPage() {
}, []); }, []);
// The one fetch this page re-runs on state change — everything else // The one fetch this page re-runs on state change — everything else
// (sort, hide-blocked, label filter) works over what's already loaded. // (sort, hide-blocked, the table's own per-column filters) works over
// what's already loaded.
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
@ -210,26 +211,14 @@ export function IssueReportPage() {
}; };
}, [repoFilter]); }, [repoFilter]);
// Label choices are derived from whatever's currently loaded, not a
// separate endpoint — naturally scoped to "all repos" or one repo
// depending on the current selection, and never asks for a label that
// isn't actually present in the report.
const allLabels = useMemo(() => {
const s = new Set<string>();
for (const row of rows ?? []) for (const l of row.labels) s.add(l);
return [...s].sort();
}, [rows]);
const visibleRows = useMemo(() => { const visibleRows = useMemo(() => {
let out = rows ?? []; let out = rows ?? [];
if (hideBlocked) out = out.filter((r) => !r.blocked); if (hideBlocked) out = out.filter((r) => !r.blocked);
if (labelFilter.length > 0)
out = out.filter((r) => r.labels.some((l) => labelFilter.includes(l)));
return [...out].sort((a, b) => { return [...out].sort((a, b) => {
const c = compareRows(a, b, sortKey); const c = compareRows(a, b, sortKey);
return sortDir === "asc" ? c : -c; return sortDir === "asc" ? c : -c;
}); });
}, [rows, hideBlocked, labelFilter, sortKey, sortDir]); }, [rows, hideBlocked, sortKey, sortDir]);
function onSort(key: SortKey) { function onSort(key: SortKey) {
if (key === sortKey) { if (key === sortKey) {
@ -240,14 +229,6 @@ export function IssueReportPage() {
} }
} }
function toggleLabel(label: string) {
setLabelFilter(
labelFilter.includes(label)
? labelFilter.filter((l) => l !== label)
: [...labelFilter, label],
);
}
// `aria-sort` for a sortable column's `<th>` — 'none' while sortable // `aria-sort` for a sortable column's `<th>` — 'none' while sortable
// but not the active column, the real direction while it is. Screen // but not the active column, the real direction while it is. Screen
// readers announce this; the ▲/▼ glyph in `SortHeader` is `aria-hidden` // readers announce this; the ▲/▼ glyph in `SortHeader` is `aria-hidden`
@ -315,6 +296,8 @@ export function IssueReportPage() {
key: "labels", key: "labels",
header: "labels", header: "labels",
render: (r) => (r.labels.length ? r.labels.join(", ") : "—"), render: (r) => (r.labels.length ? r.labels.join(", ") : "—"),
filterValues: (r) => r.labels,
filterMode: "multiselect",
}, },
{ {
key: "assignees", key: "assignees",
@ -414,20 +397,6 @@ export function IssueReportPage() {
hide blocked (open dependency) hide blocked (open dependency)
</label> </label>
</div> </div>
{allLabels.length ? (
<div class="issue-report-labels">
{allLabels.map((l) => (
<label key={l} class="issue-report-label-chip">
<input
type="checkbox"
checked={labelFilter.includes(l)}
onChange={() => toggleLabel(l)}
/>
{l}
</label>
))}
</div>
) : null}
{error ? ( {error ? (
<ApiErrorPanel <ApiErrorPanel
context="failed to load the issue report" context="failed to load the issue report"
@ -441,6 +410,7 @@ export function IssueReportPage() {
rows={visibleRows} rows={visibleRows}
rowKey={(r) => `${r.repo}#${r.number}`} rowKey={(r) => `${r.repo}#${r.number}`}
emptyMessage="no issues match the current filters" emptyMessage="no issues match the current filters"
storageKey={TABLE_FILTERS_KEY}
/> />
) : null} ) : null}
</Panel> </Panel>

View file

@ -141,3 +141,60 @@
font: inherit; font: inherit;
font-size: 0.9em; font-size: 0.9em;
} }
/* `filterMode: "multiselect"` a scrollable checkbox list rather than
a `<select multiple>`, which needs a modifier key to pick more than
one option and reads as unfamiliar to most operators; a plain
checkbox list needs neither. `max-height` + scroll keeps a
long option set (many labels) from pushing the popover off-screen. */
.ui-table-filter-multiselect {
display: flex;
flex-direction: column;
gap: 0.3em;
max-height: 12em;
overflow-y: auto;
}
.ui-table-filter-checkbox {
display: flex;
align-items: center;
gap: 0.35em;
cursor: pointer;
font-size: 0.9em;
}
/* The negate toggle sits under every filter mode's own control (mara:
"you should be able to change if the filter is negated or not")
a hairline rule separates it from the control above so "exclude"
doesn't read as one more option belonging to that control. */
.ui-table-filter-negate {
display: flex;
align-items: center;
gap: 0.35em;
margin-top: 0.5em;
padding-top: 0.5em;
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.85em;
cursor: pointer;
}
/* One button clears every column's filter on this table at once (mara:
"have a small reset filters btn") text-button styling matching
`.ui-table-sort-button`'s "no chrome, just a clickable label"
treatment, not a full `Badge`/bordered-button this is a quiet
secondary action, not a primary one. */
.ui-table-reset-filters {
display: inline-block;
background: none;
border: none;
padding: 0;
margin: 0 0 0.5em;
font: inherit;
font-size: 0.85em;
color: var(--muted);
text-decoration: underline;
cursor: pointer;
}
.ui-table-reset-filters:hover {
color: var(--fg);
}

View file

@ -13,6 +13,7 @@ import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { createPortal } from "preact/compat"; import { createPortal } from "preact/compat";
import type { ComponentChildren } from "preact"; import type { ComponentChildren } from "preact";
import { FilterIcon } from "@hive/shared/icons.js"; import { FilterIcon } from "@hive/shared/icons.js";
import { useLocalSetting } from "@hive/shared/settings-storage.js";
import "./Table.css"; import "./Table.css";
export interface TableColumn<T> { export interface TableColumn<T> {
@ -65,6 +66,14 @@ export interface TableColumn<T> {
* `.ui-table-filter-icon` for the actual rule. * `.ui-table-filter-icon` for the actual rule.
*/ */
filterValue?: (row: T) => string; 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>` * `"text"` (default): free substring, case-insensitive `<input>`
* today's only behavior, unaffected if you don't set this. * today's only behavior, unaffected if you don't set this.
@ -78,8 +87,38 @@ export interface TableColumn<T> {
* live rows rather than a hardcoded enum means the list never offers * live rows rather than a hardcoded enum means the list never offers
* a choice that would match zero rows. Ignored unless `filterValue` * a choice that would match zero rows. Ignored unless `filterValue`
* is also set. * is also set.
* `"multiselect"`: a checkbox list, same live-rows-derived option
* source as `"select"` but over `filterValues` and matching on any
* overlap rather than one exact value for a column whose row value
* is itself a set (e.g. labels). Ignored unless `filterValues` is set.
*/ */
filterMode?: "text" | "select"; filterMode?: "text" | "select" | "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"; type SortDir = "asc" | "desc";
@ -97,6 +136,7 @@ export function Table<T>({
rows, rows,
rowKey, rowKey,
emptyMessage, emptyMessage,
storageKey,
}: { }: {
columns: TableColumn<T>[]; columns: TableColumn<T>[];
rows: T[]; rows: T[];
@ -107,14 +147,27 @@ export function Table<T>({
// table entirely) has nothing useful to add here, so this stays // table entirely) has nothing useful to add here, so this stays
// opt-in rather than every table growing a mandatory default string. // opt-in rather than every table growing a mandatory default string.
emptyMessage?: ComponentChildren; 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 // One active sort column at a time, same as any spreadsheet — a
// multi-column sort is real complexity (tie-break order, a UI to // multi-column sort is real complexity (tie-break order, a UI to
// express it) nothing here has asked for yet. // express it) nothing here has asked for yet.
const [sort, setSort] = useState<{ key: string; dir: SortDir } | null>(null); const [sort, setSort] = useState<{ key: string; dir: SortDir } | null>(null);
// Filter text per filterable column key, only populated for a column // Filter state per filterable column key, only populated for a column
// whose input the operator has actually typed into. // the operator has actually touched. Persisted (see `storageKey`
const [filters, setFilters] = useState<Record<string, string>>({}); // 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 // 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 // time (opening a second closes the first) so the header row never
// shows more than one panel at once. // shows more than one panel at once.
@ -173,7 +226,33 @@ export function Table<T>({
}; };
}, [openFilterKey]); }, [openFilterKey]);
const activeFilters = Object.entries(filters).filter(([, v]) => v !== ""); 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 // Close on outside click or Escape — same contract `Dropdown` gives
// its own popover (../.../shared/src/dropdown/Dropdown.tsx), but // its own popover (../.../shared/src/dropdown/Dropdown.tsx), but
@ -205,11 +284,20 @@ export function Table<T>({
let out = rows; let out = rows;
if (activeFilters.length > 0) { if (activeFilters.length > 0) {
out = out.filter((row) => out = out.filter((row) =>
activeFilters.every(([key, query]) => { activeFilters.every(([key, f]) => {
const col = columns.find((c) => c.key === key); const col = columns.find((c) => c.key === key);
const value = col?.filterValue?.(row) ?? ""; let matches: boolean;
if (col?.filterMode === "select") return value === query; if (col?.filterMode === "multiselect") {
return value.toLowerCase().includes(query.toLowerCase()); const rowValues = col.filterValues?.(row) ?? [];
matches = f.values.some((v) => rowValues.includes(v));
} else if (col?.filterMode === "select") {
const value = col.filterValue?.(row) ?? "";
matches = value === f.value;
} else {
const value = col?.filterValue?.(row) ?? "";
matches = value.toLowerCase().includes(f.value.toLowerCase());
}
return f.negate ? !matches : matches;
}), }),
); );
} }
@ -260,6 +348,17 @@ export function Table<T>({
); );
} }
// Same reasoning as `selectOptionsFor` above, over `filterValues`
// (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 // 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 // per-column ref map) is enough to focus whichever control just
// mounted — typing immediately after the click that opened it, // mounted — typing immediately after the click that opened it,
@ -282,138 +381,200 @@ export function Table<T>({
return `filter by ${typeof c.header === "string" ? c.header : c.key}`; return `filter by ${typeof c.header === "string" ? c.header : c.key}`;
} }
function renderFilterControl(c: TableColumn<T>) { // mara: "you should be able to change if the filter is negated or not
if (c.filterMode === "select") { // (eg search for this text vs exclude it)" — one toggle, consulted the
return ( // same way regardless of which control above it produced the match.
<select function renderNegateToggle(c: TableColumn<T>) {
ref={setPopoverControlRef} const f = getFilter(c.key);
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 ( return (
<input <label class="ui-table-filter-negate">
ref={setPopoverControlRef} <input
type="text" type="checkbox"
class="ui-table-filter-input" checked={f.negate}
placeholder="filter…" onChange={(e) =>
aria-label={filterLabel(c)} updateFilter(c.key, {
value={filters[c.key] ?? ""} negate: (e.target as HTMLInputElement).checked,
onInput={(e) => { })
const v = (e.target as HTMLInputElement).value; }
setFilters((prev) => ({ ...prev, [c.key]: v })); />
}} 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)}
</>
);
}
if (c.filterMode === "select") {
return (
<>
<select
ref={setPopoverControlRef}
class="ui-table-filter-select"
aria-label={filterLabel(c)}
value={getFilter(c.key).value}
onChange={(e) => {
const v = (e.target as HTMLSelectElement).value;
updateFilter(c.key, { value: v });
}}
>
<option value="">any</option>
{selectOptionsFor(c).map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
{renderNegateToggle(c)}
</>
);
}
return (
<>
<input
ref={setPopoverControlRef}
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 ( return (
<div class="ui-table-scroll"> <>
<table class="ui-table"> {anyActiveFilter ? (
<thead> <button
<tr> type="button"
{columns.map((c) => { class="ui-table-reset-filters"
const hasFilterValue = (filters[c.key] ?? "") !== ""; onClick={resetFilters}
const filterOpen = openFilterKey === c.key; >
const headerContent = c.sortBy ? ( reset filters
<button </button>
type="button" ) : null}
class="ui-table-sort-button" <div class="ui-table-scroll">
onClick={() => toggleSort(c.key)} <table class="ui-table">
> <thead>
{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}
ref={(el) => {
if (!c.filterValue) return;
if (el) thRefs.current.set(c.key, el);
else thRefs.current.delete(c.key);
}}
>
{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) => {
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> <tr>
<td class="ui-table-empty" colSpan={columns.length}> {columns.map((c) => {
{emptyMessage} const hasFilterValue = isFilterActive(filters[c.key]);
</td> 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> </tr>
) : rows.length > 0 && visibleRows.length === 0 ? ( </thead>
// Distinct from `emptyMessage` — real rows exist, the active <tbody>
// filter(s) just matched none of them, not "there's nothing {rows.length === 0 && emptyMessage ? (
// here at all". <tr>
<tr> <td class="ui-table-empty" colSpan={columns.length}>
<td class="ui-table-empty" colSpan={columns.length}> {emptyMessage}
no rows match the current filter </td>
</td>
</tr>
) : (
visibleRows.map((row) => (
<tr key={rowKey(row)}>
{columns.map((c) => (
<td key={c.key} class={c.cellClass}>
{c.render(row)}
</td>
))}
</tr> </tr>
)) ) : rows.length > 0 && visibleRows.length === 0 ? (
)} // Distinct from `emptyMessage` — real rows exist, the active
</tbody> // filter(s) just matched none of them, not "there's nothing
</table> // 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 {openFilterKey !== null && popoverPos
? createPortal( ? createPortal(
(() => { (() => {
@ -437,6 +598,6 @@ export function Table<T>({
document.body, document.body,
) )
: null} : null}
</div> </>
); );
} }