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:
7
.changeset/fn-8073-confirm-dialog.md
Normal file
7
.changeset/fn-8073-confirm-dialog.md
Normal 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.
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user