{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/context-menu/context-menu.tsx",
      "type": "registry:component",
      "target": "@components/xzero/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 styles from \"./context-menu.module.css\";\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// 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 { x: left, y: top, originX: Math.max(0, Math.min(width, x - left)), originY: Math.max(0, Math.min(height, y - top)) };\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(() => { live.current = open; }, [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 => (current.x === next.x && current.y === next.y && current.originX === next.originX && current.originY === next.originY ? current : { ...next, key: current.key }));\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) { pressedWhileOpen.current = false; return; }\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\") 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\") { event.preventDefault(); setOpen(false); targetRef.current?.focus(); }\n  }\n\n  function place(item: HTMLElement, glide: boolean) {\n    window.clearTimeout(clearTimer.current);\n    const next = { index: Number(item.dataset.index), top: item.offsetTop, height: item.offsetHeight, danger: item.dataset.tone === \"danger\" };\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    <div ref={targetRef} className={styles.target} tabIndex={0} onPointerDown={event => { pressedWhileOpen.current = open && event.button === 0; }} onClick={onTargetClick} onContextMenu={event => { event.preventDefault(); showMenu(event.clientX, event.clientY); }} onKeyDown={onTargetKeyDown} aria-label={label} aria-haspopup=\"menu\" aria-expanded={open}>\n      {children}\n    </div>\n    {portal ? createPortal(<AnimatePresence>\n      {open && <motion.div key={point.key} ref={node => { if (node) menuRef.current = node; }} className={styles.menu} role=\"menu\" aria-label={label} tabIndex={-1} style={{ left: point.x, top: point.y, transformOrigin: `${point.originX}px ${point.originY}px` }} onKeyDown={onMenuKeyDown} onFocus={onMenuFocus} onPointerMoveCapture={() => { pointer.current = true; }} onKeyDownCapture={() => { pointer.current = false; }} onPointerLeave={event => { if (live.current) event.currentTarget.focus({ preventScroll: true }); }}\n        initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .96 }} animate={{ opacity: 1, scale: 1 }} exit={reduced ? { opacity: 0, transition: { duration: motionTokens.duration.instant } } : { opacity: 0, scale: .98, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }} transition={reduced ? { duration: motionTokens.duration.instant } : { default: motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } }}>\n        <motion.span className={styles.highlight} data-tone={highlight?.danger ? \"danger\" : undefined} aria-hidden=\"true\" initial={false} animate={highlight ? { y: highlight.top, height: highlight.height, opacity: 1 } : { opacity: 0 }} transition={{ default: highlight?.glide && !reduced ? motionTokens.spring.snappy : { duration: 0 }, opacity: { duration: reduced ? 0 : motionTokens.duration.instant } }}/>\n        {items.map((item, index) => <button key={item.id} type=\"button\" role=\"menuitem\" tabIndex={-1} data-index={index} data-tone={item.destructive ? \"danger\" : undefined} style={{ \"--i\": index } as CSSProperties} disabled={item.disabled} className={[styles.item, item.destructive ? styles.destructive : \"\"].filter(Boolean).join(\" \")} onPointerMove={event => { if (!live.current) return; window.clearTimeout(clearTimer.current); if (document.activeElement !== event.currentTarget) event.currentTarget.focus({ preventScroll: true }); if (highlight?.index !== index) place(event.currentTarget, true); }} onPointerLeave={() => { if (live.current) menuRef.current?.focus({ preventScroll: true }); }} onClick={() => { if (!live.current) return; live.current = false; item.onSelect?.(); setOpen(false); targetRef.current?.focus(); }}>\n          <span className={styles.icon} aria-hidden=\"true\">{item.icon ?? (item.checked ? <Check size={15} /> : <MoreHorizontal size={15} />)}</span><span>{item.label}</span>\n        </button>)}\n      </motion.div>}\n    </AnimatePresence>, document.body) : null}\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"
    },
    {
      "path": "registry/components/context-menu/context-menu.module.css",
      "type": "registry:component",
      "target": "@components/xzero/context-menu/context-menu.module.css",
      "content": ".target { display: block; outline: none; }\n/* Motion scales the menu from the pointer (transform-origin is set inline) and fades it out faster than it arrives. */\n.menu { position: fixed; z-index: 100; min-width: min(13rem, calc(100vw - 16px)); max-width: calc(100vw - 16px); max-height: min(300px, calc(100vh - 16px)); overflow-y: auto; padding: 5px; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface-raised); color: var(--foreground); box-shadow: var(--shadow-floating); outline: none; }\n.highlight { position: absolute; top: 0; right: 5px; left: 5px; border-radius: calc(var(--radius-panel) - 6px); background: var(--surface-muted); opacity: 0; pointer-events: none; transition: background-color var(--duration-fast) var(--ease-standard); }\n.highlight[data-tone=\"danger\"] { background: color-mix(in oklab, var(--danger) 8%, var(--surface)); }\n.item { position: relative; display: flex; width: 100%; min-height: 34px; align-items: center; gap: 9px; padding: 0 11px; border: 0; border-radius: calc(var(--radius-panel) - 6px); background: transparent; color: inherit; font: inherit; font-size: var(--text-sm); letter-spacing: var(--tracking-body); line-height: var(--leading-body); text-align: left; cursor: pointer; outline: none; transition: background-color var(--duration-instant) var(--ease-standard), color var(--duration-fast) var(--ease-standard); animation: context-item-in var(--duration-standard) var(--ease-enter) both; animation-delay: calc(min(var(--i, 0), 4) * 35ms); }\n/* The gliding highlight is the focus indicator, as in the dropdown menu. */\n.item:focus-visible { outline: none; }\n/* Rows press with a tint, not a scale. */\n.item:active:not(:disabled) { background: color-mix(in oklab, var(--foreground) 5%, transparent); }\n.item:disabled { opacity: .45; cursor: default; }\n.item.destructive { color: var(--danger); }\n.icon { display: inline-flex; flex: none; width: 16px; align-items: center; justify-content: center; color: var(--text-secondary); }\n.item > span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.destructive .icon { color: currentColor; }\n@keyframes context-item-in { from { opacity: 0; transform: translateY(-2px); } }\n@media (prefers-reduced-motion: reduce) { .item, .highlight { animation: none; transition: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/context-menu",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "menu",
      "action"
    ],
    "docs": "https://ui.xzero.co.uk/components/context-menu",
    "markdown": "https://ui.xzero.co.uk/components/context-menu/markdown"
  }
}