web/src/components/modal.tsx
3.0 KB · sha256:7e754b5afff53822ab94c10cc54a0434c99d0970629c3934cb02980dbc727d3c
import { X } from "lucide-react";
import { useEffect, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { cn } from "../lib/cn";
export function Modal({
open,
onClose,
title,
description,
children,
size = "md",
className,
}: {
open: boolean;
onClose: () => void;
title: ReactNode;
description?: ReactNode;
children: ReactNode;
size?: "sm" | "md" | "lg" | "xl";
className?: string;
}) {
const ref = useRef<HTMLDivElement | null>(null);
// Close on Esc; lock body scroll while open.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
// Focus first focusable element in the dialog.
setTimeout(() => {
const el = ref.current?.querySelector<HTMLElement>(
"input,select,textarea,button",
);
el?.focus();
}, 0);
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [open, onClose]);
if (!open) return null;
const widths: Record<typeof size, string> = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-2xl",
xl: "max-w-4xl",
};
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
onMouseDown={(e) => {
// Click on the backdrop (outside the panel) closes.
if (e.target === e.currentTarget) onClose();
}}
>
<div className="bg-black/50 absolute inset-0 backdrop-blur-sm" />
<div
ref={ref}
role="dialog"
aria-modal="true"
className={cn(
"bg-surface border-border relative z-10 w-full overflow-hidden rounded-2xl border shadow-2xl",
widths[size],
className,
)}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="border-border flex items-start justify-between gap-4 border-b px-6 pt-5 pb-4">
<div className="min-w-0">
<h3 className="text-fg text-lg font-semibold tracking-tight">
{title}
</h3>
{description && (
<p className="text-fg-muted mt-0.5 text-sm">{description}</p>
)}
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="text-fg-subtle hover:text-fg hover:bg-surface-3 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md transition"
>
<X size={16} />
</button>
</div>
<div className="px-6 py-5">{children}</div>
</div>
</div>,
document.body,
);
}
export function ModalFooter({ children }: { children: ReactNode }) {
return (
<div className="border-border bg-surface-2/40 -mx-6 -mb-5 mt-5 flex items-center justify-end gap-2 border-t px-6 py-3">
{children}
</div>
);
}