diff --git a/frontend/packages/shared/src/icons.tsx b/frontend/packages/shared/src/icons.tsx index 01b2b3eb..6a72fabd 100644 --- a/frontend/packages/shared/src/icons.tsx +++ b/frontend/packages/shared/src/icons.tsx @@ -56,26 +56,3 @@ 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 9f434e8c..30fb1f81 100644 --- a/frontend/packages/swarm-ui/src/ui/table/Table.css +++ b/frontend/packages/swarm-ui/src/ui/table/Table.css @@ -63,71 +63,12 @@ opacity: 1; } -/* `.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 - 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); -} - -/* `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 { - z-index: 10; - 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); +/* 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; } .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 5d988750..9a1c6887 100644 --- a/frontend/packages/swarm-ui/src/ui/table/Table.tsx +++ b/frontend/packages/swarm-ui/src/ui/table/Table.tsx @@ -9,10 +9,8 @@ // 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 { createPortal } from "preact/compat"; +import { useMemo, useState } from "preact/hooks"; import type { ComponentChildren } from "preact"; -import { FilterIcon } from "@hive/shared/icons.js"; import "./Table.css"; export interface TableColumn { @@ -53,16 +51,11 @@ export interface TableColumn { */ sortBy?: (row: T) => string | number; /** - * 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. + * 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`. */ filterValue?: (row: T) => string; /** @@ -115,92 +108,10 @@ 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); - // 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 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) { @@ -260,130 +171,74 @@ 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) => { - const hasFilterValue = (filters[c.key] ?? "") !== ""; - const filterOpen = openFilterKey === c.key; - const headerContent = c.sortBy ? ( - + {columns.map((c) => + c.sortBy ? ( + ) : ( - c.header - ); - return ( - + ), + )} + + {filterableColumns.length > 0 ? ( + + {columns.map((c) => ( + - ); - })} - + ))} + + ) : null} {rows.length === 0 && emptyMessage ? ( @@ -414,29 +269,6 @@ export function Table({ )}
+ + { - if (!c.filterValue) return; - if (el) thRefs.current.set(c.key, el); - else thRefs.current.delete(c.key); - }} - > - {headerContent} - {c.filterValue ? ( - + {c.header} +
+ {c.filterValue && c.filterMode === "select" ? ( + + ) : c.filterValue ? ( + { + const v = (e.target as HTMLInputElement).value; + setFilters((prev) => ({ ...prev, [c.key]: v })); + }} + /> ) : null}
- {openFilterKey !== null && popoverPos - ? createPortal( - (() => { - const openColumn = columns.find((c) => c.key === openFilterKey); - if (!openColumn) return null; - return ( - - ); - })(), - document.body, - ) - : null}
); }