diff --git a/src/components/common/ContextMenu.tsx b/src/components/common/ContextMenu.tsx index f9be746..bb7fc5a 100644 --- a/src/components/common/ContextMenu.tsx +++ b/src/components/common/ContextMenu.tsx @@ -46,11 +46,28 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { onClose(); } }; + // Scroll/resize/visibilitychange: the menu is anchored to absolute x,y + // coordinates from the original right-click. Any of these events means + // the menu is visually stale (offscreen while the user keeps scrolling, + // window resizes, or the OS hides the tab). Auto-close so the menu + // doesn't survive orphaned. (refs issue #455) + const handleScroll = () => onClose(); + const handleResize = () => onClose(); + const handleVisibilityChange = () => { + if (document.visibilityState === "hidden") onClose(); + }; + document.addEventListener("keydown", handleKey); document.addEventListener("mousedown", handleClick); + document.addEventListener("scroll", handleScroll, true); + window.addEventListener("resize", handleResize); + document.addEventListener("visibilitychange", handleVisibilityChange); return () => { document.removeEventListener("keydown", handleKey); document.removeEventListener("mousedown", handleClick); + document.removeEventListener("scroll", handleScroll, true); + window.removeEventListener("resize", handleResize); + document.removeEventListener("visibilitychange", handleVisibilityChange); }; }, [onClose]); diff --git a/src/components/common/__tests__/ContextMenu.test.tsx b/src/components/common/__tests__/ContextMenu.test.tsx index 167eb2d..7eb9593 100644 --- a/src/components/common/__tests__/ContextMenu.test.tsx +++ b/src/components/common/__tests__/ContextMenu.test.tsx @@ -87,6 +87,61 @@ describe("ContextMenu", () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it("calls onClose on scroll (any ancestor — menu is anchored to x/y)", () => { + const onClose = vi.fn(); + render( +
+
+ +
+
, + ); + + // Use capture phase since the scroll listener is registered with `{capture: true}` + const scrollable = screen.getByTestId("scrollable"); + fireEvent.scroll(scrollable, { target: { scrollTop: 10 } }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("calls onClose on window resize", () => { + const onClose = vi.fn(); + render( + , + ); + + fireEvent.resize(window); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("calls onClose when document becomes hidden", () => { + const onClose = vi.fn(); + render( + , + ); + + // visibilitychange handler only closes when state becomes "hidden" + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => "hidden", + }); + fireEvent(document, new Event("visibilitychange")); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does NOT call onClose when document becomes visible (only hidden triggers close)", () => { + const onClose = vi.fn(); + render( + , + ); + + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => "visible", + }); + fireEvent(document, new Event("visibilitychange")); + expect(onClose).not.toHaveBeenCalled(); + }); + it("renders icons when provided", () => { const items: MenuItem[] = [ { label: "With Icon", icon: *, onClick: vi.fn() },