File
Blob: src/client/components/ui/dialog.tsx
| 1 | import { type ReactNode, useEffect, useId, useRef } from "react"; |
| 2 | import { createPortal } from "react-dom"; |
| 3 | import { X } from "lucide-react"; |
| 4 | import { useFocusTrap } from "@/client/hooks"; |
| 5 | |
| 6 | interface DialogProps { |
| 7 | open: boolean; |
| 8 | onClose(): void; |
| 9 | title: string; |
| 10 | description?: string; |
| 11 | icon?: ReactNode; |
| 12 | dismissDisabled?: boolean; |
| 13 | children: ReactNode; |
| 14 | } |
| 15 | |
| 16 | const 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 | |
| 87 | export const Dialog = ({ open, ...rest }: DialogProps) => { |
| 88 | if (!open) return null; |
| 89 | return <DialogInner {...rest} />; |
| 90 | }; |