Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/shared/src/dropdown/Dropdown.tsx
iris cfd2f340fb shared: drop Dropdown's portal mode, which only the agents table used
The agents table passed portal so a wanted menu on its last row escaped the
table's scroll clip. The table is gone from that page and nothing else passes
it, so the prop, its position tracking and the portal render go with it.
2026-11-01 01:32:47 +01:00

115 lines
4.5 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). 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: 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. Put the wrapper outside any scrolling
// container: an ancestor with `overflow-x: auto` computes `overflow-y` to `auto`
// too (per the CSS overflow spec), so it clips the dropdown vertically. A
// `position: fixed` list rendered through a portal escapes that, as
// `../../ui/table/Table.tsx` does for its column filters; this component had
// that mode for the agents table, and dropped it with the table.
import { useEffect, useRef } from "preact/hooks";
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>;
}
export function Dropdown({
open,
options,
activeValue,
onSelect,
onClose,
label,
anchorRef,
}: DropdownProps) {
const ref = useRef<HTMLDivElement>(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]);
if (!open) return null;
return (
<div class="ui-dropdown" role="menu" aria-label={label} ref={ref}>
{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>
);
}