import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import Modal from "./Modal"; import { Button } from "./ui/Button"; // App-styled, promise-based replacement for window.confirm. Wrap the tree in // once, then anywhere: const confirm = useConfirm(); // if (await confirm({ message: "…", danger: true })) { … } // // For a decision with more than two outcomes use `useChoice()` instead — never chain two // confirms, because the second dialog's Cancel/Escape/backdrop is then indistinguishable from // a deliberate answer (the playlist delete used to read a dismissal as "delete here only" and // went ahead and deleted). interface ConfirmOptions { title?: string; message: ReactNode; confirmLabel?: string; cancelLabel?: string; danger?: boolean; } interface Choice { id: string; label: string; danger?: boolean; } export interface ChoiceOptions { title?: string; message: ReactNode; cancelLabel?: string; /** Rendered left→right after Cancel. Resolves to the clicked choice's `id`. */ choices: Choice[]; } type ChoiceFn = (opts: ChoiceOptions) => Promise; type ConfirmFn = (opts: ConfirmOptions) => Promise; const ChoiceContext = createContext(() => Promise.resolve(null)); /** Two outcomes: resolves true on confirm, false on cancel/Escape/backdrop. */ export function useConfirm(): ConfirmFn { const ask = useContext(ChoiceContext); return useCallback( async (opts: ConfirmOptions) => { const picked = await ask({ title: opts.title, message: opts.message, cancelLabel: opts.cancelLabel, choices: [{ id: "confirm", label: opts.confirmLabel ?? "", danger: opts.danger }], }); return picked === "confirm"; }, [ask], ); } /** Three or more outcomes. Resolves to the chosen id, or **null** when the user cancels, * presses Escape or clicks the backdrop — dismissal is never one of the actions. */ export function useChoice(): ChoiceFn { return useContext(ChoiceContext); } export function ConfirmProvider({ children }: { children: ReactNode }) { const { t } = useTranslation(); const [state, setState] = useState<{ opts: ChoiceOptions; resolve: (id: string | null) => void; } | null>(null); const ask = useCallback( (opts) => new Promise((resolve) => setState({ opts, resolve })), [], ); function close(id: string | null) { state?.resolve(id); setState(null); } const choices = state?.opts.choices ?? []; // Focus Cancel when any offered action is destructive: the confirm button used to take // autoFocus unconditionally, so a stray Enter on an open dialog executed "Delete user" or // "Clear audit log" without the user ever looking at it. const anyDanger = choices.some((c) => c.danger); const multi = choices.length > 1; return ( {children} {state && ( close(null)} maxWidth={choices.length > 1 ? "max-w-md" : "max-w-sm"} >
{state.opts.message}
{/* One action: the familiar Cancel/Confirm row. Two or more: a column, because side by side they wrap raggedly and a wrapped button reads as a second, lesser group — and these are peers. Ordered least to most destructive, top to bottom. */}
{choices.map((c, i) => ( ))}
)}
); }