64 lines
2.4 KiB
TypeScript
64 lines
2.4 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
|
|
/*
|
|
* A native <dialog>, not a div with a fixed overlay.
|
|
*
|
|
* showModal() gives focus trapping, inert background, Escape and the top layer
|
|
* for free — all four are things a hand-rolled overlay gets wrong, and the third
|
|
* is the one staff will actually reach for. The only wiring needed is keeping
|
|
* React state and the element's open state in step, and routing every close —
|
|
* Escape, backdrop, button — through one onClose.
|
|
*/
|
|
export function Modal({
|
|
open,
|
|
onClose,
|
|
title,
|
|
meta,
|
|
footer,
|
|
children,
|
|
}: {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
title: string;
|
|
meta?: React.ReactNode;
|
|
footer?: React.ReactNode;
|
|
children: React.ReactNode;
|
|
}) {
|
|
const ref = useRef<HTMLDialogElement>(null);
|
|
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
if (open && !el.open) el.showModal();
|
|
if (!open && el.open) el.close();
|
|
}, [open]);
|
|
|
|
return (
|
|
<dialog
|
|
ref={ref}
|
|
onCancel={(e) => {
|
|
e.preventDefault();
|
|
onClose();
|
|
}}
|
|
/* Clicking the backdrop hits the dialog element itself, never a
|
|
* child — so this closes on backdrop and not on content. */
|
|
onClick={(e) => {
|
|
if (e.target === ref.current) onClose();
|
|
}}
|
|
className="w-[min(44rem,94vw)] rounded border border-rule bg-panel p-0 text-ink shadow-lg backdrop:bg-[rgba(4,12,24,0.55)]"
|
|
>
|
|
<header className="flex flex-wrap items-center gap-3 border-b border-rule-soft bg-panel-2 px-4 py-3">
|
|
<h2 className="text-[1.02rem] font-bold tracking-[-0.01em]">{title}</h2>
|
|
{meta && <span className="font-mono text-[0.68rem] uppercase tracking-[0.12em] text-ink-3">{meta}</span>}
|
|
<button type="button" onClick={onClose} className="ml-auto rounded border border-rule px-2 py-1 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-ink-2 hover:border-ink-3" aria-label="Close">
|
|
Esc
|
|
</button>
|
|
</header>
|
|
<div className="grid max-h-[68vh] gap-4 overflow-y-auto p-4">{children}</div>
|
|
{footer && <footer className="flex flex-wrap items-center gap-3 border-t border-rule-soft bg-panel-2 px-4 py-3">{footer}</footer>}
|
|
</dialog>
|
|
);
|
|
}
|