'use client'; /** * One confirmation for the admin (UX stage 3, #694 §3), replacing the * browser's `confirm()`, which cannot be styled, blocks the tab and reads the * same for "discard a draft" and "delete a file". * * Policy: ask only before something that is permanent or takes effect at * once (deleting a message, a journal entry, the uploaded favicon). Removals * of unsaved state are undone by not saving and do not need a dialog. * * const confirm = useConfirm(); * if (!(await confirm({ title: 'Delete message?', confirmLabel: 'Delete', danger: true }))) return; */ import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react'; import { useModalDialog } from '@/hooks/useModalDialog'; export interface ConfirmOptions { title: string; /** What happens, in one or two sentences. */ message?: ReactNode; confirmLabel?: string; cancelLabel?: string; /** Destructive: the confirm button uses the error colour. */ danger?: boolean; } type Confirm = (options: ConfirmOptions) => Promise; /** One button of a choice; `id` is what the promise resolves to. */ export interface ChoiceAction { id: Id; label: string; danger?: boolean; } /** * More than yes or no — the concurrent-edit prompt (#601) offers three ways * out. The cancel button (and Esc, and the backdrop) resolves to null. */ export interface ChoiceOptions { title: string; message?: ReactNode; /** Left to right; the last one is the primary button. */ actions: ChoiceAction[]; cancelLabel?: string; } type Choose = (options: ChoiceOptions) => Promise; interface DialogContext { confirm: Confirm; choose: Choose; } /** Outside the provider (a component tested on its own) it falls back to window.confirm. */ const ConfirmContext = createContext({ confirm: async (o) => (typeof window === 'undefined' ? false : window.confirm(o.title)), choose: async (o) => typeof window !== 'undefined' && window.confirm(o.title) ? (o.actions[o.actions.length - 1]?.id ?? null) : null, }); export function useConfirm(): Confirm { return useContext(ConfirmContext).confirm; } export function useChoice(): Choose { return useContext(ConfirmContext).choose; } const CONFIRM_ID = 'confirm'; export function ConfirmProvider({ children }: { children: ReactNode }) { const [pending, setPending] = useState | null>(null); const resolver = useRef<((id: string | null) => void) | null>(null); const choose = useCallback( (options: ChoiceOptions) => new Promise((resolve) => { resolver.current?.(null); resolver.current = resolve; setPending(options); }), [], ) as Choose; const confirm = useCallback( async (options) => (await choose({ title: options.title, message: options.message, cancelLabel: options.cancelLabel, actions: [ { id: CONFIRM_ID, label: options.confirmLabel ?? 'Confirm', danger: options.danger, }, ], })) === CONFIRM_ID, [choose], ); const settle = useCallback((id: string | null) => { resolver.current?.(id); resolver.current = null; setPending(null); }, []); return ( {children} {pending && } ); } function Dialog({ options, onSettle, }: { options: ChoiceOptions; onSettle: (id: string | null) => void; }) { // Esc cancels, focus is trapped and returned (the same hook the modals use). const cardRef = useModalDialog(() => onSettle(null)); const last = options.actions.length - 1; return (
onSettle(null)}>
e.stopPropagation()} >

{options.title}

{options.message && (

{options.message}

)}
{options.actions.map((action, i) => ( ))}
); }