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 ''.
136 lines
3.9 KiB
TypeScript
136 lines
3.9 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
|
|
/**
|
|
* Shared <dialog>-based modal shell. Owners pass form contents and a
|
|
* submit handler; this component handles open/close imperatively, Esc
|
|
* cancellation, and backdrop click cancellation. Native <dialog> gives
|
|
* us focus trapping and ::backdrop styling for free.
|
|
*/
|
|
export default function FormDialogShell({
|
|
open,
|
|
onCancel,
|
|
onSubmit,
|
|
title,
|
|
children,
|
|
cancelLabel = "Cancel",
|
|
submitLabel = "Save",
|
|
destructive = false,
|
|
pending = false,
|
|
error,
|
|
}: {
|
|
open: boolean;
|
|
onCancel: () => void;
|
|
onSubmit: () => void;
|
|
title: string;
|
|
children: React.ReactNode;
|
|
cancelLabel?: string;
|
|
submitLabel?: string;
|
|
destructive?: boolean;
|
|
pending?: boolean;
|
|
error?: string | null;
|
|
}) {
|
|
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 on iOS Safari).
|
|
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={() => {
|
|
if (!pending) onCancel();
|
|
}}
|
|
onClick={(e) => {
|
|
if (pending) return;
|
|
if (e.target === ref.current) onCancel();
|
|
}}
|
|
className="m-auto w-[min(92vw,480px)] 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();
|
|
onSubmit();
|
|
}}
|
|
className="flex flex-col gap-4 p-6"
|
|
>
|
|
<h2 className="text-lg font-semibold tracking-tight text-zinc-900">{title}</h2>
|
|
<div className="flex flex-col gap-3">{children}</div>
|
|
{error && (
|
|
<p className="font-mono text-[11px] text-red-600" role="alert">
|
|
{error}
|
|
</p>
|
|
)}
|
|
<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"
|
|
>
|
|
{cancelLabel}
|
|
</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 ? "…" : submitLabel}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</dialog>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A labelled text input that uses 16px font-size on phones (preventing
|
|
* iOS Safari auto-zoom-on-focus) and falls back to a tighter 13px on
|
|
* tablets/desktop where there's no auto-zoom behavior.
|
|
*/
|
|
export function Field({
|
|
label,
|
|
required,
|
|
hint,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
required?: boolean;
|
|
hint?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
|
{label}
|
|
{required && <span className="ml-1 text-red-500">*</span>}
|
|
</span>
|
|
{children}
|
|
{hint && <span className="text-[11px] text-zinc-500">{hint}</span>}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
export const inputClass =
|
|
"w-full min-w-0 rounded-md border-0 bg-zinc-100 px-3 py-2 font-mono text-base text-zinc-900 outline-none ring-1 ring-zinc-300 transition-colors focus:bg-white focus:ring-2 focus:ring-zinc-900 disabled:opacity-60 sm:text-[13px]";
|