fix(editor): dismiss dropdown menus on outside clicks (#12053)

fix: dismiss dropdown menus on outside clicks
This commit is contained in:
David Luzar
2026-09-07 17:28:20 +02:00
committed by GitHub
parent f1a79b73df
commit 2b1c2a9fbf
2 changed files with 45 additions and 9 deletions
@@ -12,7 +12,7 @@ import {
} from "../../tests/test-utils";
describe("Test <DropdownMenu/>", () => {
it("should", async () => {
it("closes on Escape", async () => {
const { container } = await render(<Excalidraw />);
expect(window.h.state.openMenu).toBe(null);
@@ -25,4 +25,35 @@ describe("Test <DropdownMenu/>", () => {
expect(window.h.state.openMenu).toBe(null);
});
});
it.each([
["main menu", '[data-testid="main-menu-trigger"]'],
["more tools", ".App-toolbar__extra-tools-trigger"],
])(
"closes %s on outside pointer and touch events after reopening",
async (_, triggerSelector) => {
const { container } = await render(<Excalidraw />);
const trigger = container.querySelector<HTMLElement>(triggerSelector)!;
const canvas = container.querySelector<HTMLCanvasElement>("canvas")!;
const toolbar = getByTestId(container, "toolbar-rectangle");
for (const outsideTarget of [canvas, toolbar]) {
for (const event of [fireEvent.pointerDown, fireEvent.touchStart]) {
fireEvent.click(trigger);
await waitFor(() => {
expect(
container.querySelector('[data-testid="dropdown-menu"]'),
).not.toBeNull();
});
event(outsideTarget);
await waitFor(() => {
expect(
container.querySelector('[data-testid="dropdown-menu"]'),
).toBeNull();
});
}
}
},
);
});
@@ -1,10 +1,11 @@
import clsx from "clsx";
import React, { useCallback, useEffect, useRef } from "react";
import React, { useCallback, useEffect, useMemo } from "react";
import { CLASSES, EVENT, KEYS } from "@excalidraw/common";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { useCallbackRefState } from "../../hooks/useCallbackRefState";
import { useOutsideClick } from "../../hooks/useOutsideClick";
import { useStable } from "../../hooks/useStable";
import { useEditorInterface } from "../App";
@@ -34,7 +35,10 @@ const MenuContent = ({
align?: "start" | "center" | "end";
}) => {
const editorInterface = useEditorInterface();
const menuRef = useRef<HTMLDivElement>(null);
const [menuNode, setMenuNode] = useCallbackRefState<HTMLDivElement>();
// Radix mounts the content lazily. Rebind outside-click listeners when the
// node becomes available so they attach to its owner document.
const menuRef = useMemo(() => ({ current: menuNode }), [menuNode]);
const callbacksRef = useStable({ onClickOutside });
@@ -51,12 +55,12 @@ const MenuContent = ({
callbacksRef.onClickOutside?.();
}
},
[callbacksRef],
[callbacksRef, menuRef],
),
);
useEffect(() => {
if (!open) {
if (!open || !menuNode) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
@@ -73,11 +77,12 @@ const MenuContent = ({
capture: true,
};
document.addEventListener(EVENT.KEYDOWN, onKeyDown, option);
const ownerDocument = menuNode.ownerDocument;
ownerDocument.addEventListener(EVENT.KEYDOWN, onKeyDown, option);
return () => {
document.removeEventListener(EVENT.KEYDOWN, onKeyDown, option);
ownerDocument.removeEventListener(EVENT.KEYDOWN, onKeyDown, option);
};
}, [callbacksRef, open]);
}, [callbacksRef, open, menuNode]);
const classNames = clsx(`dropdown-menu ${className}`, {
"dropdown-menu--mobile": editorInterface.formFactor === "phone",
@@ -86,7 +91,7 @@ const MenuContent = ({
return (
<DropdownMenuContentPropsContext.Provider value={{ onSelect }}>
<DropdownMenuPrimitive.Content
ref={menuRef}
ref={setMenuNode}
className={classNames}
style={style}
data-testid="dropdown-menu"