From e234afa26a7ee526cf55d5e84523e8f825b6aecb Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 8 Sep 2026 11:25:48 +0200 Subject: [PATCH 1/2] swarm-ui: table filters move to a per-header icon + popover Replaces the permanent filter-row under Table's headers with a small filter-icon button in each filterable column's own header cell. The icon fades in on header hover/focus via a CSS opacity transition, or stays visible outright once that column actually has a filter set (mara: "instead of a filter row, add little filter icons on header hover with fade in out animation ... when a filter is set, the filter icon does not disappear"). Clicking it opens a small anchored popover directly under the header holding the exact same filter control filterMode already provides (text input or select) -- the underlying filter mechanism from hyperhive#4088 is unchanged, only where the control lives moved. Close-on-outside-click/Escape mirrors the contract Dropdown already gives its own popover, adapted to a shared listener across every column instead of a ref per column since only one popover is ever open at a time. New FilterIcon in @hive/shared's icons.tsx (a plain inline SVG funnel, same Feather/lucide-style shape as the existing GearIcon/LinkIcon) -- found and reused that pattern rather than reaching for an emoji glyph, matching the documented reason those two exist as SVG in the first place (mara, on the old emoji icons: inconsistent size/weight across platforms). --- frontend/packages/shared/src/icons.tsx | 23 ++ .../packages/swarm-ui/src/ui/table/Table.css | 67 +++++- .../packages/swarm-ui/src/ui/table/Table.tsx | 227 ++++++++++++------ 3 files changed, 244 insertions(+), 73 deletions(-) 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 (
- {columns.map((c) => - c.sortBy ? ( - - ) : ( - - ), - )} - - {filterableColumns.length > 0 ? ( - - {columns.map((c) => ( - - ))} - - ) : null} + ); + })} + {rows.length === 0 && emptyMessage ? ( From 63f3afae3ea69ba2f939392267ce2de480c104b5 Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 8 Sep 2026 11:47:31 +0200 Subject: [PATCH 2/2] swarm-ui: portal the filter popover past the table's own clip argus's review (reproduced, not speculative): .ui-table-scroll sets overflow-x: auto with overflow-y left unset. Per the CSS overflow spec, an axis left visible computes to auto too once the other axis isn't visible -- so this box silently clips vertically as well as horizontally. The popover was position: absolute; top: 100% under its header ); @@ -362,6 +414,29 @@ export function Table({ )}
- - + {columns.map((c) => { + const hasFilterValue = (filters[c.key] ?? "") !== ""; + const filterOpen = openFilterKey === c.key; + const headerContent = c.sortBy ? ( +
- {c.filterValue && c.filterMode === "select" ? ( - - ) : c.filterValue ? ( - { - const v = (e.target as HTMLInputElement).value; - setFilters((prev) => ({ ...prev, [c.key]: v })); - }} - /> + + + ) : ( + c.header + ); + return ( + + {headerContent} + {c.filterValue ? ( + <> + + {filterOpen ? ( + + ) : null} + ) : null}
, itself inside .ui-table-scroll -- on a table shorter than header-plus-popover (a small hive's roster, or any table narrowed by an existing filter), the popover got cut off at the scroll box's own bottom edge, with a stray vertical scrollbar as the visible symptom. Computes the popover's viewport position from the anchor 's own getBoundingClientRect() and renders it via a portal onto document.body, position: fixed -- escapes .ui-table-scroll's clip the same way any position: fixed element escapes an ancestor's overflow (neither .ui-table-scroll nor .ui-table establishes a new containing block). Recomputes on scroll (capture-phase window listener, the standard technique for detecting scroll on a nested scroll container without binding to every ancestor by hand) and resize while open, so the popover stays anchored rather than only positioning once at click time. createPortal comes from preact/compat, already resolvable through the existing preact dependency -- no new package.json entry. Verified against argus's own repro shape: a 1-row table, scripted click on the filter icon (same real-DOM-event technique as this PR's first round), screenshot shows the popover rendering fully rather than clipped, no stray scrollbar. --- .../packages/swarm-ui/src/ui/table/Table.css | 28 ++-- .../packages/swarm-ui/src/ui/table/Table.tsx | 129 ++++++++++++++---- 2 files changed, 118 insertions(+), 39 deletions(-) diff --git a/frontend/packages/swarm-ui/src/ui/table/Table.css b/frontend/packages/swarm-ui/src/ui/table/Table.css index 43f5cfd1..9f434e8c 100644 --- a/frontend/packages/swarm-ui/src/ui/table/Table.css +++ b/frontend/packages/swarm-ui/src/ui/table/Table.css @@ -63,14 +63,14 @@ opacity: 1; } -/* 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; -} +/* `.ui-table-th-filterable` (set on a filterable column's ``, no + rule of its own) exists purely as a `:hover`/`:focus-within` selector + target for the fade below — nothing to do with positioning the + popover itself, which is computed in JS and rendered via a portal + instead (see Table.tsx's own comment on `popoverPos` for why it + isn't just `position: absolute` under this ``). -/* The filter-icon trigger — invisible by default, `opacity` transition + 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 @@ -112,12 +112,16 @@ color: var(--purple); } +/* `position`/`top`/`left` are inline styles, not here — Table.tsx + computes them from the anchor ``'s own `getBoundingClientRect()` + and renders this via a portal onto `document.body` (see its own + `popoverPos` comment for the full reasoning: escaping + `.ui-table-scroll`'s clip). `z-index: 10` matches the existing + overlay-menu convention (`LinksMenu`/`UserMenu`), now that this + sits alongside them as a `document.body`-level sibling rather than + nested inside the table. */ .ui-table-filter-popover { - position: absolute; - top: 100%; - left: 0; - z-index: 5; - margin-top: 0.35em; + z-index: 10; min-width: 12em; padding: 0.5em; background: var(--bg-elev); diff --git a/frontend/packages/swarm-ui/src/ui/table/Table.tsx b/frontend/packages/swarm-ui/src/ui/table/Table.tsx index 7d3572b9..5d988750 100644 --- a/frontend/packages/swarm-ui/src/ui/table/Table.tsx +++ b/frontend/packages/swarm-ui/src/ui/table/Table.tsx @@ -10,6 +10,7 @@ // 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 "./Table.css"; @@ -118,6 +119,59 @@ export function Table({ // 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 ``'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(([, v]) => v !== ""); @@ -297,36 +351,34 @@ export function Table({ 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 ? ( - <> - - {filterOpen ? ( - - ) : null} - + ) : null}
+ {openFilterKey !== null && popoverPos + ? createPortal( + (() => { + const openColumn = columns.find((c) => c.key === openFilterKey); + if (!openColumn) return null; + return ( + + ); + })(), + document.body, + ) + : null} ); }