swarm-ui/agents: shared filters across views, real second panel for detail

Two more asks from mara's live review:

- "i want the same filters for the cards tho, thats why i suggested
  separating data and filter from view" - extracted Table's filter
  *state* (not its popover UI, which stays table-shaped) into a new
  exported useTableFilters hook. Table calls it internally, unchanged
  behavior for every existing caller. AgentsPage now calls the same hook
  with the same storageKey, so card view and table view read/write one
  shared filter state instead of each having their own (or cards having
  none at all). Card view gets its own toolbar (AgentFilterBar) - a
  name search input plus one FilterMultiselect per multiselect column,
  same checkbox-list markup Table's own popover uses, driving the same
  state. Switching the view toggle no longer loses or hides whatever's
  filtered.

- "why no separate panel? i mean a second panel on agent page" - replaced
  the modal Dialog with a real second Panel, always mounted (empty state
  when nothing's selected, so selecting an agent never shifts the page's
  own layout). Panel gained an optional `class` prop so the two panels
  can flex-size themselves in a row. List/detail panels sit side by side
  in a flex-wrap row that stacks on a narrow viewport - content-driven,
  same approach the shell's own nav uses, not a second hardcoded
  breakpoint. Selected card gets a highlight so it's clear which one the
  detail panel is showing.

Verified with real CDP clicks: split layout with nothing selected,
selecting a card highlights it and populates the detail panel, opening
a card-view filter and checking a value narrows both the card list AND
(after switching the toggle) the table to the identical row set.
This commit is contained in:
iris 2026-09-11 22:03:04 +02:00
commit 60d7e8ce84
4 changed files with 586 additions and 263 deletions

View file

@ -60,6 +60,14 @@
border-color: var(--purple); border-color: var(--purple);
outline: none; outline: none;
} }
/* The row currently shown in the detail panel a solid border (not
just the hover tint above, which needs to keep meaning "hovering",
not double as "selected") plus a faint fill so it still reads once
the pointer moves away. */
.ui-agent-card-selected {
border-color: var(--purple);
background: color-mix(in srgb, var(--purple) 10%, var(--bg-elev));
}
.ui-agent-card-line1 { .ui-agent-card-line1 {
display: flex; display: flex;
align-items: center; align-items: center;
@ -82,11 +90,8 @@
word-break: break-word; word-break: break-word;
} }
/* Detail panel (`Dialog` body) the fields `AgentCard`'s main view /* Detail panel body its own `Panel`, title supplied by the panel's
doesn't show. */ `title` prop (the agent's name), not repeated in here. */
.ui-agent-detail-name {
margin: 0 0 0.75em;
}
.ui-agent-detail-fields { .ui-agent-detail-fields {
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
@ -104,3 +109,55 @@
align-items: center; align-items: center;
gap: 0.75em; gap: 0.75em;
} }
/* List panel + detail panel side by side `flex-wrap`, not a
`@media` breakpoint, so a narrow viewport stacks them the same
content-driven way the shell's own nav already wraps (`Shell.css`),
rather than picking a second, independent magic-number breakpoint. */
.ui-agents-layout {
display: flex;
flex-wrap: wrap;
gap: 1em;
align-items: flex-start;
}
.ui-agents-list-panel {
flex: 2 1 480px;
min-width: 0;
}
.ui-agents-detail-panel {
flex: 1 1 320px;
min-width: 0;
}
/* Card view's filter toolbar — `Table`'s per-column popovers, minus the
table to hang them off of. */
.ui-agents-filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5em;
margin-bottom: 0.75em;
}
.ui-agents-filter-search {
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.35em 0.6em;
font: inherit;
font-size: 0.9em;
}
.ui-agents-filter {
position: relative;
}
/* `.ui-table-filter-popover` (Table.css) supplies the panel chrome
(background/border/shadow) this just positions it under the
trigger, `position: absolute` rather than the fixed/portal recipe
`Table`'s own version needs: nothing here clips this popover, so the
simpler positioning is enough (see this component's own comment). */
.ui-agents-filter-popover {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.25em;
}

View file

