import { useEffect, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { X } from "lucide-react"; import { create } from "zustand"; interface DialogProps { open: boolean; onClose: () => void; title?: ReactNode; children: ReactNode; footer?: ReactNode; size?: "sm" | "md" | "lg" | "xl"; closeOnBackdrop?: boolean; className?: string; } export function Dialog({ open, onClose, title, children, footer, size = "md", closeOnBackdrop = true, className }: DialogProps) { const ref = useRef(null); /* * Callers almost always pass an inline arrow for onClose, so its identity * changes on every render of the parent. Depending on it here would tear the * effect down and set it up again on every keystroke in a dialog that holds * state, and the autofocus below would drag the caret back to the first * field mid-typing. Keep the latest handler in a ref instead, so the effect * depends only on `open`. */ const onCloseRef = useRef(onClose); onCloseRef.current = onClose; useEffect(() => { if (!open) return; const prev = document.activeElement as HTMLElement | null; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); onCloseRef.current(); } if (e.key === "Tab" && ref.current) { const focusables = ref.current.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"]),[contenteditable="true"]'); if (!focusables.length) return; const first = focusables[0]!; const last = focusables[focusables.length - 1]!; 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, true); // autofocus first input window.setTimeout(() => { const el = ref.current?.querySelector("[autofocus],input,textarea,select,button.btn-primary"); el?.focus(); }, 10); return () => { document.removeEventListener("keydown", onKey, true); prev?.focus?.(); }; }, [open]); if (!open) return null; return createPortal(
{ if (closeOnBackdrop && e.target === e.currentTarget) onClose(); }} >
{title !== undefined && (

{title}

)}
{children}
{footer &&
{footer}
}
, document.body, ); } /* ---------- Imperative confirm / prompt ---------- */ interface ConfirmRequest { id: number; kind: "confirm" | "prompt"; title: string; message?: ReactNode; confirmLabel?: string; cancelLabel?: string; danger?: boolean; defaultValue?: string; placeholder?: string; resolve: (v: boolean | string | null) => void; } const useConfirmStore = create<{ queue: ConfirmRequest[]; push(r: ConfirmRequest): void; pop(): void }>((set, get) => ({ queue: [], push: (r) => set({ queue: [...get().queue, r] }), pop: () => set({ queue: get().queue.slice(1) }), })); let reqId = 1; export function confirmDialog(opts: { title: string; message?: ReactNode; confirmLabel?: string; cancelLabel?: string; danger?: boolean }): Promise { return new Promise((resolve) => { useConfirmStore.getState().push({ id: reqId++, kind: "confirm", ...opts, resolve: (v) => resolve(Boolean(v)) }); }); } export function promptDialog(opts: { title: string; message?: ReactNode; defaultValue?: string; placeholder?: string; confirmLabel?: string }): Promise { return new Promise((resolve) => { useConfirmStore.getState().push({ id: reqId++, kind: "prompt", ...opts, resolve: (v) => resolve(typeof v === "string" ? v : null) }); }); } export function ConfirmHost() { const req = useConfirmStore((s) => s.queue[0]); const pop = useConfirmStore((s) => s.pop); const [value, setValue] = useState(""); useEffect(() => setValue(req?.defaultValue ?? ""), [req?.id, req?.defaultValue]); if (!req) return null; const done = (v: boolean | string | null) => { req.resolve(v); pop(); }; return ( done(req.kind === "prompt" ? null : false)} title={req.title} size="sm" footer={ <> } > {req.message &&

{req.message}

} {req.kind === "prompt" && (
{ e.preventDefault(); done(value); }} > setValue(e.target.value)} />
)}
); }