106 lines
3.4 KiB
TypeScript
106 lines
3.4 KiB
TypeScript
"use client";
|
|
|
|
import type { ReactNode, RefObject } from "react";
|
|
import { useEffect, useId, useRef, useState } from "react";
|
|
import { useFormStatus } from "react-dom";
|
|
|
|
export function AdminModalForm({
|
|
action,
|
|
children,
|
|
description,
|
|
intent = "default",
|
|
submitLabel,
|
|
title,
|
|
triggerClassName,
|
|
triggerLabel,
|
|
triggerPressed,
|
|
}: {
|
|
action: (formData: FormData) => Promise<void>;
|
|
children?: ReactNode;
|
|
description: string;
|
|
intent?: "default" | "danger";
|
|
submitLabel: string;
|
|
title: string;
|
|
triggerClassName?: string;
|
|
triggerLabel: string;
|
|
triggerPressed?: boolean;
|
|
}) {
|
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
const titleId = useId();
|
|
const descriptionId = useId();
|
|
const [submitting, setSubmitting] = useState(false);
|
|
return (
|
|
<>
|
|
<button
|
|
aria-haspopup="dialog"
|
|
aria-pressed={triggerPressed}
|
|
className={triggerClassName ?? "font-mono text-[10px] font-bold uppercase underline underline-offset-4"}
|
|
disabled={submitting}
|
|
onClick={() => dialogRef.current?.showModal()}
|
|
type="button"
|
|
>
|
|
{triggerLabel}
|
|
</button>
|
|
<dialog
|
|
aria-describedby={descriptionId}
|
|
aria-labelledby={titleId}
|
|
className="admin-modal m-auto w-[min(92vw,36rem)] border border-ink bg-panel p-0 text-ink shadow-[10px_10px_0_var(--color-shadow)] backdrop:bg-ink/70"
|
|
onCancel={(event) => { if (submitting) event.preventDefault(); }}
|
|
ref={dialogRef}
|
|
>
|
|
<form action={action} className="p-6 sm:p-8" onSubmit={() => setSubmitting(true)}>
|
|
<p className="font-mono text-[9px] font-bold uppercase tracking-[0.2em] text-accent">Confirm operation</p>
|
|
<h2 className="mt-3 font-display text-3xl font-black uppercase" id={titleId}>{title}</h2>
|
|
<p className="mt-3 text-sm leading-6 text-muted" id={descriptionId}>{description}</p>
|
|
{children && <div className="mt-6">{children}</div>}
|
|
<ModalActions dialogRef={dialogRef} intent={intent} onPendingChange={setSubmitting} submitLabel={submitLabel} />
|
|
</form>
|
|
</dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ModalActions({
|
|
dialogRef,
|
|
intent,
|
|
onPendingChange,
|
|
submitLabel,
|
|
}: {
|
|
dialogRef: RefObject<HTMLDialogElement | null>;
|
|
intent: "default" | "danger";
|
|
onPendingChange: (pending: boolean) => void;
|
|
submitLabel: string;
|
|
}) {
|
|
const { pending } = useFormStatus();
|
|
const observedPending = useRef(false);
|
|
useEffect(() => {
|
|
if (pending) {
|
|
observedPending.current = true;
|
|
onPendingChange(true);
|
|
} else if (observedPending.current) {
|
|
observedPending.current = false;
|
|
onPendingChange(false);
|
|
}
|
|
}, [onPendingChange, pending]);
|
|
return (
|
|
<div className="mt-8 flex flex-wrap justify-end gap-3 border-t border-line pt-5">
|
|
<button
|
|
className="border border-line px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider disabled:opacity-50"
|
|
disabled={pending}
|
|
onClick={() => dialogRef.current?.close()}
|
|
type="button"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
className={`px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas disabled:cursor-wait disabled:opacity-60 ${intent === "danger" ? "bg-accent" : "bg-ink"}`}
|
|
disabled={pending}
|
|
type="submit"
|
|
>
|
|
{pending ? "Applying…" : submitLabel}
|
|
</button>
|
|
<span aria-live="polite" className="sr-only">{pending ? "Operation in progress." : ""}</span>
|
|
</div>
|
|
);
|
|
}
|