@@ -287,6 +296,7 @@ onBeforeUnmount(() => {
{
app.unmount();
});
+
+ it("keeps the menu open when scrolling inside a scrollable submenu", async () => {
+ const children = Array.from({ length: 40 }, (_, index) => ({ label: `Copy option ${index}` }));
+ const root = defineComponent({
+ setup() {
+ return () =>
+ h(
+ CustomContextMenu,
+ { items: [{ label: "Copy", children }] },
+ {
+ default: ({ onContextMenu }: { onContextMenu: (event: MouseEvent) => void }) => h("div", { id: "context-target", onContextmenu: onContextMenu }, "Target"),
+ },
+ );
+ },
+ });
+ const container = document.createElement("div");
+ mountedContainers.push(container);
+ document.body.append(container);
+ const app = createApp(root);
+ app.mount(container);
+
+ const target = container.querySelector("#context-target");
+ target?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, clientX: 10, clientY: 20 }));
+ await nextTick();
+
+ const copyTrigger = Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent?.includes("Copy"));
+ expect(copyTrigger).toBeTruthy();
+ copyTrigger?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true, clientX: 20, clientY: 30 }));
+ await nextTick();
+
+ const submenu = Array.from(document.body.querySelectorAll("[data-dbx-context-menu]")).find((el) => el.textContent?.includes("Copy option 0"));
+ expect(submenu).toBeTruthy();
+ submenu?.dispatchEvent(new Event("scroll", { bubbles: true }));
+ await nextTick();
+ expect(document.body.textContent).toContain("Copy option 0");
+
+ document.dispatchEvent(new Event("scroll", { bubbles: true }));
+ await nextTick();
+ expect(document.body.textContent).not.toContain("Copy option 0");
+
+ app.unmount();
+ });
});
diff --git a/apps/desktop/src/components/ui/__tests__/customContextMenuRegistry.spec.ts b/apps/desktop/src/components/ui/__tests__/customContextMenuRegistry.spec.ts
index 613b65001..f32604269 100644
--- a/apps/desktop/src/components/ui/__tests__/customContextMenuRegistry.spec.ts
+++ b/apps/desktop/src/components/ui/__tests__/customContextMenuRegistry.spec.ts
@@ -1,5 +1,8 @@
+/**
+ * @vitest-environment happy-dom
+ */
import { describe, expect, it, vi } from "vitest";
-import { createContextMenuRegistry } from "@/components/ui/customContextMenuRegistry";
+import { CONTEXT_MENU_SCROLL_ROOT_ATTR, createContextMenuRegistry } from "@/components/ui/customContextMenuRegistry";
function callsFor(spy: ReturnType, eventName: string) {
return spy.mock.calls.filter(([name]) => name === eventName);
@@ -34,6 +37,28 @@ describe("customContextMenuRegistry", () => {
registrations.forEach((registration) => registration.dispose());
});
+ it("does not close open menus when scroll originates inside a context menu root", () => {
+ const documentTarget = document;
+ const windowTarget = window;
+ const registry = createContextMenuRegistry(documentTarget, windowTarget);
+ const closeMenu = vi.fn();
+ const registration = registry.register(closeMenu);
+ registration.setOpen(true);
+
+ const menuRoot = document.createElement("div");
+ menuRoot.setAttribute(CONTEXT_MENU_SCROLL_ROOT_ATTR, "");
+ document.body.append(menuRoot);
+
+ menuRoot.dispatchEvent(new Event("scroll", { bubbles: true }));
+ expect(closeMenu).not.toHaveBeenCalled();
+
+ document.dispatchEvent(new Event("scroll", { bubbles: true }));
+ expect(closeMenu).toHaveBeenCalledOnce();
+
+ menuRoot.remove();
+ registration.dispose();
+ });
+
it("removes disposed callbacks and detaches listeners after the final host", () => {
const documentTarget = new EventTarget();
const windowTarget = new EventTarget();
diff --git a/apps/desktop/src/components/ui/customContextMenuRegistry.ts b/apps/desktop/src/components/ui/customContextMenuRegistry.ts
index aa2cdab74..fcca5d2e2 100644
--- a/apps/desktop/src/components/ui/customContextMenuRegistry.ts
+++ b/apps/desktop/src/components/ui/customContextMenuRegistry.ts
@@ -1,5 +1,13 @@
export type ContextMenuClose = () => void;
+/** Marker attribute on scrollable context menu / submenu roots. */
+export const CONTEXT_MENU_SCROLL_ROOT_ATTR = "data-dbx-context-menu";
+
+export function isContextMenuInternalScroll(event: Event): boolean {
+ const target = event.target;
+ return target instanceof Element && target.closest(`[${CONTEXT_MENU_SCROLL_ROOT_ATTR}]`) !== null;
+}
+
export interface ContextMenuRegistration {
setOpen(open: boolean): void;
dispose(): void;
@@ -20,10 +28,16 @@ export function createContextMenuRegistry(documentTarget: EventTarget, windowTar
for (const close of closers) close();
}
+ function closeAllOnScroll(event: Event) {
+ // Ignore scroll that originates from within an open menu/submenu.
+ if (isContextMenuInternalScroll(event)) return;
+ closeAll();
+ }
+
function attachListeners() {
if (listenersAttached) return;
documentTarget.addEventListener("contextmenu", closeAll, true);
- documentTarget.addEventListener("scroll", closeAll, true);
+ documentTarget.addEventListener("scroll", closeAllOnScroll, true);
windowTarget.addEventListener("resize", closeAll);
listenersAttached = true;
}
@@ -31,7 +45,7 @@ export function createContextMenuRegistry(documentTarget: EventTarget, windowTar
function detachListeners() {
if (!listenersAttached) return;
documentTarget.removeEventListener("contextmenu", closeAll, true);
- documentTarget.removeEventListener("scroll", closeAll, true);
+ documentTarget.removeEventListener("scroll", closeAllOnScroll, true);
windowTarget.removeEventListener("resize", closeAll);
listenersAttached = false;
openMenus.clear();