fix(desktop): keep context submenu open while scrolling

This commit is contained in:
Freedom 2026-07-28 20:15:30 +08:00 committed by GitHub
parent 2980e32c9d
commit 7ae36ee832
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 96 additions and 5 deletions

View File

@ -70,7 +70,16 @@ function onPointerDownOutside(e: PointerEvent) {
}
}
function onScroll() {
function isScrollInsideMenu(e: Event): boolean {
const target = e.target;
if (!(target instanceof Node)) return false;
return !!(menuRef.value?.contains(target) || subRef.value?.contains(target));
}
function onScroll(e: Event) {
// Submenus (and tall main menus) are scrollable; ignore their own scroll
// so wheel/trackpad scrolling does not dismiss the menu.
if (isScrollInsideMenu(e)) return;
close();
}
@ -262,7 +271,7 @@ onBeforeUnmount(() => {
<slot :onContextMenu="onContextMenu" />
<!-- Main menu -->
<Teleport to="body">
<div v-if="show" ref="menuRef" :style="{ position: 'fixed', left: x + 'px', top: y + 'px', zIndex: 9999 }" class="bg-popover text-popover-foreground min-w-40 w-max max-w-[calc(100vw-16px)] rounded-md p-1 overflow-y-auto ring-1 ring-foreground/10 shadow-lg">
<div v-if="show" ref="menuRef" data-dbx-context-menu :style="{ position: 'fixed', left: x + 'px', top: y + 'px', zIndex: 9999 }" class="bg-popover text-popover-foreground min-w-40 w-max max-w-[calc(100vw-16px)] rounded-md p-1 overflow-y-auto ring-1 ring-foreground/10 shadow-lg">
<template v-for="(item, index) in activeItems" :key="index">
<template v-if="item.visible !== false">
<div v-if="item.separator" class="-mx-1 my-1 flex items-center px-1">
@ -287,6 +296,7 @@ onBeforeUnmount(() => {
<div
v-if="show && activeSubIndex !== null && activeItems[activeSubIndex]?.children?.length"
ref="subRef"
data-dbx-context-menu
:style="{ position: 'fixed', left: subX + 'px', top: subY + 'px', zIndex: 10000, maxHeight: 'min(420px, calc(100vh - 16px))' }"
class="bg-popover text-popover-foreground min-w-56 w-max max-w-[calc(100vw-16px)] rounded-md p-1 overflow-y-auto ring-1 ring-foreground/10 shadow-lg"
@mouseenter="onSubMouseEnter"

View File

@ -96,4 +96,46 @@ describe("CustomContextMenu lifecycle", () => {
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();
});
});

View File

@ -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<typeof vi.spyOn>, 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();

View File

@ -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();