Dropdown Menu
浮层菜单:32 高的行,指针或键盘所在行是粉色淡底,分组之间是缝线,危险行是红色。
dropdown-menu-demo.tsx
"use client"
import { DownloadIcon, FlagIcon, LinkIcon, MagnetIcon, MoreHorizontalIcon, RssIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export default function DropdownMenuDemo() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" aria-label="更多操作">
<MoreHorizontalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end">
<DropdownMenuLabel>药屋少女的呢喃 第三季 · 12</DropdownMenuLabel>
<DropdownMenuGroup>
<DropdownMenuItem>
<MagnetIcon />
复制磁力链接
<DropdownMenuShortcut>M</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<DownloadIcon />
下载种子
<DropdownMenuShortcut>D</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<LinkIcon />
复制资源页链接
</DropdownMenuItem>
<DropdownMenuItem>
<RssIcon />
订阅这个字幕组
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<FlagIcon />
举报资源
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}安装
npx shadcn@latest add @anibt/dropdown-menu第一次用之前:在 components.json 里登记 @anibt,再单独运行一次 npx shadcn@latest add @anibt/theme(见快速开始)。只有单独安装主题时,CLI 才会把 shadcn 的标准颜色换成白樱 / 夜樱;随组件带进来的主题只补上 AniBT 自己的变量和配方。
手动安装
- 1. 先装主题组件只读
@anibt/theme的 token 和配方,没有主题就没有样子。npx shadcn@latest add @anibt/theme - 2. 安装依赖
npm install lucide-react radix-ui - 3. 把下面的文件放进项目components/ui/dropdown-menu.tsx
"use client" import * as React from "react" import { CheckIcon, ChevronRightIcon } from "lucide-react" import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" import { cn } from "@/lib/utils" /** * DropdownMenu — AniBT 「贴纸手账」. * * The panel is the floating tier (`anibt-pop`) with 4px of padding. Rows are * 32px on radius 10 with a muted icon; the row under the pointer or the * keyboard takes the pink wash, pink ink and a pink icon. Groups part on the * dashed stitch. A destructive row is red ink with a red icon and the red * wash when highlighted. A disabled row drops to the muted ink instead of * fading. Checked rows lead with a pink tick, the chosen radio with a pink * dot. Shortcuts sit at the right end in the round face. * * Export names and props are shadcn's, so this file can replace * `components/ui/dropdown-menu.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 } const PANEL = "anibt-pop z-50 min-w-[8rem] overflow-x-hidden overflow-y-auto overscroll-contain p-1 duration-150 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 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1" const ROW = "relative flex min-h-8 cursor-default items-center gap-2 rounded-[10px] px-2 py-1 text-[13px] text-foreground outline-hidden transition-colors duration-150 select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:text-muted-foreground data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-highlighted:[&_svg:not([class*='text-'])]:text-anibt-mark" function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} /> } function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> } function DropdownMenuTrigger(props: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger> & RenderCompat) { return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...renderAsChild(props)} /> } function DropdownMenuContent({ className, sideOffset = 6, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) { return ( <DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Content data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn( PANEL, "max-h-(--radix-dropdown-menu-content-available-height) origin-(--radix-dropdown-menu-content-transform-origin)", className )} {...props} /> </DropdownMenuPrimitive.Portal> ) } function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> } function DropdownMenuItem({ className, inset, variant = "default", ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { /** Indent the label to line up with checkbox and radio rows. */ inset?: boolean /** `destructive`: red ink and icon, the red wash when highlighted. */ variant?: "default" | "destructive" }) { return ( <DropdownMenuPrimitive.Item data-slot="dropdown-menu-item" data-inset={inset || undefined} data-variant={variant} className={cn( ROW, "data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-anibt-danger-wash data-[variant=destructive]:data-highlighted:text-destructive data-[variant=destructive]:[&_svg:not([class*='text-'])]:text-anibt-danger-fill data-[variant=destructive]:data-highlighted:[&_svg:not([class*='text-'])]:text-anibt-danger-fill", className )} {...props} /> ) } function DropdownMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) { return ( <DropdownMenuPrimitive.CheckboxItem data-slot="dropdown-menu-checkbox-item" className={cn(ROW, "pl-8", className)} checked={checked} {...props} > <span className="pointer-events-none absolute left-2 flex size-4 items-center justify-center"> <DropdownMenuPrimitive.ItemIndicator> <CheckIcon className="size-4 text-anibt-mark" strokeWidth={2.75} /> </DropdownMenuPrimitive.ItemIndicator> </span> {children} </DropdownMenuPrimitive.CheckboxItem> ) } function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) { return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} /> } function DropdownMenuRadioItem({ className, children, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) { return ( <DropdownMenuPrimitive.RadioItem data-slot="dropdown-menu-radio-item" className={cn(ROW, "pl-8", className)} {...props} > <span className="pointer-events-none absolute left-2 flex size-4 items-center justify-center"> <DropdownMenuPrimitive.ItemIndicator> <span className="block size-2 rounded-full bg-anibt-mark" /> </DropdownMenuPrimitive.ItemIndicator> </span> {children} </DropdownMenuPrimitive.RadioItem> ) } /** A group head: 12/600, muted. */ function DropdownMenuLabel({ className, inset, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }) { return ( <DropdownMenuPrimitive.Label data-slot="dropdown-menu-label" data-inset={inset || undefined} className={cn("px-2 pt-2 pb-1 text-[12px] font-semibold text-muted-foreground data-inset:pl-8", className)} {...props} /> ) } /** The dashed stitch between groups. */ function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) { return ( <DropdownMenuPrimitive.Separator data-slot="dropdown-menu-separator" className={cn("anibt-stitch mx-2 my-1", className)} {...props} /> ) } /** The key at the row's right end, in the round face. */ function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { return ( <span data-slot="dropdown-menu-shortcut" className={cn("anibt-num ml-auto pl-3 text-[12px] font-medium text-muted-foreground", className)} {...props} /> ) } function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} /> } function DropdownMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }) { return ( <DropdownMenuPrimitive.SubTrigger data-slot="dropdown-menu-sub-trigger" data-inset={inset || undefined} className={cn( ROW, "data-[state=open]:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:[&_svg:not([class*='text-'])]:text-anibt-mark", className )} {...props} > {children} <ChevronRightIcon className="ml-auto size-4" /> </DropdownMenuPrimitive.SubTrigger> ) } function DropdownMenuSubContent({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) { return ( <DropdownMenuPrimitive.SubContent data-slot="dropdown-menu-sub-content" className={cn(PANEL, "origin-(--radix-dropdown-menu-content-transform-origin)", className)} {...props} /> ) } export { DropdownMenu, DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, }
用法
components/release-actions.tsx
import { FlagIcon, MagnetIcon, MoreHorizontalIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" aria-label="更多操作">
<MoreHorizontalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<MagnetIcon />
复制磁力链接
<DropdownMenuShortcut>M</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<FlagIcon />
举报资源
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>行的样子
- 面板是浮层(
anibt-pop),内边 4。行高 32、圆角 10、13 号字,图标是弱色。 - 指针或键盘所在的行:粉色淡底(
accent)、粉字、粉色图标。 variant="destructive":红字红图标,选中时是红色淡底。- 禁用的行降成弱色字,不是半透明。
DropdownMenuLabel是 12/600 的弱色组名;DropdownMenuSeparator是虚线缝线。DropdownMenuShortcut放在行尾,圆体。
勾选、单选与子菜单
dropdown-menu-options.tsx
"use client"
import { CopyIcon, MagnetIcon, Settings2Icon, TextIcon, TicketIcon } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export default function DropdownMenuOptions() {
const [columns, setColumns] = useState({ group: true, size: true, date: false })
const [sort, setSort] = useState("latest")
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary">
<Settings2Icon />
列表设置
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start">
<DropdownMenuLabel>显示的列</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={columns.group}
onCheckedChange={(checked) => setColumns((current) => ({ ...current, group: checked }))}
onSelect={(event) => event.preventDefault()}
>
字幕组
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={columns.size}
onCheckedChange={(checked) => setColumns((current) => ({ ...current, size: checked }))}
onSelect={(event) => event.preventDefault()}
>
文件大小
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={columns.date}
onCheckedChange={(checked) => setColumns((current) => ({ ...current, date: checked }))}
onSelect={(event) => event.preventDefault()}
>
发布时间
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>排序</DropdownMenuLabel>
<DropdownMenuRadioGroup value={sort} onValueChange={setSort}>
<DropdownMenuRadioItem value="latest">最新发布</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="seeders">做种最多</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="size">文件最大</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<CopyIcon />
复制为…
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-44">
<DropdownMenuItem>
<MagnetIcon />
磁力链接
</DropdownMenuItem>
<DropdownMenuItem>
<TicketIcon />
种子链接
</DropdownMenuItem>
<DropdownMenuItem>
<TextIcon />
资源标题
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem disabled>
<Settings2Icon />
高级设置(即将推出)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}DropdownMenuCheckboxItem 前面是粉色的勾,DropdownMenuRadioItem 前面是粉色的点;行的文字自动缩进到对齐。想勾选后菜单不关,在 onSelect 里 event.preventDefault()。DropdownMenuSub 打开时触发行保持粉色淡底。
属性
DropdownMenuItem
variant"default" | "destructive"默认 "default"危险行是红色
insetboolean默认 false缩进到和勾选行对齐
DropdownMenuContent
sideOffsetnumber默认 6离触发器的距离
align"start" | "center" | "end"默认 "center"对齐方式(Radix)
导出名和属性与 shadcn 一致(DropdownMenuPortal、Group、CheckboxItem、RadioGroup、RadioItem、Label、Separator、Shortcut、Sub、SubTrigger、SubContent),DropdownMenuTrigger 也接受 Base UI 的 render。
