AniBTUI
✦ 组件@anibt/dropdown-menuregistry JSON

Dropdown Menu

浮层菜单:32 高的行,指针或键盘所在行是粉色淡底,分组之间是缝线,危险行是红色。

安装

npx shadcn@latest add @anibt/dropdown-menu

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

手动安装
  1. 1. 先装主题组件只读 @anibt/theme 的 token 和配方,没有主题就没有样子。
    npx shadcn@latest add @anibt/theme
  2. 2. 安装依赖
    npm install lucide-react radix-ui
  3. 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 放在行尾,圆体。

勾选、单选与子菜单

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。