Dialog
一条记录的弹窗:浮层纸面、48 高的缝线头、安静的关闭方块,头和脚固定、中间一块滚动。
dialog-demo.tsx
"use client"
import { PencilIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
export default function DialogDemo() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="secondary">
<PencilIcon />
编辑资源
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>编辑资源</DialogTitle>
<DialogDescription>改动会同步到资源页和 RSS。</DialogDescription>
</DialogHeader>
<form id="edit-release" className="flex flex-col gap-4" onSubmit={(event) => event.preventDefault()}>
<div>
<label htmlFor="release-title" className="anibt-label">
标题
</label>
<input
id="release-title"
className="anibt-input"
defaultValue="[喵萌奶茶屋] 药屋少女的呢喃 第三季 - 12 [1080p][简繁内封]"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="release-episode" className="anibt-label">
集数
</label>
<input id="release-episode" className="anibt-input anibt-num" defaultValue="12" />
</div>
<div>
<label htmlFor="release-version" className="anibt-label">
版本
</label>
<input id="release-version" className="anibt-input anibt-num" defaultValue="v2" />
</div>
</div>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost">取消</Button>
</DialogClose>
<DialogClose asChild>
<Button type="submit" form="edit-release">
保存
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}安装
npx shadcn@latest add @anibt/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 lucide-react radix-ui - 3. 它还用到
npx shadcn@latest add @anibt/button - 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会在前面加一个安静的「关闭」。
滚动
dialog-scroll.tsx
"use client"
import { FileVideoIcon, FolderOpenIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
const FILES = Array.from({ length: 24 }, (_, index) => {
const episode = String(index + 1).padStart(2, "0")
return {
name: `[LoliHouse] Sousou no Frieren S2 - ${episode} [WebRip 1080p HEVC-10bit AAC ASSx2].mkv`,
size: `${612 + ((index * 37) % 180)} MiB`,
}
})
export default function DialogScroll() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="secondary">
<FolderOpenIcon />
查看文件列表
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>文件列表</DialogTitle>
<DialogDescription>葬送的芙莉莲 第二季 · 合集 · 24 个文件</DialogDescription>
</DialogHeader>
<DialogBody className="max-h-80">
<ul className="flex flex-col">
{FILES.map((file) => (
<li key={file.name} className="anibt-stitch-under flex items-center gap-2 py-2 last:bg-none">
<FileVideoIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="anibt-ell min-w-0 flex-1 font-mono text-[12px] text-foreground">{file.name}</span>
<span className="anibt-num shrink-0 text-[12px] text-muted-foreground">{file.size}</span>
</li>
))}
</ul>
</DialogBody>
<DialogFooter showCloseButton />
</DialogContent>
</Dialog>
)
}弹窗最高是视口减 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 等组件照常工作。
