A `WantedMenu` badge dropdown on the agents table clips against `.ui-table-scroll`s overflow the moment its row is the last (or near-last) one — the popover extends past the table content the scroll container bounds itself to. `Table.tsx` already solved the identical clip for its own column-filter popover with a position:fixed + portal computed from the anchor rect; `Dropdown` now takes an optional `portal` prop that opts a caller into that same recipe instead of a second hand-rolled copy of it. Off by default — every other current caller (StatusChips x2, the components-page demo) keeps its existing non-portal behavior unchanged.
168 lines
6.8 KiB
TypeScript
168 lines
6.8 KiB
TypeScript
// <Dropdown> — a small anchored option list, meant to sit directly under
|
|
// the `Badge` that opened it (../badge/Badge.tsx) so the control is
|
|
// visually attached to the thing it affects, instead of living three
|
|
// clicks away in a catch-all menu (the junk-drawer pattern
|
|
// `docs/web-ui/design-guide.md` calls out by name against the old
|
|
// per-agent terminal's model picker). Not a native `<dialog>` — a
|
|
// `showModal()` dialog's backdrop would visually detach it from its
|
|
// anchor badge, and a non-modal `show()` gets no click-outside-to-close
|
|
// for free, so this hand-rolls the same "outside click or Escape
|
|
// closes" contract `Dialog` gets from the platform.
|
|
//
|
|
// Positioning is the caller's job by default: render `<Dropdown>` as a
|
|
// child of a `position: relative` wrapper (the badge + dropdown pair)
|
|
// and it anchors to that box's bottom-left via CSS — no portal, no
|
|
// re-parenting to `<body>` to reason about. That's wrong the moment the
|
|
// wrapper sits inside a scrolling container, though: an ancestor with
|
|
// `overflow-x: auto` computes `overflow-y` to `auto` too (per the CSS
|
|
// overflow spec), so it clips the dropdown vertically the instant the
|
|
// container is shorter than header-plus-dropdown — reported against
|
|
// swarm-ui's agent table (a `WantedMenu` badge two rows from the
|
|
// bottom). `../../ui/table/Table.tsx` hit the identical clip for its
|
|
// own column-filter popover and fixed it with `position: fixed`
|
|
// computed from the anchor's `getBoundingClientRect()`, rendered via a
|
|
// portal outside the scrolling subtree; the optional `portal` prop
|
|
// below opts a caller into that same recipe instead of a second
|
|
// hand-rolled copy of it.
|
|
import { useEffect, useRef, useState } from "preact/hooks";
|
|
import { createPortal } from "preact/compat";
|
|
import type { ComponentChildren, RefObject } from "preact";
|
|
import "./Dropdown.css";
|
|
|
|
export interface DropdownOption {
|
|
value: string;
|
|
label: ComponentChildren;
|
|
/** Optional dim secondary text, e.g. "sonnet (balanced)". */
|
|
description?: ComponentChildren;
|
|
/** Red-toned row for a destructive action (e.g. cancel turn), not a picker option. */
|
|
danger?: boolean;
|
|
}
|
|
|
|
export interface DropdownProps {
|
|
open: boolean;
|
|
options: DropdownOption[];
|
|
activeValue?: string;
|
|
onSelect: (value: string) => void;
|
|
onClose: () => void;
|
|
/** `aria-label` for the option list (e.g. "select model"). */
|
|
label: string;
|
|
/**
|
|
* Ref to the trigger badge/button that opened this dropdown. Without
|
|
* it, a click on the trigger while open closes-then-reopens instead
|
|
* of closing: the trigger's own `onClick` toggles `open`, but this
|
|
* component's outside-click listener doesn't know the trigger is
|
|
* "part of" the dropdown (it's a sibling, not a descendant of this
|
|
* `<div>`), so it *also* fires `onClose` on the same click — the two
|
|
* updates race, and the toggle's `!open` reads the pre-close value
|
|
* and wins, reopening it (mara: "clicking badge with open drop down
|
|
* reopens it instead of closing it"). Passing the same ref the
|
|
* caller's wrapper div already needs for CSS positioning excludes
|
|
* clicks on the trigger from the outside-click check, matching how
|
|
* `MetaNav`'s own hand-rolled popover (which wraps trigger+popover in
|
|
* one ref) already avoids this.
|
|
*/
|
|
anchorRef?: RefObject<HTMLElement>;
|
|
/**
|
|
* Escape a clipping scroll ancestor by portal-rendering to `<body>`
|
|
* with `position: fixed`, computed from `anchorRef`'s rect — see the
|
|
* file-top comment. Requires `anchorRef`; a caller in an unscrolled,
|
|
* un-clipped context (the common case) can omit this entirely.
|
|
*/
|
|
portal?: boolean;
|
|
}
|
|
|
|
export function Dropdown({
|
|
open,
|
|
options,
|
|
activeValue,
|
|
onSelect,
|
|
onClose,
|
|
label,
|
|
anchorRef,
|
|
portal,
|
|
}: DropdownProps) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
// Only populated (and only consulted) in `portal` mode — see the
|
|
// effect below and the file-top comment for why fixed-position
|
|
// coordinates are needed at all here.
|
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
function handlePointerDown(e: PointerEvent) {
|
|
if (!(e.target instanceof Node)) return;
|
|
if (ref.current?.contains(e.target)) return;
|
|
if (anchorRef?.current?.contains(e.target)) return;
|
|
onClose();
|
|
}
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
if (e.key === "Escape") onClose();
|
|
}
|
|
// `pointerdown` (not `click`) so a drag-to-select that ends outside
|
|
// still closes; capture phase so this sees the event before a
|
|
// stopPropagation() elsewhere in the tree could swallow it.
|
|
document.addEventListener("pointerdown", handlePointerDown, true);
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => {
|
|
document.removeEventListener("pointerdown", handlePointerDown, true);
|
|
document.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, [open, onClose]);
|
|
|
|
// Same recipe as `Table.tsx`'s column-filter popover: compute once on
|
|
// open, then keep it pinned to the anchor across any scroll in the
|
|
// tree (`capture: true` sees a nested scroll container too, since
|
|
// plain `scroll` doesn't bubble) or a viewport resize.
|
|
useEffect(() => {
|
|
if (!open || !portal) return;
|
|
function recompute() {
|
|
const rect = anchorRef?.current?.getBoundingClientRect();
|
|
if (rect) setPos({ top: rect.bottom + 4, left: rect.left });
|
|
}
|
|
recompute();
|
|
window.addEventListener("scroll", recompute, true);
|
|
window.addEventListener("resize", recompute);
|
|
return () => {
|
|
window.removeEventListener("scroll", recompute, true);
|
|
window.removeEventListener("resize", recompute);
|
|
};
|
|
}, [open, portal, anchorRef]);
|
|
|
|
if (!open) return null;
|
|
|
|
const list = (
|
|
<div
|
|
class="ui-dropdown"
|
|
role="menu"
|
|
aria-label={label}
|
|
ref={ref}
|
|
style={portal && pos ? { position: "fixed", ...pos } : undefined}
|
|
>
|
|
{options.map((opt) => (
|
|
<button
|
|
type="button"
|
|
key={opt.value}
|
|
role="menuitemradio"
|
|
aria-checked={opt.value === activeValue}
|
|
class={
|
|
"ui-dropdown-item" +
|
|
(opt.value === activeValue ? " ui-dropdown-item-active" : "") +
|
|
(opt.danger ? " ui-dropdown-item-danger" : "")
|
|
}
|
|
onClick={() => onSelect(opt.value)}
|
|
>
|
|
<span class="ui-dropdown-item-label">{opt.label}</span>
|
|
{opt.description ? (
|
|
<span class="ui-dropdown-item-desc">{opt.description}</span>
|
|
) : null}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
// Not yet positioned (first render after opening, before the effect
|
|
// above runs) — skip the portal render entirely rather than flashing
|
|
// the list at `document.body`'s origin for one frame.
|
|
if (portal) return pos ? createPortal(list, document.body) : null;
|
|
return list;
|
|
}
|