// — 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 `
` 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 { 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 `` — 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 >(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(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 `
` — 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 `` — `'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 `
`'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 `` 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>(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) { 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, ): "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): string[] { if (!c.filterValues) return []; const values = new Set(); 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(null); useEffect(() => { if (openFilterKey !== null) popoverControlRef.current?.focus(); }, [openFilterKey]); function filterLabel(c: TableColumn): 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) { const f = getFilter(c.key); return ( ); } function renderFilterControl(c: TableColumn) { if (c.filterMode === "multiselect") { const f = getFilter(c.key); return ( <>
{multiselectOptionsFor(c).map((v) => ( ))}
{renderNegateToggle(c)} ); } return ( <> { const v = (e.target as HTMLInputElement).value; updateFilter(c.key, { value: v }); }} /> {renderNegateToggle(c)} ); } const anyActiveFilter = activeFilters.length > 0; return ( <> {anyActiveFilter ? ( ) : null}
{columns.map((c) => { const hasFilterValue = isFilterActive(filters[c.key]); const filterOpen = openFilterKey === c.key; const headerContent = c.sortBy ? ( ) : ( c.header ); return ( ); })} {rows.length === 0 && emptyMessage ? ( ) : 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". ) : ( visibleRows.map((row) => ( {columns.map((c) => ( ))} )) )}
{ if (!isFilterable(c)) return; if (el) thRefs.current.set(c.key, el); else thRefs.current.delete(c.key); }} > {headerContent} {isFilterable(c) ? ( ) : null}
{emptyMessage}
no rows match the current filter
{c.render(row)}
{openFilterKey !== null && popoverPos ? createPortal( (() => { const openColumn = columns.find((c) => c.key === openFilterKey); if (!openColumn) return null; return ( ); })(), document.body, ) : null} ); }