diff --git a/frontend/packages/shared/src/icons.tsx b/frontend/packages/shared/src/icons.tsx
index 6a72fabd..01b2b3eb 100644
--- a/frontend/packages/shared/src/icons.tsx
+++ b/frontend/packages/shared/src/icons.tsx
@@ -56,3 +56,26 @@ export function LinkIcon() {
);
}
+
+// A funnel, the standard "filter" glyph — smaller than the other two
+// (1em not 1.3em) since its first caller sits inside a table header
+// cell, not a nav trigger button; scale via the caller's own
+// `font-size` like the others rather than a hardcoded size prop, same
+// reasoning as `GearIcon`/`LinkIcon` above (`currentColor` stroke).
+export function FilterIcon() {
+ return (
+
+ );
+}
diff --git a/frontend/packages/swarm-ui/src/ui/table/Table.css b/frontend/packages/swarm-ui/src/ui/table/Table.css
index 30fb1f81..43f5cfd1 100644
--- a/frontend/packages/swarm-ui/src/ui/table/Table.css
+++ b/frontend/packages/swarm-ui/src/ui/table/Table.css
@@ -63,12 +63,67 @@
opacity: 1;
}
-/* The per-column filter inputs' row — same cell padding as a header row,
- no bottom border of its own (the header row above already has one). */
-.ui-table-filter-row th {
- padding-top: 0;
- padding-bottom: 0.5em;
- font-weight: 400;
+/* A filterable header cell anchors its own popover — `position: relative`
+ is what lets `.ui-table-filter-popover`'s `position: absolute` below
+ sit under this `
` specifically rather than the page. */
+.ui-table-th-filterable {
+ position: relative;
+}
+
+/* The filter-icon trigger — invisible by default, `opacity` transition
+ rather than a hard show/hide (mara: "fade in out animation"). Shown
+ on three signals, matching Table.tsx's own comment on `filterValue`:
+ hovering/focusing the header cell (so it's discoverable without a
+ filter already set), the popover being open (still visible while
+ you're using it, even if the pointer has moved off the header), or
+ `.ui-table-filter-icon-active` — a filter is *set* on this column,
+ so the icon stays put as a persistent "this column is filtered"
+ indicator rather than disappearing the moment the header isn't
+ hovered (mara: "when a filter is set, the filter icon does not
+ disappear"). `:focus-within` (not just `:hover`) so a keyboard user
+ tabbing to the icon sees it appear too, not just a mouse hovering. */
+.ui-table-filter-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ margin-left: 0.35em;
+ padding: 0.15em;
+ background: none;
+ border: none;
+ border-radius: 4px;
+ color: var(--muted);
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity 0.15s ease;
+}
+.ui-table-th-filterable:hover .ui-table-filter-icon,
+.ui-table-th-filterable:focus-within .ui-table-filter-icon,
+.ui-table-filter-icon-active {
+ opacity: 1;
+}
+.ui-table-filter-icon:hover {
+ color: var(--fg);
+ background: var(--bg);
+}
+/* A filter is live on this column — `--purple`, theme.css's own "active
+ tabs, links, highlights" accent, so an operator scanning the header
+ row reads it the same way anything else marked active does. */
+.ui-table-filter-icon-active {
+ color: var(--purple);
+}
+
+.ui-table-filter-popover {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ z-index: 5;
+ margin-top: 0.35em;
+ min-width: 12em;
+ padding: 0.5em;
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
}
.ui-table-filter-input,
.ui-table-filter-select {
diff --git a/frontend/packages/swarm-ui/src/ui/table/Table.tsx b/frontend/packages/swarm-ui/src/ui/table/Table.tsx
index 9a1c6887..7d3572b9 100644
--- a/frontend/packages/swarm-ui/src/ui/table/Table.tsx
+++ b/frontend/packages/swarm-ui/src/ui/table/Table.tsx
@@ -9,8 +9,9 @@
// 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 { useMemo, useState } from "preact/hooks";
+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 {
@@ -51,11 +52,16 @@ export interface TableColumn {
*/
sortBy?: (row: T) => string | number;
/**
- * Column is filterable (a control in its own row under the headers)
- * 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`.
+ * 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;
/**
@@ -108,10 +114,39 @@ export function Table({
// 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 filterableColumns = columns.filter((c) => c.filterValue);
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) {
@@ -171,74 +206,132 @@ export function Table({
);
}
+ // 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 (