1"use client";
2
3
8import { useEffect, type ReactNode } from "react";
9import { X } from "lucide-react";
10
11export interface DialogProps {
12 open: boolean;
13 title: string;
14 description?: ReactNode;
15 onClose: () => void;
16 children: ReactNode;
17
18 footer?: ReactNode;
19
20 width?: string;
21
22 testId?: string;
23}
24
25export function Dialog({ open, title, description, onClose, children, footer, width = "max-w-lg", testId }: DialogProps) {
26 useEffect(() => {
27 if (!open) return;
28 const onKey = (e: KeyboardEvent) => {
29 if (e.key === "Escape") onClose();
30 };
31 window.addEventListener("keydown", onKey);
32 return () => window.removeEventListener("keydown", onKey);
33 }, [open, onClose]);
34
35 if (!open) return null;
36
37 return (
38 <div className="fixed inset-0 z-[90] flex items-center justify-center p-4">
39 <div className="absolute inset-0 bg-black/40" onClick={onClose} aria-hidden />
40 <div
41 role="dialog"
42 aria-modal="true"
43 aria-label={title}
44 data-testid={testId}
45 className={`relative flex max-h-[85vh] w-full flex-col overflow-hidden rounded-lg border border-border bg-background shadow-xl ${width}`}
46 >
47 <div className="flex items-start gap-3 border-b border-border px-4 py-3">
48 <div className="min-w-0 flex-1">
49 <h2 className="text-base font-semibold">{title}</h2>
50 {description ? <div className="mt-0.5 text-sm text-muted-foreground">{description}</div> : null}
51 </div>
52 <button
53 type="button"
54 className="rounded p-1 text-muted-foreground hover:bg-muted"
55 onClick={onClose}
56 aria-label="Close"
57 >
58 <X className="size-4" />
59 </button>
60 </div>
61 <div className="min-h-0 flex-1 overflow-y-auto p-4">{children}</div>
62 {footer ? <div className="flex justify-end gap-2 border-t border-border px-4 py-3">{footer}</div> : null}
63 </div>
64 </div>
65 );
66}
67
68
69export function DialogFooter({
70 onCancel,
71 onAction,
72 actionLabel,
73 busy,
74 destructive,
75 disabled,
76 testId
77}: {
78 onCancel: () => void;
79 onAction: () => void;
80 actionLabel: string;
81 busy?: boolean;
82 destructive?: boolean;
83 disabled?: boolean;
84
85 testId?: string;
86}) {
87 return (
88 <>
89 <button
90 type="button"
91 onClick={onCancel}
92 disabled={busy}
93 className="rounded-md border border-border bg-background px-3 py-1.5 text-sm font-medium hover:bg-accent disabled:opacity-50"
94 >
95 Cancel
96 </button>
97 <button
98 type="button"
99 onClick={onAction}
100 disabled={busy || disabled}
101 data-testid={testId}
102 className={
103
104
105
106 "rounded-md px-3 py-1.5 text-sm font-medium disabled:opacity-50 " +
107 (destructive
108 ? "bg-red-600 text-white hover:bg-red-700"
109 : "bg-primary text-primary-foreground hover:opacity-90")
110 }
111 >
112 {busy ? "Working…" : actionLabel}
113 </button>
114 </>
115 );
116}
117