fix(desktop): keep context submenu open while scrolling
This commit is contained in:
parent
2980e32c9d
commit
7ae36ee832
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue