From b1254f89cd19684c6fa6343fa8f9a28701406722 Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 30 Aug 2026 15:29:59 +0200 Subject: [PATCH] shared dropdown: don't reopen when the trigger is clicked while open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../agent/src/components/StatusChips.tsx | 10 +++++--- .../packages/shared/src/dropdown/Dropdown.tsx | 25 ++++++++++++++++--- .../swarm-ui/src/pages/ComponentsPage.tsx | 6 +++-- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/frontend/packages/agent/src/components/StatusChips.tsx b/frontend/packages/agent/src/components/StatusChips.tsx index b0372773..8f32966e 100644 --- a/frontend/packages/agent/src/components/StatusChips.tsx +++ b/frontend/packages/agent/src/components/StatusChips.tsx @@ -10,7 +10,7 @@ // selection state live in the caller (`Root.tsx`, via the // `useAgentState` hook), so this component can still be demoed and // reviewed against plain sample data independent of live `/api/state`. -import { useState } from 'preact/hooks'; +import { useRef, useState } from 'preact/hooks'; import { Badge, type BadgeTone } from '@hive/shared/badge.js'; import { Dropdown, type DropdownOption } from '@hive/shared/dropdown.js'; import './StatusChips.css'; @@ -68,13 +68,14 @@ function Picker({ title?: string; }) { const [open, setOpen] = useState(false); + const anchorRef = useRef(null); const dropdownOptions: DropdownOption[] = options.map((name) => ({ value: name, label: name, description: descriptions[name] || undefined, })); return ( -
+
setOpen((o) => !o)} expanded={open} title={title} /> setOpen(false)} + anchorRef={anchorRef} />
); @@ -112,6 +114,7 @@ function StatusMenu({ >) { const [open, setOpen] = useState(false); const [confirmCancel, setConfirmCancel] = useState(false); + const anchorRef = useRef(null); function close() { setOpen(false); @@ -128,7 +131,7 @@ function StatusMenu({ } return ( -
+
); diff --git a/frontend/packages/shared/src/dropdown/Dropdown.tsx b/frontend/packages/shared/src/dropdown/Dropdown.tsx index 69e76d8f..4031e1f5 100644 --- a/frontend/packages/shared/src/dropdown/Dropdown.tsx +++ b/frontend/packages/shared/src/dropdown/Dropdown.tsx @@ -15,7 +15,7 @@ // document-flow header never needs one, and skipping it keeps focus // management simple (no re-parenting to `` 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 + * `
`), 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; } -export function Dropdown({ open, options, activeValue, onSelect, onClose, label }: DropdownProps) { +export function Dropdown({ open, options, activeValue, onSelect, onClose, label, anchorRef }: DropdownProps) { const ref = useRef(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(); diff --git a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx index 076febed..e850e6fb 100644 --- a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx @@ -5,7 +5,7 @@ // primitive gets a section here the same day it's added. Sample data // only, no network calls — this page must render the same whether // swarm-controller's API is up or not. -import { useState } from 'preact/hooks'; +import { useRef, useState } from 'preact/hooks'; import type { ComponentChildren } from 'preact'; import { Panel } from '../ui/panel/Panel.js'; import { RelativeTime } from '../ui/relative-time/RelativeTime.js'; @@ -101,8 +101,9 @@ function RefreshIntervalPickerSample() { function BadgePickerSample() { const [value, setValue] = useState('sonnet'); const [open, setOpen] = useState(false); + const anchorRef = useRef(null); return ( -
+
setOpen(false)} + anchorRef={anchorRef} />
);