mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
fix: ux revamp dropdown close when scroll or another dropdown is open (#12855)
- #1971
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user