AniBTUI
✦ 组件@anibt/alert-dialogregistry JSON

Alert Dialog

必须回答的问题:同一块浮层和缝线头,头里有 24px 的标记块,取消在左、操作在右。

安装

npx shadcn@latest add @anibt/alert-dialog

第一次用之前:在 components.json 里登记 @anibt,再单独运行一次 npx shadcn@latest add @anibt/theme(见快速开始)。只有单独安装主题时,CLI 才会把 shadcn 的标准颜色换成白樱 / 夜樱;随组件带进来的主题只补上 AniBT 自己的变量和配方。 CLI 会一并装好 @anibt/button。

手动安装
  1. 1. 先装主题组件只读 @anibt/theme 的 token 和配方,没有主题就没有样子。
    npx shadcn@latest add @anibt/theme
  2. 2. 安装依赖
    npm install class-variance-authority radix-ui
  3. 3. 它还用到
    npx shadcn@latest add @anibt/button
  4. 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" 的红色键,其余用默认的糖果键。

小尺寸

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。