Confirm Dialog
破坏性或不可撤销操作前的一次确认。返回 Promise 时弹窗保持打开并显示进行中,失败不关闭。
使用中
confirm-dialog-demo.tsx
"use client"
import { KeyRoundIcon } from "lucide-react"
import { useState } from "react"
import { StatusBadge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
function revokeKey() {
return new Promise<void>((resolve) => setTimeout(resolve, 1200))
}
export default function ConfirmDialogDemo() {
const [revoked, setRevoked] = useState(false)
return (
<div className="flex items-center gap-3">
<ConfirmDialog
destructive
trigger={
<Button variant="destructive" disabled={revoked}>
<KeyRoundIcon />
撤销 API 密钥
</Button>
}
title="撤销这个 API 密钥?"
description="用它发布的脚本会立刻失效,撤销后不能恢复。"
confirmLabel="撤销"
// A promise keeps the dialog open and busy until it settles.
onConfirm={() => revokeKey().then(() => setRevoked(true))}
/>
{revoked ? <StatusBadge tone="mute">已撤销</StatusBadge> : <StatusBadge tone="ok">使用中</StatusBadge>}
</div>
)
}安装
npx shadcn@latest add @anibt/confirm-dialog第一次用之前:在 components.json 里登记 @anibt,再单独运行一次 npx shadcn@latest add @anibt/theme(见快速开始)。只有单独安装主题时,CLI 才会把 shadcn 的标准颜色换成白樱 / 夜樱;随组件带进来的主题只补上 AniBT 自己的变量和配方。 CLI 会一并装好 @anibt/alert-dialog、@anibt/spinner。
手动安装
- 1. 先装主题组件只读
@anibt/theme的 token 和配方,没有主题就没有样子。npx shadcn@latest add @anibt/theme - 2. 安装依赖
npm install lucide-react - 3. 它还用到
npx shadcn@latest add @anibt/alert-dialog @anibt/spinner - 4. 把下面的文件放进项目components/ui/confirm-dialog.tsx
"use client" import * as React from "react" import { TriangleAlertIcon } from "lucide-react" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogMedia, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog" import { Spinner } from "@/components/ui/spinner" /** * ConfirmDialog — AniBT 「贴纸手账」: the one way to ask before a destructive * or irreversible action, built on `@anibt/alert-dialog`. * * The footer is one order everywhere: the quiet 取消 on the left, the confirm * on the right — candy, or for `destructive` the red danger key, with A's red * mark in the bar. * * Closing: * - `onConfirm` returning nothing closes the dialog at once. * - `onConfirm` returning a promise keeps it open and busy until it settles: * it closes on resolve and stays open on reject, so a failed delete leaves * the reader where they can read the error and retry. * - A caller that owns the pending flag can pass `loading`; the confirm then * shows the spinner and refuses further clicks. */ type ConfirmDialogProps = { /** Controlled open state. Omit when using `trigger`. */ open?: boolean onOpenChange?: (open: boolean) => void /** The element that opens the dialog (uncontrolled use). */ trigger?: React.ReactNode title: React.ReactNode description?: React.ReactNode /** Extra content between the bar and the buttons. */ children?: React.ReactNode confirmLabel: React.ReactNode cancelLabel?: React.ReactNode /** The red confirm and the red mark. Deletes and revokes are destructive. */ destructive?: boolean /** The icon in the bar's mark; a destructive dialog defaults to a warning sign. */ icon?: React.ReactNode /** External pending state, merged with the promise `onConfirm` returns. */ loading?: boolean confirmDisabled?: boolean onConfirm: () => void | Promise<unknown> } function ConfirmDialog({ open, onOpenChange, trigger, title, description, children, confirmLabel, cancelLabel = "取消", destructive = false, icon, loading = false, confirmDisabled = false, onConfirm, }: ConfirmDialogProps) { const [pending, setPending] = React.useState(false) const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false) const busy = loading || pending const isOpen = open ?? uncontrolledOpen const setOpen = React.useCallback( (next: boolean) => { if (open === undefined) setUncontrolledOpen(next) onOpenChange?.(next) }, [open, onOpenChange] ) const handleConfirm = (event: React.MouseEvent<HTMLButtonElement>) => { if (busy) { event.preventDefault() return } const result = onConfirm() if (!isPromiseLike(result)) return // Radix closes an alert dialog from its action unless the click's default // is prevented: keep it open while the work runs. event.preventDefault() setPending(true) void Promise.resolve(result).then( () => { setPending(false) setOpen(false) }, () => { setPending(false) } ) } const mark = icon ?? (destructive ? <TriangleAlertIcon /> : null) return ( <AlertDialog open={isOpen} onOpenChange={setOpen}> {trigger ? <AlertDialogTrigger {...{ asChild: true }}>{trigger}</AlertDialogTrigger> : null} <AlertDialogContent> <AlertDialogHeader> {mark ? <AlertDialogMedia tone={destructive ? "destructive" : "accent"}>{mark}</AlertDialogMedia> : null} <AlertDialogTitle>{title}</AlertDialogTitle> {description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null} </AlertDialogHeader> {children} <AlertDialogFooter> <AlertDialogCancel disabled={busy}>{cancelLabel}</AlertDialogCancel> <AlertDialogAction variant={destructive ? "destructive" : "default"} disabled={busy || confirmDisabled} aria-busy={busy || undefined} onClick={handleConfirm} > {busy ? <Spinner className="size-4" /> : null} {confirmLabel} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> ) } function isPromiseLike(value: unknown): value is PromiseLike<unknown> { return typeof value === "object" && value !== null && typeof (value as PromiseLike<unknown>).then === "function" } export { ConfirmDialog } export type { ConfirmDialogProps }
用法
components/revoke-key.tsx
import { Button } from "@/components/ui/button"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
export function RevokeKey({ revoke }: { revoke: () => Promise<void> }) {
return (
<ConfirmDialog
destructive
trigger={<Button variant="destructive">撤销 API 密钥</Button>}
title="撤销这个 API 密钥?"
description="用它发布的脚本会立刻失效,撤销后不能恢复。"
confirmLabel="撤销"
onConfirm={revoke}
/>
)
}关闭的时机
onConfirm不返回东西:点确认立即关闭。onConfirm返回 Promise:弹窗保持打开,确认键显示转圈并拒绝再次点击;成功后关闭,失败则留在原地,读者能看到错误再试一次。- 调用方自己管着进行中的状态时,传
loading。
非破坏性
confirm-dialog-plain.tsx
"use client"
import { CheckCheckIcon } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
export default function ConfirmDialogPlain() {
const [open, setOpen] = useState(false)
const [finished, setFinished] = useState(false)
return (
<>
<Button variant="secondary" onClick={() => setOpen(true)}>
<CheckCheckIcon />
{finished ? "已标记完结" : "标记为完结"}
</Button>
<ConfirmDialog
open={open}
onOpenChange={setOpen}
icon={<CheckCheckIcon />}
title="把这部番标记为完结?"
description="间谍过家家 第三季 不会再出现在「今日放送」里。"
confirmLabel="标记完结"
onConfirm={() => setFinished(true)}
/>
</>
)
}不传 destructive 时确认键是糖果主按钮;传了 icon 就在头里放一个粉色标记。这个例子用 open / onOpenChange 受控打开,不用 trigger。
属性
ConfirmDialog
titleReactNode默认 —必填:问题本身
confirmLabelReactNode默认 —必填:确认键的文字,写动作(「删除」「撤销」)
onConfirm() => void | Promise<unknown>默认 —必填:返回 Promise 时等它结束再关闭
descriptionReactNode默认 —头部标题下的一行说明
childrenReactNode默认 —头和按钮之间的额外内容
destructiveboolean默认 false红色确认键和红色警示标记
iconReactNode默认 —头部标记里的图标;破坏性时默认是警示三角
cancelLabelReactNode默认 "取消"取消键的文字
triggerReactNode默认 —打开弹窗的元素(非受控用法)
open / onOpenChangeboolean / (open) => void默认 —受控用法
loadingboolean默认 false外部的进行中状态
confirmDisabledboolean默认 false禁用确认键
