/** * @file ConfirmModal.tsx * @description Centered confirmation dialog for destructive or irreversible * actions (delete webhook, remove alert rule, etc.). Replaces `window.confirm` * with themed UI that matches the dashboard and distinguishes a loading (`busy`) * confirm button from one refused outright (`disabled`). * * ## Dismissal * Clicking the backdrop, pressing Escape, or clicking the X cancels. The confirm * button can be styled non-destructive for neutral confirmations. * * ## Accessibility * Focus moves to Cancel on open (safer default), Tab cycles within the dialog, * Escape cancels, and focus restores to the previously focused element on close. * * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useId, useRef, type ReactNode } from "react"; import { AlertTriangle, X } from "lucide-react"; /** Props for {@link ConfirmModal}. */ export interface ConfirmModalProps { /** When false, nothing is rendered. */ open: boolean; /** Dialog heading. */ title: string; /** Optional supporting message below the title. */ message?: string; /** Primary action label (e.g. "Delete"). */ confirmLabel: string; /** Secondary cancel label. */ cancelLabel: string; /** When true (default), confirm button uses red destructive styling. */ destructive?: boolean; /** Disables confirm while an async delete is in flight. */ busy?: boolean; /** * Disables confirm because the action is not permitted right now (a blocker, an * unmet checkbox, facts that failed to load). Distinct from `busy`: nothing is * in flight, so callers must not conflate the two — passing a refusal as `busy` * makes a blocked action read as perpetually loading. */ disabled?: boolean; /** Optional action-specific facts shown before the confirmation controls. */ children?: ReactNode; /** Called when the user confirms. */ onConfirm: () => void; /** Called on cancel, backdrop click, Escape, or X. */ onCancel: () => void; } /** * Modal confirmation overlay. * @param props See {@link ConfirmModalProps}. */ export function ConfirmModal({ open, title, message, confirmLabel, cancelLabel, destructive = true, busy = false, disabled = false, children, onConfirm, onCancel, }: ConfirmModalProps) { const titleId = useId(); const messageId = useId(); const panelRef = useRef(null); const cancelRef = useRef(null); const previouslyFocused = useRef(null); useEffect(() => { if (!open) return; previouslyFocused.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; // Prefer Cancel so Enter/activation doesn't immediately destroy data. const focusTimer = window.setTimeout(() => cancelRef.current?.focus(), 0); const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); onCancel(); return; } if (e.key !== "Tab" || !panelRef.current) return; const focusable = panelRef.current.querySelectorAll( 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' ); if (focusable.length === 0) return; const first = focusable.item(0); const last = focusable.item(focusable.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(); } }; document.addEventListener("keydown", onKey); return () => { window.clearTimeout(focusTimer); document.removeEventListener("keydown", onKey); previouslyFocused.current?.focus?.(); previouslyFocused.current = null; }; }, [open, onCancel]); if (!open) return null; return (
e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby={titleId} aria-describedby={message ? messageId : undefined} >
{destructive && (
)}

{title}

{message && (

{message}

)} {children}
); }