Alert Dialog
必须回答的问题:同一块浮层和缝线头,头里有 24px 的标记块,取消在左、操作在右。
alert-dialog-demo.tsx
"use client"
import { Trash2Icon } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
export default function AlertDialogDemo() {
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2Icon />
删除资源
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia tone="destructive">
<Trash2Icon />
</AlertDialogMedia>
<AlertDialogTitle>删除这个资源?</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
药屋少女的呢喃 第三季 第 12 集会从发布列表和 RSS 中移除,已经下载的人不受影响。
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction variant="destructive">删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}安装
npx shadcn@latest add @anibt/alert-dialog第一次用之前:在 components.json 里登记 @anibt,再单独运行一次 npx shadcn@latest add @anibt/theme(见快速开始)。只有单独安装主题时,CLI 才会把 shadcn 的标准颜色换成白樱 / 夜樱;随组件带进来的主题只补上 AniBT 自己的变量和配方。 CLI 会一并装好 @anibt/button。
手动安装
- 1. 先装主题组件只读
@anibt/theme的 token 和配方,没有主题就没有样子。npx shadcn@latest add @anibt/theme - 2. 安装依赖
npm install class-variance-authority radix-ui - 3. 它还用到
npx shadcn@latest add @anibt/button - 4. 把下面的文件放进项目components/ui/alert-dialog.tsx
"use client" import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { AlertDialog as AlertDialogPrimitive } from "radix-ui" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" /** * AlertDialog — AniBT 「贴纸手账」: a question that must be answered. * * The same floating panel and 48px stitched bar as Dialog, without a close * button: the reader leaves through 取消 or the action. `AlertDialogMedia` is * A's dialog mark, a 24px tinted tile in the bar before the title — red for a * destructive question. The footer is one order everywhere: the quiet cancel * on the left, the action on the right (`variant="destructive"` for the red * one). * * Export names and props are shadcn's (including `size` on the content and * `variant` / `size` on the action and the cancel), so this file can replace * `components/ui/alert-dialog.tsx`. */ type RenderCompat = { /** Base UI's `asChild`: render this element instead, with the part's props. */ render?: React.ReactElement /** Base UI compatibility; ignored (Radix decides from the rendered element). */ nativeButton?: boolean } /** Maps Base UI's `render` onto Radix's `asChild`. */ function renderAsChild<P extends { asChild?: boolean; children?: React.ReactNode }>({ render, nativeButton: _nativeButton, ...props }: P & RenderCompat): P { void _nativeButton if (!render) return props as P const children = props.children === undefined ? render : React.cloneElement(render, undefined, props.children) return { ...props, asChild: true, children } as P } function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) { return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} /> } function AlertDialogTrigger(props: React.ComponentProps<typeof AlertDialogPrimitive.Trigger> & RenderCompat) { return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...renderAsChild(props)} /> } function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) { return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} /> } function AlertDialogOverlay({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) { return ( <AlertDialogPrimitive.Overlay data-slot="alert-dialog-overlay" className={cn( "fixed inset-0 z-50 bg-anibt-scrim duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0", className )} {...props} /> ) } function AlertDialogContent({ className, size = "default", ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Content> & { /** `default` is 420 wide; `sm` is 320 with the two buttons side by side. */ size?: "default" | "sm" }) { return ( <AlertDialogPortal> <AlertDialogOverlay /> <AlertDialogPrimitive.Content data-slot="alert-dialog-content" data-size={size} className={cn( "group/alert-dialog-content anibt-pop fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col gap-3 overflow-y-auto overscroll-contain p-4 text-[14px] duration-200 outline-none data-[size=default]:sm:max-w-[420px] data-[size=sm]:max-w-xs", "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", className )} {...props} /> </AlertDialogPortal> ) } /** The 48px stitched bar. With a media tile the tile sits left of the title. */ function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="alert-dialog-header" className={cn( "anibt-stitch-under -mx-4 -mt-4 grid min-h-12 shrink-0 content-center items-center gap-x-2 gap-y-1 px-4 py-3 text-left has-data-[slot=alert-dialog-media]:grid-cols-[auto_minmax(0,1fr)] *:data-[slot=alert-dialog-description]:col-start-1 has-data-[slot=alert-dialog-media]:*:data-[slot=alert-dialog-description]:col-start-2", className )} {...props} /> ) } /** Right-aligned buttons; `size="sm"` puts them side by side at equal width. */ function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="alert-dialog-footer" className={cn( "flex shrink-0 flex-col-reverse gap-2 pt-1 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end", className )} {...props} /> ) } function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) { return ( <AlertDialogPrimitive.Title data-slot="alert-dialog-title" className={cn("text-[14px] leading-snug font-bold text-foreground", className)} {...props} /> ) } function AlertDialogDescription({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Description>) { return ( <AlertDialogPrimitive.Description data-slot="alert-dialog-description" className={cn("text-[13px] leading-normal text-anibt-ink2", className)} {...props} /> ) } const alertDialogMediaVariants = cva( "grid size-6 shrink-0 place-items-center rounded-[6px] [&>svg]:pointer-events-none [&>svg:not([class*='size-'])]:size-4", { variants: { tone: { default: "bg-muted text-anibt-ink2", destructive: "bg-anibt-danger-wash text-destructive", warning: "bg-anibt-warn-wash text-anibt-warn", accent: "bg-accent text-accent-foreground", }, }, defaultVariants: { tone: "default", }, } ) /** A's dialog mark: a 24px tinted tile holding one icon, left of the title. */ function AlertDialogMedia({ className, tone, ...props }: React.ComponentProps<"div"> & VariantProps<typeof alertDialogMediaVariants>) { return ( <div data-slot="alert-dialog-media" aria-hidden="true" className={cn(alertDialogMediaVariants({ tone }), className)} {...props} /> ) } /** The answer: candy by default, `variant="destructive"` for the red one. */ function AlertDialogAction({ className, variant = "default", size = "default", ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Action> & Pick<React.ComponentProps<typeof Button>, "variant" | "size">) { return ( <Button variant={variant} size={size} {...{ asChild: true }}> <AlertDialogPrimitive.Action data-slot="alert-dialog-action" className={cn(className)} {...props} /> </Button> ) } /** The way out: the quiet tier, left of the action. */ function AlertDialogCancel({ className, variant = "ghost", size = "default", ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> & Pick<React.ComponentProps<typeof Button>, "variant" | "size">) { return ( <Button variant={variant} size={size} {...{ asChild: true }}> <AlertDialogPrimitive.Cancel data-slot="alert-dialog-cancel" className={cn(className)} {...props} /> </Button> ) } export { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogMedia, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger, alertDialogMediaVariants, }
用法
components/delete-release.tsx
import { Trash2Icon } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
export function DeleteRelease() {
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">删除资源</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia tone="destructive">
<Trash2Icon />
</AlertDialogMedia>
<AlertDialogTitle>删除这个资源?</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>会从发布列表和 RSS 中移除。</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction variant="destructive">删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}只问一件事的时候,用 Confirm Dialog 更省事。
结构
- 没有关闭按钮:读者只能通过「取消」或操作离开,Esc 等同取消。
AlertDialogMedia是 A 的弹窗标记:24px 的淡色方块放一个图标,排在标题左边。tone:default(下沉灰)、destructive(红)、warning(琥珀)、accent(粉)。破坏性的问题一定带红色标记。- 说明可以放在头里(标题下面一行),也可以放到头外当正文,像上面的例子那样。
- 脚部顺序固定:安静的
AlertDialogCancel在左,AlertDialogAction在右。删除、撤销用variant="destructive"的红色键,其余用默认的糖果键。
小尺寸
alert-dialog-small.tsx
"use client"
import { LogOutIcon } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
export default function AlertDialogSmall() {
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost">
<LogOutIcon />
退出登录
</Button>
</AlertDialogTrigger>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogMedia tone="accent">
<LogOutIcon />
</AlertDialogMedia>
<AlertDialogTitle>退出登录?</AlertDialogTitle>
<AlertDialogDescription>订阅和 RSS 密钥都会保留。</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>留下</AlertDialogCancel>
<AlertDialogAction>退出</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}size="sm" 是 320 宽,两个按钮并排等宽,适合一句话的问题。
属性
AlertDialogContent
size"default" | "sm"默认 "default"420 宽 / 320 宽、按钮并排
AlertDialogMedia
tone"default" | "destructive" | "warning" | "accent"默认 "default"标记块的颜色
AlertDialogAction / AlertDialogCancel
variantButton 的 variant默认 Action "default",Cancel "ghost"按钮材质,见 Button
sizeButton 的 size默认 "default"按钮尺寸
导出名和属性与 shadcn 一致(含 AlertDialogMedia、AlertDialogOverlay、AlertDialogPortal),AlertDialogTrigger 也接受 Base UI 的 render。
