{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "context-menu",
  "type": "registry:ui",
  "registryDependencies": [],
  "dependencies": ["@radix-ui/react-slot", "motion"],
  "devDependencies": [],
  "files": [
    {
      "path": "context-menu.tsx",
      "content": "\"use client\";\n\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst controlCornerClassName =\n  \"rounded-lg supports-[corner-shape:squircle]:corner-squircle supports-[corner-shape:squircle]:rounded-[11px]\";\n\nconst controlCornerInheritClassName =\n  \"rounded-[inherit] supports-[corner-shape:squircle]:[corner-shape:inherit]\";\n\nconst contextMenuThemeClassName =\n  \"[--cm-surface:#ffffff] [--cm-foreground:#111111] [--cm-border:#e3e7ec] [--cm-muted-foreground:#6d7480] [--cm-accent:#f3f5f8] [--color-accent:var(--cm-accent)] [--color-accent-foreground:var(--cm-accent-foreground)] [--cm-accent-foreground:#111111] [--cm-destructive:#dc2626] [--cm-ring:rgba(17,17,17,0.16)] dark:[--cm-surface:#111111] dark:[--cm-foreground:#f6f3ec] dark:[--cm-border:#2b2a25] dark:[--cm-muted-foreground:#9a958a] dark:[--cm-accent:#1a1a18] [--color-accent:var(--cm-accent)] [--color-accent-foreground:var(--cm-accent-foreground)] dark:[--cm-accent-foreground:#f6f3ec] dark:[--cm-destructive:#f87171] dark:[--cm-ring:rgba(246,243,236,0.18)]\";\n\nconst contextMenuPanelClassName = cn(\n  controlCornerClassName,\n  \"border border-[color:color-mix(in_oklch,var(--cm-border),transparent_40%)] bg-[color:var(--cm-surface)] p-1.5 text-[color:var(--cm-foreground)] shadow-2xl\"\n);\n\nconst contextMenuTriggerClassName =\n  \"outline-none focus-visible:ring-2 focus-visible:ring-[color:color-mix(in_oklch,var(--cm-ring),transparent_50%)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--cm-surface)]\";\n\nconst contextMenuItemHighlightClassName = cn(\n  controlCornerInheritClassName,\n  \"absolute inset-0 bg-[color:var(--cm-accent)]\"\n);\n\nconst contextMenuItemClassName = cn(\n  controlCornerClassName,\n  \"relative flex w-full cursor-pointer items-center gap-2.5 px-3 py-2.5 text-left font-medium text-sm outline-none transition-colors\"\n);\n\nconst contextMenuItemDefaultClassName =\n  \"text-[color:color-mix(in_oklch,var(--cm-foreground),transparent_15%)] hover:bg-accent/60\";\n\nconst contextMenuItemDestructiveClassName =\n  \"text-[color:var(--cm-destructive)] hover:bg-accent/60\";\n\nexport type ContextMenuItem = {\n  label: string;\n  icon?: React.ReactNode;\n  shortcut?: string;\n  onSelect?: () => void;\n  destructive?: boolean;\n  disabled?: boolean;\n  separatorAfter?: boolean;\n};\n\ntype Position = { x: number; y: number };\ntype MenuOrigin = \"top-left\" | \"top-right\" | \"bottom-left\" | \"bottom-right\";\n\nexport interface ContextMenuProps {\n  items: ContextMenuItem[];\n  children: React.ReactNode;\n  className?: string;\n  menuClassName?: string;\n}\n\nconst MENU_WIDTH = 232;\nconst ITEM_HEIGHT = 44;\nconst TYPEAHEAD_RESET_MS = 500;\nconst VIEWPORT_MARGIN = 8;\n\nexport function ContextMenu({\n  items,\n  children,\n  className,\n  menuClassName,\n}: ContextMenuProps) {\n  const [open, setOpen] = React.useState(false);\n  const [mounted, setMounted] = React.useState(false);\n  const [anchor, setAnchor] = React.useState<Position>({ x: 0, y: 0 });\n  const [pos, setPos] = React.useState<Position>({ x: 0, y: 0 });\n  const [origin, setOrigin] = React.useState<MenuOrigin>(\"top-left\");\n  const [activeIndex, setActiveIndex] = React.useState<number | null>(null);\n  const triggerRef = React.useRef<HTMLElement | null>(null);\n  const menuRef = React.useRef<HTMLDivElement | null>(null);\n  const itemRefs = React.useRef<Array<HTMLButtonElement | null>>([]);\n  const returnFocusRef = React.useRef<HTMLElement | null>(null);\n  const typeaheadRef = React.useRef(\"\");\n  const typeaheadTimeoutRef = React.useRef<number | null>(null);\n  const shouldSyncFocusRef = React.useRef(false);\n  const enabledIndexes = React.useMemo(\n    () =>\n      items.reduce<number[]>((indexes, item, index) => {\n        if (!item.disabled) {\n          indexes.push(index);\n        }\n\n        return indexes;\n      }, []),\n    [items]\n  );\n\n  React.useEffect(() => {\n    setMounted(true);\n  }, []);\n\n  const clearTypeahead = React.useCallback(() => {\n    if (typeaheadTimeoutRef.current !== null) {\n      window.clearTimeout(typeaheadTimeoutRef.current);\n      typeaheadTimeoutRef.current = null;\n    }\n\n    typeaheadRef.current = \"\";\n  }, []);\n\n  React.useEffect(() => clearTypeahead, [clearTypeahead]);\n\n  const getFirstEnabledIndex = React.useCallback(\n    () => enabledIndexes[0] ?? null,\n    [enabledIndexes]\n  );\n\n  const getLastEnabledIndex = React.useCallback(\n    () => (enabledIndexes.length > 0 ? (enabledIndexes.at(-1) ?? null) : null),\n    [enabledIndexes]\n  );\n\n  const focusItem = React.useCallback((index: number | null) => {\n    if (index === null) {\n      menuRef.current?.focus();\n      return;\n    }\n\n    itemRefs.current[index]?.focus();\n  }, []);\n\n  const closeMenu = React.useCallback(\n    (restoreFocus = false) => {\n      clearTypeahead();\n      setOpen(false);\n      setActiveIndex(null);\n\n      if (!restoreFocus) {\n        return;\n      }\n\n      const nextFocusTarget = returnFocusRef.current ?? triggerRef.current;\n\n      if (!nextFocusTarget) {\n        return;\n      }\n\n      window.requestAnimationFrame(() => {\n        nextFocusTarget.focus();\n      });\n    },\n    [clearTypeahead]\n  );\n\n  const getNextEnabledIndex = React.useCallback(\n    (currentIndex: number | null, direction: 1 | -1) => {\n      if (!enabledIndexes.length) {\n        return null;\n      }\n\n      if (currentIndex === null) {\n        return direction === 1\n          ? (enabledIndexes[0] ?? null)\n          : (enabledIndexes.at(-1) ?? null);\n      }\n\n      const currentPosition = enabledIndexes.indexOf(currentIndex);\n\n      if (currentPosition === -1) {\n        return direction === 1\n          ? (enabledIndexes[0] ?? null)\n          : (enabledIndexes.at(-1) ?? null);\n      }\n\n      const nextPosition =\n        (currentPosition + direction + enabledIndexes.length) %\n        enabledIndexes.length;\n\n      return enabledIndexes[nextPosition] ?? null;\n    },\n    [enabledIndexes]\n  );\n\n  const findMatchingIndex = React.useCallback(\n    (query: string, fromIndex: number | null) => {\n      const normalizedQuery = query.trim().toLowerCase();\n\n      if (!normalizedQuery) {\n        return null;\n      }\n\n      const startIndex =\n        fromIndex === null ? 0 : (fromIndex + 1) % Math.max(items.length, 1);\n\n      for (let offset = 0; offset < items.length; offset += 1) {\n        const index = (startIndex + offset) % items.length;\n        const item = items[index];\n\n        if (item?.disabled) {\n          continue;\n        }\n\n        if (item?.label.toLowerCase().startsWith(normalizedQuery)) {\n          return index;\n        }\n      }\n\n      return null;\n    },\n    [items]\n  );\n\n  const updatePosition = React.useCallback(\n    (clientX: number, clientY: number, width: number, height: number) => {\n      const vw = window.innerWidth;\n      const vh = window.innerHeight;\n      const overflowRight = clientX + width + VIEWPORT_MARGIN > vw;\n      const overflowBottom = clientY + height + VIEWPORT_MARGIN > vh;\n      const x = overflowRight\n        ? Math.max(VIEWPORT_MARGIN, clientX - width)\n        : Math.min(clientX, vw - width - VIEWPORT_MARGIN);\n      const y = overflowBottom\n        ? Math.max(VIEWPORT_MARGIN, clientY - height)\n        : Math.min(clientY, vh - height - VIEWPORT_MARGIN);\n\n      setOrigin(\n        `${overflowBottom ? \"bottom\" : \"top\"}-${overflowRight ? \"right\" : \"left\"}`\n      );\n      setPos({ x, y });\n    },\n    []\n  );\n\n  const openAt = React.useCallback(\n    (clientX: number, clientY: number) => {\n      if (!items.length) {\n        return;\n      }\n\n      const activeElement =\n        document.activeElement instanceof HTMLElement\n          ? document.activeElement\n          : null;\n\n      returnFocusRef.current =\n        activeElement && triggerRef.current?.contains(activeElement)\n          ? activeElement\n          : triggerRef.current;\n\n      shouldSyncFocusRef.current = true;\n      clearTypeahead();\n      setAnchor({ x: clientX, y: clientY });\n      setPos({ x: clientX, y: clientY });\n      setActiveIndex(getFirstEnabledIndex());\n      setOpen(true);\n    },\n    [clearTypeahead, getFirstEnabledIndex, items.length]\n  );\n\n  const openFromElement = React.useCallback(\n    (element: HTMLElement) => {\n      const rect = element.getBoundingClientRect();\n\n      openAt(rect.left + Math.min(rect.width / 2, 24), rect.bottom);\n    },\n    [openAt]\n  );\n\n  const moveActiveIndex = React.useCallback((index: number | null) => {\n    if (index === null) {\n      return;\n    }\n\n    shouldSyncFocusRef.current = true;\n    setActiveIndex(index);\n  }, []);\n\n  const handleSelect = React.useCallback(\n    (index: number | null, restoreFocus = false) => {\n      if (index === null) {\n        return;\n      }\n\n      const item = items[index];\n\n      if (!item || item.disabled) {\n        return;\n      }\n\n      item.onSelect?.();\n      closeMenu(restoreFocus);\n    },\n    [closeMenu, items]\n  );\n\n  const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => {\n    e.preventDefault();\n    openAt(e.clientX, e.clientY);\n  };\n\n  const handleTriggerKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {\n    if (!(e.shiftKey && e.key === \"F10\") && e.key !== \"ContextMenu\") {\n      return;\n    }\n\n    e.preventDefault();\n\n    const target =\n      e.target instanceof HTMLElement ? e.target : triggerRef.current;\n\n    if (!target) {\n      return;\n    }\n\n    openFromElement(target);\n  };\n\n  const getNavigationIndexForKey = React.useCallback(\n    (key: string) => {\n      switch (key) {\n        case \"ArrowDown\":\n          return getNextEnabledIndex(activeIndex, 1);\n        case \"ArrowUp\":\n          return getNextEnabledIndex(activeIndex, -1);\n        case \"Home\":\n          return getFirstEnabledIndex();\n        case \"End\":\n          return getLastEnabledIndex();\n        default:\n          return undefined;\n      }\n    },\n    [\n      activeIndex,\n      getFirstEnabledIndex,\n      getLastEnabledIndex,\n      getNextEnabledIndex,\n    ]\n  );\n\n  const handleTypeaheadKey = React.useCallback(\n    (key: string) => {\n      const normalizedKey = key.toLowerCase();\n      const nextQuery =\n        typeaheadRef.current === normalizedKey\n          ? normalizedKey\n          : `${typeaheadRef.current}${normalizedKey}`;\n      const match = findMatchingIndex(nextQuery, activeIndex);\n\n      typeaheadRef.current = nextQuery;\n\n      if (typeaheadTimeoutRef.current !== null) {\n        window.clearTimeout(typeaheadTimeoutRef.current);\n      }\n\n      typeaheadTimeoutRef.current = window.setTimeout(() => {\n        typeaheadRef.current = \"\";\n        typeaheadTimeoutRef.current = null;\n      }, TYPEAHEAD_RESET_MS);\n\n      moveActiveIndex(match);\n    },\n    [activeIndex, findMatchingIndex, moveActiveIndex]\n  );\n\n  const handleMenuKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (e.key === \"Escape\") {\n      e.preventDefault();\n      closeMenu(true);\n      return;\n    }\n\n    if (e.key === \"Tab\") {\n      closeMenu();\n      return;\n    }\n\n    const navigationIndex = getNavigationIndexForKey(e.key);\n\n    if (navigationIndex !== undefined) {\n      e.preventDefault();\n      moveActiveIndex(navigationIndex);\n      return;\n    }\n\n    if (e.key === \"Enter\" || e.key === \" \") {\n      e.preventDefault();\n      handleSelect(activeIndex, true);\n      return;\n    }\n\n    if (\n      e.altKey ||\n      e.ctrlKey ||\n      e.metaKey ||\n      e.key.length !== 1 ||\n      e.key === \" \"\n    ) {\n      return;\n    }\n\n    handleTypeaheadKey(e.key);\n  };\n\n  React.useLayoutEffect(() => {\n    if (!(open && menuRef.current)) {\n      return;\n    }\n\n    const menuElement = menuRef.current;\n    const measureAndPosition = () => {\n      const { height, width } = menuElement.getBoundingClientRect();\n\n      updatePosition(anchor.x, anchor.y, width, height);\n    };\n\n    measureAndPosition();\n\n    const observer = new ResizeObserver(() => {\n      measureAndPosition();\n    });\n\n    observer.observe(menuElement);\n\n    return () => observer.disconnect();\n  }, [anchor.x, anchor.y, open, updatePosition]);\n\n  React.useEffect(() => {\n    if (!open) {\n      return;\n    }\n\n    const handleWindowMouseDown = () => closeMenu();\n    const handleWindowResize = () => closeMenu();\n    const handleWindowScroll = (event: Event) => {\n      if (\n        event.target instanceof Node &&\n        menuRef.current?.contains(event.target)\n      ) {\n        return;\n      }\n\n      closeMenu();\n    };\n\n    window.addEventListener(\"mousedown\", handleWindowMouseDown);\n    window.addEventListener(\"scroll\", handleWindowScroll, true);\n    window.addEventListener(\"resize\", handleWindowResize);\n\n    return () => {\n      window.removeEventListener(\"mousedown\", handleWindowMouseDown);\n      window.removeEventListener(\"scroll\", handleWindowScroll, true);\n      window.removeEventListener(\"resize\", handleWindowResize);\n    };\n  }, [closeMenu, open]);\n\n  React.useEffect(() => {\n    if (!(open && shouldSyncFocusRef.current)) {\n      return;\n    }\n\n    shouldSyncFocusRef.current = false;\n\n    const frame = window.requestAnimationFrame(() => {\n      focusItem(activeIndex);\n    });\n\n    return () => window.cancelAnimationFrame(frame);\n  }, [activeIndex, focusItem, open]);\n\n  const transformOrigin = {\n    \"top-left\": \"top left\",\n    \"top-right\": \"top right\",\n    \"bottom-left\": \"bottom left\",\n    \"bottom-right\": \"bottom right\",\n  }[origin];\n\n  const menu = (\n    <AnimatePresence>\n      {open && mounted ? (\n        <motion.div\n          animate={{ opacity: 1, scale: 1, y: 0 }}\n          aria-orientation=\"vertical\"\n          className={cn(\n            contextMenuThemeClassName,\n            contextMenuPanelClassName,\n            menuClassName\n          )}\n          exit={{ opacity: 0, scale: 0.96, y: -2 }}\n          initial={{ opacity: 0, scale: 0.94, y: -4 }}\n          onContextMenu={(e) => e.preventDefault()}\n          onKeyDown={handleMenuKeyDown}\n          onMouseDown={(e) => e.stopPropagation()}\n          ref={menuRef}\n          role=\"menu\"\n          style={{\n            position: \"fixed\",\n            top: pos.y,\n            left: pos.x,\n            width: MENU_WIDTH,\n            maxHeight: `calc(100vh - ${VIEWPORT_MARGIN * 2}px)`,\n            overflowY: \"auto\",\n            overscrollBehavior: \"contain\",\n            transformOrigin,\n            zIndex: 50,\n          }}\n          tabIndex={-1}\n          transition={{\n            duration: 0.14,\n            ease: [0.16, 1, 0.3, 1],\n          }}\n        >\n          {items.map((item, i) => (\n            <React.Fragment key={`${item.label}-${i}`}>\n              <MenuItem\n                active={activeIndex === i}\n                buttonRef={(node) => {\n                  itemRefs.current[i] = node;\n                }}\n                item={item}\n                onClick={() => {\n                  if (item.disabled) {\n                    return;\n                  }\n\n                  handleSelect(i);\n                }}\n                onFocus={() => setActiveIndex(i)}\n                onHover={() => setActiveIndex(i)}\n              />\n              {item.separatorAfter && i < items.length - 1 && (\n                <div className=\"my-1 h-px bg-[color:color-mix(in_oklch,var(--cm-border),transparent_40%)]\" />\n              )}\n            </React.Fragment>\n          ))}\n        </motion.div>\n      ) : null}\n    </AnimatePresence>\n  );\n\n  const triggerClassName = cn(\n    contextMenuThemeClassName,\n    contextMenuTriggerClassName,\n    className\n  );\n  const useSlottedTrigger =\n    React.isValidElement(children) && children.type !== React.Fragment;\n\n  return (\n    <>\n      {useSlottedTrigger ? (\n        <Slot\n          aria-expanded={open}\n          aria-haspopup=\"menu\"\n          className={triggerClassName}\n          onContextMenu={handleContextMenu}\n          onKeyDown={handleTriggerKeyDown}\n          ref={triggerRef}\n          role=\"button\"\n          tabIndex={0}\n        >\n          {children}\n        </Slot>\n      ) : (\n        <button\n          aria-expanded={open}\n          aria-haspopup=\"menu\"\n          className={triggerClassName}\n          onContextMenu={handleContextMenu}\n          onKeyDown={handleTriggerKeyDown}\n          ref={triggerRef as React.RefObject<HTMLButtonElement | null>}\n          type=\"button\"\n        >\n          {children}\n        </button>\n      )}\n      {mounted ? createPortal(menu, document.body) : null}\n    </>\n  );\n}\n\nfunction MenuItem({\n  item,\n  active,\n  buttonRef,\n  onFocus,\n  onHover,\n  onClick,\n}: {\n  item: ContextMenuItem;\n  active: boolean;\n  buttonRef: (node: HTMLButtonElement | null) => void;\n  onFocus: () => void;\n  onHover: () => void;\n  onClick: () => void;\n}) {\n  return (\n    <motion.button\n      animate={{ opacity: 1, x: 0 }}\n      aria-disabled={item.disabled}\n      className={cn(\n        contextMenuItemClassName,\n        \"disabled:cursor-not-allowed disabled:opacity-40\",\n        item.destructive\n          ? contextMenuItemDestructiveClassName\n          : contextMenuItemDefaultClassName\n      )}\n      disabled={item.disabled}\n      initial={{ opacity: 0, x: -4 }}\n      onClick={onClick}\n      onFocus={onFocus}\n      onMouseEnter={onHover}\n      onPointerMove={onHover}\n      ref={buttonRef}\n      role=\"menuitem\"\n      style={{ minHeight: ITEM_HEIGHT }}\n      tabIndex={item.disabled ? -1 : active ? 0 : -1}\n      transition={{\n        duration: 0.12,\n        ease: [0.16, 1, 0.3, 1],\n      }}\n      type=\"button\"\n    >\n      {active && !item.disabled && (\n        <motion.div\n          className={contextMenuItemHighlightClassName}\n          layoutId=\"context-menu-active\"\n          transition={{ type: \"spring\", stiffness: 600, damping: 38 }}\n        />\n      )}\n      <span className=\"relative z-10 flex flex-1 items-center gap-2.5\">\n        {item.icon && (\n          <span className=\"flex h-4 w-4 items-center justify-center opacity-70\">\n            {item.icon}\n          </span>\n        )}\n        <span className=\"truncate\">{item.label}</span>\n      </span>\n      {item.shortcut && (\n        <span className=\"relative z-10 text-[color:color-mix(in_oklch,var(--cm-muted-foreground),transparent_30%)] text-xs tracking-widest\">\n          {item.shortcut}\n        </span>\n      )}\n    </motion.button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "title": "Context Menu",
  "description": "Native-feeling context menu with fixed-position viewport-aware placement, per-item icons and shortcuts, and spring entrance motion."
}
