import { createContext, type FC, type ReactNode, useContext, useMemo, useState, } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { m } from "@/paraglide/messages"; /** * `window.confirm` / `window.prompt`, replaced by the console's own Dialog. * * WHY A PROVIDER AND NOT A COMPONENT PER SITE. The native calls this replaces are *expressions* — * `if (!confirm(…)) return;` — sixteen of them, threaded through mutation handlers and one router * navigation guard. Rewriting each into "hold the pending action in state, render a dialog, run the * action from its onConfirm" would have put a piece of dialog machinery in every section file and * turned each linear handler inside out. Handing back a PROMISE keeps the call sites the shape they * already are: * * if (!(await confirm({ title: … }))) return; * * which is also why the navigation guard could come along: TanStack's `shouldBlockFn` accepts * `Promise`. The one native prompt that necessarily stays is `beforeunload` — a reload or * a tab close is the browser's dialog to draw, not ours. */ export type ConfirmOptions = { /** The question. Short — it is a heading. */ title: string; /** What the operator is not being told by the title alone: the consequence. */ description?: string; /** Affirmative label. Use the same verb as the control that opened the dialog. */ confirmLabel?: string; /** Paint the affirmative button red — anything that destroys, removes, or interrupts. */ destructive?: boolean; }; export type PromptOptions = { title: string; description?: string; confirmLabel?: string; /** Field label. */ label: string; defaultValue?: string; placeholder?: string; }; type Dialogs = { /** Resolves true only if the operator confirmed; false on cancel, Esc, or overlay click. */ confirm: (options: ConfirmOptions) => Promise; /** Resolves the entered text, or null if the operator backed out — same contract as `prompt`. */ promptText: (options: PromptOptions) => Promise; }; type Pending = | { kind: "confirm"; options: ConfirmOptions; settle: (ok: boolean) => void } | { kind: "prompt"; options: PromptOptions; settle: (text: string | null) => void; }; const DialogsContext = createContext(null); /** Settle a request negatively. Must switch on `kind` — the two `settle`s take different types. */ const cancel = (p: Pending | null) => { if (!p) return; if (p.kind === "confirm") p.settle(false); else p.settle(null); }; export const DialogsProvider: FC<{ children: ReactNode }> = ({ children }) => { const [pending, setPending] = useState(null); const [draft, setDraft] = useState(""); // `setPending` takes the updater form so a second ask while one is already open cannot strand the // first promise unresolved — the caller would await forever. Two at once shouldn't happen (the // open dialog is modal), but the navigation guard can fire from outside the page's own UI. const api = useMemo( () => ({ confirm: (options) => new Promise((resolve) => setPending((prev) => { cancel(prev); return { kind: "confirm", options, settle: resolve }; }), ), promptText: (options) => new Promise((resolve) => { setDraft(options.defaultValue ?? ""); setPending((prev) => { cancel(prev); return { kind: "prompt", options, settle: resolve }; }); }), }), [], ); /** Resolve and close. Every exit — button, Esc, overlay — goes through here exactly once. */ const close = (value: boolean | string | null) => { if (!pending) return; if (pending.kind === "confirm") pending.settle(value === true); else pending.settle(typeof value === "string" ? value : null); setPending(null); }; const cancelled = () => close(pending?.kind === "prompt" ? null : false); const options = pending?.options; return ( {children} { if (!open) cancelled(); }} > {pending && options && ( {options.title} {/* Radix warns when a dialog has no description; render the element only when there is one to say, and tell it so explicitly otherwise. */} {options.description ? ( {options.description} ) : ( {options.title} )} {pending.kind === "prompt" && (
setDraft(e.target.value)} onKeyDown={(e) => { // Enter submits — the reflex `prompt()` trained everyone into. if (e.key === "Enter") { e.preventDefault(); close(draft); } }} />
)}
)}
); }; /** * The console's `confirm` / `prompt`. Both return a promise, so a handler reads top to bottom: * * const onDelete = async () => { * if (!(await confirm({ title: m.x(), confirmLabel: m.y(), destructive: true }))) return; * await remove.mutateAsync(…); * }; */ export const useDialogs = (): Dialogs => { const ctx = useContext(DialogsContext); if (!ctx) throw new Error( "useDialogs must be used inside (__root)", ); return ctx; };