Native <dialog> in iOS Safari (and a few other browsers) doesn't prevent the page underneath from scrolling when the user scrolls inside the dialog or near its edges. Save and restore body overflow on open/close so the background stays put. Stays correct for stacked dialogs because we save the previous value rather than blanket-reset to ''.
102 lines
2.9 KiB
TypeScript
102 lines
2.9 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
|
|
type Props = {
|
|
open: boolean;
|
|
onCancel: () => void;
|
|
onConfirm: () => void;
|
|
title: string;
|
|
message: React.ReactNode;
|
|
confirmLabel?: string;
|
|
destructive?: boolean;
|
|
pending?: boolean;
|
|
};
|
|
|
|
/**
|
|
* Centered modal confirmation dialog. Uses the native <dialog> element
|
|
* so we get Esc-to-close, focus trapping, and the ::backdrop pseudo for
|
|
* the scrim — no a11y tax we'd pay rolling our own. Backdrop click
|
|
* cancels.
|
|
*/
|
|
export default function ConfirmDialog({
|
|
open,
|
|
onCancel,
|
|
onConfirm,
|
|
title,
|
|
message,
|
|
confirmLabel = "Confirm",
|
|
destructive = false,
|
|
pending = false,
|
|
}: Props) {
|
|
const ref = useRef<HTMLDialogElement>(null);
|
|
|
|
useEffect(() => {
|
|
const dialog = ref.current;
|
|
if (!dialog) return;
|
|
if (open && !dialog.open) {
|
|
dialog.showModal();
|
|
} else if (!open && dialog.open) {
|
|
dialog.close();
|
|
}
|
|
}, [open]);
|
|
|
|
// Lock body scroll while open. Native <dialog> doesn't do this in all
|
|
// browsers (notably iOS Safari), so background scroll can leak through
|
|
// when scrolling on the dialog content. We restore the previous value
|
|
// on close so other modals stacking later don't regress this.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const previous = document.body.style.overflow;
|
|
document.body.style.overflow = "hidden";
|
|
return () => {
|
|
document.body.style.overflow = previous;
|
|
};
|
|
}, [open]);
|
|
|
|
return (
|
|
<dialog
|
|
ref={ref}
|
|
onClose={onCancel}
|
|
onClick={(e) => {
|
|
// Click on the dialog background (not the inner form) cancels.
|
|
if (e.target === ref.current) onCancel();
|
|
}}
|
|
className="m-auto w-[min(92vw,440px)] rounded-2xl bg-white p-0 ring-1 ring-zinc-200/60 backdrop:bg-zinc-900/50 backdrop:backdrop-blur-sm"
|
|
>
|
|
<form
|
|
method="dialog"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
onConfirm();
|
|
}}
|
|
className="flex flex-col gap-4 p-6"
|
|
>
|
|
<h2 className="text-lg font-semibold tracking-tight text-zinc-900">
|
|
{title}
|
|
</h2>
|
|
<div className="text-sm leading-relaxed text-zinc-600">{message}</div>
|
|
<div className="mt-2 flex items-center justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
disabled={pending}
|
|
className="rounded-full px-4 py-2 text-xs font-medium text-zinc-700 transition-colors hover:bg-zinc-100 hover:text-zinc-900 disabled:opacity-60"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={pending}
|
|
className={`rounded-full px-4 py-2 text-xs font-medium text-white transition-colors disabled:opacity-60 ${
|
|
destructive ? "bg-red-600 hover:bg-red-700" : "bg-zinc-900 hover:bg-zinc-700"
|
|
}`}
|
|
>
|
|
{pending ? "…" : confirmLabel}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</dialog>
|
|
);
|
|
}
|