fix: ux revamp dropdown close when scroll or another dropdown is open (#12855)

- #1971
This commit is contained in:
ktx-vaidehi
2026-06-24 10:37:57 +00:00
committed by GitHub
parent 8de2d7039c
commit cd63db08cb
5 changed files with 235 additions and 21 deletions
@@ -292,7 +292,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Legend Width + unit selector -->
<div
v-if="shouldShowLegendWidth(dashboardPanelData)"
class="input-container"
class="tw:flex tw:items-end tw:justify-between tw:gap-1.5 tw:w-full tw:min-w-0"
>
<OInput
v-model.number="legendWidthValue"
@@ -300,9 +300,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
type="number"
:placeholder="t('dashboard.auto')"
data-test="dashboard-config-legend-width"
class="tw:flex-1 tw:min-w-0"
/>
<div
class="unit-container"
class="tw:flex tw:items-center tw:gap-1 tw:mt-[0.5625rem] tw:shrink-0"
v-if="shouldShowLegendWidthUnitContainer(dashboardPanelData)"
>
<OButton
@@ -343,7 +344,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Legend Height + unit selector -->
<div
v-if="shouldShowLegendHeight(dashboardPanelData)"
class="input-container"
class="tw:flex tw:items-end tw:justify-between tw:gap-1.5 tw:w-full tw:min-w-0"
>
<OInput
v-model.number="legendHeightValue"
@@ -351,9 +352,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
type="number"
:placeholder="t('dashboard.auto')"
data-test="dashboard-config-legend-height"
class="tw:flex-1 tw:min-w-0"
/>
<div
class="unit-container"
class="tw:flex tw:items-center tw:gap-1 tw:mt-[0.5625rem] tw:shrink-0"
v-if="shouldShowLegendHeightUnitContainer(dashboardPanelData)"
>
<OButton
@@ -2814,20 +2816,6 @@ export default defineComponent({
pointer-events: auto !important;
}
.input-container {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 6px;
}
.unit-container {
display: flex;
align-items: center;
gap: 4px;
margin-top: 9px;
}
.config-legend-input {
min-width: 80px;
}
+76 -2
View File
@@ -44,7 +44,11 @@ import {
watch,
watchEffect,
} from "vue";
import { O_DROPDOWN_NESTED_KEY } from "@/lib/overlay/Dropdown/ODropdown.context";
import {
O_DROPDOWN_NESTED_KEY,
setActiveOverlay,
clearActiveOverlay,
} from "@/lib/overlay/Dropdown/ODropdown.context";
type NormalizedOption = {
label: string;
@@ -711,6 +715,23 @@ onBeforeUnmount(() => {
}
});
// 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<Ref<number> | 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<HTMLElement | null>(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<HTMLElement | null>(null);
const triggerWidth = ref(0);
let triggerResizeObserver: ResizeObserver | null = null;
@@ -1432,6 +1502,7 @@ const fieldWidthClass = computed(() => {
<SelectRoot
v-else
:model-value="stringValue"
:open="selectOpen"
:disabled="disabled"
:name="name"
@update:model-value="handleUpdate"
@@ -1442,7 +1513,10 @@ const fieldWidthClass = computed(() => {
}
"
>
<div class="tw:relative tw:flex tw:items-center">
<div
ref="selectTriggerWrapperRef"
class="tw:relative tw:flex tw:items-center"
>
<SelectTrigger
:id="inputId"
:data-test="
@@ -0,0 +1,54 @@
// Copyright 2026 OpenObserve Inc.
import { describe, it, expect, vi } from "vitest";
import { setActiveOverlay, clearActiveOverlay } from "./ODropdown.context";
describe("single-active-overlay coordinator", () => {
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();
});
});
@@ -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;
}
+57 -1
View File
@@ -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<DropdownProps>(), {
@@ -46,6 +48,11 @@ defineSlots<DropdownSlots>();
// 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);
}
});
</script>
<template>
@@ -213,7 +269,7 @@ if (sidebarScrollTick) {
:modal="modal"
@update:open="handleOpenChange"
>
<DropdownMenuTrigger as-child>
<DropdownMenuTrigger ref="triggerRef" as-child>
<slot name="trigger" />
</DropdownMenuTrigger>