diff --git a/web/src/components/dashboards/addPanel/ConfigPanel.vue b/web/src/components/dashboards/addPanel/ConfigPanel.vue index 3ec1f0a974..8f0a4bb331 100644 --- a/web/src/components/dashboards/addPanel/ConfigPanel.vue +++ b/web/src/components/dashboards/addPanel/ConfigPanel.vue @@ -292,7 +292,7 @@ along with this program. If not, see .
. type="number" :placeholder="t('dashboard.auto')" data-test="dashboard-config-legend-width" + class="tw:flex-1 tw:min-w-0" />
.
. type="number" :placeholder="t('dashboard.auto')" data-test="dashboard-config-legend-height" + class="tw:flex-1 tw:min-w-0" />
{ } }); +// Single-active-overlay coordination. Opening any other top-level O overlay +// closes this one (and vice-versa), so two unrelated dropdowns are never open +// at once — even when the new one is opened by focus rather than a click that +// Reka would treat as a dismiss. Nested selects (inside an open ODropdown) opt +// out so they don't close their ancestor. +const closeSelf = () => { + popoverOpen.value = false; + selectOpen.value = false; +}; +if (!parentDropdownRegistry) { + watch(isOpen, (open) => { + if (open) setActiveOverlay(closeSelf); + else clearActiveOverlay(closeSelf); + }); + onBeforeUnmount(() => clearActiveOverlay(closeSelf)); +} + // Close when the sidebar scroll container scrolls, preventing the portal // from floating disconnected at the top of the screen. const sidebarScrollTick = inject | null>('sidebarScrollTick', null); @@ -721,6 +742,52 @@ if (sidebarScrollTick) { }); } +// Generic close-on-scroll: while the dropdown is open, scrolling any ancestor +// scroll container (page, query-builder area, joins popup, etc.) would leave +// the portaled list floating detached from the trigger — close it instead. +// Scrolls that originate inside the option list are ignored so the list stays +// scrollable. +function handleViewportScroll(event: Event) { + if (!isOpen.value) return; + const target = event.target as (Element & Node) | Document | null; + if (!target) return; + // Ignore scrolls that originate inside the option list so it stays scrollable. + if ( + target instanceof Element && + (listboxScrollEl.value?.contains(target) || + target.closest?.('[role="listbox"]')) + ) { + return; + } + // Only close when the scrolled container actually holds this select's + // trigger — i.e. the scroll moves the trigger and would leave the portaled + // menu detached. Scrolls in unrelated sections (e.g. the field list while a + // config-panel dropdown is open) are ignored. + const triggerEl = triggerWrapperRef.value ?? selectTriggerWrapperRef.value; + if (triggerEl && target.contains(triggerEl)) { + popoverOpen.value = false; + selectOpen.value = false; + } +} + +watch(isOpen, (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); + } +}); + // ── Chip overflow (width-aware) ────────────────────────────────────────── // In multi-select mode, fit as many chips as the trigger width allows; the // rest collapse into a "+N more" indicator. When `maxVisibleChips` is set, @@ -729,6 +796,9 @@ if (sidebarScrollTick) { /** The outer wrapper around the trigger. Measured to size the chip row. */ const triggerWrapperRef = ref(null); +// Wrapper around the native-select (non-listbox) trigger, used by the +// close-on-scroll handler to detect whether a scroll moves this select's trigger. +const selectTriggerWrapperRef = ref(null); const triggerWidth = ref(0); let triggerResizeObserver: ResizeObserver | null = null; @@ -1432,6 +1502,7 @@ const fieldWidthClass = computed(() => { { } " > -
+
{ + 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); + } +});