// — 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 `` — 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 `` 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 `` 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 * `
`), 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; /** * Escape a clipping scroll ancestor by portal-rendering to `` * 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(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 = ( ); // 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; }