feat(FN-5482): suppress touch-synthesized mouse events on overlay dismiss

Adds a `useOverlayDismiss` hook that suppresses touch-synthesized mouse events on modal/dropdown overlays to prevent unintended close behavior on touch devices, with tests covering TaskCard dismissal and overlay interaction edge cases. Documentation updates in AGENTS.md and docs/architecture.md capt

Fusion-Task-Id: FN-5482

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5482
This commit is contained in:
gsxdsm
2026-05-23 02:05:16 -07:00
parent f36abcc56e
commit b22112af89
6 changed files with 213 additions and 3 deletions

View File

@@ -1,6 +1,9 @@
import React from "react";
import { afterEach, describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { TaskCard, formatElapsedDurationDone, __test_areTaskCardPropsEqual } from "../TaskCard";
import { NavigationHistoryProvider, useNavigationHistory } from "../../hooks/useNavigationHistory";
import { useOverlayDismiss } from "../../hooks/useOverlayDismiss";
import type { ConfirmOptions } from "../../hooks/useConfirm";
import type { Task } from "@fusion/core";
@@ -3979,6 +3982,126 @@ describe("TaskCard mission badge", () => {
});
});
describe("TaskCard Android tap regression", () => {
function AndroidTapHarness({
task,
onOpenDetail,
onOpenDetailWithTab,
onClose,
}: {
task: Task;
onOpenDetail: (task: Task) => void;
onOpenDetailWithTab: (task: Task, tab: "changes") => void;
onClose: () => void;
}) {
const [isOpen, setIsOpen] = React.useState(false);
const nav = useNavigationHistory({ enabled: true });
const overlayDismiss = useOverlayDismiss(() => {
onClose();
setIsOpen(false);
});
return (
<NavigationHistoryProvider value={nav}>
<TaskCard
task={task}
onOpenDetail={(nextTask) => {
onOpenDetail(nextTask as Task);
setIsOpen(true);
nav.pushNav({
type: "modal",
close: () => {
onClose();
setIsOpen(false);
},
});
}}
onOpenDetailWithTab={onOpenDetailWithTab}
addToast={noop}
/>
{isOpen && (
<div className="modal-overlay" data-testid="android-modal-overlay" {...overlayDismiss}>
<div className="modal-content">detail</div>
</div>
)}
</NavigationHistoryProvider>
);
}
it("keeps modal open after Android compatibility mouse sequence and supports popstate close", () => {
const onOpenDetail = vi.fn();
const onOpenDetailWithTab = vi.fn();
const onClose = vi.fn();
const pushStateSpy = vi.spyOn(window.history, "pushState");
render(
<AndroidTapHarness
task={makeTask({ column: "todo", status: undefined, mergeDetails: { landedFiles: ["a.ts"] } } as any)}
onOpenDetail={onOpenDetail}
onOpenDetailWithTab={onOpenDetailWithTab}
onClose={onClose}
/>,
);
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);
expect(pushStateSpy).toHaveBeenCalledTimes(1);
const overlay = screen.getByTestId("android-modal-overlay");
fireEvent.mouseDown(overlay);
fireEvent.mouseUp(overlay);
expect(onClose).toHaveBeenCalledTimes(0);
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("keeps files-changed chip touch path opening changes tab once", () => {
const onOpenDetail = vi.fn();
const onOpenDetailWithTab = vi.fn();
const onClose = vi.fn();
const task = makeTask({
column: "done",
status: undefined,
mergeDetails: { landedFiles: ["a.ts", "b.ts"] },
} as any);
render(
<AndroidTapHarness
task={task}
onOpenDetail={onOpenDetail}
onOpenDetailWithTab={onOpenDetailWithTab as any}
onClose={onClose}
/>,
);
const filesChip = screen.getByRole("button", { name: "2 files changed" });
fireEvent.touchStart(filesChip, {
touches: [{ clientX: 12, clientY: 12 }],
changedTouches: [{ clientX: 12, clientY: 12 }],
});
fireEvent.touchEnd(filesChip, {
touches: [],
changedTouches: [{ clientX: 12, clientY: 12 }],
});
fireEvent.click(filesChip);
expect(onOpenDetailWithTab).toHaveBeenCalledTimes(1);
expect(onOpenDetailWithTab).toHaveBeenCalledWith(task, "changes");
expect(onOpenDetail).toHaveBeenCalledTimes(0);
expect(onClose).toHaveBeenCalledTimes(0);
});
});
describe("TaskCard agent badge", () => {
let clearAgentCache: () => void;