{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dropdown-menu",
  "type": "registry:ui",
  "title": "Dropdown menu",
  "description": "A focused list of actions anchored to a trigger.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "@radix-ui/react-dropdown-menu",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/dropdown-menu/dropdown-menu.tsx",
      "content": "\"use client\";\n\nimport { Fragment, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { CSSProperties, FocusEvent, ReactNode } from \"react\";\nimport * as DropdownPrimitive from \"@radix-ui/react-dropdown-menu\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ChevronDown } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface DropdownItem {\n  label: string;\n  onSelect?: () => void;\n  disabled?: boolean;\n  icon?: ReactNode;\n  destructive?: boolean;\n  separatorBefore?: boolean;\n}\nexport interface DropdownMenuProps {\n  label: string;\n  items: DropdownItem[];\n  icon?: ReactNode;\n}\n\ntype Highlight = { top: number; height: number; danger: boolean; glide: boolean };\n\n/* The trigger anchors the menu, so press feedback stays in colour. A scaled rect would be measured on pointerdown and shift the menu. */\nconst triggerClass =\n  \"group/trig inline-flex min-h-control-sm max-w-full cursor-pointer items-center gap-2 rounded-button border border-line bg-surface px-3 text-sm leading-[inherit] font-medium text-ink [transition:background-color_var(--duration-instant)_var(--ease-standard),border-color_var(--duration-fast)_var(--ease-standard),box-shadow_var(--duration-fast)_var(--ease-standard)] hover:border-line-strong hover:bg-hover hover:shadow-resting active:border-line-strong active:bg-[color-mix(in_oklab,var(--hover),var(--line)_55%)] active:shadow-none data-[state=open]:border-line-strong data-[state=open]:bg-hover data-[state=open]:shadow-none motion-reduce:transition-none\";\n/* The menu grows from the trigger edge: offset toward the trigger, scale from the Radix origin, fade quickly. Transitions instead\n   of keyframes, so a close that interrupts the open (or a reopen during the close) reverses from where it is. Radix unmounts when\n   the exit animation ends, so a no-op keyframe (dropdown-menu-exit) times the exit while the transition does the visual work. */\nconst menuClass =\n  \"group/menu relative z-60 max-w-(--radix-dropdown-menu-content-available-width) min-w-[min(12rem,var(--radix-dropdown-menu-content-available-width))] origin-(--radix-dropdown-menu-content-transform-origin) rounded-panel border border-line bg-surface p-[5px] shadow-floating [--menu-x:0px] [--menu-y:-5px] [transition:opacity_var(--duration-fast)_var(--ease-enter),transform_var(--duration-spring)_var(--ease-spring)] data-[side=left]:[--menu-x:5px] data-[side=left]:[--menu-y:0px] data-[side=right]:[--menu-x:-5px] data-[side=right]:[--menu-y:0px] data-[side=top]:[--menu-y:5px] data-[state=open]:starting:opacity-0 data-[state=open]:starting:[transform:translate(var(--menu-x),var(--menu-y))_scale(.97)] data-[state=closed]:pointer-events-none data-[state=closed]:animate-dropdown-menu-exit data-[state=closed]:opacity-0 data-[state=closed]:[transform:translate(calc(var(--menu-x)*.5),calc(var(--menu-y)*.5))_scale(.985)] data-[state=closed]:[transition:opacity_130ms_var(--ease-standard),transform_130ms_var(--ease-standard)] motion-reduce:[transform:none] motion-reduce:[transition:opacity_var(--duration-instant)_linear] motion-reduce:data-[state=closed]:[transform:none] motion-reduce:data-[state=closed]:[transition:opacity_var(--duration-instant)_linear]\";\nconst itemClass =\n  \"relative flex min-h-9 cursor-pointer items-center gap-2.5 rounded-[calc(var(--radius-panel)-6px)] px-[11px] text-sm leading-[inherit] text-ink outline-none [transition:color_var(--duration-fast)_var(--ease-standard),opacity_var(--duration-standard)_var(--ease-enter)_calc(min(var(--i,0),4)*35ms),transform_var(--duration-standard)_var(--ease-enter)_calc(min(var(--i,0),4)*35ms)] focus-visible:outline-none data-disabled:cursor-default data-disabled:opacity-45 group-data-[state=open]/menu:starting:opacity-0 group-data-[state=open]/menu:starting:[transform:translate(calc(var(--menu-x)*.4),calc(var(--menu-y)*.4))] motion-reduce:transition-none\";\n\n/** A new trigger label rises in while the old one leaves, and the trigger width springs to the measured text instead of snapping. */\nfunction TriggerLabel({ text }: { text: string }) {\n  const reduced = useReducedMotion();\n  const measure = useRef<HTMLSpanElement>(null);\n  const measured = useRef<string | null>(null);\n  const [size, setSize] = useState<{ width: number | \"auto\"; animate: boolean }>({ width: \"auto\", animate: false });\n  useLayoutEffect(() => {\n    const node = measure.current;\n    if (!node) return;\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      const current = node.textContent;\n      // Only a text change morphs; the first measure and font swaps settle instantly.\n      const animate = measured.current !== null && measured.current !== current;\n      measured.current = current;\n      setSize({ width: Math.ceil(entry.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth), animate });\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n  return (\n    <motion.span\n      className=\"relative inline-flex min-w-0 flex-[0_1_auto] overflow-x-clip\"\n      initial={false}\n      animate={{ width: size.width }}\n      transition={size.animate && !reduced ? motionTokens.spring.morph : { duration: 0 }}\n    >\n      <span\n        ref={measure}\n        className=\"pointer-events-none invisible absolute top-0 left-0 whitespace-nowrap\"\n        aria-hidden=\"true\"\n      >\n        {text}\n      </span>\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <motion.span\n          key={text}\n          className=\"block min-w-0 truncate\"\n          initial={reduced ? false : { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` }}\n          animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n          exit={\n            reduced\n              ? { opacity: 0, transition: { duration: 0 } }\n              : {\n                  opacity: 0,\n                  y: \"-0.3em\",\n                  filter: `blur(${motionTokens.blur.subtle}px)`,\n                  transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] },\n                }\n          }\n          transition={{ duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}\n        >\n          {text}\n        </motion.span>\n      </AnimatePresence>\n    </motion.span>\n  );\n}\n\nexport function DropdownMenu({ label, items, icon }: DropdownMenuProps) {\n  const reduced = useReducedMotion();\n  const [highlight, setHighlight] = useState<Highlight | null>(null);\n  const pointer = useRef(false);\n  const clearTimer = useRef(0);\n  useEffect(() => () => window.clearTimeout(clearTimer.current), []);\n  // Radix focuses the highlighted item (pointer or keyboard) and the content when the pointer leaves an item.\n  function onMenuFocus(event: FocusEvent<HTMLDivElement>) {\n    const item = event.target instanceof HTMLElement ? event.target.closest<HTMLElement>('[role=\"menuitem\"]') : null;\n    window.clearTimeout(clearTimer.current);\n    if (!item) {\n      // A short grace period keeps the highlight gliding across separators and item gaps.\n      clearTimer.current = window.setTimeout(() => setHighlight(null), pointer.current ? 70 : 0);\n      return;\n    }\n    const next = { top: item.offsetTop, height: item.offsetHeight, danger: item.dataset.tone === \"danger\" };\n    const glide = pointer.current;\n    setHighlight((current) => ({ ...next, glide: glide && current !== null }));\n  }\n  return (\n    <DropdownPrimitive.Root\n      onOpenChange={(open) => {\n        if (open) {\n          window.clearTimeout(clearTimer.current);\n          setHighlight(null);\n        }\n      }}\n    >\n      <DropdownPrimitive.Trigger className={triggerClass} type=\"button\">\n        {icon && (\n          <span className=\"inline-flex flex-none text-ink-2\" aria-hidden=\"true\">\n            {icon}\n          </span>\n        )}\n        <TriggerLabel text={label} />\n        <ChevronDown\n          className=\"flex-none text-ink-3 [transition:transform_var(--duration-spring)_var(--ease-spring),color_var(--duration-fast)_var(--ease-standard)] group-data-[state=open]/trig:[transform:rotate(180deg)] motion-reduce:transition-none\"\n          size={15}\n          strokeWidth={1.8}\n          aria-hidden=\"true\"\n        />\n      </DropdownPrimitive.Trigger>\n      <DropdownPrimitive.Portal>\n        <DropdownPrimitive.Content\n          className={menuClass}\n          sideOffset={6}\n          align=\"end\"\n          collisionPadding={12}\n          loop\n          onFocus={onMenuFocus}\n          onPointerMoveCapture={() => {\n            pointer.current = true;\n          }}\n          onKeyDownCapture={() => {\n            pointer.current = false;\n          }}\n        >\n          {/* One highlight glides between items for the pointer and jumps instantly for the keyboard. */}\n          <motion.span\n            className=\"pointer-events-none absolute top-0 right-[5px] left-[5px] rounded-[calc(var(--radius-panel)-6px)] bg-hover opacity-0 transition-[background-color] duration-fast ease-standard data-[tone=danger]:bg-[color-mix(in_oklab,var(--red)_8%,var(--surface))] motion-reduce:transition-none\"\n            data-tone={highlight?.danger ? \"danger\" : undefined}\n            aria-hidden=\"true\"\n            initial={false}\n            animate={highlight ? { y: highlight.top, height: highlight.height, opacity: 1 } : { opacity: 0 }}\n            transition={{\n              default: highlight?.glide && !reduced ? motionTokens.spring.snappy : { duration: 0 },\n              opacity: { duration: reduced ? 0 : 0.08 },\n            }}\n          />\n          {items.map((item, index) => (\n            <Fragment key={item.label}>\n              {item.separatorBefore && <DropdownPrimitive.Separator className=\"-mx-1 my-1 h-px bg-line-soft\" />}\n              <DropdownPrimitive.Item\n                className={cn(itemClass, item.destructive && \"text-red\")}\n                data-tone={item.destructive ? \"danger\" : undefined}\n                style={{ \"--i\": index } as CSSProperties}\n                disabled={item.disabled}\n                onSelect={item.onSelect}\n              >\n                {item.icon && (\n                  <span\n                    className={cn(\"inline-flex w-[17px] text-ink-2\", item.destructive && \"text-red\")}\n                    aria-hidden=\"true\"\n                  >\n                    {item.icon}\n                  </span>\n                )}\n                {item.label}\n              </DropdownPrimitive.Item>\n            </Fragment>\n          ))}\n        </DropdownPrimitive.Content>\n      </DropdownPrimitive.Portal>\n    </DropdownPrimitive.Root>\n  );\n}\n\nexport default DropdownMenu;\n",
      "type": "registry:component",
      "target": "@components/iris/dropdown-menu/dropdown-menu.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/dropdown-menu",
  "meta": {
    "kind": "component",
    "group": "Actions",
    "export": "DropdownMenu",
    "tags": [
      "menu",
      "action"
    ],
    "docs": "https://irisui.dev/components/dropdown-menu"
  }
}
