{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"dropdown-menu","type":"registry:component","title":"Dropdown Menu","description":"Action menu that opens from a trigger, flips upward when there is no room below, and supports a destructive item.","author":"Ryan","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/dropdown-menu.tsx","type":"registry:component","target":"@components/motion/dropdown-menu.tsx","content":"\"use client\";\n// easeui.dev/components/motion/dropdown-menu\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport {\n  type ButtonHTMLAttributes,\n  createContext,\n  type KeyboardEvent,\n  type ReactNode,\n  type RefObject,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst EASE = [0.23, 1, 0.32, 1] as const;\n// Matches Select: opens in 150ms, closes a little faster.\nconst OPEN = { duration: 0.15, ease: EASE } as const;\nconst CLOSE = { duration: 0.1, ease: EASE } as const;\n\ninterface DropdownMenuContextValue {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  triggerRef: RefObject<HTMLButtonElement | null>;\n  listRef: RefObject<HTMLDivElement | null>;\n  triggerId: string;\n  listId: string;\n}\n\nconst DropdownMenuContext = createContext<DropdownMenuContextValue | null>(null);\n\nfunction useDropdownMenu(part: string) {\n  const ctx = useContext(DropdownMenuContext);\n  if (!ctx) throw new Error(`${part} must be used inside <DropdownMenu>`);\n  return ctx;\n}\n\nfunction enabledItems(list: HTMLElement | null) {\n  return Array.from(\n    list?.querySelectorAll<HTMLButtonElement>('[role=\"menuitem\"]:not(:disabled)') ?? [],\n  );\n}\n\nexport interface DropdownMenuProps {\n  /** Controlled open state. */\n  open?: boolean;\n  /** Initial open state when uncontrolled. Default false. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  className?: string;\n  children: ReactNode;\n}\n\nexport function DropdownMenu({\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  className,\n  children,\n}: DropdownMenuProps) {\n  const id = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const [innerOpen, setInnerOpen] = useState(defaultOpen);\n  const open = openProp ?? innerOpen;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (openProp === undefined) setInnerOpen(next);\n      onOpenChange?.(next);\n    },\n    [openProp, onOpenChange],\n  );\n\n  // A press anywhere outside the menu closes it.\n  useEffect(() => {\n    if (!open) return;\n    const onPointerDown = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) setOpen(false);\n    };\n    window.addEventListener(\"pointerdown\", onPointerDown);\n    return () => window.removeEventListener(\"pointerdown\", onPointerDown);\n  }, [open, setOpen]);\n\n  const ctx = useMemo<DropdownMenuContextValue>(\n    () => ({\n      open,\n      setOpen,\n      triggerRef,\n      listRef,\n      triggerId: `${id}-trigger`,\n      listId: `${id}-menu`,\n    }),\n    [open, setOpen, id],\n  );\n\n  return (\n    <DropdownMenuContext.Provider value={ctx}>\n      <div ref={rootRef} className={cn(\"relative inline-block\", className)}>\n        {children}\n      </div>\n    </DropdownMenuContext.Provider>\n  );\n}\n\nexport type DropdownMenuTriggerProps = Omit<\n  ButtonHTMLAttributes<HTMLButtonElement>,\n  \"type\" | \"id\" | \"onClick\" | \"onKeyDown\"\n>;\n\nexport function DropdownMenuTrigger({\n  className,\n  children,\n  ...props\n}: DropdownMenuTriggerProps) {\n  const m = useDropdownMenu(\"DropdownMenuTrigger\");\n\n  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      m.setOpen(true);\n    }\n  };\n\n  return (\n    <button\n      ref={m.triggerRef}\n      type=\"button\"\n      id={m.triggerId}\n      aria-haspopup=\"menu\"\n      aria-expanded={m.open}\n      aria-controls={m.listId}\n      onClick={() => m.setOpen(!m.open)}\n      onKeyDown={onKeyDown}\n      className={cn(\n        \"relative inline-flex h-9 w-9 touch-manipulation items-center justify-center rounded-full text-muted-foreground outline-none after:absolute after:-inset-1 transition-colors duration-150\",\n        \"hover:bg-muted hover:text-foreground\",\n        \"focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </button>\n  );\n}\n\nexport interface DropdownMenuContentProps {\n  /** Which trigger edge the menu hangs from. Default \"start\". */\n  align?: \"start\" | \"end\";\n  className?: string;\n  children: ReactNode;\n}\n\nexport function DropdownMenuContent({\n  align = \"start\",\n  className,\n  children,\n}: DropdownMenuContentProps) {\n  const m = useDropdownMenu(\"DropdownMenuContent\");\n  const reduce = useReducedMotion();\n  const [placement, setPlacement] = useState<\"top\" | \"bottom\">(\"bottom\");\n  const { open, triggerRef, listRef } = m;\n\n  // Open upward when there is not enough room below the trigger.\n  useLayoutEffect(() => {\n    if (!open) return;\n    const trigger = triggerRef.current;\n    const list = listRef.current;\n    if (!trigger || !list) return;\n    const rect = trigger.getBoundingClientRect();\n    const below = window.innerHeight - rect.bottom;\n    setPlacement(below < list.offsetHeight + 16 && rect.top > below ? \"top\" : \"bottom\");\n  }, [open, triggerRef, listRef]);\n\n  useEffect(() => {\n    if (!open) return;\n    enabledItems(listRef.current)[0]?.focus({ preventScroll: true });\n  }, [open, listRef]);\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      m.setOpen(false);\n      triggerRef.current?.focus();\n      return;\n    }\n    if (event.key === \"Tab\") {\n      m.setOpen(false);\n      return;\n    }\n    const items = enabledItems(listRef.current);\n    if (items.length === 0) return;\n    const index = items.indexOf(document.activeElement as HTMLButtonElement);\n    const next =\n      event.key === \"ArrowDown\"\n        ? index + 1\n        : event.key === \"ArrowUp\"\n          ? index - 1\n          : event.key === \"Home\"\n            ? 0\n            : event.key === \"End\"\n              ? items.length - 1\n              : null;\n    if (next === null) return;\n    event.preventDefault();\n    items[(next + items.length) % items.length].focus();\n  };\n\n  const isTop = placement === \"top\";\n\n  // Items stay mounted while closed so the exit transition can play.\n  return (\n    <motion.div\n      ref={listRef}\n      id={m.listId}\n      role=\"menu\"\n      aria-labelledby={m.triggerId}\n      aria-hidden={!open}\n      inert={!open}\n      onKeyDown={onKeyDown}\n      initial={false}\n      animate={reduce ? { opacity: open ? 1 : 0 } : { opacity: open ? 1 : 0, scale: open ? 1 : 0.97 }}\n      transition={reduce ? { duration: 0 } : open ? OPEN : CLOSE}\n      style={{\n        transformOrigin: `${isTop ? \"bottom\" : \"top\"} ${align === \"end\" ? \"right\" : \"left\"}`,\n        pointerEvents: open ? \"auto\" : \"none\",\n      }}\n      className={cn(\n        \"absolute z-30 flex min-w-40 max-w-[calc(100vw-2rem)] flex-col gap-0.5 rounded-lg bg-background p-1\",\n        \"shadow-[0_0_0_1px_var(--border-strong),0_12px_24px_-12px_rgb(0_0_0/0.3)]\",\n        isTop ? \"bottom-full mb-1.5\" : \"top-full mt-1.5\",\n        align === \"end\" ? \"right-0\" : \"left-0\",\n        className,\n      )}\n    >\n      {children}\n    </motion.div>\n  );\n}\n\nexport interface DropdownMenuItemProps {\n  onSelect?: () => void;\n  disabled?: boolean;\n  /** Styles the item for a destructive action, such as Delete. Default false. */\n  destructive?: boolean;\n  className?: string;\n  children: ReactNode;\n}\n\nexport function DropdownMenuItem({\n  onSelect,\n  disabled = false,\n  destructive = false,\n  className,\n  children,\n}: DropdownMenuItemProps) {\n  const m = useDropdownMenu(\"DropdownMenuItem\");\n\n  return (\n    <button\n      type=\"button\"\n      role=\"menuitem\"\n      disabled={disabled}\n      tabIndex={-1}\n      onClick={() => {\n        onSelect?.();\n        m.setOpen(false);\n        m.triggerRef.current?.focus();\n      }}\n      className={cn(\n        \"flex min-h-9 w-full touch-manipulation items-center gap-2 rounded-md px-2.5 text-left text-sm outline-none transition-colors duration-150\",\n        destructive\n          ? \"text-destructive hover:bg-destructive/10 focus:bg-destructive/10\"\n          : \"text-foreground hover:bg-muted focus:bg-muted\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        className,\n      )}\n    >\n      {children}\n    </button>\n  );\n}\n\nexport function DropdownMenuSeparator({ className }: { className?: string }) {\n  return <hr className={cn(\"-mx-1 my-1 border-t border-border\", className)} />;\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}