diff --git a/.changeset/fn-8073-confirm-dialog.md b/.changeset/fn-8073-confirm-dialog.md new file mode 100644 index 0000000000..44e2de04bd --- /dev/null +++ b/.changeset/fn-8073-confirm-dialog.md @@ -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. diff --git a/packages/dashboard/app/components/ConfirmDialog.tsx b/packages/dashboard/app/components/ConfirmDialog.tsx index ca67b607fe..0d599f0f98 100644 --- a/packages/dashboard/app/components/ConfirmDialog.tsx +++ b/packages/dashboard/app/components/ConfirmDialog.tsx @@ -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(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) => { + backdropPressStartedHereRef.current = event.target === event.currentTarget; + }; + + const dismissFromBackdropClick = (event: React.MouseEvent) => { + 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( -
+
event.stopPropagation()} diff --git a/packages/dashboard/app/components/__tests__/ConfirmDialog.test.tsx b/packages/dashboard/app/components/__tests__/ConfirmDialog.test.tsx index 9825879c82..09e6fb5bf1 100644 --- a/packages/dashboard/app/components/__tests__/ConfirmDialog.test.tsx +++ b/packages/dashboard/app/components/__tests__/ConfirmDialog.test.tsx @@ -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( + , + ); + + 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( { // 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( + , + ); + + const overlay = document.querySelector(".confirm-dialog-overlay"); + expect(overlay?.style.zIndex).not.toBe(""); + }); + it("uses compact mobile override classes on overlay and dialog surface", () => { render( { }); }); + 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(