Skip to content
File

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

typescript83 lines
1import { useEffect, useRef, type ReactNode } from "react";
2import { createPortal } from "react-dom";
3 
4interface DialogProps {
5 open: boolean;
6 onClose: () => void;
7 children: ReactNode;
8 className?: string;
9 ariaLabelledBy?: string;
10 ariaLabel?: string;
11}
12 
13export function Dialog({ open, onClose, children, className, ariaLabelledBy, ariaLabel }: DialogProps) {
14 const panelRef = useRef<HTMLDivElement>(null);
15 
16 useEffect(() => {
17 if (!open) return;
18 const handler = (e: KeyboardEvent) => {
19 if (e.key === "Escape") onClose();
20 };
21 document.addEventListener("keydown", handler);
22 return () => document.removeEventListener("keydown", handler);
23 }, [open, onClose]);
24 
25 // Focus trap: keep Tab within the dialog, re-query on each Tab press
26 useEffect(() => {
27 if (!open || !panelRef.current) return;
28 const panel = panelRef.current;
29 
30 const FOCUSABLE_SELECTOR = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
31 const getVisible = () =>
32 [...panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
33 (el) => el.offsetParent !== null && !el.closest(".hidden"),
34 );
35 
36 // Auto-focus first element
37 const initial = getVisible();
38 if (initial.length > 0) initial[0].focus();
39 
40 const handler = (e: KeyboardEvent) => {
41 if (e.key !== "Tab") return;
42 const focusable = getVisible();
43 if (focusable.length === 0) return;
44 const first = focusable[0];
45 const last = focusable[focusable.length - 1];
46 if (e.shiftKey && document.activeElement === first) {
47 e.preventDefault();
48 last.focus();
49 } else if (!e.shiftKey && document.activeElement === last) {
50 e.preventDefault();
51 first.focus();
52 }
53 };
54 panel.addEventListener("keydown", handler);
55 return () => panel.removeEventListener("keydown", handler);
56 }, [open]);
57 
58 if (!open) return null;
59 
60 const content = (
61 <div
62 className="fixed inset-0 z-[90] flex items-start justify-center pt-[20vh]"
63 role="presentation"
64 onClick={onClose}
65 >
66 <div className="fixed inset-0 bg-zinc-950/50 backdrop-blur-sm" aria-hidden="true" />
67 <div
68 ref={panelRef}
69 role="dialog"
70 aria-modal="true"
71 aria-labelledby={ariaLabelledBy}
72 aria-label={ariaLabelledBy ? undefined : ariaLabel}
73 className={`animate-slide-up relative rounded-xl border border-zinc-700 bg-zinc-800 shadow-2xl ${className ?? ""}`}
74 onClick={(e) => e.stopPropagation()}
75 >
76 {children}
77 </div>
78 </div>
79 );
80 
81 return createPortal(content, document.body);
82}