@ -28,7 +28,7 @@
// //
// The "wanted" column is one `WantedMenu` badge+dropdown per row — see // The "wanted" column is one `WantedMenu` badge+dropdown per row — see
// that component's own comment above `AgentsPage` for why. // that component's own comment above `AgentsPage` for why.
import { useRef, useState } from "preact/hooks"; import { useEffect, useRef, useState } from "preact/hooks";
import type { ComponentChildren } from "preact"; import type { ComponentChildren } from "preact";
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";
@ -45,7 +45,7 @@ import {
useRefreshInterval, useRefreshInterval,
type RefreshIntervalMs, type RefreshIntervalMs,
} from "../ui/refresh-interval/RefreshInterval.js"; } from "../ui/refresh-interval/RefreshInterval.js";
import { Table, type TableColumn } from "../ui/table/Table.js"; import { Table, useTableFilters, type TableColumn } from "../ui/table/Table.js";
import { CreateAgentForm } from "./CreateAgentForm.js"; import { CreateAgentForm } from "./CreateAgentForm.js";
import { LinkMatrixAccountForm } from "./LinkMatrixAccountForm.js"; import { LinkMatrixAccountForm } from "./LinkMatrixAccountForm.js";
import "./AgentsPage.css"; import "./AgentsPage.css";
@ -289,6 +289,7 @@ function AgentCard({
row, row,
pending, pending,
error, error,
selected,
onSelectUp, onSelectUp,
onSelectOffline, onSelectOffline,
onSelectPaused, onSelectPaused,
@ -297,6 +298,11 @@ function AgentCard({
row: AgentRow; row: AgentRow;
pending: boolean; pending: boolean;
error: ProblemDetails | undefined; error: ProblemDetails | undefined;
/** This row is the one currently shown in the detail panel a
* highlight only (the panel itself is the source of truth), so a
* page-refresh landing on a still-valid `detailTarget` reads as
* obviously "that one" rather than a silent selection nothing marks. */
selected: boolean;
onSelectUp: (row: AgentRow) => void; onSelectUp: (row: AgentRow) => void;
onSelectOffline: (row: AgentRow) => void; onSelectOffline: (row: AgentRow) => void;
onSelectPaused: (row: AgentRow) => void; onSelectPaused: (row: AgentRow) => void;
@ -305,7 +311,9 @@ function AgentCard({
const { tone, label } = FRESHNESS[row.freshness]; const { tone, label } = FRESHNESS[row.freshness];
return ( return (
<div <div
class="ui-agent-card" class={
selected ? "ui-agent-card ui-agent-card-selected" : "ui-agent-card"
}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => onOpenDetail(row)} onClick={() => onOpenDetail(row)}
@ -363,6 +371,180 @@ function AgentCard({
); );
} }
// One `<input>` (the "name" column's plain-text filter) plus one
// `FilterMultiselect` per multiselect-mode filterable column — the card
// view's answer to `Table`'s own per-column popover icons, driving the
// exact same `useTableFilters` state `AgentsPage` passes down (mara:
// "same filters for the cards tho"). Deliberately not a generic
// "render every filterable column automatically" loop: the name column's
// free-text filter needs its own always-visible `<input>`, not a
// dropdown, so there's no single shape that covers every column anyway.
function AgentFilterBar({
columns,
getFilter,
updateFilter,
toggleFilterValue,
resetFilters,
activeFilters,
multiselectOptionsFor,
}: {
columns: TableColumn<AgentRow>[];
getFilter: (key: string) => {
value: string;
values: string[];
negate: boolean;
};
updateFilter: (
key: string,
patch: Partial<{ value: string; values: string[]; negate: boolean }>,
) => void;
toggleFilterValue: (key: string, value: string) => void;
resetFilters: () => void;
activeFilters: unknown[];
multiselectOptionsFor: (c: TableColumn<AgentRow>) => string[];
}) {
const hiveCol = columns.find((c) => c.key === "hive");
const statusCol = columns.find((c) => c.key === "status");
const wantedCol = columns.find((c) => c.key === "wanted");
return (
<div class="ui-agents-filter-bar">
<input
type="text"
class="ui-agents-filter-search"
placeholder="filter by name…"
aria-label="filter by name"
value={getFilter("name").value}
onInput={(e) =>
updateFilter("name", {
value: (e.target as HTMLInputElement).value,
})
}
/>
{hiveCol ? (
<FilterMultiselect
label="hive"
options={multiselectOptionsFor(hiveCol)}
filter={getFilter("hive")}
onToggle={(v) => toggleFilterValue("hive", v)}
onNegateChange={(negate) => updateFilter("hive", { negate })}
/>
) : null}
{statusCol ? (
<FilterMultiselect
label="status"
options={multiselectOptionsFor(statusCol)}
filter={getFilter("status")}
onToggle={(v) => toggleFilterValue("status", v)}
onNegateChange={(negate) => updateFilter("status", { negate })}
/>
) : null}
{wantedCol ? (
<FilterMultiselect
label="wanted"
options={multiselectOptionsFor(wantedCol)}
filter={getFilter("wanted")}
onToggle={(v) => toggleFilterValue("wanted", v)}
onNegateChange={(negate) => updateFilter("wanted", { negate })}
/>
) : null}
{activeFilters.length > 0 ? (
<button
type="button"
class="ui-table-reset-filters"
onClick={resetFilters}
>
reset filters
</button>
) : null}
</div>
);
}
// One multiselect filter's trigger + popover — same checkbox-list markup
// `Table`'s own popover renders (`.ui-table-filter-checkbox`/
// `-negate`, reused rather than duplicated), just `position: absolute`
// under a `Badge` trigger instead of a portal: this never sits inside a
// clipping scroll container or a `<dialog>` the way `WantedMenu`'s
// portal mode has to route around, so the simpler positioning is enough.
function FilterMultiselect({
label,
options,
filter,
onToggle,
onNegateChange,
}: {
label: string;
options: string[];
filter: { values: string[]; negate: boolean };
onToggle: (value: string) => void;
onNegateChange: (negate: boolean) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handlePointerDown(e: PointerEvent) {
if (e.target instanceof Node && ref.current?.contains(e.target)) return;
setOpen(false);
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
return (
<div class="ui-agents-filter" ref={ref}>
<Badge
variant="quiet"
value={
filter.values.length > 0
? `${label} (${filter.values.length})`
: label
}
onClick={() => setOpen((o) => !o)}
expanded={open}
/>
{open ? (
<div
class="ui-agents-filter-popover ui-table-filter-popover"
role="dialog"
aria-label={`filter by ${label}`}
>
<div class="ui-table-filter-multiselect">
{options.map((v) => (
<label key={v} class="ui-table-filter-checkbox">
<input
type="checkbox"
checked={filter.values.includes(v)}
onChange={() => onToggle(v)}
/>
{v}
</label>
))}
</div>
<label class="ui-table-filter-negate">
<input
type="checkbox"
checked={filter.negate}
onChange={(e) =>
onNegateChange((e.target as HTMLInputElement).checked)
}
/>
exclude
</label>
</div>
) : null}
</div>
);
}
export function AgentsPage() { export function AgentsPage() {
const [rows, setRows] = useState<AgentRow[] | null>(null); const [rows, setRows] = useState<AgentRow[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null); const [error, setError] = useState<ProblemDetails | null>(null);
@ -666,10 +848,28 @@ export function AgentsPage() {
}, },
]; ];
// Same hook `Table` itself now uses internally, called again here with
// the identical `columns` + `storageKey` — one filter state (in
// localStorage), two independent readers. `rows ?? []`: the hook wants
// a real array, and there's nothing to filter before the first
// `refresh()` resolves anyway.
const {
visibleRows,
activeFilters,
getFilter,
updateFilter,
toggleFilterValue,
resetFilters,
multiselectOptionsFor,
} = useTableFilters(columns, rows ?? [], "swarm-ui:agents:table-filters");
return ( return (
<>
<div class="ui-agents-layout">
<Panel <Panel
title="agents" title="agents"
icon="👥" icon="👥"
class="ui-agents-list-panel"
actions={ actions={
<> <>
<Button variant="primary" onClick={() => setCreateOpen(true)}> <Button variant="primary" onClick={() => setCreateOpen(true)}>
@ -710,24 +910,45 @@ export function AgentsPage() {
{!error && rows === null ? <p>loading</p> : null} {!error && rows === null ? <p>loading</p> : null}
{rows && rows.length === 0 ? ( {rows && rows.length === 0 ? (
<p class="ui-agents-empty"> <p class="ui-agents-empty">
no agents yet the swarm-wide identity store has no agents registered no agents yet the swarm-wide identity store has no agents
on any hive registered on any hive
</p> </p>
) : null} ) : null}
{/* Card view's own filter toolbar table view keeps its
existing per-column popovers instead (see `columns` above);
both read/write the same `useTableFilters` state (same
`storageKey`), so switching the view toggle doesn't reset or
hide whatever's filtered. */}
{rows && rows.length > 0 && viewMode === "cards" ? (
<AgentFilterBar
columns={columns}
getFilter={getFilter}
updateFilter={updateFilter}
toggleFilterValue={toggleFilterValue}
resetFilters={resetFilters}
activeFilters={activeFilters}
multiselectOptionsFor={multiselectOptionsFor}
/>
) : null}
{rows && rows.length > 0 && viewMode === "cards" ? ( {rows && rows.length > 0 && viewMode === "cards" ? (
<div class="ui-agent-card-list"> <div class="ui-agent-card-list">
{rows.map((a) => ( {visibleRows.length === 0 ? (
<p class="ui-agents-empty">no rows match the current filter</p>
) : (
visibleRows.map((a) => (
<AgentCard <AgentCard
key={a.name} key={a.name}
row={a} row={a}
pending={pendingAgents.has(a.name)} pending={pendingAgents.has(a.name)}
error={actionErrors.get(a.name)} error={actionErrors.get(a.name)}
selected={detailTarget?.name === a.name}
onSelectUp={selectUp} onSelectUp={selectUp}
onSelectOffline={selectOffline} onSelectOffline={selectOffline}
onSelectPaused={selectPaused} onSelectPaused={selectPaused}
onOpenDetail={setDetailTarget} onOpenDetail={setDetailTarget}
/> />
))} ))
)}
</div> </div>
) : null} ) : null}
{rows && rows.length > 0 && viewMode === "table" ? ( {rows && rows.length > 0 && viewMode === "table" ? (
@ -738,18 +959,22 @@ export function AgentsPage() {
storageKey="swarm-ui:agents:table-filters" storageKey="swarm-ui:agents:table-filters"
/> />
) : null} ) : null}
<Dialog </Panel>
open={detailTarget !== null} {/* A second on-page `Panel`, not a modal mara: "why no separate
onClose={() => setDetailTarget(null)} panel? ... i mean a second panel on agent page." Always
label={detailTarget ? `${detailTarget.name} details` : "agent details"} mounted (an empty state when nothing's selected) rather than
conditionally rendered, so picking an agent never causes the
page's own layout to jump. `.ui-agents-layout`'s `flex-wrap`
stacks this below the list on a narrow viewport, same
content-driven-not-a-fixed-breakpoint approach the shell's own
nav already uses (`Shell.css`), rather than a new media query. */}
<Panel
title={detailTarget ? detailTarget.name : "agent details"}
icon="🔎"
class="ui-agents-detail-panel"
> >
{/* Mara: "the info from main list should be included in the
agent view" so this repeats status/message/wanted rather
than showing only what `AgentCard`'s main view doesn't, plus
hive/config-PR/matrix-link, which really are panel-only. */}
{detailTarget ? ( {detailTarget ? (
<div class="ui-agent-detail"> <div class="ui-agent-detail">
<h2 class="ui-agent-detail-name">{detailTarget.name}</h2>
<dl class="ui-agent-detail-fields"> <dl class="ui-agent-detail-fields">
<dt>status</dt> <dt>status</dt>
<dd> <dd>
@ -790,10 +1015,7 @@ export function AgentsPage() {
onSelectUp={selectUp} onSelectUp={selectUp}
onSelectOffline={selectOffline} onSelectOffline={selectOffline}
onSelectPaused={selectPaused} onSelectPaused={selectPaused}
onDestroy={(row) => { onDestroy={setDestroyTarget}
setDestroyTarget(row);
setDetailTarget(null);
}}
/> />
</dd> </dd>
<dt>hive</dt> <dt>hive</dt>
@ -829,10 +1051,7 @@ export function AgentsPage() {
value="link matrix account" value="link matrix account"
onClick={ onClick={
detailTarget.hive detailTarget.hive
? () => { ? () => setMatrixTarget(detailTarget)
setMatrixTarget(detailTarget);
setDetailTarget(null);
}
: undefined : undefined
} }
disabled={!detailTarget.hive} disabled={!detailTarget.hive}
@ -844,8 +1063,11 @@ export function AgentsPage() {
/> />
</div> </div>
</div> </div>
) : null} ) : (
</Dialog> <p class="ui-agents-empty">select an agent to see its details</p>
)}
</Panel>
</div>
<Dialog <Dialog
open={createOpen} open={createOpen}
onClose={() => setCreateOpen(false)} onClose={() => setCreateOpen(false)}
@ -900,6 +1122,6 @@ export function AgentsPage() {
</p> </p>
) : null} ) : null}
</ConfirmDialog> </ConfirmDialog>
</Panel> </>
); );
} }

View file

@ -34,14 +34,19 @@ export function Panel({
icon, icon,
actions, actions,
children, children,
class: extraClass,
}: { }: {
title?: string; title?: string;
icon?: string; icon?: string;
actions?: ComponentChildren; actions?: ComponentChildren;
children: ComponentChildren; children: ComponentChildren;
/** Extra class on the outer `<section>` e.g. flex-sizing a panel
* that's one of several sharing a row (`AgentsPage`'s list/detail
* split). Omit for the plain single-panel case every other caller is. */
class?: string;
}) { }) {
return ( return (
<section class="ui-panel"> <section class={extraClass ? `ui-panel ${extraClass}` : "ui-panel"}>
{title || icon || actions ? ( {title || icon || actions ? (
<div class="ui-panel-header"> <div class="ui-panel-header">
{icon ? ( {icon ? (

View file

@ -133,6 +133,101 @@ function compareValues(a: string | number, b: string | number): number {
}); });
} }
// The filter *state* half of `<Table>`, split out so a second, differently
// shaped view over the same rows (AgentsPage's card list, switchable
// against its own `Table`) can share one filter state instead of forking
// its own — mara: "separating data and filter from view" lets the choice
// of view (table vs. card) stop mattering to what's filtered. `Table`
// itself calls this internally below; the popover-trigger UI stays here,
// table-shaped (a `<th>`-anchored icon) — a caller with a different
// layout renders its own trigger against the same `getFilter`/
// `updateFilter`/`toggleFilterValue`/`multiselectOptionsFor` this returns.
export function useTableFilters<T>(
columns: TableColumn<T>[],
rows: T[],
storageKey: string,
) {
const [filters, setFilters] = useLocalSetting<
Record<string, ColumnFilterState>
>(storageKey, {});
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({});
}
// 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 }),
);
}
const visibleRows = useMemo(() => {
if (activeFilters.length === 0) return rows;
return rows.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;
}),
);
// 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, JSON.stringify(activeFilters)]);
return {
visibleRows,
activeFilters,
getFilter,
updateFilter,
toggleFilterValue,
resetFilters,
multiselectOptionsFor,
};
}
export function Table<T>({ export function Table<T>({
columns, columns,
rows, rows,
@ -163,13 +258,18 @@ export function Table<T>({
// 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 state per filterable column key, only populated for a column // Filter *state* lives in `useTableFilters` now (see its own comment) —
// the operator has actually touched. Persisted (see `storageKey` // this component only owns the popover-trigger UI below, table-shaped
// above), same `useLocalSetting` plumbing `IssueReportPage` already // (a `<th>`-anchored icon).
// used for its own now-folded-in label filter. const {
const [filters, setFilters] = useLocalSetting< visibleRows: filteredRows,
Record<string, ColumnFilterState> activeFilters,
>(storageKey, {}); getFilter,
updateFilter,
toggleFilterValue,
resetFilters,
multiselectOptionsFor,
} = useTableFilters(columns, rows, 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.
@ -228,34 +328,6 @@ export function Table<T>({
}; };
}, [openFilterKey]); }, [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 // 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
// checked by CSS class rather than a ref: unlike `Dropdown`, there's // checked by CSS class rather than a ref: unlike `Dropdown`, there's
@ -282,40 +354,23 @@ export function Table<T>({
}; };
}, [openFilterKey]); }, [openFilterKey]);
// Sort applies on top of `useTableFilters`'s already-filtered rows —
// sorting stays table-only (no other view has asked for it), so it's
// layered here rather than folded into the shared hook.
const visibleRows = useMemo(() => { const visibleRows = useMemo(() => {
let out = rows; if (!sort) return filteredRows;
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 col = columns.find((c) => c.key === sort.key);
const sortBy = col?.sortBy; const sortBy = col?.sortBy;
if (sortBy) { if (!sortBy) return filteredRows;
out = [...out].sort((a, b) => { return [...filteredRows].sort((a, b) => {
const cmp = compareValues(sortBy(a), sortBy(b)); const cmp = compareValues(sortBy(a), sortBy(b));
return sort.dir === "asc" ? cmp : -cmp; return sort.dir === "asc" ? cmp : -cmp;
}); });
}
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps -- `columns` is // eslint-disable-next-line react-hooks/exhaustive-deps -- `columns` is
// a fresh array every render (built inline by every caller); keying // a fresh array every render (built inline by every caller); keying
// off its identity would recompute every render regardless, so this // off its identity would recompute every render regardless, so this
// depends on the values that actually determine the output instead. // depends on the values that actually determine the output instead.
}, [rows, sort, JSON.stringify(activeFilters)]); }, [filteredRows, sort]);
function toggleSort(key: string) { function toggleSort(key: string) {
setSort((prev) => { setSort((prev) => {
@ -333,22 +388,6 @@ export function Table<T>({
return sort.dir === "asc" ? "ascending" : "descending"; 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 // 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,
@ -443,7 +482,7 @@ export function Table<T>({
<thead> <thead>
<tr> <tr>
{columns.map((c) => { {columns.map((c) => {
const hasFilterValue = isFilterActive(filters[c.key]); const hasFilterValue = isFilterActive(getFilter(c.key));
const filterOpen = openFilterKey === c.key; const filterOpen = openFilterKey === c.key;
const headerContent = c.sortBy ? ( const headerContent = c.sortBy ? (
<button <button