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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-01 09:47:14 -07:00
parent 9c9dd7db2f
commit 5209e7fb8f
2 changed files with 81 additions and 1 deletions

View File

@@ -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 (
<div
ref={cardRef}
@@ -3515,8 +3520,20 @@ function TaskCardComponent({
ref={contextMenuRef}
className="task-card-context-menu-popover"
style={{ left: contextMenuPosition.x, top: contextMenuPosition.y } as CSSProperties}
onPointerDown={(event) => 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();
}}
>
<TaskContextMenu
actions={contextMenuActions}

View File

@@ -1100,6 +1100,69 @@ describe("TaskCard", () => {
}
});
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(
<TaskCard
task={makeTask({ paused: true, userPaused: true })}
onOpenDetail={onOpenDetail}
addToast={noop}
onUnpauseTask={onUnpauseTask}
onPauseTask={onPauseTask}
/>,
);
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(
<TaskCard
task={makeTask({ paused: false, userPaused: false })}
onOpenDetail={onOpenDetail}
addToast={noop}
onUnpauseTask={onUnpauseTask}
onPauseTask={onPauseTask}
/>,
);
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();