diff --git a/frontend/packages/swarm-ui/src/ui/table/Table.tsx b/frontend/packages/swarm-ui/src/ui/table/Table.tsx index 0b2d797b..f4a54f45 100644 --- a/frontend/packages/swarm-ui/src/ui/table/Table.tsx +++ b/frontend/packages/swarm-ui/src/ui/table/Table.tsx @@ -9,12 +9,19 @@ // 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 { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "preact/hooks"; import { createPortal } from "preact/compat"; import type { ComponentChildren } from "preact"; import { FilterIcon } from "@hive/shared/icons.js"; import { useLocalSetting } from "@hive/shared/settings-storage.js"; import { MultiselectFilterOptions } from "../multiselect-filter/MultiselectFilter.js"; +import { clampLeft } from "./popover-position.js"; import "./Table.css"; export interface TableColumn { @@ -299,6 +306,13 @@ export function Table({ // 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()); + // The open popover's own width, measured once it is on screen, so its + // left edge can be pulled back inside the window. + const popoverRef = useRef(null); + const [popoverWidth, setPopoverWidth] = useState(0); + useLayoutEffect(() => { + setPopoverWidth(popoverRef.current?.offsetWidth ?? 0); + }, [openFilterKey, popoverPos]); function computePopoverPos(key: string) { const th = thRefs.current.get(key); @@ -579,10 +593,11 @@ export function Table({ class="ui-table-filter-popover" role="dialog" aria-label={filterLabel(openColumn)} + ref={popoverRef} style={{ position: "fixed", top: `${popoverPos.top}px`, - left: `${popoverPos.left}px`, + left: `${clampLeft(popoverPos.left, popoverWidth, window.innerWidth)}px`, }} > {renderFilterControl(openColumn)} diff --git a/frontend/packages/swarm-ui/src/ui/table/popover-position.ts b/frontend/packages/swarm-ui/src/ui/table/popover-position.ts new file mode 100644 index 00000000..b2b5e21e --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/table/popover-position.ts @@ -0,0 +1,19 @@ +// Where a table's filter popover sits, without a DOM. + +// Space kept between a floating box and the viewport's edge. +export const POPOVER_EDGE_PX = 8; + +/** + * The left edge for a popover of `width` that wants to start at `left`: + * pulled in so its right edge stays on screen, but never past the left edge. + * A filterable column near the right of the window would otherwise open + * partly outside it. + */ +export function clampLeft( + left: number, + width: number, + viewportWidth: number, +): number { + const maxLeft = viewportWidth - width - POPOVER_EDGE_PX; + return Math.max(POPOVER_EDGE_PX, Math.min(left, maxLeft)); +} diff --git a/frontend/packages/swarm-ui/test/popover-position.test.mjs b/frontend/packages/swarm-ui/test/popover-position.test.mjs new file mode 100644 index 00000000..4fa69d46 --- /dev/null +++ b/frontend/packages/swarm-ui/test/popover-position.test.mjs @@ -0,0 +1,17 @@ +// Where a table's filter popover sits. Run by `npm test`, which Node runs +// with its built-in test runner and type stripping, so the `.ts` module is +// imported as it is. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { clampLeft } from "../src/ui/table/popover-position.ts"; + +test("a popover near the right edge is pulled back on screen", () => { + // 300 wide in a 1000 wide window: the right edge may reach 992. + assert.equal(clampLeft(900, 300, 1000), 692); + assert.equal(clampLeft(100, 300, 1000), 100); +}); + +test("a popover never goes past the left edge", () => { + assert.equal(clampLeft(5, 300, 1000), 8); + assert.equal(clampLeft(50, 2000, 1000), 8); +});