Skip to content
File

Blob: src/client/components/ui/dialog.tsx

typescript91 lines
1import { type ReactNode, useEffect, useId, useRef } from "react";
2import { createPortal } from "react-dom";
3import { X } from "lucide-react";
4import { useFocusTrap } from "@/client/hooks";
5 
6interface DialogProps {
7 open: boolean;
8 onClose(): void;
9 title: string;
10 description?: string;
11 icon?: ReactNode;
12 dismissDisabled?: boolean;
13 children: ReactNode;
14}
15 
16const DialogInner = ({ onClose, title, description, icon, dismissDisabled, children }: Omit<DialogProps, "open">) => {
17 const titleId = useId();
18 const descId = useId();
19 const panelRef = useRef<HTMLDivElement>(null);
20 const previousFocusRef = useRef<HTMLElement | null>(null);
21 
22 useFocusTrap(panelRef);
23 
24 useEffect(() => {
25 previousFocusRef.current = document.activeElement as HTMLElement | null;
26 return () => {
27 previousFocusRef.current?.focus();
28 };
29 }, []);
30 
31 useEffect(() => {
32 if (dismissDisabled) return;
33 const onKeyDown = (e: KeyboardEvent) => {
34 if (e.key === "Escape") onClose();
35 };
36 document.addEventListener("keydown", onKeyDown);
37 return () => document.removeEventListener("keydown", onKeyDown);
38 }, [dismissDisabled, onClose]);
39 
40 return createPortal(
41 <div className="fixed inset-0 z-[90] flex items-center justify-center">
42 <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={dismissDisabled ? undefined : onClose} />
43 <div
44 ref={panelRef}
45 role="dialog"
46 aria-modal="true"
47 aria-labelledby={titleId}
48 aria-describedby={description ? descId : undefined}
49 className="relative z-10 w-full max-w-lg rounded-2xl border border-zinc-800/60 bg-zinc-900 p-6 shadow-2xl shadow-black/40"
50 >
51 <div className="flex items-start justify-between gap-4">
52 <div className="flex items-center gap-3">
53 {icon ? (
54 <div aria-hidden="true" className="rounded-xl bg-accent-500/10 p-2 text-accent-300">
55 {icon}
56 </div>
57 ) : null}
58 <div>
59 <h3 id={titleId} className="text-lg font-semibold text-zinc-100">
60 {title}
61 </h3>
62 {description ? (
63 <p id={descId} className="text-sm text-zinc-500">
64 {description}
65 </p>
66 ) : null}
67 </div>
68 </div>
69 {dismissDisabled ? null : (
70 <button
71 type="button"
72 aria-label="Close"
73 className="rounded-lg p-1.5 text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-100"
74 onClick={onClose}
75 >
76 <X className="h-5 w-5" />
77 </button>
78 )}
79 </div>
80 <div className="mt-5">{children}</div>
81 </div>
82 </div>,
83 document.body,
84 );
85};
86 
87export const Dialog = ({ open, ...rest }: DialogProps) => {
88 if (!open) return null;
89 return <DialogInner {...rest} />;
90};