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 { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { ConfirmOptions } from "../hooks/useConfirm"; 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. 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); const [overlayZ, setOverlayZ] = useState<number | undefined>(undefined);
useEffect(() => { const backdropPressStartedHereRef = useRef(false);
useLayoutEffect(() => {
if (isOpen) { if (isOpen) {
setOverlayZ(nextFloatingZ()); setOverlayZ(nextFloatingZ());
} }
}, [isOpen]); }, [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(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
return; return;
@@ -64,7 +83,14 @@ export function ConfirmDialog({
} }
return createPortal( 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 <div
className="modal confirm-dialog" className="modal confirm-dialog"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}

View File

@@ -48,6 +48,21 @@ describe("ConfirmDialog", () => {
expect(onCancel).toHaveBeenCalledTimes(1); 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", () => { it("calls onCancel on Escape key", () => {
const onCancel = vi.fn(); const onCancel = vi.fn();
render( render(
@@ -63,7 +78,7 @@ describe("ConfirmDialog", () => {
expect(onCancel).toHaveBeenCalledTimes(1); 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(); const onCancel = vi.fn();
render( render(
<ConfirmDialog <ConfirmDialog
@@ -77,6 +92,7 @@ describe("ConfirmDialog", () => {
// FNXC: ConfirmDialog portals to document.body, so query from document (not the render container). // FNXC: ConfirmDialog portals to document.body, so query from document (not the render container).
const overlay = document.querySelector(".modal-overlay"); const overlay = document.querySelector(".modal-overlay");
expect(overlay).toBeTruthy(); expect(overlay).toBeTruthy();
fireEvent.pointerDown(overlay as Element);
fireEvent.click(overlay as Element); fireEvent.click(overlay as Element);
expect(onCancel).toHaveBeenCalledTimes(1); expect(onCancel).toHaveBeenCalledTimes(1);
}); });
@@ -110,6 +126,20 @@ describe("ConfirmDialog", () => {
expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus(); 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", () => { it("uses compact mobile override classes on overlay and dialog surface", () => {
render( render(
<ConfirmDialog <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 () => { it("resolves false when cancel is clicked", async () => {
render( render(
React.createElement( React.createElement(