feat(FN-4996): complete Step 1 — add tertiary confirm choice

Fusion-Task-Id: FN-4996
Fusion-Task-Lineage: 650bb5e4-18f2-41a0-ae46-b9112c4a0d74
This commit is contained in:
Fusion (runfusion.ai)
2026-05-18 05:28:21 -07:00
committed by gsxdsm
parent dd0d4b74b7
commit 44562cdf78
5 changed files with 115 additions and 9 deletions

View File

@@ -13,6 +13,7 @@
.confirm-dialog__actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-sm);
}
@@ -35,4 +36,9 @@
padding-bottom: 0;
overflow: hidden;
}
.confirm-dialog__actions {
flex-direction: column;
align-items: stretch;
}
}

View File

@@ -6,10 +6,11 @@ export interface ConfirmDialogProps {
isOpen: boolean;
options: ConfirmOptions | null;
onConfirm: () => void;
onTertiary?: () => void;
onCancel: () => void;
}
export function ConfirmDialog({ isOpen, options, onConfirm, onCancel }: ConfirmDialogProps) {
export function ConfirmDialog({ isOpen, options, onConfirm, onTertiary, onCancel }: ConfirmDialogProps) {
const cancelButtonRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
@@ -56,6 +57,11 @@ export function ConfirmDialog({ isOpen, options, onConfirm, onCancel }: ConfirmD
<button ref={cancelButtonRef} className="btn" onClick={onCancel}>
{options.cancelLabel ?? "Cancel"}
</button>
{options.tertiaryLabel && onTertiary ? (
<button className={`btn ${options.tertiaryDanger ? "btn-danger" : ""}`.trim()} onClick={onTertiary}>
{options.tertiaryLabel}
</button>
) : null}
<button className={`btn ${options.danger ? "btn-danger" : "btn-primary"}`} onClick={onConfirm}>
{options.confirmLabel ?? "Confirm"}
</button>

View File

@@ -79,6 +79,22 @@ describe("ConfirmDialog", () => {
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("renders and handles tertiary action when configured", () => {
const onTertiary = vi.fn();
render(
<ConfirmDialog
isOpen={true}
options={{ title: "Delete Done", message: "Delete or archive?", tertiaryLabel: "Archive Instead" }}
onConfirm={vi.fn()}
onTertiary={onTertiary}
onCancel={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Archive Instead" }));
expect(onTertiary).toHaveBeenCalledTimes(1);
});
it("focuses cancel button on mount", () => {
render(
<ConfirmDialog

View File

@@ -4,7 +4,7 @@ import React, { useState } from "react";
import { ConfirmDialogProvider, useConfirm } from "../useConfirm";
function Harness() {
const { confirm } = useConfirm();
const { confirm, confirmWithChoice } = useConfirm();
const [result, setResult] = useState<string>("idle");
return React.createElement(
@@ -30,6 +30,21 @@ function Harness() {
},
"queue"
),
React.createElement(
"button",
{
onClick: async () => {
const choice = await confirmWithChoice({
title: "Delete Done",
message: "Delete or archive?",
confirmLabel: "Delete",
tertiaryLabel: "Archive Instead",
});
setResult(choice);
},
},
"open-choice"
),
React.createElement("div", { "data-testid": "result" }, result)
);
}
@@ -69,6 +84,57 @@ describe("useConfirm", () => {
});
});
it("resolves tertiary choice when tertiary button clicked", async () => {
render(
React.createElement(
ConfirmDialogProvider,
null,
React.createElement(Harness)
)
);
fireEvent.click(screen.getByText("open-choice"));
fireEvent.click(await screen.findByRole("button", { name: "Archive Instead" }));
await waitFor(() => {
expect(screen.getByTestId("result").textContent).toBe("tertiary");
});
});
it("resolves cancel choice when cancel is clicked", async () => {
render(
React.createElement(
ConfirmDialogProvider,
null,
React.createElement(Harness)
)
);
fireEvent.click(screen.getByText("open-choice"));
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
await waitFor(() => {
expect(screen.getByTestId("result").textContent).toBe("cancel");
});
});
it("resolves primary choice when confirm is clicked", async () => {
render(
React.createElement(
ConfirmDialogProvider,
null,
React.createElement(Harness)
)
);
fireEvent.click(screen.getByText("open-choice"));
fireEvent.click(await screen.findByRole("button", { name: "Delete" }));
await waitFor(() => {
expect(screen.getByTestId("result").textContent).toBe("primary");
});
});
it("queues confirmations sequentially", async () => {
render(
React.createElement(

View File

@@ -9,15 +9,20 @@ export interface ConfirmOptions {
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
tertiaryLabel?: string;
tertiaryDanger?: boolean;
}
export type ConfirmChoice = "primary" | "tertiary" | "cancel";
interface PendingConfirm {
options: ConfirmOptions;
resolve: (value: boolean) => void;
resolve: (value: ConfirmChoice) => void;
}
interface ConfirmContextValue {
confirm: (options: ConfirmOptions) => Promise<boolean>;
confirmWithChoice: (options: ConfirmOptions) => Promise<ConfirmChoice>;
}
const ConfirmContext = createContext<ConfirmContextValue | null>(null);
@@ -34,13 +39,18 @@ export function ConfirmDialogProvider({ children }: { children: ReactNode }) {
});
}, []);
const confirm = useCallback((options: ConfirmOptions) => {
return new Promise<boolean>((resolve) => {
const confirmWithChoice = useCallback((options: ConfirmOptions) => {
return new Promise<ConfirmChoice>((resolve) => {
updateQueue((current) => [...current, { options, resolve }]);
});
}, [updateQueue]);
const resolveCurrent = useCallback((value: boolean) => {
const confirm = useCallback(async (options: ConfirmOptions) => {
const choice = await confirmWithChoice(options);
return choice === "primary";
}, [confirmWithChoice]);
const resolveCurrent = useCallback((value: ConfirmChoice) => {
const current = queueRef.current[0];
if (!current) {
return;
@@ -52,7 +62,7 @@ export function ConfirmDialogProvider({ children }: { children: ReactNode }) {
const active = queue[0] ?? null;
const contextValue = useMemo<ConfirmContextValue>(() => ({ confirm }), [confirm]);
const contextValue = useMemo<ConfirmContextValue>(() => ({ confirm, confirmWithChoice }), [confirm, confirmWithChoice]);
return React.createElement(
ConfirmContext.Provider,
@@ -61,8 +71,9 @@ export function ConfirmDialogProvider({ children }: { children: ReactNode }) {
React.createElement(ConfirmDialog, {
isOpen: active !== null,
options: active?.options ?? null,
onConfirm: () => resolveCurrent(true),
onCancel: () => resolveCurrent(false),
onConfirm: () => resolveCurrent("primary"),
onTertiary: () => resolveCurrent("tertiary"),
onCancel: () => resolveCurrent("cancel"),
})
);
}
@@ -75,5 +86,6 @@ export function useConfirm(): ConfirmContextValue {
return {
confirm: async (_options: ConfirmOptions) => false,
confirmWithChoice: async (_options: ConfirmOptions) => "cancel",
};
}