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