+
{
+ it("should close the previously-active overlay when a new one opens", () => {
+ const closeA = vi.fn();
+ const closeB = vi.fn();
+
+ setActiveOverlay(closeA);
+ expect(closeA).not.toHaveBeenCalled();
+
+ // Opening B closes A.
+ setActiveOverlay(closeB);
+ expect(closeA).toHaveBeenCalledTimes(1);
+ expect(closeB).not.toHaveBeenCalled();
+ });
+
+ it("should not close an overlay when it re-registers itself", () => {
+ const closeA = vi.fn();
+ setActiveOverlay(closeA);
+ setActiveOverlay(closeA);
+ expect(closeA).not.toHaveBeenCalled();
+ });
+
+ it("should clear only when the overlay is still the active one", () => {
+ const closeA = vi.fn();
+ const closeB = vi.fn();
+
+ setActiveOverlay(closeA);
+ setActiveOverlay(closeB); // B is now active, A already closed
+ closeA.mockClear();
+
+ // A closing afterwards must not disturb B's active registration.
+ clearActiveOverlay(closeA);
+ const closeC = vi.fn();
+ setActiveOverlay(closeC);
+ expect(closeB).toHaveBeenCalledTimes(1);
+ });
+
+ it("should leave no active overlay after the active one clears", () => {
+ const closeA = vi.fn();
+ const closeB = vi.fn();
+
+ setActiveOverlay(closeA);
+ clearActiveOverlay(closeA);
+
+ // With nothing active, opening B must not call any stale close fn.
+ setActiveOverlay(closeB);
+ expect(closeA).not.toHaveBeenCalled();
+ expect(closeB).not.toHaveBeenCalled();
+ });
+});
diff --git a/web/src/lib/overlay/Dropdown/ODropdown.context.ts b/web/src/lib/overlay/Dropdown/ODropdown.context.ts
index 29988987f5..0127d8d7c5 100644
--- a/web/src/lib/overlay/Dropdown/ODropdown.context.ts
+++ b/web/src/lib/overlay/Dropdown/ODropdown.context.ts
@@ -20,3 +20,45 @@ export interface DropdownNestedRegistry {
*/
open: () => (skipGrace?: boolean) => void;
}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Single-active-overlay coordinator
+// ═══════════════════════════════════════════════════════════════════════════
+// Reka only auto-dismisses an open overlay when a *real* outside pointer/focus
+// event reaches its DismissableLayer. Opening a sibling overlay programmatically
+// — or moving focus into another field/select without a dismiss-triggering
+// click — leaves the first overlay open, so two unrelated dropdowns can be open
+// at once. This module-level coordinator enforces "only one top-level O overlay
+// open at a time": whenever a top-level overlay opens it closes whichever
+// top-level overlay was previously open.
+//
+// Nested overlays (an OSelect/ODropdown rendered *inside* another open
+// ODropdown — i.e. ones that injected O_DROPDOWN_NESTED_KEY) do NOT participate:
+// closing their ancestor would dismiss the very tree they live in. They are
+// coordinated separately by the nested registry above.
+
+type OverlayCloseFn = () => void;
+
+let activeOverlayClose: OverlayCloseFn | null = null;
+
+/**
+ * Register a freshly-opened top-level overlay. Closes the previously-active
+ * top-level overlay (if any and not this same one) so only one stays open.
+ */
+export function setActiveOverlay(close: OverlayCloseFn): void {
+ if (activeOverlayClose && activeOverlayClose !== close) {
+ const previous = activeOverlayClose;
+ activeOverlayClose = close;
+ previous();
+ return;
+ }
+ activeOverlayClose = close;
+}
+
+/**
+ * Deregister an overlay when it closes. No-op if a different overlay has since
+ * become active (it already replaced this one).
+ */
+export function clearActiveOverlay(close: OverlayCloseFn): void {
+ if (activeOverlayClose === close) activeOverlayClose = null;
+}
diff --git a/web/src/lib/overlay/Dropdown/ODropdown.vue b/web/src/lib/overlay/Dropdown/ODropdown.vue
index cc008f6191..cc2a83f4b3 100644
--- a/web/src/lib/overlay/Dropdown/ODropdown.vue
+++ b/web/src/lib/overlay/Dropdown/ODropdown.vue
@@ -27,6 +27,8 @@ import { computed, inject, onBeforeUnmount, provide, ref, watch, type Ref } from
import {
O_DROPDOWN_NESTED_KEY,
type DropdownNestedRegistry,
+ setActiveOverlay,
+ clearActiveOverlay,
} from "./ODropdown.context";
const props = withDefaults(defineProps(), {
@@ -46,6 +48,11 @@ defineSlots();
// so reka-ui stays responsive in both uncontrolled and controlled usage.
const internalOpen = ref(props.open ?? false);
+// Ref to the DropdownMenuTrigger; its `$el` is the rendered trigger element.
+// Used by the close-on-scroll handler to tell whether a scroll moves this
+// dropdown's trigger (vs. an unrelated section).
+const triggerRef = ref<{ $el?: Node } | null>(null);
+
watch(
() => props.open,
(v) => {
@@ -107,6 +114,18 @@ onBeforeUnmount(() => {
}
});
+// Single-active-overlay coordination. Only top-level dropdowns participate —
+// a dropdown nested inside another open ODropdown must not close its ancestor.
+// Stable identity so set/clearActiveOverlay match across calls.
+const closeSelf = () => handleOpenChange(false);
+if (!parentDropdownRegistry) {
+ watch(internalOpen, (open) => {
+ if (open) setActiveOverlay(closeSelf);
+ else clearActiveOverlay(closeSelf);
+ });
+ onBeforeUnmount(() => clearActiveOverlay(closeSelf));
+}
+
// ═══════════════════════════════════════════════════════════════════════════
// Nested-overlay coordination
// ═══════════════════════════════════════════════════════════════════════════
@@ -205,6 +224,43 @@ if (sidebarScrollTick) {
if (internalOpen.value) handleOpenChange(false);
});
}
+
+// Generic close-on-scroll: while open, any scroll on an ancestor scroll
+// container (the page, the query-builder area, a joins popup, etc.) would
+// otherwise leave the portaled menu floating detached from its trigger. We
+// close instead. Scrolls that originate *inside* an open menu (a long,
+// scrollable menu list) are ignored so the menu itself stays scrollable.
+function handleViewportScroll(event: Event) {
+ if (!internalOpen.value) return;
+ const target = event.target as (Element & Node) | Document | null;
+ if (!target) return;
+ // Ignore scrolls inside an open menu so long menu lists stay scrollable.
+ if (target instanceof Element && target.closest?.('[role="menu"]')) return;
+ // Only close when the scrolled container actually holds this dropdown's
+ // trigger — scrolling an unrelated section must not dismiss the menu.
+ const triggerEl = triggerRef.value?.$el as Node | undefined;
+ if (triggerEl && target.contains(triggerEl)) {
+ handleOpenChange(false);
+ }
+}
+
+watch(internalOpen, (open) => {
+ if (typeof window === "undefined") return;
+ if (open) {
+ window.addEventListener("scroll", handleViewportScroll, {
+ capture: true,
+ passive: true,
+ });
+ } else {
+ window.removeEventListener("scroll", handleViewportScroll, true);
+ }
+});
+
+onBeforeUnmount(() => {
+ if (typeof window !== "undefined") {
+ window.removeEventListener("scroll", handleViewportScroll, true);
+ }
+});
@@ -213,7 +269,7 @@ if (sidebarScrollTick) {
:modal="modal"
@update:open="handleOpenChange"
>
-
+