jengacore components · @jengacore/confirm-dialog
ConfirmDialog
Maker-checker and destructive confirmation that repeats the object and its details; optional required reason.
Install
terminal
npx shadcn@latest add @jengacore/confirm-dialogWithout the namespace configured: npx shadcn@latest add https://ui.jengacore.com/r/<new-york-v4|base-nova>/confirm-dialog.json
Registry dependencies
Source
components/jc/confirm-dialog.tsx
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { FormField } from "@/components/jc/form-field"
export type ConfirmDetail = { label: string; value: React.ReactNode }
type ConfirmDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: React.ReactNode
details?: ConfirmDetail[]
/** A verb that names the action: "Suspend participant", "Record funds in". */
confirmLabel: string
cancelLabel?: string
destructive?: boolean
requireReason?: boolean
reasonLabel?: string
reasonHelper?: string
onConfirm: (reason?: string) => void | Promise<void>
}
/**
* Maker-checker and destructive confirmation. The title repeats the object ("Suspend Equity Bank?"),
* the details repeat participant, currency, amount and reference, and a reason can be required
* (dispute decisions, NDC changes). The confirm button keeps its width while pending.
*/
export function ConfirmDialog(props: ConfirmDialogProps) {
const { open, onOpenChange } = props
const [pending, setPending] = React.useState(false)
return (
<Dialog open={open} onOpenChange={(next: boolean) => !pending && onOpenChange(next)}>
{/* The body mounts with the dialog and unmounts with it, so every opening starts clean. */}
<DialogContent showCloseButton={!pending}>{open ? <ConfirmBody {...props} pending={pending} setPending={setPending} /> : null}</DialogContent>
</Dialog>
)
}
function ConfirmBody({
onOpenChange,
title,
description,
details = [],
confirmLabel,
cancelLabel = "Cancel",
destructive = false,
requireReason = false,
reasonLabel = "Reason",
reasonHelper = "Recorded in the audit trail with your name.",
onConfirm,
pending,
setPending,
}: ConfirmDialogProps & { pending: boolean; setPending: (pending: boolean) => void }) {
const [reason, setReason] = React.useState("")
const [error, setError] = React.useState<string | null>(null)
async function confirm() {
if (requireReason && reason.trim().length < 5) {
setError("Write at least a short sentence explaining the decision.")
return
}
setPending(true)
try {
await onConfirm(requireReason ? reason.trim() : undefined)
setPending(false)
onOpenChange(false)
} catch (e) {
setPending(false)
setError(e instanceof Error ? e.message : "The action failed. Try again or contact the hub operator.")
}
}
return (
<>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
{details.length ? (
<dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 rounded-md bg-muted px-4 py-3 text-[13.5px]">
{details.map((d) => (
<React.Fragment key={d.label}>
<dt className="font-mono text-[10.5px] leading-5 tracking-[0.1em] text-muted-foreground uppercase">{d.label}</dt>
<dd className="min-w-0 font-medium">{d.value}</dd>
</React.Fragment>
))}
</dl>
) : null}
{requireReason ? (
<FormField label={reasonLabel} htmlFor="jc-confirm-reason" required helper={reasonHelper} error={error ?? undefined}>
<Textarea id="jc-confirm-reason" value={reason} onChange={(e) => setReason(e.target.value)} disabled={pending} />
</FormField>
) : error ? (
<p className="text-[13px] text-destructive">{error}</p>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
{cancelLabel}
</Button>
<Button variant={destructive ? "destructive" : "default"} onClick={confirm} loading={pending}>
{confirmLabel}
</Button>
</DialogFooter>
</>
)
}