onRedisRowContextMenu(event, row.node, onContextMenu)"
diff --git a/apps/desktop/src/components/ui/CustomContextMenu.vue b/apps/desktop/src/components/ui/CustomContextMenu.vue
index 3bf9c6a475..051187e499 100644
--- a/apps/desktop/src/components/ui/CustomContextMenu.vue
+++ b/apps/desktop/src/components/ui/CustomContextMenu.vue
@@ -13,11 +13,12 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
+ open: [];
close: [];
}>();
defineSlots<{
- default(props: { onContextMenu: (event: MouseEvent) => void }): any;
+ default(props: { onContextMenu: (event: MouseEvent) => void; isOpen: boolean }): any;
}>();
const show = ref(false);
@@ -36,6 +37,7 @@ let subAnchorRect: { left: number; right: number; top: number; bottom: number }
let contextMenuRegistration: ContextMenuRegistration | null = null;
function close() {
+ if (!show.value) return;
activeSubIndex.value = null;
subAnchorRect = null;
activeItems.value = [];
@@ -125,7 +127,9 @@ function onContextMenu(event: MouseEvent, itemsOverride?: ContextMenuItem[]) {
event.stopPropagation();
x.value = event.clientX;
y.value = event.clientY;
+ contextMenuRegistration?.activate();
show.value = true;
+ emit("open");
nextTick(() => {
if (!menuRef.value) return;
const rect = menuRef.value.getBoundingClientRect();
@@ -258,7 +262,7 @@ onBeforeUnmount(() => {
-
+
diff --git a/apps/desktop/src/components/ui/__tests__/CustomContextMenu.spec.ts b/apps/desktop/src/components/ui/__tests__/CustomContextMenu.spec.ts
index 0d756d2f78..ec31f5ce38 100644
--- a/apps/desktop/src/components/ui/__tests__/CustomContextMenu.spec.ts
+++ b/apps/desktop/src/components/ui/__tests__/CustomContextMenu.spec.ts
@@ -20,6 +20,62 @@ afterEach(() => {
});
describe("CustomContextMenu lifecycle", () => {
+ it("exposes the open state and closes only the active target", async () => {
+ const firstOpen = vi.fn();
+ const firstClose = vi.fn();
+ const secondOpen = vi.fn();
+ const secondClose = vi.fn();
+ const root = defineComponent({
+ setup() {
+ const menu = (id: string, onOpen: () => void, onClose: () => void) =>
+ h(
+ CustomContextMenu,
+ { items: [{ label: `Inspect ${id}` }], onOpen, onClose },
+ {
+ default: ({ onContextMenu, isOpen }: { onContextMenu: (event: MouseEvent) => void; isOpen: boolean }) => h("div", { id, "data-context-open": String(isOpen), onContextmenu: onContextMenu }, id),
+ },
+ );
+ return () => [menu("first-target", firstOpen, firstClose), menu("second-target", secondOpen, secondClose)];
+ },
+ });
+ const container = document.createElement("div");
+ mountedContainers.push(container);
+ document.body.append(container);
+ const app = createApp(root);
+ app.mount(container);
+
+ const firstTarget = container.querySelector("#first-target");
+ const secondTarget = container.querySelector("#second-target");
+ firstTarget?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
+ await nextTick();
+ expect(firstTarget?.getAttribute("data-context-open")).toBe("true");
+ expect(secondTarget?.getAttribute("data-context-open")).toBe("false");
+ expect(firstOpen).toHaveBeenCalledTimes(1);
+ expect(firstClose).not.toHaveBeenCalled();
+
+ firstTarget?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
+ await nextTick();
+ expect(firstTarget?.getAttribute("data-context-open")).toBe("true");
+ expect(secondTarget?.getAttribute("data-context-open")).toBe("false");
+ expect(firstOpen).toHaveBeenCalledTimes(2);
+ expect(firstClose).toHaveBeenCalledTimes(1);
+
+ secondTarget?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
+ await nextTick();
+ expect(firstTarget?.getAttribute("data-context-open")).toBe("false");
+ expect(secondTarget?.getAttribute("data-context-open")).toBe("true");
+ expect(firstClose).toHaveBeenCalledTimes(2);
+ expect(secondOpen).toHaveBeenCalledTimes(1);
+
+ window.dispatchEvent(new Event("resize"));
+ window.dispatchEvent(new Event("resize"));
+ await nextTick();
+ expect(secondTarget?.getAttribute("data-context-open")).toBe("false");
+ expect(secondClose).toHaveBeenCalledTimes(1);
+
+ app.unmount();
+ });
+
it("removes the capture keydown listener when unmounted while open", async () => {
const documentAdd = vi.spyOn(document, "addEventListener");
const documentRemove = vi.spyOn(document, "removeEventListener");
diff --git a/apps/desktop/src/components/ui/__tests__/customContextMenuRegistry.spec.ts b/apps/desktop/src/components/ui/__tests__/customContextMenuRegistry.spec.ts
index f326042693..b3d25af2e7 100644
--- a/apps/desktop/src/components/ui/__tests__/customContextMenuRegistry.spec.ts
+++ b/apps/desktop/src/components/ui/__tests__/customContextMenuRegistry.spec.ts
@@ -59,6 +59,30 @@ describe("customContextMenuRegistry", () => {
registration.dispose();
});
+ it("atomically reactivates a host after the capture listener clears it", () => {
+ const documentTarget = new EventTarget();
+ const windowTarget = new EventTarget();
+ const registry = createContextMenuRegistry(documentTarget, windowTarget);
+ const closeFirst = vi.fn();
+ const closeSecond = vi.fn();
+ const first = registry.register(closeFirst);
+ const second = registry.register(closeSecond);
+
+ first.activate();
+ documentTarget.dispatchEvent(new Event("contextmenu"));
+ first.activate();
+ second.activate();
+
+ expect(closeFirst).toHaveBeenCalledTimes(2);
+ expect(closeSecond).not.toHaveBeenCalled();
+
+ windowTarget.dispatchEvent(new Event("resize"));
+ expect(closeSecond).toHaveBeenCalledOnce();
+
+ first.dispose();
+ second.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 38de807111..12380aca8c 100644
--- a/apps/desktop/src/components/ui/customContextMenuRegistry.ts
+++ b/apps/desktop/src/components/ui/customContextMenuRegistry.ts
@@ -26,6 +26,7 @@ export function isContextMenuInternalScroll(event: Event): boolean {
}
export interface ContextMenuRegistration {
+ activate(): void;
setOpen(open: boolean): void;
dispose(): void;
}
@@ -75,6 +76,15 @@ export function createContextMenuRegistry(documentTarget: EventTarget, windowTar
let disposed = false;
return {
+ activate() {
+ if (disposed) return;
+ const closers = [...openMenus];
+ openMenus.clear();
+ for (const activeClose of closers) {
+ if (activeClose !== close) activeClose();
+ }
+ openMenus.add(close);
+ },
setOpen(open) {
if (disposed) return;
if (open) openMenus.add(close);