FN-8073: preserve deletion confirmations until explicitly dismissed

Keep task deletion confirmations open until users intentionally confirm, cancel, or dismiss the backdrop.

- Track backdrop press origin to ignore trigger click-through events
- Claim dialog stacking order before paint and cover mouse and touch regressions
- Add a patch changeset for the confirmation behavior fix

Files changed:
 .changeset/fn-8073-confirm-dialog.md               |  7 ++++
 packages/dashboard/app/components/ConfirmDialog.tsx | 32 ++++++++++++++++--
 .../components/__tests__/ConfirmDialog.test.tsx    | 32 +++++++++++++++++-
 .../app/hooks/__tests__/useConfirm.test.ts         | 38 ++++++++++++++++++++++
 4 files changed, 105 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-8073

Fusion-Task-Lineage: 15a8636f-a058-4b15-834f-6b3549fee4c2

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 03:28:07 -07:00
parent 90ce57f127
commit 297edd93e4
4 changed files with 105 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep task deletion confirmations visible until users explicitly choose an action.
category: fix
dev: Backdrop dismissal now requires an overlay-originated press, preventing the delete trigger's trailing click from cancelling the portaled confirmation.

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import type { ConfirmOptions } from "../hooks/useConfirm";
@@ -35,12 +35,31 @@ export function ConfirmDialog({
The confirm dialog (e.g. the "discard changes" prompt when cancelling New Task) MUST sit above the floating modal stack. Floating windows (New Task, pop-outs) live at the shared floating z-band (nextFloatingZ) and are portaled to document.body, so a confirm rendered inline at the page .modal-overlay z (~10000) paints BEHIND them. Portal the confirm to body and claim the TOP of the shared stack each time it opens so it always appears over whatever floating window triggered it.
*/
const [overlayZ, setOverlayZ] = useState<number | undefined>(undefined);
useEffect(() => {
const backdropPressStartedHereRef = useRef(false);
useLayoutEffect(() => {
if (isOpen) {
setOverlayZ(nextFloatingZ());
}
}, [isOpen]);
/*
FNXC:Confirm 2026-07-16-10:00:
A confirm opened from a task delete must remain visible until an explicit user
action. The trigger's trailing click can reach this newly portaled backdrop,
so outside-dismiss is valid only after a press that began on the backdrop.
*/
const recordBackdropPress = (event: React.SyntheticEvent<HTMLDivElement>) => {
backdropPressStartedHereRef.current = event.target === event.currentTarget;
};
const dismissFromBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
const startedOnBackdrop = backdropPressStartedHereRef.current;
backdropPressStartedHereRef.current = false;
if (startedOnBackdrop && event.target === event.currentTarget) {
onCancel();
}
};
useEffect(() => {
if (!isOpen) {
return;
@@ -64,7 +83,14 @@ export function ConfirmDialog({
}
return createPortal(
<div className="modal-overlay open confirm-dialog-overlay" onClick={onCancel} style={overlayZ ? { zIndex: overlayZ } : undefined}>
<div
className="modal-overlay open confirm-dialog-overlay"
onPointerDown={recordBackdropPress}
onMouseDown={recordBackdropPress}
onTouchStart={recordBackdropPress}
onClick={dismissFromBackdropClick}
style={overlayZ ? { zIndex: overlayZ } : undefined}
>
<div
className="modal confirm-dialog"
onClick={(event) => event.stopPropagation()}

View File

@@ -48,6 +48,21 @@ describe("ConfirmDialog", () => {
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("calls onCancel when the header close button is clicked", () => {
const onCancel = vi.fn();
render(
<ConfirmDialog
isOpen={true}
options={{ title: "Discard", message: "Discard changes?" }}
onConfirm={vi.fn()}
onCancel={onCancel}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Close confirmation dialog" }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("calls onCancel on Escape key", () => {
const onCancel = vi.fn();
render(
@@ -63,7 +78,7 @@ describe("ConfirmDialog", () => {
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("calls onCancel when overlay clicked", () => {
it("calls onCancel when a backdrop press and click both originate on the overlay", () => {
const onCancel = vi.fn();
render(
<ConfirmDialog
@@ -77,6 +92,7 @@ describe("ConfirmDialog", () => {
// FNXC: ConfirmDialog portals to document.body, so query from document (not the render container).
const overlay = document.querySelector(".modal-overlay");
expect(overlay).toBeTruthy();
fireEvent.pointerDown(overlay as Element);
fireEvent.click(overlay as Element);
expect(onCancel).toHaveBeenCalledTimes(1);
});
@@ -110,6 +126,20 @@ describe("ConfirmDialog", () => {
expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus();
});
it("claims a floating-stack z-index before the dialog is painted", () => {
render(
<ConfirmDialog
isOpen={true}
options={{ title: "Discard", message: "Discard changes?" }}
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>,
);
const overlay = document.querySelector<HTMLElement>(".confirm-dialog-overlay");
expect(overlay?.style.zIndex).not.toBe("");
});
it("uses compact mobile override classes on overlay and dialog surface", () => {
render(
<ConfirmDialog

View File

@@ -95,6 +95,44 @@ describe("useConfirm", () => {
});
});
it("keeps a mouse-opened confirm visible when its trailing click reaches the backdrop", async () => {
render(React.createElement(ConfirmDialogProvider, null, React.createElement(Harness)));
const trigger = screen.getByText("open");
fireEvent.pointerDown(trigger, { pointerType: "mouse" });
fireEvent.click(trigger);
const overlay = await screen.findByText("Delete FN-001?").then(() => document.querySelector(".confirm-dialog-overlay"));
expect(overlay).toBeTruthy();
fireEvent.pointerUp(overlay as Element, { pointerType: "mouse" });
fireEvent.click(overlay as Element);
expect(screen.getByRole("dialog", { name: "Delete Task" })).toBeInTheDocument();
expect(screen.getByTestId("result")).toHaveTextContent("idle");
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
await waitFor(() => expect(screen.getByTestId("result")).toHaveTextContent("confirmed"));
});
it("keeps a touch-opened confirm visible when its trailing tap reaches the backdrop", async () => {
render(React.createElement(ConfirmDialogProvider, null, React.createElement(Harness)));
const trigger = screen.getByText("open");
fireEvent.touchStart(trigger);
fireEvent.click(trigger);
const overlay = await screen.findByText("Delete FN-001?").then(() => document.querySelector(".confirm-dialog-overlay"));
expect(overlay).toBeTruthy();
fireEvent.touchEnd(overlay as Element);
fireEvent.click(overlay as Element);
expect(screen.getByRole("dialog", { name: "Delete Task" })).toBeInTheDocument();
expect(screen.getByTestId("result")).toHaveTextContent("idle");
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
await waitFor(() => expect(screen.getByTestId("result")).toHaveTextContent("confirmed"));
});
it("resolves false when cancel is clicked", async () => {
render(
React.createElement(