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} ); @@ -362,6 +414,29 @@ export function Table({ )} + {openFilterKey !== null && popoverPos + ? createPortal( + (() => { + const openColumn = columns.find((c) => c.key === openFilterKey); + if (!openColumn) return null; + return ( + + ); + })(), + document.body, + ) + : null} ); }