/ web / src / components / ui / confirm-dialog.tsx
confirm-dialog.tsx
  1  import { useEffect, useRef } from "react";
  2  import { createPortal } from "react-dom";
  3  import { AlertTriangle } from "lucide-react";
  4  import { Button } from "@nous-research/ui/ui/components/button";
  5  import { cn } from "@/lib/utils";
  6  
  7  export function ConfirmDialog({
  8    cancelLabel = "Cancel",
  9    confirmLabel = "Confirm",
 10    description,
 11    destructive = false,
 12    loading = false,
 13    onCancel,
 14    onConfirm,
 15    open,
 16    title,
 17  }: ConfirmDialogProps) {
 18    const dialogRef = useRef<HTMLDivElement>(null);
 19  
 20    // Focus the confirm button when opened; trap ESC to cancel.
 21    useEffect(() => {
 22      if (!open) return;
 23  
 24      const prevActive = document.activeElement as HTMLElement | null;
 25      dialogRef.current
 26        ?.querySelector<HTMLButtonElement>("[data-confirm]")
 27        ?.focus();
 28  
 29      const onKey = (e: KeyboardEvent) => {
 30        if (e.key === "Escape") {
 31          e.preventDefault();
 32          onCancel();
 33        }
 34      };
 35  
 36      document.addEventListener("keydown", onKey);
 37      const prevOverflow = document.body.style.overflow;
 38      document.body.style.overflow = "hidden";
 39  
 40      return () => {
 41        document.removeEventListener("keydown", onKey);
 42        document.body.style.overflow = prevOverflow;
 43        prevActive?.focus?.();
 44      };
 45    }, [open, onCancel]);
 46  
 47    if (!open) return null;
 48  
 49    return createPortal(
 50      <div
 51        role="dialog"
 52        aria-modal="true"
 53        aria-labelledby="confirm-dialog-title"
 54        aria-describedby={description ? "confirm-dialog-desc" : undefined}
 55        onClick={(e) => {
 56          if (e.target === e.currentTarget) onCancel();
 57        }}
 58        className={cn(
 59          "fixed inset-0 z-50 flex items-center justify-center",
 60          "bg-black/60 backdrop-blur-sm",
 61          "animate-[fade-in_150ms_ease-out]",
 62        )}
 63      >
 64        <div
 65          ref={dialogRef}
 66          className={cn(
 67            "relative w-full max-w-md mx-4",
 68            "border border-border bg-card shadow-lg",
 69            "animate-[dialog-in_180ms_ease-out]",
 70          )}
 71        >
 72          <div className="flex items-start gap-3 p-4 border-b border-border">
 73            {destructive && (
 74              <div
 75                aria-hidden
 76                className="mt-0.5 shrink-0 text-destructive"
 77              >
 78                <AlertTriangle className="h-4 w-4" />
 79              </div>
 80            )}
 81  
 82            <div className="flex-1 min-w-0 flex flex-col gap-1">
 83              <h2
 84                id="confirm-dialog-title"
 85                className="font-expanded text-sm font-bold tracking-[0.08em] uppercase blend-lighter"
 86              >
 87                {title}
 88              </h2>
 89  
 90              {description && (
 91                <p
 92                  id="confirm-dialog-desc"
 93                  className="font-mondwest text-xs text-muted-foreground leading-relaxed"
 94                >
 95                  {description}
 96                </p>
 97              )}
 98            </div>
 99          </div>
100  
101          <div className="flex items-center justify-end gap-2 p-3">
102            <Button
103              type="button"
104              outlined
105              onClick={onCancel}
106              disabled={loading}
107            >
108              {cancelLabel}
109            </Button>
110            <Button
111              data-confirm
112              type="button"
113              destructive={destructive}
114              onClick={onConfirm}
115              disabled={loading}
116            >
117              {loading ? "…" : confirmLabel}
118            </Button>
119          </div>
120        </div>
121      </div>,
122      document.body,
123    );
124  }
125  
126  interface ConfirmDialogProps {
127    cancelLabel?: string;
128    confirmLabel?: string;
129    description?: string;
130    destructive?: boolean;
131    loading?: boolean;
132    onCancel: () => void;
133    onConfirm: () => void;
134    open: boolean;
135    title: string;
136  }