{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "context-menu",
  "type": "registry:ui",
  "title": "Context menu",
  "description": "Secondary actions kept close to the selected object.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "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/context-menu/context-menu.tsx",
      "content": "\"use client\";\n\nimport { createPortal } from \"react-dom\";\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { CSSProperties, FocusEvent, KeyboardEvent, ReactNode } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Check, Copy, MoreHorizontal, Trash2 } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface ContextMenuItem {\n  id: string;\n  label: string;\n  icon?: ReactNode;\n  onSelect?: () => void;\n  disabled?: boolean;\n  destructive?: boolean;\n  checked?: boolean;\n}\n\nexport interface ContextMenuProps {\n  children: ReactNode;\n  items: ContextMenuItem[];\n  label?: string;\n}\n\ntype Highlight = { index: number; top: number; height: number; danger: boolean; glide: boolean };\n\nconst MENU_WIDTH = 208;\n\n/* Rows press with a tint, not a scale, and drop in one after another (context-menu-item-in, staggered by --i). The gliding\n   highlight is the focus indicator, as in the dropdown menu. */\nconst itemClass =\n  \"relative flex min-h-[34px] w-full animate-context-menu-item-in cursor-pointer items-center gap-[9px] rounded-[calc(var(--radius-panel)-6px)] border-0 bg-transparent px-[11px] text-left text-sm leading-body tracking-body text-inherit outline-none [transition:background-color_var(--duration-instant)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard)] focus-visible:outline-none enabled:active:bg-[color-mix(in_oklab,var(--ink)_5%,transparent)] disabled:cursor-default disabled:opacity-45 motion-reduce:animate-none motion-reduce:transition-none [&>span:last-child]:min-w-0 [&>span:last-child]:truncate\";\n\n// Keeps the menu 8px inside the viewport. Rows are 34px with 5px padding and a 1px border; the real size is measured before paint.\nfunction clampTo(x: number, y: number, width: number, height: number) {\n  const left = Math.max(8, Math.min(x, window.innerWidth - width - 8));\n  const top = Math.max(8, Math.min(y, window.innerHeight - height - 8));\n  // The menu grows from the pointer, even when it is clamped away from a viewport edge.\n  return {\n    x: left,\n    y: top,\n    originX: Math.max(0, Math.min(width, x - left)),\n    originY: Math.max(0, Math.min(height, y - top)),\n  };\n}\n\nexport function ContextMenu({ children, items, label = \"Context menu\" }: ContextMenuProps) {\n  const reduced = useReducedMotion();\n  const [open, setOpen] = useState(false);\n  // The portal mounts on first open (client only) and stays so the menu can animate out.\n  const [portal, setPortal] = useState(false);\n  // Each open gets its own key, so reopening at a new point fades the old menu out in place\n  // and grows a fresh one from the pointer instead of teleporting the visible menu.\n  const [point, setPoint] = useState({ x: 0, y: 0, originX: 0, originY: 0, key: 0 });\n  const [highlight, setHighlight] = useState<Highlight | null>(null);\n  const menuRef = useRef<HTMLDivElement | null>(null);\n  const targetRef = useRef<HTMLDivElement>(null);\n  const pointer = useRef(false);\n  const clearTimer = useRef(0);\n  // A primary press on the target while the menu is open closes it; the click that follows must not reopen it.\n  const pressedWhileOpen = useRef(false);\n  // An exiting menu keeps its last props, so its handlers check this before moving focus or selecting.\n  const live = useRef(false);\n\n  const anchor = useRef({ x: 0, y: 0 });\n\n  useEffect(() => {\n    live.current = open;\n  }, [open]);\n  // Re-clamp against the menu's real size before the first frame, so long labels or many rows never overflow the viewport.\n  useLayoutEffect(() => {\n    const node = menuRef.current;\n    if (!open || !node) return;\n    const next = clampTo(anchor.current.x, anchor.current.y, node.offsetWidth, node.offsetHeight);\n    setPoint((current) =>\n      current.x === next.x &&\n      current.y === next.y &&\n      current.originX === next.originX &&\n      current.originY === next.originY\n        ? current\n        : { ...next, key: current.key },\n    );\n  }, [open, point.key]);\n  useEffect(() => () => window.clearTimeout(clearTimer.current), []);\n  useEffect(() => {\n    if (!open) return;\n    menuRef.current?.querySelector<HTMLElement>('[role=\"menuitem\"]:not(:disabled)')?.focus({ preventScroll: true });\n    const closeOnPointerDown = (event: PointerEvent) => {\n      if (!menuRef.current?.contains(event.target as Node)) setOpen(false);\n    };\n    const closeOnViewportChange = (event: Event) => {\n      if (event.target instanceof Node && menuRef.current?.contains(event.target)) return;\n      setOpen(false);\n    };\n    document.addEventListener(\"pointerdown\", closeOnPointerDown);\n    window.addEventListener(\"scroll\", closeOnViewportChange, true);\n    window.addEventListener(\"resize\", closeOnViewportChange);\n    return () => {\n      document.removeEventListener(\"pointerdown\", closeOnPointerDown);\n      window.removeEventListener(\"scroll\", closeOnViewportChange, true);\n      window.removeEventListener(\"resize\", closeOnViewportChange);\n    };\n  }, [open, point.key]);\n\n  function showMenu(x: number, y: number) {\n    anchor.current = { x, y };\n    const estimate = clampTo(x, y, MENU_WIDTH, Math.min(300, items.length * 34 + 12));\n    setPoint((current) => ({ ...estimate, key: current.key + 1 }));\n    window.clearTimeout(clearTimer.current);\n    pointer.current = false;\n    setHighlight(null);\n    setPortal(true);\n    setOpen(true);\n  }\n\n  function onTargetKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    if (event.key === \"ContextMenu\" || (event.shiftKey && event.key === \"F10\")) {\n      event.preventDefault();\n      const rect = targetRef.current?.getBoundingClientRect();\n      if (rect) showMenu(rect.left + 12, rect.bottom + 8);\n    }\n  }\n\n  function onTargetClick() {\n    if (pressedWhileOpen.current) {\n      pressedWhileOpen.current = false;\n      return;\n    }\n    const rect = targetRef.current?.getBoundingClientRect();\n    if (rect) showMenu(rect.left + 12, rect.bottom + 8);\n  }\n\n  function onMenuKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    const enabled = items.map((item, index) => ({ item, index })).filter(({ item }) => !item.disabled);\n    const current = document.activeElement?.getAttribute(\"data-index\");\n    const currentPosition = enabled.findIndex(({ index }) => String(index) === current);\n    let nextPosition: number | undefined;\n    if (event.key === \"ArrowDown\") nextPosition = (currentPosition + 1) % enabled.length;\n    if (event.key === \"ArrowUp\")\n      nextPosition = currentPosition < 0 ? enabled.length - 1 : (currentPosition - 1 + enabled.length) % enabled.length;\n    if (event.key === \"Home\") nextPosition = 0;\n    if (event.key === \"End\") nextPosition = enabled.length - 1;\n    if (nextPosition !== undefined && enabled.length) {\n      event.preventDefault();\n      event.currentTarget.querySelector<HTMLElement>(`[data-index=\"${enabled[nextPosition]!.index}\"]`)?.focus();\n    }\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      setOpen(false);\n      targetRef.current?.focus();\n    }\n  }\n\n  function place(item: HTMLElement, glide: boolean) {\n    window.clearTimeout(clearTimer.current);\n    const next = {\n      index: Number(item.dataset.index),\n      top: item.offsetTop,\n      height: item.offsetHeight,\n      danger: item.dataset.tone === \"danger\",\n    };\n    setHighlight((current) => ({ ...next, glide: glide && current !== null }));\n  }\n\n  // Keyboard focus places the highlight instantly; the pointer makes it glide between rows.\n  function onMenuFocus(event: FocusEvent<HTMLDivElement>) {\n    const item = event.target instanceof HTMLElement ? event.target.closest<HTMLElement>('[role=\"menuitem\"]') : null;\n    if (item) {\n      // A pointer-opened menu focuses its first row without showing a highlight, like a native menu.\n      if (!pointer.current && item.matches(\":focus-visible\")) place(item, false);\n      return;\n    }\n    // A short grace period keeps the highlight gliding across the gap between rows.\n    window.clearTimeout(clearTimer.current);\n    clearTimer.current = window.setTimeout(() => setHighlight(null), pointer.current ? 70 : 0);\n  }\n\n  return (\n    <>\n      {/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- the target is a content region, not a button (a button role would hide its content); it takes focus so Shift+F10 and the ContextMenu key can open the menu, like a native context-menu target */}\n      <div\n        ref={targetRef}\n        className=\"block outline-none\"\n        // oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- the target is a content region, not a button (a button role would hide its content); it takes focus so Shift+F10 and the ContextMenu key can open the menu, like a native context-menu target\n        tabIndex={0}\n        onPointerDown={(event) => {\n          pressedWhileOpen.current = open && event.button === 0;\n        }}\n        onClick={onTargetClick}\n        onContextMenu={(event) => {\n          event.preventDefault();\n          showMenu(event.clientX, event.clientY);\n        }}\n        onKeyDown={onTargetKeyDown}\n        aria-label={label}\n        aria-haspopup=\"menu\"\n        aria-expanded={open}\n      >\n        {children}\n      </div>\n      {portal\n        ? createPortal(\n            <AnimatePresence>\n              {open && (\n                <motion.div\n                  key={point.key}\n                  ref={(node) => {\n                    if (node) menuRef.current = node;\n                  }}\n                  className=\"fixed z-100 max-h-[min(300px,calc(100vh-16px))] max-w-[calc(100vw-16px)] min-w-[min(13rem,calc(100vw-16px))] overflow-y-auto rounded-panel border border-line bg-surface p-[5px] text-ink shadow-floating outline-none\"\n                  role=\"menu\"\n                  aria-label={label}\n                  tabIndex={-1}\n                  style={{ left: point.x, top: point.y, transformOrigin: `${point.originX}px ${point.originY}px` }}\n                  onKeyDown={onMenuKeyDown}\n                  onFocus={onMenuFocus}\n                  onPointerMoveCapture={() => {\n                    pointer.current = true;\n                  }}\n                  onKeyDownCapture={() => {\n                    pointer.current = false;\n                  }}\n                  onPointerLeave={(event) => {\n                    if (live.current) event.currentTarget.focus({ preventScroll: true });\n                  }}\n                  initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.96 }}\n                  animate={{ opacity: 1, scale: 1 }}\n                  exit={\n                    reduced\n                      ? { opacity: 0, transition: { duration: motionTokens.duration.instant } }\n                      : {\n                          opacity: 0,\n                          scale: 0.98,\n                          transition: {\n                            duration: motionTokens.duration.instant,\n                            ease: [...motionTokens.ease.standard],\n                          },\n                        }\n                  }\n                  transition={\n                    reduced\n                      ? { duration: motionTokens.duration.instant }\n                      : {\n                          default: motionTokens.spring.snappy,\n                          opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] },\n                        }\n                  }\n                >\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:animate-none 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 : motionTokens.duration.instant },\n                    }}\n                  />\n                  {items.map((item, index) => (\n                    <button\n                      key={item.id}\n                      type=\"button\"\n                      role=\"menuitem\"\n                      tabIndex={-1}\n                      data-index={index}\n                      data-tone={item.destructive ? \"danger\" : undefined}\n                      style={{ \"--i\": index } as CSSProperties}\n                      disabled={item.disabled}\n                      className={cn(itemClass, item.destructive && \"text-red\")}\n                      onPointerMove={(event) => {\n                        if (!live.current) return;\n                        window.clearTimeout(clearTimer.current);\n                        if (document.activeElement !== event.currentTarget)\n                          event.currentTarget.focus({ preventScroll: true });\n                        if (highlight?.index !== index) place(event.currentTarget, true);\n                      }}\n                      onPointerLeave={() => {\n                        if (live.current) menuRef.current?.focus({ preventScroll: true });\n                      }}\n                      onClick={() => {\n                        if (!live.current) return;\n                        live.current = false;\n                        item.onSelect?.();\n                        setOpen(false);\n                        targetRef.current?.focus();\n                      }}\n                    >\n                      <span\n                        className={cn(\n                          \"inline-flex w-4 flex-none items-center justify-center text-ink-2\",\n                          item.destructive && \"text-current\",\n                        )}\n                        aria-hidden=\"true\"\n                      >\n                        {item.icon ?? (item.checked ? <Check size={15} /> : <MoreHorizontal size={15} />)}\n                      </span>\n                      <span>{item.label}</span>\n                    </button>\n                  ))}\n                </motion.div>\n              )}\n            </AnimatePresence>,\n            document.body,\n          )\n        : null}\n    </>\n  );\n}\n\nexport const contextMenuExampleItems: ContextMenuItem[] = [\n  { id: \"copy\", label: \"Copy link\", icon: <Copy size={15} /> },\n  { id: \"delete\", label: \"Delete project\", icon: <Trash2 size={15} />, destructive: true },\n];\n\nexport default ContextMenu;\n",
      "type": "registry:component",
      "target": "@components/iris/context-menu/context-menu.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/context-menu",
  "meta": {
    "kind": "component",
    "group": "Actions",
    "export": "ContextMenu",
    "tags": [
      "menu",
      "action"
    ],
    "docs": "https://irisui.dev/components/context-menu"
  }
}
