Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/components/common/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
55 changes: 55 additions & 0 deletions src/components/common/__tests__/ContextMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<div>
<div data-testid="scrollable" style={{ overflow: "auto" }}>
<ContextMenu x={0} y={0} items={createItems()} onClose={onClose} />
</div>
</div>,
);

// 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(
<ContextMenu x={0} y={0} items={createItems()} onClose={onClose} />,
);

fireEvent.resize(window);
expect(onClose).toHaveBeenCalledTimes(1);
});

it("calls onClose when document becomes hidden", () => {
const onClose = vi.fn();
render(
<ContextMenu x={0} y={0} items={createItems()} onClose={onClose} />,
);

// 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(
<ContextMenu x={0} y={0} items={createItems()} onClose={onClose} />,
);

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: <span data-testid="menu-icon">*</span>, onClick: vi.fn() },
Expand Down
Loading