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 ? (
- - + {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}