From 5209e7fb8f037a0de55276927a9f6fa8bcbb8e22 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 1 Aug 2026 09:47:14 -0700 Subject: [PATCH] FN-8708: isolate task card menu interactions Prevent portal menu interactions from bubbling into task-card detail navigation. - Stop pointer, touch, click, keyboard, and context-menu propagation at the portaled menu wrapper. - Cover pause and unpause mobile actions while retaining intentional card-tap navigation. Files changed: packages/dashboard/app/components/TaskCard.tsx | 19 ++++++- .../app/components/__tests__/TaskCard.test.tsx | 63 ++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-8708 Fusion-Task-Lineage: 247d7101-ff3f-4423-b9b0-71f60f89e09b Co-authored-by: Fusion (runfusion.ai) --- .../dashboard/app/components/TaskCard.tsx | 19 +++++- .../components/__tests__/TaskCard.test.tsx | 63 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) 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();