{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dropdown-menu",
  "title": "Dropdown Menu",
  "description": "A menu of actions from a button, with groups, toggles, choices, submenus and filtering.",
  "dependencies": [
    "@base-ui/react",
    "@hugeicons/core-free-icons",
    "@hugeicons/react",
    "@stylexjs/stylex"
  ],
  "registryDependencies": [
    "https://constella.aayush.cv/r/base.json",
    "https://constella.aayush.cv/r/input-group.json"
  ],
  "files": [
    {
      "path": "src/components/primitives/dropdown-menu.tsx",
      "content": "\"use client\";\n\nimport { Menu as MenuPrimitive } from \"@base-ui/react/menu\";\nimport {\n  ArrowRight01Icon,\n  Search01Icon,\n  Tick02Icon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { createContext, use, useContext } from \"react\";\nimport type { ComponentProps } from \"react\";\n\nimport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupInput,\n} from \"@/components/primitives/input-group\";\nimport type { InputGroupSize } from \"@/components/primitives/input-group\";\nimport { useSkipMotion } from \"@/lib/constella/motion\";\nimport type { Sx } from \"@/lib/constella/styles\";\nimport { typeStyles } from \"@/lib/constella/styles\";\nimport {\n  colors,\n  durations,\n  easings,\n  fontWeights,\n  layers,\n  media,\n  motion,\n  offsets,\n  opacities,\n  radii,\n  shadows,\n  sizes,\n  space,\n  strokes,\n} from \"@/lib/constella/tokens.stylex\";\n\ntype Styled<T> = Omit<T, \"className\" | \"style\"> & {\n  sx?: Sx;\n};\n\ninterface Inset {\n  inset?: boolean;\n}\n\nconst dropdownMenuSizes = [\"sm\", \"default\", \"lg\"] as const;\n\ntype DropdownMenuSize = (typeof dropdownMenuSizes)[number];\n\ntype DropdownMenuContentProps = Styled<MenuPrimitive.Popup.Props> &\n  Pick<\n    MenuPrimitive.Positioner.Props,\n    \"align\" | \"alignOffset\" | \"side\" | \"sideOffset\"\n  > & { size?: DropdownMenuSize };\n\nconst dropdownMenuItemColors = [\"danger\"] as const;\n\ntype DropdownMenuItemColor = (typeof dropdownMenuItemColors)[number];\n\nconst offstage = \":is([data-starting-style], [data-ending-style])\";\nconst closing =\n  \":is([data-ending-style]):not([data-instant], [data-skip-motion])\";\nconst instant = \":is([data-instant], [data-skip-motion])\";\nconst highlighted = \":is([data-highlighted], [data-popup-open])\";\nconst danger = \":is([data-color='danger'])\";\nconst off = \":is([data-disabled])\";\nconst rtl = \":is([dir='rtl'], [dir='rtl'] *)\";\nconst inset = \":is([data-inset])\";\nconst filtering = \":has(> [data-slot='dropdown-menu-list'])\";\n\n// An inset row starts where the text of a row with an icon does.\nconst insetStart = (padding: string) =>\n  `calc(${padding} + ${sizes.icon} + ${space.row})`;\n\nconst styles = stylex.create({\n  positioner: {\n    zIndex: layers.popover,\n  },\n  // Select's surface and motion, so every popup opens the same way.\n  popup: {\n    backgroundColor: colors.raised,\n    borderStartStartRadius: radii.lg,\n    borderStartEndRadius: radii.lg,\n    borderEndStartRadius: radii.lg,\n    borderEndEndRadius: radii.lg,\n    boxShadow: shadows.lg,\n    color: colors.textPrimary,\n    // A filtered popup keeps its field in place and scrolls the list instead.\n    display: { default: null, [filtering]: \"flex\" },\n    flexDirection: { default: null, [filtering]: \"column\" },\n    maxHeight: \"var(--available-height)\",\n    maxWidth: \"var(--available-width)\",\n    // Wide enough for a label beside its value.\n    minWidth: `max(var(--anchor-width), ${sizes.menuLg})`,\n    opacity: { default: 1, [offstage]: 0 },\n    overflowY: { default: \"auto\", [filtering]: \"hidden\" },\n    paddingBlockEnd: space.xxs,\n    paddingBlockStart: space.xxs,\n    paddingInlineEnd: space.xxs,\n    paddingInlineStart: space.xxs,\n    transform: { default: \"none\", [offstage]: `scale(${motion.popoverScale})` },\n    transformOrigin: \"var(--transform-origin)\",\n    transitionDuration: {\n      default: durations.popover,\n      [closing]: durations.popoverExit,\n      [instant]: 0,\n    },\n    transitionProperty: {\n      default: \"opacity, transform\",\n      [media.reducedMotion]: \"opacity\",\n    },\n    transitionTimingFunction: easings.out,\n  },\n  popupSm: {\n    minWidth: `max(var(--anchor-width), ${sizes.menu})`,\n  },\n  // Inside the popup's padding, so its corner nests in the popup's.\n  input: {\n    borderStartStartRadius: radii.md,\n    borderStartEndRadius: radii.md,\n    borderEndStartRadius: radii.md,\n    borderEndEndRadius: radii.md,\n    flexShrink: 0,\n  },\n  searchIcon: {\n    color: colors.textMuted,\n    display: \"flex\",\n  },\n  list: {\n    maxHeight: sizes.menuHeight,\n    minHeight: 0,\n    overflowX: \"hidden\",\n    overflowY: \"auto\",\n    // Filtered-out groups stay mounted with `hidden`.\n    paddingBlockStart: {\n      default: space.xxs,\n      \":not(:has(> :not([hidden])))\": 0,\n    },\n    scrollPaddingBlock: space.xxs,\n  },\n  empty: {\n    color: colors.textSecondary,\n    paddingBlockEnd: { default: space.lg, \":empty\": 0 },\n    paddingBlockStart: { default: space.lg, \":empty\": 0 },\n    textAlign: \"center\",\n  },\n  // Its first item level with the trigger, or its last when it flips to `end`. A translate,\n  // not Base UI's px `alignOffset`, so it follows the padding in rem.\n  submenu: {\n    transform: \"none\",\n    translate: {\n      default: null,\n      \":is([data-align='start'])\": `0 calc(-1 * ${space.xxs})`,\n      \":is([data-align='end'])\": `0 ${space.xxs}`,\n    },\n    transitionDuration: { default: durations.popoverExit, [instant]: 0 },\n    transitionProperty: \"opacity\",\n  },\n  // Select's item, so a menu and a select read as one family.\n  item: {\n    alignItems: \"center\",\n    backgroundColor: {\n      default: \"transparent\",\n      [highlighted]: colors.fillSubtle,\n      [`${danger}${highlighted}`]: colors.dangerSubtle,\n    },\n    borderStartStartRadius: radii.md,\n    borderStartEndRadius: radii.md,\n    borderEndStartRadius: radii.md,\n    borderEndEndRadius: radii.md,\n    color: { default: colors.textPrimary, [danger]: colors.dangerText },\n    cursor: { default: \"pointer\", [off]: \"not-allowed\" },\n    display: \"flex\",\n    gap: space.row,\n    minHeight: sizes.controlMd,\n    opacity: { default: 1, [off]: opacities.disabled },\n    overflowWrap: \"anywhere\",\n    paddingInlineEnd: space.sm,\n    paddingInlineStart: { default: space.sm, [inset]: insetStart(space.sm) },\n    position: \"relative\",\n    textDecorationLine: \"none\",\n    userSelect: \"none\",\n  },\n  itemSm: {\n    minHeight: sizes.controlSm,\n    paddingInlineEnd: space.row,\n    paddingInlineStart: { default: space.row, [inset]: insetStart(space.row) },\n  },\n  itemLg: {\n    minHeight: sizes.controlLg,\n    paddingInlineEnd: space.md,\n    paddingInlineStart: { default: space.md, [inset]: insetStart(space.md) },\n  },\n  // Room on the end for the tick, as in a select.\n  checkable: {\n    paddingInlineEnd: `calc(${sizes.icon} + ${space.sm})`,\n  },\n  checkableSm: {\n    paddingInlineEnd: `calc(${sizes.icon} + ${space.row} * 2)`,\n  },\n  checkableLg: {\n    paddingInlineEnd: `calc(${sizes.icon} + ${space.md})`,\n  },\n  // Checkbox's tick: draws in from its start, fades out quickly.\n  indicator: {\n    alignItems: \"center\",\n    clipPath: {\n      default: \"inset(0)\",\n      \":is([data-starting-style])\": \"inset(0 100% 0 0)\",\n    },\n    display: \"flex\",\n    insetInlineEnd: space.sm,\n    justifyContent: \"center\",\n    opacity: { default: 1, \":is([data-ending-style])\": 0 },\n    pointerEvents: \"none\",\n    position: \"absolute\",\n    transitionDuration: {\n      default: durations.move,\n      \":is([data-ending-style])\": durations.hover,\n    },\n    transitionProperty: {\n      default: \"clip-path, opacity\",\n      [media.reducedMotion]: \"opacity\",\n    },\n    transitionTimingFunction: easings.out,\n  },\n  indicatorSm: {\n    insetInlineEnd: space.row,\n  },\n  indicatorLg: {\n    insetInlineEnd: space.md,\n  },\n  chevron: {\n    color: colors.textMuted,\n    display: \"flex\",\n    marginInlineStart: \"auto\",\n    transform: { default: null, [rtl]: \"scaleX(-1)\" },\n  },\n  label: {\n    color: colors.textMuted,\n    fontWeight: fontWeights.medium,\n    paddingBlockEnd: space.xxs,\n    paddingBlockStart: space.xxs,\n    paddingInlineEnd: space.sm,\n    paddingInlineStart: { default: space.sm, [inset]: insetStart(space.sm) },\n  },\n  labelSm: {\n    paddingInlineEnd: space.row,\n    paddingInlineStart: { default: space.row, [inset]: insetStart(space.row) },\n  },\n  labelLg: {\n    paddingInlineEnd: space.md,\n    paddingInlineStart: { default: space.md, [inset]: insetStart(space.md) },\n  },\n  // In line with the rows' text.\n  separator: {\n    backgroundColor: colors.borderSubtle,\n    height: strokes.border,\n    marginBlock: space.xxs,\n    marginInlineEnd: space.sm,\n    marginInlineStart: space.sm,\n  },\n  separatorSm: {\n    marginInlineEnd: space.row,\n    marginInlineStart: space.row,\n  },\n  separatorLg: {\n    marginInlineEnd: space.md,\n    marginInlineStart: space.md,\n  },\n  shortcut: {\n    color: colors.textMuted,\n    flexShrink: 0,\n    marginInlineStart: \"auto\",\n    whiteSpace: \"nowrap\",\n  },\n});\n\nfunction Glyph({ icon }: { icon: typeof Tick02Icon }) {\n  return (\n    <HugeiconsIcon\n      aria-hidden\n      icon={icon}\n      size={sizes.icon}\n      strokeWidth={Number(strokes.icon)}\n    />\n  );\n}\n\n// The base styles are `default`'s. The field sits a step above the rows, up to `lg`.\nconst sizeStyles = {\n  default: {\n    checkable: null,\n    indicator: null,\n    input: \"lg\",\n    item: null,\n    label: null,\n    popup: null,\n    separator: null,\n    text: typeStyles.textMd,\n  },\n  lg: {\n    checkable: styles.checkableLg,\n    indicator: styles.indicatorLg,\n    input: \"lg\",\n    item: styles.itemLg,\n    label: styles.labelLg,\n    popup: null,\n    separator: styles.separatorLg,\n    text: typeStyles.textBase,\n  },\n  sm: {\n    checkable: styles.checkableSm,\n    indicator: styles.indicatorSm,\n    input: \"default\",\n    item: styles.itemSm,\n    label: styles.labelSm,\n    popup: styles.popupSm,\n    separator: styles.separatorSm,\n    text: typeStyles.textSm,\n  },\n} satisfies Record<\n  DropdownMenuSize,\n  Record<\n    | \"checkable\"\n    | \"indicator\"\n    | \"item\"\n    | \"label\"\n    | \"popup\"\n    | \"separator\"\n    | \"text\",\n    stylex.StyleXStyles | null\n  > & { input: InputGroupSize }\n>;\n\nconst SkipMotion = createContext(false);\nconst Size = createContext<DropdownMenuSize>(\"default\");\n\nfunction useSizeStyles() {\n  return sizeStyles[use(Size)];\n}\n\nfunction DropdownMenu({ onOpenChange, ...props }: MenuPrimitive.Root.Props) {\n  const [skip, handleOpenChange] = useSkipMotion(onOpenChange);\n  return (\n    <SkipMotion value={skip}>\n      <MenuPrimitive.Root\n        data-slot=\"dropdown-menu\"\n        onOpenChange={handleOpenChange}\n        {...props}\n      />\n    </SkipMotion>\n  );\n}\n\nfunction DropdownMenuPortal({\n  sx,\n  ...props\n}: Styled<MenuPrimitive.Portal.Props>) {\n  return (\n    <MenuPrimitive.Portal\n      data-slot=\"dropdown-menu-portal\"\n      {...props}\n      {...stylex.props(sx)}\n    />\n  );\n}\n\nfunction DropdownMenuTrigger({\n  sx,\n  ...props\n}: Styled<MenuPrimitive.Trigger.Props>) {\n  return (\n    <MenuPrimitive.Trigger\n      data-slot=\"dropdown-menu-trigger\"\n      {...props}\n      {...stylex.props(sx)}\n    />\n  );\n}\n\n/** Renders the portal, the positioner and the popup. Placement props go to the positioner. */\nfunction DropdownMenuContent({\n  align = \"start\",\n  alignOffset = 0,\n  side = \"bottom\",\n  sideOffset = Number(offsets.popover),\n  size: ownSize,\n  sx,\n  ...props\n}: DropdownMenuContentProps) {\n  const skip = useContext(SkipMotion);\n  // A submenu takes its parent's size unless it sets its own.\n  const size = ownSize ?? use(Size);\n  return (\n    <Size value={size}>\n      <MenuPrimitive.Portal>\n        <MenuPrimitive.Positioner\n          align={align}\n          alignOffset={alignOffset}\n          side={side}\n          sideOffset={sideOffset}\n          {...stylex.props(styles.positioner)}\n        >\n          <MenuPrimitive.Popup\n            data-size={size}\n            data-skip-motion={skip || undefined}\n            data-slot=\"dropdown-menu-content\"\n            {...props}\n            {...stylex.props(styles.popup, sizeStyles[size].popup, sx)}\n          />\n        </MenuPrimitive.Positioner>\n      </MenuPrimitive.Portal>\n    </Size>\n  );\n}\n\n/** Makes the `DropdownMenu` or `DropdownMenuSub` directly inside it filterable. Highlights the first match while filtering, so Enter runs it. */\nfunction DropdownMenuFilterProvider({\n  autoHighlight = true,\n  ...props\n}: MenuPrimitive.FilterProvider.Props) {\n  return (\n    <MenuPrimitive.FilterProvider autoHighlight={autoHighlight} {...props} />\n  );\n}\n\nfunction DropdownMenuInput({\n  sx,\n  ...props\n}: Omit<Styled<MenuPrimitive.Input.Props>, \"render\">) {\n  return (\n    <InputGroup\n      data-slot=\"dropdown-menu-input\"\n      size={useSizeStyles().input}\n      sx={[styles.input, sx]}\n    >\n      {/* Keeps the group's `input-group-control` slot, so the group draws the focus ring. */}\n      <MenuPrimitive.Input render={<InputGroupInput />} {...props} />\n      <InputGroupAddon>\n        <span {...stylex.props(styles.searchIcon)}>\n          <Glyph icon={Search01Icon} />\n        </span>\n      </InputGroupAddon>\n    </InputGroup>\n  );\n}\n\nfunction DropdownMenuList({ sx, ...props }: Styled<MenuPrimitive.List.Props>) {\n  return (\n    <MenuPrimitive.List\n      data-slot=\"dropdown-menu-list\"\n      {...props}\n      {...stylex.props(styles.list, sx)}\n    />\n  );\n}\n\nfunction DropdownMenuEmpty({\n  sx,\n  ...props\n}: Styled<MenuPrimitive.Empty.Props>) {\n  return (\n    <MenuPrimitive.Empty\n      data-slot=\"dropdown-menu-empty\"\n      {...props}\n      {...stylex.props([useSizeStyles().text, styles.empty], sx)}\n    />\n  );\n}\n\nfunction DropdownMenuGroup({\n  sx,\n  ...props\n}: Styled<MenuPrimitive.Group.Props>) {\n  return (\n    <MenuPrimitive.Group\n      data-slot=\"dropdown-menu-group\"\n      {...props}\n      {...stylex.props(sx)}\n    />\n  );\n}\n\nfunction DropdownMenuLabel({\n  inset: isInset,\n  sx,\n  ...props\n}: Styled<MenuPrimitive.GroupLabel.Props> & Inset) {\n  return (\n    <MenuPrimitive.GroupLabel\n      data-inset={isInset}\n      data-slot=\"dropdown-menu-label\"\n      {...props}\n      {...stylex.props(\n        [typeStyles.captionLg, styles.label],\n        useSizeStyles().label,\n        sx\n      )}\n    />\n  );\n}\n\nfunction DropdownMenuItem({\n  inset: isInset,\n  color,\n  sx,\n  ...props\n}: Styled<MenuPrimitive.Item.Props> &\n  Inset & { color?: DropdownMenuItemColor }) {\n  const size = useSizeStyles();\n  return (\n    <MenuPrimitive.Item\n      data-inset={isInset}\n      data-slot=\"dropdown-menu-item\"\n      data-color={color}\n      {...props}\n      {...stylex.props([size.text, styles.item], size.item, sx)}\n    />\n  );\n}\n\nfunction DropdownMenuSub({\n  onOpenChange,\n  ...props\n}: MenuPrimitive.SubmenuRoot.Props) {\n  const inherited = useContext(SkipMotion);\n  const [skip, handleOpenChange] = useSkipMotion(onOpenChange);\n  return (\n    <SkipMotion value={inherited || skip}>\n      <MenuPrimitive.SubmenuRoot\n        data-slot=\"dropdown-menu-sub\"\n        onOpenChange={handleOpenChange}\n        {...props}\n      />\n    </SkipMotion>\n  );\n}\n\nfunction DropdownMenuSubTrigger({\n  children,\n  inset: isInset,\n  sx,\n  ...props\n}: Styled<MenuPrimitive.SubmenuTrigger.Props> & Inset) {\n  const size = useSizeStyles();\n  return (\n    <MenuPrimitive.SubmenuTrigger\n      data-inset={isInset}\n      data-slot=\"dropdown-menu-sub-trigger\"\n      {...props}\n      {...stylex.props([size.text, styles.item], size.item, sx)}\n    >\n      {children}\n      <span {...stylex.props(styles.chevron)}>\n        <Glyph icon={ArrowRight01Icon} />\n      </span>\n    </MenuPrimitive.SubmenuTrigger>\n  );\n}\n\n/** A submenu's popup: opens beside its trigger, with its first item level with it. */\nfunction DropdownMenuSubContent({\n  align = \"start\",\n  side = \"inline-end\",\n  sideOffset = 0,\n  sx,\n  ...props\n}: DropdownMenuContentProps) {\n  return (\n    <DropdownMenuContent\n      align={align}\n      data-slot=\"dropdown-menu-sub-content\"\n      side={side}\n      sideOffset={sideOffset}\n      sx={[styles.submenu, sx]}\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuCheckboxItem({\n  children,\n  inset: isInset,\n  sx,\n  ...props\n}: Styled<MenuPrimitive.CheckboxItem.Props> & Inset) {\n  const size = useSizeStyles();\n  return (\n    <MenuPrimitive.CheckboxItem\n      data-inset={isInset}\n      data-slot=\"dropdown-menu-checkbox-item\"\n      {...props}\n      {...stylex.props(\n        [size.text, styles.item],\n        size.item,\n        styles.checkable,\n        size.checkable,\n        sx\n      )}\n    >\n      {children}\n      <MenuPrimitive.CheckboxItemIndicator\n        {...stylex.props(styles.indicator, size.indicator)}\n      >\n        <Glyph icon={Tick02Icon} />\n      </MenuPrimitive.CheckboxItemIndicator>\n    </MenuPrimitive.CheckboxItem>\n  );\n}\n\nfunction DropdownMenuRadioGroup({\n  sx,\n  ...props\n}: Styled<MenuPrimitive.RadioGroup.Props>) {\n  return (\n    <MenuPrimitive.RadioGroup\n      data-slot=\"dropdown-menu-radio-group\"\n      {...props}\n      {...stylex.props(sx)}\n    />\n  );\n}\n\nfunction DropdownMenuRadioItem({\n  children,\n  inset: isInset,\n  sx,\n  ...props\n}: Styled<MenuPrimitive.RadioItem.Props> & Inset) {\n  const size = useSizeStyles();\n  return (\n    <MenuPrimitive.RadioItem\n      data-inset={isInset}\n      data-slot=\"dropdown-menu-radio-item\"\n      {...props}\n      {...stylex.props(\n        [size.text, styles.item],\n        size.item,\n        styles.checkable,\n        size.checkable,\n        sx\n      )}\n    >\n      {children}\n      <MenuPrimitive.RadioItemIndicator\n        {...stylex.props(styles.indicator, size.indicator)}\n      >\n        <Glyph icon={Tick02Icon} />\n      </MenuPrimitive.RadioItemIndicator>\n    </MenuPrimitive.RadioItem>\n  );\n}\n\nfunction DropdownMenuSeparator({\n  sx,\n  ...props\n}: Styled<MenuPrimitive.Separator.Props>) {\n  return (\n    <MenuPrimitive.Separator\n      data-slot=\"dropdown-menu-separator\"\n      {...props}\n      {...stylex.props(styles.separator, useSizeStyles().separator, sx)}\n    />\n  );\n}\n\nfunction DropdownMenuShortcut({\n  sx,\n  ...props\n}: Styled<ComponentProps<\"span\">>) {\n  return (\n    <span\n      data-slot=\"dropdown-menu-shortcut\"\n      {...props}\n      {...stylex.props([typeStyles.captionLg, styles.shortcut], sx)}\n    />\n  );\n}\n\nexport {\n  DropdownMenu,\n  DropdownMenuCheckboxItem,\n  DropdownMenuContent,\n  DropdownMenuEmpty,\n  DropdownMenuFilterProvider,\n  DropdownMenuGroup,\n  DropdownMenuInput,\n  DropdownMenuItem,\n  dropdownMenuItemColors,\n  dropdownMenuSizes,\n  DropdownMenuLabel,\n  DropdownMenuList,\n  DropdownMenuPortal,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuTrigger,\n};\nexport type {\n  DropdownMenuContentProps,\n  DropdownMenuItemColor,\n  DropdownMenuSize,\n};\n",
      "type": "registry:component"
    }
  ],
  "docs": "Usage: https://constella.aayush.cv/docs/components/dropdown-menu/index.md",
  "type": "registry:ui"
}