shared dropdown: don't reopen when the trigger is clicked while open

mara, #3775: clicking a badge with its own dropdown open reopens it
instead of closing it. Root cause: the outside-click listener only
excludes the dropdown's own ref, not the sibling trigger that opened
it — a click on the trigger closes via that listener (pointerdown
fires first), then the trigger's own onClick toggle fires straight
after and reopens it, since its closure reads the pre-close state.

Dropdown now takes an optional anchorRef (the trigger's wrapper,
which callers already have for CSS positioning) and excludes it from
the outside-click check too — the same shape MetaNav's own hand-rolled
popover already uses correctly. Wired into StatusChips's Picker +
StatusMenu and swarm-ui's ComponentsPage demo (the only three Dropdown
consumers).

Verified two ways: reverted the fix, rebuilt, confirmed the bug
reproduces via a raw-CDP interaction test (two real clicks dispatched
through headless chromium, not just a static screenshot); restored
the fix, rebuilt, confirmed it passes.
This commit is contained in:
iris 2026-08-30 15:29:59 +02:00
commit b1254f89cd
3 changed files with 33 additions and 8 deletions

View file

@ -15,7 +15,7 @@
// document-flow header never needs one, and skipping it keeps focus
// management simple (no re-parenting to `<body>` to reason about).
import { useEffect, useRef } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import type { ComponentChildren, RefObject } from 'preact';
import './Dropdown.css';
export interface DropdownOption {
@ -35,15 +35,34 @@ export interface DropdownProps {
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 }: DropdownProps) {
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 (ref.current && e.target instanceof Node && !ref.current.contains(e.target)) onClose();
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();