{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dropdown",
  "type": "registry:ui",
  "title": "Dropdown",
  "description": "Composable popover menu (trigger, panel, groups, rows, dividers) built on React Aria — the recipe behind the sidebar team/account menus.",
  "dependencies": [
    "react-aria-components@^1.17.0"
  ],
  "registryDependencies": [
    "cx",
    "use-dismiss-on-outside-press"
  ],
  "files": [
    {
      "path": "components/base/dropdown/dropdown.tsx",
      "content": "\"use client\";\n\nimport { createContext, useContext, useRef, useState, type ComponentProps, type ReactNode, type RefObject } from \"react\";\nimport {\n  Button as AriaButton,\n  Dialog as AriaDialog,\n  DialogTrigger as AriaDialogTrigger,\n  Popover as AriaPopover,\n} from \"react-aria-components\";\nimport {\n  MENU_ITEM,\n  MENU_ITEM_ACTIVE,\n  MENU_ITEM_INTERACTIVE,\n  MENU_POPOVER_SURFACE,\n  MENU_POPOVER_WIDTH,\n} from \"@/components/base/dropdown/menu-styles\";\nimport { cx } from \"@/utils/cx\";\nimport { useDismissOnOutsidePress, useTriggerToggle } from \"@/utils/use-dismiss-on-outside-press\";\n\n/**\n * Dropdown — the BoardUI popover-menu recipe as composable primitives, built\n * on React Aria's DialogTrigger/Popover. Unlike Select (which picks a value\n * into a trigger), Dropdown is a free-form menu surface: grouped rows of\n * actions, headers, footers, whatever the panel needs.\n *\n * The same recipe powers the dashboard sidebar's team/account menus and the\n * AI chat template's add/model/folder menus:\n *\n * - Panel: white, 1px border/button/default, radius 16, p 10, shadow/dropdown.\n * - Appear animation: 150ms fade + scale-95 + 2px blur in and out.\n * - Rows: rounded-2lg, background/secondary/hover on hover and on the selected\n *   row, body-medium labels, 4px apart (the panel's flex column carries a\n *   gap-1, the same rhythm as the Select listbox).\n *\n * Composition:\n *\n * ```tsx\n * <Dropdown isOpen={isOpen} onOpenChange={setIsOpen}>\n *   <DropdownTrigger>Open</DropdownTrigger>\n *   <DropdownPopover aria-label=\"Actions\" placement=\"bottom start\">\n *     <DropdownGroup label=\"Add\">\n *       <DropdownItem onSelect={close}>…row content…</DropdownItem>\n *     </DropdownGroup>\n *     <DropdownDivider />\n *     <DropdownGroup label=\"Plugins\">…</DropdownGroup>\n *   </DropdownPopover>\n * </Dropdown>\n * ```\n *\n * State can be uncontrolled (omit isOpen/onOpenChange) — control it when the\n * trigger needs to react to the open state (e.g. rotating a chevron).\n */\n\n/* ------------------------------------------------------------------- shell */\n\ninterface DropdownContextValue {\n  triggerRef: RefObject<HTMLButtonElement | null>;\n  popoverRef: RefObject<HTMLElement | null>;\n}\n\nconst DropdownContext = createContext<DropdownContextValue | null>(null);\n\nexport interface DropdownProps {\n  isOpen?: boolean;\n  onOpenChange?: (isOpen: boolean) => void;\n  /** Trigger (a DropdownTrigger) followed by a DropdownPopover. */\n  children: ReactNode;\n}\n\n/**\n * The popover is `isNonModal`: react-aria's modal scroll lock puts\n * `overflow: hidden` on <html>, which collapses the page scroll position and\n * visibly yanks sticky layout (e.g. the docs sidebar) whenever a menu opens.\n * Non-modal skips the lock, but react-aria hard-couples outside-press\n * dismissal to modality — so open state lives here (bridging any controlled\n * props) and dismissal is restored via useDismissOnOutsidePress, the same\n * fix as Select and the date-picker family.\n */\nexport function Dropdown({ isOpen: controlledOpen, onOpenChange, children }: DropdownProps) {\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const popoverRef = useRef<HTMLElement>(null);\n  const [uncontrolledOpen, setUncontrolledOpen] = useState(false);\n\n  const isOpen = controlledOpen ?? uncontrolledOpen;\n  const setOpen = (next: boolean) => {\n    setUncontrolledOpen(next);\n    onOpenChange?.(next);\n  };\n\n  useDismissOnOutsidePress(isOpen, () => setOpen(false), [triggerRef, popoverRef]);\n  // Pressing the trigger while open closes the menu instead of reopening\n  const allowOpenChange = useTriggerToggle(isOpen, triggerRef);\n\n  return (\n    <DropdownContext.Provider value={{ triggerRef, popoverRef }}>\n      <AriaDialogTrigger isOpen={isOpen} onOpenChange={(o) => allowOpenChange(o) && setOpen(o)}>\n        {children}\n      </AriaDialogTrigger>\n    </DropdownContext.Provider>\n  );\n}\n\n/** The element that opens the menu. Style it entirely via className. */\nexport function DropdownTrigger({ className, ...props }: ComponentProps<typeof AriaButton>) {\n  const context = useContext(DropdownContext);\n  return (\n    <AriaButton\n      ref={context?.triggerRef}\n      {...props}\n      className={cx(\n        \"cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring\",\n        className as string,\n      )}\n    />\n  );\n}\n\n/* ------------------------------------------------------------------- panel */\n\nexport interface DropdownPopoverProps\n  extends Pick<ComponentProps<typeof AriaPopover>, \"placement\" | \"offset\" | \"crossOffset\"> {\n  \"aria-label\": string;\n  /** Extra classes on the panel — e.g. a width override (default w-[266px]). */\n  className?: string;\n  /** Classes on the inner dialog (the flex column), e.g. gap between groups. */\n  dialogClassName?: string;\n  children: ReactNode;\n}\n\nexport function DropdownPopover({\n  \"aria-label\": ariaLabel,\n  placement = \"bottom start\",\n  offset = 4,\n  crossOffset,\n  className,\n  dialogClassName,\n  children,\n}: DropdownPopoverProps) {\n  const context = useContext(DropdownContext);\n  return (\n    <AriaPopover\n      ref={context?.popoverRef}\n      isNonModal\n      placement={placement}\n      offset={offset}\n      crossOffset={crossOffset}\n      className={cx(\n        MENU_POPOVER_WIDTH,\n        MENU_POPOVER_SURFACE,\n        className,\n      )}\n    >\n      {/* gap-1 keeps bare DropdownItems 4px apart, the same rhythm as the\n          Select listbox; DropdownDivider's margins are sized to absorb it. */}\n      <AriaDialog aria-label={ariaLabel} className={cx(\"flex flex-col gap-1 outline-none\", dialogClassName)}>\n        {children}\n      </AriaDialog>\n    </AriaPopover>\n  );\n}\n\n/* ----------------------------------------------------------------- content */\n\nexport interface DropdownGroupProps {\n  /** Muted body-medium heading above the rows. */\n  label?: string;\n  className?: string;\n  children: ReactNode;\n}\n\nexport function DropdownGroup({ label, className, children }: DropdownGroupProps) {\n  return (\n    // pt-1 is spacing for the group LABEL — a label-less group must not\n    // carry it, or its first row floats 4px lower than the panel padding\n    // implies (visible as extra space above the first item's hover pill).\n    <div className={cx(\"flex w-full flex-col gap-1.5\", label && \"pt-1\", className)}>\n      {label && <span className=\"ps-2 text-body-medium text-text-secondary\">{label}</span>}\n      <div className=\"flex w-full flex-col gap-1\">{children}</div>\n    </div>\n  );\n}\n\nexport interface DropdownItemProps {\n  /** Highlights the row like the hover state (current selection). */\n  selected?: boolean;\n  onSelect?: () => void;\n  /** Row padding defaults to p-2 — override for denser rows (px-2 py-1.5). */\n  className?: string;\n  children: ReactNode;\n}\n\n/**\n * A menu row. Content is free-form — icon + label, avatar + name, label +\n * trailing badge — laid out in a gap-2 flex row.\n */\nexport function DropdownItem({ selected, onSelect, className, children }: DropdownItemProps) {\n  return (\n    <button\n      type=\"button\"\n      aria-pressed={selected}\n      onClick={onSelect}\n      className={cx(\n        MENU_ITEM,\n        selected ? MENU_ITEM_ACTIVE : MENU_ITEM_INTERACTIVE,\n        className,\n      )}\n    >\n      {children}\n    </button>\n  );\n}\n\n/** Full-bleed 1px divider between groups (bleeds through the panel's p-2.5).\n *  my-1.5 + the dialog's gap-1 on both sides = the original 10px breathing room. */\nexport function DropdownDivider({ className }: { className?: string }) {\n  return <div className={cx(\"-mx-2.5 my-1.5 h-px shrink-0 bg-border-button-default\", className)} />;\n}\n",
      "type": "registry:component",
      "target": "components/base/dropdown/dropdown.tsx"
    },
    {
      "path": "components/base/dropdown/menu-styles.ts",
      "content": "/**\n * Shared visual recipe for BoardUI menu surfaces.\n *\n * Select and Dropdown intentionally keep their distinct React Aria semantics\n * (single-value listbox vs. action menu), while consuming the same panel,\n * motion, and row treatments from here.\n */\nexport const MENU_POPOVER_SURFACE = [\n  // z-110 sits above the modal layer (z-100), so menus opened inside a\n  // Modal or Drawer render over it rather than behind its backdrop.\n  \"z-110 max-w-[calc(100vw-32px)] overflow-y-auto\",\n  \"rounded-2xl border border-border-button-default bg-background-primary-default p-2.5 shadow-dropdown\",\n  \"transition duration-150 ease-out\",\n  \"data-[entering]:opacity-0 data-[entering]:scale-95 data-[entering]:blur-[2px]\",\n  \"data-[exiting]:opacity-0 data-[exiting]:scale-95 data-[exiting]:blur-[2px]\",\n  \"data-[placement=bottom]:origin-top-left rtl:data-[placement=bottom]:origin-top-right data-[placement=top]:origin-bottom-left rtl:data-[placement=top]:origin-bottom-right\",\n  \"data-[placement=left]:origin-right data-[placement=right]:origin-left\",\n].join(\" \");\n\nexport const MENU_POPOVER_WIDTH = \"w-[266px]\";\n\nexport const MENU_ITEMS_CONTAINER = \"flex w-full flex-col gap-1 outline-none\";\n\nexport const MENU_ITEM = [\n  \"flex w-full cursor-pointer items-center gap-2 rounded-2lg p-2 text-start\",\n  \"text-text-primary outline-none transition-colors\",\n].join(\" \");\n\nexport const MENU_ITEM_ACTIVE = \"bg-dropdown-item-hover-background\";\n\nexport const MENU_ITEM_INTERACTIVE =\n  \"hover:bg-dropdown-item-hover-background focus-visible:bg-dropdown-item-hover-background\";\n",
      "type": "registry:component",
      "target": "components/base/dropdown/menu-styles.ts"
    }
  ],
  "meta": {
    "docsUrl": "https://www.boardui.com/components/dropdown",
    "example": "import { useState } from \"react\";\nimport { RiAddLine, RiAttachment2, RiFocus3Line, RiListCheck3 } from \"@remixicon/react\";\nimport {\n  Dropdown,\n  DropdownGroup,\n  DropdownItem,\n  DropdownPopover,\n  DropdownTrigger,\n} from \"@/components/base/dropdown/dropdown\";\nimport { cx } from \"@/utils/cx\";\n\nconst ROWS = [\n  { icon: RiAttachment2, label: \"Files and folders\" },\n  { icon: RiFocus3Line, label: \"Goal\", description: \"Set a goal for faster results\" },\n  { icon: RiListCheck3, label: \"Plan mode\", description: \"Manage complex tasks\" },\n];\n\nexport function Example() {\n  const [isOpen, setIsOpen] = useState(false);\n\n  return (\n    <Dropdown isOpen={isOpen} onOpenChange={setIsOpen}>\n      <DropdownTrigger\n        aria-label=\"Add to chat\"\n        className=\"flex size-9 items-center justify-center rounded-full bg-background-secondary-default p-2 transition-colors hover:bg-background-secondary-hover\"\n      >\n        <RiAddLine\n          className={cx(\n            \"size-5 text-foreground-icon-secondary transition-transform duration-200\",\n            isOpen && \"rotate-45\",\n          )}\n          aria-hidden\n        />\n      </DropdownTrigger>\n\n      <DropdownPopover aria-label=\"Add to chat\" className=\"w-[361px] p-2\" dialogClassName=\"gap-2\">\n        <DropdownGroup label=\"Add\">\n          {ROWS.map(({ icon: Icon, label, description }) => (\n            <DropdownItem key={label} onSelect={() => setIsOpen(false)} className=\"px-2 py-1.5\">\n              <Icon className=\"size-5 shrink-0 text-foreground-icon-secondary\" aria-hidden />\n              <span className=\"truncate text-body-medium whitespace-nowrap\">\n                <span className=\"text-text-primary\">{label}</span>\n                {description && <span className=\"ml-1.5 text-text-secondary\">{description}</span>}\n              </span>\n            </DropdownItem>\n          ))}\n        </DropdownGroup>\n      </DropdownPopover>\n    </Dropdown>\n  );\n}",
    "snippets": [
      {
        "label": "model",
        "code": "{/* Radio-group rows: selected state on the row + a radio glyph at the end */}\nconst [model, setModel] = useState(\"Fable 5\");\n\n<Dropdown isOpen={isOpen} onOpenChange={setIsOpen}>\n  <DropdownTrigger className=\"…pill trigger with the current model + chevron…\">\n    {model}\n    <ChevronDownSmall className={cx(\"size-4\", isOpen && \"rotate-180\")} />\n  </DropdownTrigger>\n\n  <DropdownPopover aria-label=\"Models\" placement=\"bottom start\">\n    <DropdownGroup label=\"Models\">\n      <div role=\"radiogroup\" aria-label=\"Model\" className=\"flex w-full flex-col gap-1\">\n        {MODELS.map((name) => (\n          <DropdownItem\n            key={name}\n            selected={name === model}\n            onSelect={() => {\n              setModel(name);\n              setIsOpen(false);\n            }}\n            className=\"justify-between gap-2.5\"\n          >\n            <span className=\"truncate text-body-medium text-text-primary\">{name}</span>\n            <RadioDot selected={name === model} />\n          </DropdownItem>\n        ))}\n      </div>\n    </DropdownGroup>\n  </DropdownPopover>\n</Dropdown>"
      },
      {
        "label": "team",
        "code": "{/* Header, grouped rows with badges, and a footer in one panel */}\n<Dropdown isOpen={isOpen} onOpenChange={setIsOpen}>\n  <DropdownTrigger aria-label=\"Board team\" className=\"…team card styles…\">\n    <Avatar size=\"md\" color=\"blue\" src=\"/logo.png\" alt=\"Board team\" />\n    …name, email, chevron…\n  </DropdownTrigger>\n\n  <DropdownPopover aria-label=\"Board team menu\" className=\"w-[265px]\" dialogClassName=\"gap-[7px]\">\n    <div className=\"flex items-center gap-2 px-2 pt-1\">\n      <Avatar size=\"md\" color=\"blue\" src=\"/logo.png\" alt=\"Board team\" />\n      …name + email…\n    </div>\n\n    <DropdownGroup>\n      <DropdownItem onSelect={close}>\n        <span className=\"flex min-w-0 flex-1 items-center gap-2\">\n          <RiMessage2Line className=\"size-5 text-foreground-icon-secondary\" aria-hidden />\n          <span className=\"truncate text-body-medium text-text-primary\">Messages</span>\n        </span>\n        <Badge color=\"neutral\" className=\"bg-team-menu-count-background text-team-menu-count-foreground\">94</Badge>\n      </DropdownItem>\n    </DropdownGroup>\n\n    <DropdownDivider />\n\n    <DropdownGroup label=\"Company\">…</DropdownGroup>\n  </DropdownPopover>\n</Dropdown>"
      },
      {
        "label": "account",
        "code": "{/* Row content is free-form: avatars, then buttons below a divider */}\n<DropdownPopover aria-label=\"Account menu\" className=\"w-[265px]\">\n  <DropdownGroup label=\"Users with access\">\n    {users.map((user) => (\n      <DropdownItem key={user.name} onSelect={close} className=\"px-2 py-1.5\">\n        <Avatar size=\"xs\" color={user.color} initials={user.initials} />\n        <span className=\"truncate text-body-medium text-text-primary\">{user.name}</span>\n      </DropdownItem>\n    ))}\n  </DropdownGroup>\n\n  <DropdownDivider className=\"my-3.5\" />\n\n  <div className=\"flex items-center gap-3 px-2 pb-2\">\n    <Button variant=\"secondary\" size=\"small\" leadingIcon={RiAddFill} className=\"flex-1\">\n      Add user\n    </Button>\n    <Button variant=\"secondary\" size=\"small\" leadingIcon={RiEqualizer3Line} className=\"flex-1\">\n      Manage\n    </Button>\n  </div>\n</DropdownPopover>"
      }
    ]
  }
}
