diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx
index 8ba8ec8400..7d4c84165d 100644
--- a/packages/dashboard/app/components/TaskCard.tsx
+++ b/packages/dashboard/app/components/TaskCard.tsx
@@ -3483,6 +3483,11 @@ function TaskCardComponent({
);
}
+ /*
+ FNXC:TaskCardMenu 2026-08-01-16:06:
+ React portal events bubble through the TaskCard owner tree even though this menu lives under document.body.
+ Stop every touch, pointer, compatibility-click, and keyboard path at the portal wrapper so selecting any menu action cannot invoke card detail opening while TaskContextMenu keeps its own dispatch and navigation behavior.
+ */
return (
event.stopPropagation()}
+ onPointerMove={(event) => event.stopPropagation()}
+ onPointerUp={(event) => event.stopPropagation()}
+ onPointerCancel={(event) => event.stopPropagation()}
+ onTouchStart={(event) => event.stopPropagation()}
+ onTouchMove={(event) => event.stopPropagation()}
+ onTouchEnd={(event) => event.stopPropagation()}
+ onTouchCancel={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
- onContextMenu={(event) => event.preventDefault()}
+ onKeyDown={(event) => event.stopPropagation()}
+ onContextMenu={(event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ }}
>
{
}
});
+ it("isolates mobile menu actions from card detail while intentional card taps still open it", async () => {
+ const cleanupGeometry = mockBoardContextMenuGeometry();
+ const onOpenDetail = vi.fn();
+ const onUnpauseTask = vi.fn(async () => makeTask());
+ const onPauseTask = vi.fn(async () => makeTask({ paused: true }));
+ try {
+ const { rerender } = render(
+ ,
+ );
+
+ fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
+ await waitFor(() => expectBoardContextMenuPortaled());
+ const unpause = screen.getByRole("menuitem", { name: "Unpause" });
+ fireEvent.pointerDown(unpause, { pointerType: "touch", pointerId: 1 });
+ fireEvent.touchStart(unpause, { touches: [{ clientX: 20, clientY: 20 }] });
+ fireEvent.pointerUp(unpause, { pointerType: "touch", pointerId: 1 });
+ await waitFor(() => expect(onUnpauseTask).toHaveBeenCalledWith("FN-001"));
+ expect(onUnpauseTask).toHaveBeenCalledTimes(1);
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
+ expect(onOpenDetail).not.toHaveBeenCalled();
+
+ rerender(
+ ,
+ );
+
+ fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
+ await waitFor(() => expectBoardContextMenuPortaled());
+ const pause = screen.getByRole("menuitem", { name: "Pause" });
+ fireEvent.pointerDown(pause, { pointerType: "touch", pointerId: 2 });
+ fireEvent.touchStart(pause, { touches: [{ clientX: 20, clientY: 20 }] });
+ fireEvent.pointerUp(pause, { pointerType: "touch", pointerId: 2 });
+ await waitFor(() => expect(onPauseTask).toHaveBeenCalledWith("FN-001"));
+ expect(onPauseTask).toHaveBeenCalledTimes(1);
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
+ expect(onOpenDetail).not.toHaveBeenCalled();
+
+ const card = document.querySelector(".card") as HTMLElement;
+ fireEvent.touchStart(card, {
+ touches: [{ clientX: 20, clientY: 20 }],
+ changedTouches: [{ clientX: 20, clientY: 20 }],
+ });
+ fireEvent.touchEnd(card, {
+ touches: [],
+ changedTouches: [{ clientX: 20, clientY: 20 }],
+ });
+ expect(onOpenDetail).toHaveBeenCalledTimes(1);
+ } finally {
+ cleanupGeometry();
+ }
+ });
+
it("suppresses native text selection when touch long-press opens the board card context menu", async () => {
vi.useFakeTimers();
const cleanupGeometry = mockBoardContextMenuGeometry();