AniBTUI
✦ 组件@anibt/dialogregistry JSON

Dialog

一条记录的弹窗:浮层纸面、48 高的缝线头、安静的关闭方块,头和脚固定、中间一块滚动。

安装

npx shadcn@latest add @anibt/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 lucide-react radix-ui
  3. 3. 它还用到
    npx shadcn@latest add @anibt/button
  4. 4. 把下面的文件放进项目
    components/ui/dialog.tsx
    "use client"
    
    import * as React from "react"
    import { XIcon } from "lucide-react"
    import { Dialog as DialogPrimitive } from "radix-ui"
    
    import { cn } from "@/lib/utils"
    import { Button } from "@/components/ui/button"
    
    /**
     * Dialog — AniBT 「贴纸手账」: the one container for "one record".
     *
     * The surface is the floating panel (`anibt-pop`): paper, a 1px edge and the
     * pop shadow on radius 14, over the theme's scrim. `DialogHeader` is A's 48px
     * bar across the top, parted from the body by the dashed stitch; the close
     * button is a quiet 32px icon square inside that bar.
     *
     * Scrolling has one convention: the content is a flex column capped at the
     * viewport, `DialogHeader` and `DialogFooter` stay put, and `DialogBody` is the
     * region that scrolls. A dialog without a body still reaches its footer,
     * because the content itself is the fallback scroll region.
     *
     * Export names and props are shadcn's, so this file can replace
     * `components/ui/dialog.tsx`. Triggers and closes also take Base UI's
     * `render` (shadcn's `base-*` styles) as a form of `asChild`.
     */
    
    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 Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
      return <DialogPrimitive.Root data-slot="dialog" {...props} />
    }
    
    function DialogTrigger(props: React.ComponentProps<typeof DialogPrimitive.Trigger> & RenderCompat) {
      return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...renderAsChild(props)} />
    }
    
    function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
      return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
    }
    
    function DialogClose(props: React.ComponentProps<typeof DialogPrimitive.Close> & RenderCompat) {
      return <DialogPrimitive.Close data-slot="dialog-close" {...renderAsChild(props)} />
    }
    
    /** The scrim: the theme's `--anibt-scrim`, fading in and out. */
    function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
      return (
        <DialogPrimitive.Overlay
          data-slot="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 DialogContent({
      className,
      children,
      showCloseButton = true,
      closeLabel = "关闭",
      ...props
    }: React.ComponentProps<typeof DialogPrimitive.Content> & {
      /** The quiet ✕ square in the header bar. */
      showCloseButton?: boolean
      /** The close button's accessible name. */
      closeLabel?: string
    }) {
      return (
        <DialogPortal>
          <DialogOverlay />
          <DialogPrimitive.Content
            data-slot="dialog-content"
            className={cn(
              "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 sm:max-w-lg",
              "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",
              // A title never runs under the close button.
              showCloseButton && "[&>[data-slot=dialog-header]]:pe-12",
              className
            )}
            {...props}
          >
            {children}
            {showCloseButton ? (
              <DialogPrimitive.Close
                data-slot="dialog-close"
                className="anibt-icobtn anibt-icobtn-ghost absolute top-2 right-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:outline-solid disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
              >
                <XIcon />
                <span className="sr-only">{closeLabel}</span>
              </DialogPrimitive.Close>
            ) : null}
          </DialogPrimitive.Content>
        </DialogPortal>
      )
    }
    
    /** A's 48px bar: title (and description) over the stitched bottom edge. */
    function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
      return (
        <div
          data-slot="dialog-header"
          className={cn(
            "anibt-stitch-under -mx-4 -mt-4 flex min-h-12 shrink-0 flex-col justify-center gap-1 px-4 py-3 text-left",
            className
          )}
          {...props}
        />
      )
    }
    
    /** The one scrolling region of a dialog; the header and the footer stay reachable. */
    function DialogBody({ className, ...props }: React.ComponentProps<"div">) {
      return (
        <div
          data-slot="dialog-body"
          className={cn("-m-1 min-h-0 flex-1 overflow-y-auto overscroll-contain p-1 text-anibt-ink2", className)}
          {...props}
        />
      )
    }
    
    /** Buttons at the foot, right-aligned: the quiet one first, the action last. */
    function DialogFooter({
      className,
      showCloseButton = false,
      closeLabel = "关闭",
      children,
      ...props
    }: React.ComponentProps<"div"> & {
      /** Adds a quiet 关闭 button before the footer's own buttons. */
      showCloseButton?: boolean
      closeLabel?: React.ReactNode
    }) {
      return (
        <div
          data-slot="dialog-footer"
          className={cn("flex shrink-0 flex-col-reverse gap-2 pt-1 sm:flex-row sm:justify-end", className)}
          {...props}
        >
          {showCloseButton ? (
            <DialogPrimitive.Close {...{ asChild: true }}>
              <Button variant="ghost">{closeLabel}</Button>
            </DialogPrimitive.Close>
          ) : null}
          {children}
        </div>
      )
    }
    
    /** 14/700 ink, one line of the bar. */
    function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
      return (
        <DialogPrimitive.Title
          data-slot="dialog-title"
          className={cn("flex items-center gap-2 text-[14px] leading-snug font-bold text-foreground", className)}
          {...props}
        />
      )
    }
    
    /** 13, the secondary ink. */
    function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
      return (
        <DialogPrimitive.Description
          data-slot="dialog-description"
          className={cn("text-[13px] leading-normal text-anibt-ink2", className)}
          {...props}
        />
      )
    }
    
    export {
      Dialog,
      DialogBody,
      DialogClose,
      DialogContent,
      DialogDescription,
      DialogFooter,
      DialogHeader,
      DialogOverlay,
      DialogPortal,
      DialogTitle,
      DialogTrigger,
    }

