Skip to content
Merged
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
9 changes: 9 additions & 0 deletions packages/shadcn/src/menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@ export const MenuTrigger = (
} else {
return (
<ShadCNComponents.DropdownMenu.DropdownMenuTrigger
// Base UI normally opens on mouse-down, but a draggable trigger must
// stay closed until a click completes. Native dragging suppresses that
// click, so starting or finishing a drag won't open the menu.
// Skip pointer-down too: Base UI records the pointer type there and
// then ignores the click, assuming mouse-down already handled it.
// Only skip Base UI's handlers, not the browser defaults, so native
// dragging and focus still work. Keep click and keyboard handling intact.
onPointerDown={(event) => event.preventBaseUIHandler()}
onMouseDown={(event) => event.preventBaseUIHandler()}
render={children as ReactElement}
/>
);
Expand Down
87 changes: 86 additions & 1 deletion tests/src/end-to-end/shadcn/shadcn.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import App from "@examples/01-basic/09-shadcn/src/App";
// to bootstrap Tailwind v4 + the ShadCN theme variables. Without it the
// ShadCN UI components render unstyled (no popovers, no borders, no theme).
import "@examples/01-basic/09-shadcn/tailwind.css";
import { beforeEach, describe, test } from "vite-plus/test";
import { beforeEach, describe, expect, test, vi } from "vite-plus/test";
import { render } from "vitest-browser-react";
import { userEvent } from "../../utils/context.js";
import {
Expand All @@ -26,6 +26,17 @@ import {
} from "../../utils/mouse.js";
import { executeSlashCommand } from "../../utils/slashmenu.js";

// ShadCN portals the dropdown outside .bn-side-menu.
const DRAG_HANDLE_MENU_SELECTOR = ".bn-drag-handle-menu";

async function waitForMenuUpdates() {
// Base UI schedules mouse-down opening in an animation frame. Wait through
// the following frame so absence assertions also observe deferred updates.
await new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
}

beforeEach(async () => {
await render(<App />);
await waitForSelector(EDITOR_SELECTOR);
Expand Down Expand Up @@ -102,6 +113,80 @@ describe("Check ShadCN UI", () => {
"shadcn-drag-handle-menu",
);
});
test("Drag handle menu opens on release instead of press", async () => {
await focusOnEditor();
await waitForSelector(PARAGRAPH_SELECTOR);
await moveMouseOverElement(PARAGRAPH_SELECTOR);

const dragHandle = await waitForSelector(DRAG_HANDLE_SELECTOR);
await moveMouseOverElement(dragHandle);
await mouseSequence([{ type: "down" }]);

await waitForMenuUpdates();
expect(document.querySelector(DRAG_HANDLE_MENU_SELECTOR)).toBeNull();

await mouseSequence([{ type: "up" }]);
await waitForSelector(DRAG_HANDLE_MENU_SELECTOR);
});
test.each(["click", "Enter", "Space"] as const)(
"Dragging keeps the menu closed and allows a subsequent %s",
async (activation) => {
await focusOnEditor();
await waitForSelector(PARAGRAPH_SELECTOR);
await moveMouseOverElement(PARAGRAPH_SELECTOR);

const dragHandle = await waitForSelector(DRAG_HANDLE_SELECTOR);
const dragHandleButton = dragHandle.closest("button");
if (!dragHandleButton) {
throw new Error("Drag handle button not found");
}
const onDragStart = vi.fn();
dragHandleButton.addEventListener("dragstart", onDragStart, {
once: true,
});
const dragHandleRect = getRect(dragHandle);
const x = dragHandleRect.x + dragHandleRect.width / 2;
const y = dragHandleRect.y + dragHandleRect.height / 2;
await mouseSequence([
{ type: "move", x, y },
{ type: "down" },
{ type: "move", x: x + 20, y: y + 20, steps: 5 },
]);

expect(onDragStart).toHaveBeenCalledOnce();
await waitForMenuUpdates();
expect(document.querySelector(DRAG_HANDLE_MENU_SELECTOR)).toBeNull();

// Release over the trigger itself: a mouse-up there must not be treated
// as a click after a drag, even when the block ends up in the same place.
await mouseSequence([{ type: "move", x, y, steps: 5 }, { type: "up" }]);
await waitForMenuUpdates();
expect(document.querySelector(DRAG_HANDLE_MENU_SELECTOR)).toBeNull();

await moveMouseOverElement(PARAGRAPH_SELECTOR);
const dragHandleAfterDragging =
await waitForSelector(DRAG_HANDLE_SELECTOR);
const buttonAfterDragging = dragHandleAfterDragging.closest("button");
if (!buttonAfterDragging) {
throw new Error("Drag handle button not found");
}
switch (activation) {
case "click":
await moveMouseOverElement(buttonAfterDragging);
await mouseSequence([{ type: "down" }, { type: "up" }]);
break;
case "Enter":
buttonAfterDragging.focus();
await userEvent.keyboard("{Enter}");
break;
case "Space":
buttonAfterDragging.focus();
await userEvent.keyboard(" ");
break;
}
await waitForSelector(DRAG_HANDLE_MENU_SELECTOR);
},
);
test("Check image toolbar", async () => {
await focusOnEditor();
await executeSlashCommand("image");
Expand Down
Loading