cm_bot_v2/web/components/confirm-dialog.tsx
yiekheng 6c984b6200 fix(web): lock body scroll while modal is open
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 ''.
2026-05-03 08:12:35 +08:00

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>
);
}