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).
This commit is contained in:
iris 2026-09-08 11:25:48 +02:00 committed by mara
commit e234afa26a
3 changed files with 244 additions and 73 deletions

View file

@ -56,3 +56,26 @@ export function LinkIcon() {
</svg>
);
}
// 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 (
<svg
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</svg>
);
}

View file

@ -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 `<th>` 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 {

View file

@ -9,8 +9,9 @@
// a `<table>` 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<T> {
@ -51,11 +52,16 @@ export interface TableColumn<T> {
*/
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<T>({
// Filter text per filterable column key, only populated for a column
// whose input the operator has actually typed into.
const [filters, setFilters] = useState<Record<string, string>>({});
// 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<string | null>(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<T>({
);
}
// 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<HTMLInputElement |
// HTMLSelectElement>` doesn't structurally satisfy either element's
// own narrower `ref` prop type, but a plain callback taking the
// union does.
const popoverControlRef = useRef<HTMLElement | null>(null);
function setPopoverControlRef(el: HTMLElement | null) {
popoverControlRef.current = el;
}
useEffect(() => {
if (openFilterKey !== null) popoverControlRef.current?.focus();
}, [openFilterKey]);
function filterLabel(c: TableColumn<T>): string {
return `filter by ${typeof c.header === "string" ? c.header : c.key}`;
}
function renderFilterControl(c: TableColumn<T>) {
if (c.filterMode === "select") {
return (
<select
ref={setPopoverControlRef}
class="ui-table-filter-select"
aria-label={filterLabel(c)}
value={filters[c.key] ?? ""}
onChange={(e) => {
const v = (e.target as HTMLSelectElement).value;
setFilters((prev) => ({ ...prev, [c.key]: v }));
}}
>
<option value="">any</option>
{selectOptionsFor(c).map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
);
}
return (
<input
ref={setPopoverControlRef}
type="text"
class="ui-table-filter-input"
placeholder="filter…"
aria-label={filterLabel(c)}
value={filters[c.key] ?? ""}
onInput={(e) => {
const v = (e.target as HTMLInputElement).value;
setFilters((prev) => ({ ...prev, [c.key]: v }));
}}
/>
);
}
return (
<div class="ui-table-scroll">
<table class="ui-table">
<thead>
<tr>
{columns.map((c) =>
c.sortBy ? (
<th key={c.key} aria-sort={ariaSortFor(c)}>
<button
type="button"
class="ui-table-sort-button"
onClick={() => toggleSort(c.key)}
>
{c.header}
<span class="ui-table-sort-glyph" aria-hidden="true">
{sort?.key === c.key
? sort.dir === "asc"
? "▲"
: "▼"
: "↕"}
</span>
</button>
</th>
) : (
<th key={c.key} aria-sort={ariaSortFor(c)}>
{columns.map((c) => {
const hasFilterValue = (filters[c.key] ?? "") !== "";
const filterOpen = openFilterKey === c.key;
const headerContent = c.sortBy ? (
<button
type="button"
class="ui-table-sort-button"
onClick={() => toggleSort(c.key)}
>
{c.header}
</th>
),
)}
</tr>
{filterableColumns.length > 0 ? (
<tr class="ui-table-filter-row">
{columns.map((c) => (
<th key={c.key}>
{c.filterValue && c.filterMode === "select" ? (
<select
class="ui-table-filter-select"
aria-label={`filter by ${typeof c.header === "string" ? c.header : c.key}`}
value={filters[c.key] ?? ""}
onChange={(e) => {
const v = (e.target as HTMLSelectElement).value;
setFilters((prev) => ({ ...prev, [c.key]: v }));
}}
>
<option value="">any</option>
{selectOptionsFor(c).map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
) : c.filterValue ? (
<input
type="text"
class="ui-table-filter-input"
placeholder="filter…"
aria-label={`filter by ${typeof c.header === "string" ? c.header : c.key}`}
value={filters[c.key] ?? ""}
onInput={(e) => {
const v = (e.target as HTMLInputElement).value;
setFilters((prev) => ({ ...prev, [c.key]: v }));
}}
/>
<span class="ui-table-sort-glyph" aria-hidden="true">
{sort?.key === c.key
? sort.dir === "asc"
? "▲"
: "▼"
: "↕"}
</span>
</button>
) : (
c.header
);
return (
<th
key={c.key}
aria-sort={ariaSortFor(c)}
class={c.filterValue ? "ui-table-th-filterable" : undefined}
>
{headerContent}
{c.filterValue ? (
<>
<button
type="button"
class={
"ui-table-filter-icon" +
(filterOpen || hasFilterValue
? " ui-table-filter-icon-active"
: "")
}
aria-label={filterLabel(c)}
aria-expanded={filterOpen}
onClick={() =>
setOpenFilterKey((k) => (k === c.key ? null : c.key))
}
>
<FilterIcon />
</button>
{filterOpen ? (
<div
class="ui-table-filter-popover"
role="dialog"
aria-label={filterLabel(c)}
>
{renderFilterControl(c)}
</div>
) : null}
</>
) : null}
</th>
))}
</tr>
) : null}
);
})}
</tr>
</thead>
<tbody>
{rows.length === 0 && emptyMessage ? (