// Modal confirmation dialog. Replaces `window.confirm` across every
// plugin's destructive-action path. Hand-rolled (no Radix dep) but
// honours the same a11y contract:
//
// - Focus trap inside the dialog while open.
// - Escape closes (treated as cancel).
// - Click on the scrim closes.
// - Auto-focus the cancel button on open so a misclick on Enter
// doesn't fire the destructive action.
// - aria-modal + role="dialog" + aria-labelledby tying the heading
// to the dialog.
//
// Usage:
//
// ```tsx
// const confirm = useConfirm();
// const ok = await confirm({
// title: "Revoke session?",
// message: "You'll be signed out of this tab.",
// confirmLabel: "Revoke",
// destructive: true,
// });
// if (ok) revokeMutation.mutate(id);
// ```
//
// One <ConfirmDialogProvider> wraps the App so the hook can reach
// the surface. The hook returns a promise that resolves when the
// operator picks one button, so calling code reads linearly.
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
export interface ConfirmOptions {
title: string;
message?: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
/** Style the confirm button as destructive (red border + hover). */
destructive?: boolean;
}
type Resolver = (ok: boolean) => void;
interface ConfirmCtx {
request: (options: ConfirmOptions) => Promise<boolean>;
}
const Ctx = createContext<ConfirmCtx | null>(null);
export function ConfirmDialogProvider({ children }: { children: ReactNode }) {
const [pending, setPending] = useState<
(ConfirmOptions & { resolve: Resolver }) | null
>(null);
const request = useCallback(
(options: ConfirmOptions) =>
new Promise<boolean>((resolve) => {
setPending({ ...options, resolve });
}),
[],
);
const close = useCallback(
(ok: boolean) => {
if (pending) {
pending.resolve(ok);
setPending(null);
}
},
[pending],
);
const api = useMemo<ConfirmCtx>(() => ({ request }), [request]);
return (
<Ctx.Provider value={api}>
{children}
{pending && <Dialog options={pending} onClose={close} />}
</Ctx.Provider>
);
}
export function useConfirm(): (options: ConfirmOptions) => Promise<boolean> {
const ctx = useContext(Ctx);
if (!ctx) {
throw new Error("useConfirm must be used inside <ConfirmDialogProvider>");
}
return ctx.request;
}
function Dialog({
options,
onClose,
}: {
options: ConfirmOptions;
onClose: (ok: boolean) => void;
}) {
const cancelRef = useRef<HTMLButtonElement>(null);
const confirmRef = useRef<HTMLButtonElement>(null);
const surfaceRef = useRef<HTMLDivElement>(null);
// Auto-focus the safer (cancel) button so an Enter keypress
// doesn't fire the destructive action by accident.
useEffect(() => {
cancelRef.current?.focus();
}, []);
// Escape closes; treat as cancel. Trap Tab inside the dialog so
// focus never escapes back to the underlying page while modal.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose(false);
return;
}
if (e.key !== "Tab") return;
const surface = surfaceRef.current;
if (!surface) return;
const tabbable = surface.querySelectorAll<HTMLElement>(
"button:not([disabled]), [tabindex]:not([tabindex='-1'])",
);
const first = tabbable[0];
const last = tabbable[tabbable.length - 1];
if (!first || !last) return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("keydown", onKey);
};
}, [onClose]);
const confirmLabel = options.confirmLabel ?? "Confirm";
const cancelLabel = options.cancelLabel ?? "Cancel";
return (
<div
className="confirm-scrim"
onClick={(e) => {
// Only treat clicks *on the scrim itself* as a dismiss —
// a click that bubbled up from inside the surface (e.g.
// text-selection mouse-up that ends outside) shouldn't
// close.
if (e.target === e.currentTarget) onClose(false);
}}
>
<div
ref={surfaceRef}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
className="confirm-dialog"
>
<h3 id="confirm-dialog-title">{options.title}</h3>
{options.message && <p>{options.message}</p>}
<div className="form-actions">
<button
ref={cancelRef}
type="button"
className="secondary"
onClick={() => onClose(false)}
>
{cancelLabel}
</button>
<button
ref={confirmRef}
type="button"
className={
options.destructive ? "secondary destructive" : "primary"
}
onClick={() => onClose(true)}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}