{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dropdown-menu",
  "title": "Dropdown Menu",
  "description": "An accessible dropdown that springs open from its trigger edge, with submenus, shortcuts, separators, and keyboard navigation.",
  "dependencies": ["framer-motion"],
  "registryDependencies": ["@godui/godui-theme"],
  "files": [
    {
      "path": "packages/components/src/dropdown-menu/dropdown-menu.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"framer-motion\";\nimport * as React from \"react\";\n\nexport type DropdownMenuItem =\n  | { type: \"separator\" }\n  | { type: \"label\"; label: React.ReactNode }\n  | {\n      type?: \"item\";\n      label: React.ReactNode;\n      icon?: React.ReactNode;\n      shortcut?: string;\n      disabled?: boolean;\n      href?: string;\n      onSelect?: () => void;\n      submenu?: DropdownMenuItem[];\n    };\n\nexport type DropdownSide = \"top\" | \"bottom\" | \"left\" | \"right\";\nexport type DropdownAlign = \"start\" | \"center\" | \"end\";\n\nexport type DropdownMenuProps = {\n  trigger: React.ReactNode;\n  items: DropdownMenuItem[];\n  side?: DropdownSide;\n  align?: DropdownAlign;\n  className?: string;\n};\n\nconst sideClasses: Record<DropdownSide, string> = {\n  bottom: \"top-full mt-2\",\n  top: \"bottom-full mb-2\",\n  right: \"left-full top-0 ml-2\",\n  left: \"right-full top-0 mr-2\",\n};\n\nconst alignClasses: Record<DropdownSide, Record<DropdownAlign, string>> = {\n  bottom: {\n    start: \"left-0\",\n    center: \"left-1/2 -translate-x-1/2\",\n    end: \"right-0\",\n  },\n  top: { start: \"left-0\", center: \"left-1/2 -translate-x-1/2\", end: \"right-0\" },\n  right: {\n    start: \"top-0\",\n    center: \"top-1/2 -translate-y-1/2\",\n    end: \"bottom-0\",\n  },\n  left: { start: \"top-0\", center: \"top-1/2 -translate-y-1/2\", end: \"bottom-0\" },\n};\n\nconst originClasses: Record<DropdownSide, Record<DropdownAlign, string>> = {\n  bottom: {\n    start: \"origin-top-left\",\n    center: \"origin-top\",\n    end: \"origin-top-right\",\n  },\n  top: {\n    start: \"origin-bottom-left\",\n    center: \"origin-bottom\",\n    end: \"origin-bottom-right\",\n  },\n  right: {\n    start: \"origin-top-left\",\n    center: \"origin-left\",\n    end: \"origin-bottom-left\",\n  },\n  left: {\n    start: \"origin-top-right\",\n    center: \"origin-right\",\n    end: \"origin-bottom-right\",\n  },\n};\n\nconst SubArrow = (\n  <svg\n    aria-hidden=\"true\"\n    viewBox=\"0 0 24 24\"\n    className=\"ml-auto h-4 w-4 opacity-60\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth=\"2\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n  >\n    <path d=\"m9 18 6-6-6-6\" />\n  </svg>\n);\n\nconst MenuPanel = ({\n  items,\n  side,\n  align,\n  onClose,\n  reduceMotion,\n  isSub,\n}: {\n  items: DropdownMenuItem[];\n  side: DropdownSide;\n  align: DropdownAlign;\n  onClose: () => void;\n  reduceMotion: boolean | null;\n  isSub?: boolean;\n}) => {\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const [openSub, setOpenSub] = React.useState<number | null>(null);\n\n  const focusable = () =>\n    Array.from(\n      listRef.current?.querySelectorAll<HTMLElement>(\"[data-menu-item]\") ?? [],\n    );\n\n  const moveFocus = (dir: 1 | -1) => {\n    const nodes = focusable();\n    if (nodes.length === 0) return;\n    const idx = nodes.indexOf(document.activeElement as HTMLElement);\n    const next = (idx + dir + nodes.length) % nodes.length;\n    nodes[next]?.focus();\n  };\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: focus only on mount\n  React.useEffect(() => {\n    focusable()[0]?.focus();\n  }, []);\n\n  const spring = reduceMotion\n    ? { duration: 0 }\n    : ({ type: \"spring\", stiffness: 520, damping: 32 } as const);\n\n  return (\n    <motion.div\n      ref={listRef}\n      role=\"menu\"\n      initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.94 }}\n      animate={{ opacity: 1, scale: 1 }}\n      exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.94 }}\n      transition={spring}\n      onKeyDown={(e) => {\n        if (e.key === \"ArrowDown\") {\n          e.preventDefault();\n          moveFocus(1);\n        } else if (e.key === \"ArrowUp\") {\n          e.preventDefault();\n          moveFocus(-1);\n        } else if (e.key === \"Escape\") {\n          e.preventDefault();\n          onClose();\n        }\n      }}\n      className={`${isSub ? \"\" : `absolute ${sideClasses[side]} ${alignClasses[side][align]}`} z-popover min-w-52 ${originClasses[side][align]} rounded-xl border border-border bg-background p-1 shadow-xl`}\n    >\n      {items.map((item, index) => {\n        if (item.type === \"separator\") {\n          return (\n            <hr\n              // biome-ignore lint/suspicious/noArrayIndexKey: positional menu structure\n              key={`sep-${index}`}\n              className=\"my-1 border-border border-t-0 border-b\"\n            />\n          );\n        }\n        if (item.type === \"label\") {\n          return (\n            <div\n              // biome-ignore lint/suspicious/noArrayIndexKey: positional menu structure\n              key={`label-${index}`}\n              className=\"px-3 pt-2 pb-1 font-medium text-muted-foreground text-xs\"\n            >\n              {item.label}\n            </div>\n          );\n        }\n        const hasSub = !!item.submenu?.length;\n        const baseClass =\n          \"group flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-foreground text-sm outline-none [transition:background-color_120ms_ease] focus:bg-accent hover:bg-accent disabled:pointer-events-none disabled:opacity-40\";\n        const inner = (\n          <>\n            {item.icon && (\n              <span className=\"shrink-0 text-muted-foreground\">\n                {item.icon}\n              </span>\n            )}\n            <span className=\"flex-1 truncate\">{item.label}</span>\n            {item.shortcut && (\n              <span className=\"ml-auto text-muted-foreground text-xs tracking-widest\">\n                {item.shortcut}\n              </span>\n            )}\n            {hasSub && SubArrow}\n          </>\n        );\n\n        if (hasSub) {\n          return (\n            // biome-ignore lint/a11y/noStaticElementInteractions: hover region for submenu\n            <div\n              // biome-ignore lint/suspicious/noArrayIndexKey: positional menu structure\n              key={`sub-${index}`}\n              className=\"relative\"\n              onMouseEnter={() => setOpenSub(index)}\n              onMouseLeave={() => setOpenSub((v) => (v === index ? null : v))}\n            >\n              <button\n                type=\"button\"\n                role=\"menuitem\"\n                data-menu-item\n                aria-haspopup=\"menu\"\n                aria-expanded={openSub === index}\n                disabled={item.disabled}\n                onKeyDown={(e) => {\n                  if (e.key === \"ArrowRight\") {\n                    e.preventDefault();\n                    setOpenSub(index);\n                  } else if (e.key === \"ArrowLeft\") {\n                    setOpenSub(null);\n                  }\n                }}\n                onClick={() => setOpenSub(openSub === index ? null : index)}\n                className={baseClass}\n              >\n                {inner}\n              </button>\n              <AnimatePresence>\n                {openSub === index && item.submenu && (\n                  <div className=\"absolute top-0 left-full ml-1\">\n                    <MenuPanel\n                      items={item.submenu}\n                      side=\"right\"\n                      align=\"start\"\n                      onClose={onClose}\n                      reduceMotion={reduceMotion}\n                      isSub\n                    />\n                  </div>\n                )}\n              </AnimatePresence>\n            </div>\n          );\n        }\n\n        const handleSelect = () => {\n          item.onSelect?.();\n          onClose();\n        };\n\n        if (item.href) {\n          return (\n            <a\n              // biome-ignore lint/suspicious/noArrayIndexKey: positional menu structure\n              key={`item-${index}`}\n              role=\"menuitem\"\n              data-menu-item\n              href={item.href}\n              onClick={handleSelect}\n              className={baseClass}\n            >\n              {inner}\n            </a>\n          );\n        }\n        return (\n          <button\n            // biome-ignore lint/suspicious/noArrayIndexKey: positional menu structure\n            key={`item-${index}`}\n            type=\"button\"\n            role=\"menuitem\"\n            data-menu-item\n            disabled={item.disabled}\n            onClick={handleSelect}\n            className={baseClass}\n          >\n            {inner}\n          </button>\n        );\n      })}\n    </motion.div>\n  );\n};\n\nconst DropdownMenu = React.forwardRef<HTMLDivElement, DropdownMenuProps>(\n  ({ trigger, items, side = \"bottom\", align = \"start\", className }, ref) => {\n    const reduceMotion = useReducedMotion();\n    const [open, setOpen] = React.useState(false);\n    const rootRef = React.useRef<HTMLDivElement>(null);\n    const triggerRef = React.useRef<HTMLButtonElement>(null);\n\n    React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);\n\n    React.useEffect(() => {\n      if (!open) return;\n      const onDown = (e: MouseEvent) => {\n        if (rootRef.current && !rootRef.current.contains(e.target as Node)) {\n          setOpen(false);\n        }\n      };\n      document.addEventListener(\"mousedown\", onDown);\n      return () => document.removeEventListener(\"mousedown\", onDown);\n    }, [open]);\n\n    const close = () => {\n      setOpen(false);\n      triggerRef.current?.focus();\n    };\n\n    return (\n      <div ref={rootRef} className={`relative inline-block ${className ?? \"\"}`}>\n        <button\n          ref={triggerRef}\n          type=\"button\"\n          aria-haspopup=\"menu\"\n          aria-expanded={open}\n          onClick={() => setOpen((v) => !v)}\n          onKeyDown={(e) => {\n            if (e.key === \"ArrowDown\" && !open) {\n              e.preventDefault();\n              setOpen(true);\n            }\n          }}\n          className=\"inline-flex outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n        >\n          {trigger}\n        </button>\n        <AnimatePresence>\n          {open && (\n            <MenuPanel\n              items={items}\n              side={side}\n              align={align}\n              onClose={close}\n              reduceMotion={reduceMotion}\n            />\n          )}\n        </AnimatePresence>\n      </div>\n    );\n  },\n);\nDropdownMenu.displayName = \"DropdownMenu\";\n\nexport { DropdownMenu };\n",
      "type": "registry:ui",
      "target": "components/godui/dropdown-menu.tsx"
    }
  ],
  "type": "registry:ui"
}
