swarm-ui: portal the filter popover past the table's own clip
argus's review (reproduced, not speculative): .ui-table-scroll sets overflow-x: auto with overflow-y left unset. Per the CSS overflow spec, an axis left visible computes to auto too once the other axis isn't visible -- so this box silently clips vertically as well as horizontally. The popover was position: absolute; top: 100% under its header <th>, itself inside .ui-table-scroll -- on a table shorter than header-plus-popover (a small hive's roster, or any table narrowed by an existing filter), the popover got cut off at the scroll box's own bottom edge, with a stray vertical scrollbar as the visible symptom. Computes the popover's viewport position from the anchor <th>'s own getBoundingClientRect() and renders it via a portal onto document.body, position: fixed -- escapes .ui-table-scroll's clip the same way any position: fixed element escapes an ancestor's overflow (neither .ui-table-scroll nor .ui-table establishes a new containing block). Recomputes on scroll (capture-phase window listener, the standard technique for detecting scroll on a nested scroll container without binding to every ancestor by hand) and resize while open, so the popover stays anchored rather than only positioning once at click time. createPortal comes from preact/compat, already resolvable through the existing preact dependency -- no new package.json entry. Verified against argus's own repro shape: a 1-row table, scripted click on the filter icon (same real-DOM-event technique as this PR's first round), screenshot shows the popover rendering fully rather than clipped, no stray scrollbar.
This commit is contained in:
parent
e234afa26a
commit
63f3afae3e
2 changed files with 118 additions and 39 deletions
|
|
@ -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 `<th>` specifically rather than the page. */
|
||||
.ui-table-th-filterable {
|
||||
position: relative;
|
||||
}
|
||||
/* `.ui-table-th-filterable` (set on a filterable column's `<th>`, 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 `<th>`).
|
||||
|
||||
/* 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 `<th>`'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);
|
||||
|
|
|
|||
|
|
@ -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<T>({
|
|||
// 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);
|
||||
// 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 `<th>`'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 `<th>` 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<Map<string, HTMLTableCellElement>>(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<T>({
|
|||
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 ? (
|
||||
<>
|
||||
<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}
|
||||
</>
|
||||
<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) => {
|
||||
const next = k === c.key ? null : c.key;
|
||||
if (next !== null) computePopoverPos(next);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FilterIcon />
|
||||
</button>
|
||||
) : null}
|
||||
</th>
|
||||
);
|
||||
|
|
@ -362,6 +414,29 @@ export function Table<T>({
|
|||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{openFilterKey !== null && popoverPos
|
||||
? createPortal(
|
||||
(() => {
|
||||
const openColumn = columns.find((c) => c.key === openFilterKey);
|
||||
if (!openColumn) return null;
|
||||
return (
|
||||
<div
|
||||
class="ui-table-filter-popover"
|
||||
role="dialog"
|
||||
aria-label={filterLabel(openColumn)}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: `${popoverPos.top}px`,
|
||||
left: `${popoverPos.left}px`,
|
||||
}}
|
||||
>
|
||||
{renderFilterControl(openColumn)}
|
||||
</div>
|
||||
);
|
||||
})(),
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue