// — 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 type { ComponentChildren } from "preact"; import { FilterIcon } from "@hive/shared/icons.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; }) { // 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 text per filterable column key, only populated for a column // whose input the operator has actually typed into. const [filters, setFilters] = useState>({}); // 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); const activeFilters = Object.entries(filters).filter(([, v]) => v !== ""); // 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, query]) => { const col = columns.find((c) => c.key === key); const value = col?.filterValue?.(row) ?? ""; if (col?.filterMode === "select") return value === query; return value.toLowerCase().includes(query.toLowerCase()); }), ); } 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 `"select"`-mode column, so // the dropdown 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. function selectOptionsFor(c: TableColumn): string[] { if (!c.filterValue) return []; const values = new Set(rows.map(c.filterValue)); 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. A callback ref // (not `useRef`'s object form attached directly to the element) // because the element is one of two different concrete types // depending on `filterMode` — `RefObject` doesn't structurally satisfy either element's // own narrower `ref` prop type, but a plain callback taking the // union does. const popoverControlRef = useRef(null); function setPopoverControlRef(el: HTMLElement | null) { popoverControlRef.current = el; } useEffect(() => { if (openFilterKey !== null) popoverControlRef.current?.focus(); }, [openFilterKey]); function filterLabel(c: TableColumn): string { return `filter by ${typeof c.header === "string" ? c.header : c.key}`; } function renderFilterControl(c: TableColumn) { if (c.filterMode === "select") { return ( ); } return ( { const v = (e.target as HTMLInputElement).value; setFilters((prev) => ({ ...prev, [c.key]: v })); }} /> ); } return (
` — 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 `
{columns.map((c) => { const hasFilterValue = (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) => ( ))} )) )}
{headerContent} {c.filterValue ? ( <> {filterOpen ? ( ) : null} ) : null}
{emptyMessage}
no rows match the current filter
{c.render(row)}
); }