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:
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user