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