用法

components/edit-release.tsx
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"

export function EditRelease() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="secondary">编辑资源</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>编辑资源</DialogTitle>
          <DialogDescription>改动会同步到资源页和 RSS。</DialogDescription>
        </DialogHeader>
        {/* 表单 */}
        <DialogFooter>
          <DialogClose asChild>
            <Button variant="ghost">取消</Button>
          </DialogClose>
          <Button>保存</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

材质

  • 弹窗是浮层(anibt-pop):纸面、1px 外边、shadow-pop、圆角 14,铺在主题的遮罩 --anibt-scrim 上,打开时淡入并轻微放大。
  • DialogHeader 是顶上 48 高的一条,底边是虚线缝线;标题 14/700,说明 13 号次要字。右端留给关闭按钮,标题不会压到它下面。
  • 关闭按钮是 32 的安静图标方块(anibt-icobtn-ghost),悬停是粉色淡底。showCloseButton={false} 去掉它。
  • DialogFooter 右对齐:安静的取消在左,主操作在右。手机上竖排,主操作在最上面。showCloseButton 会在前面加一个安静的「关闭」。

滚动

弹窗最高是视口减 32px。头和脚不动,DialogBody 是唯一滚动的区域;不用 DialogBody 时整个内容区兜底滚动,脚永远够得着。

Base UI 写法

shadcn 的 base-* 风格用 render 代替 asChild。DialogTrigger 和 DialogClose 两种都接受:

两种写法等价
<DialogTrigger asChild><Button>打开</Button></DialogTrigger>
<DialogTrigger render={<Button />}>打开</DialogTrigger>

属性

DialogContent
showCloseButtonboolean默认 true头部右端的安静关闭方块
closeLabelstring默认 "关闭"关闭按钮的读屏名称
DialogFooter
showCloseButtonboolean默认 false在脚部最前面加一个安静的关闭按钮
closeLabelReactNode默认 "关闭"这个按钮的文字
DialogTrigger / DialogClose
asChildboolean默认 false把行为交给唯一的子元素(Radix Slot)
renderReactElement默认 —Base UI 的写法,等同 asChild

其余部件(Dialog、DialogPortal、DialogOverlay、DialogTitle、DialogDescription)的属性透传给 Radix Dialog。另外导出 DialogBody。导出名和 shadcn 一致,可以直接替换 components/ui/dialog.tsx,shadcn 的 command 等组件照常工作。