{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"context-menu","type":"registry:component","title":"Context menu","description":"Opens at your cursor and grows from it.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/context-menu.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, motion, useIsPresent } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type MenuItem = {\n  label: string;\n  icon: React.ReactNode;\n  // Shown inside the surface once the item is chosen.\n  done?: string;\n  disabled?: boolean;\n  destructive?: boolean;\n  // Draws a divider above the item.\n  separated?: boolean;\n};\n\ntype Open = { id: number; x: number; y: number };\n\n// Long enough that a resting thumb or a scroll that starts slowly never\n// opens it, the same threshold the platforms use.\nconst LONG_PRESS = 500;\n// A finger drifting further than this is scrolling, not pressing.\nconst LONG_PRESS_SLOP = 10;\n// Keeps the menu off the very edge of the viewport.\nconst EDGE = 8;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n\nexport function ContextMenuArea({\n  items,\n  onSelect,\n  label = \"Canvas\",\n  className,\n  children,\n}: {\n  items: MenuItem[];\n  onSelect?: (item: MenuItem) => void;\n  label?: string;\n  className?: string;\n  children?: React.ReactNode;\n}) {\n  const surfaceRef = useRef<HTMLDivElement>(null);\n  const hintId = useId();\n  const [menu, setMenu] = useState<Open | null>(null);\n  const nextId = useRef(0);\n  const press = useRef<{\n    id: number;\n    x: number;\n    y: number;\n    timer: ReturnType<typeof setTimeout>;\n  } | null>(null);\n  // The keyboard and a long-press both fire their own contextmenu event\n  // right after we have already opened; this swallows that echo.\n  const openedAt = useRef(-Infinity);\n\n  useEffect(() => () => clearTimeout(press.current?.timer), []);\n\n  const open = (x: number, y: number) => {\n    openedAt.current = performance.now();\n    setMenu({ id: ++nextId.current, x, y });\n  };\n\n  const openAtCenter = () => {\n    const rect = surfaceRef.current?.getBoundingClientRect();\n    if (rect) open(rect.left + rect.width / 2, rect.top + rect.height / 2);\n  };\n\n  const cancelPress = () => {\n    clearTimeout(press.current?.timer);\n    press.current = null;\n  };\n\n  const close = (returnFocus: boolean) => {\n    setMenu(null);\n    if (returnFocus) surfaceRef.current?.focus({ preventScroll: true });\n  };\n\n  return (\n    <div\n      ref={surfaceRef}\n      tabIndex={0}\n      role=\"group\"\n      aria-label={label}\n      aria-describedby={hintId}\n      // React bubbles events from the portalled menu up to here; only the\n      // surface's own DOM should open a menu.\n      onContextMenu={(e) => {\n        e.preventDefault();\n        if (!e.currentTarget.contains(e.target as Node)) return;\n        cancelPress();\n        if (performance.now() - openedAt.current < 500) return;\n        // A menu key press with no pointer position reports 0,0.\n        if (e.clientX === 0 && e.clientY === 0) openAtCenter();\n        else open(e.clientX, e.clientY);\n      }}\n      onKeyDown={(e) => {\n        if (!e.currentTarget.contains(e.target as Node)) return;\n        if ((e.key === \"F10\" && e.shiftKey) || e.key === \"ContextMenu\") {\n          e.preventDefault();\n          openAtCenter();\n        }\n      }}\n      // Mouse and pen get the native contextmenu event; touch needs a hold.\n      onPointerDown={(e) => {\n        if (e.pointerType !== \"touch\" || !e.isPrimary) return;\n        if (!e.currentTarget.contains(e.target as Node)) return;\n        cancelPress();\n        const { clientX: x, clientY: y, pointerId: id } = e;\n        press.current = {\n          id,\n          x,\n          y,\n          timer: setTimeout(() => {\n            press.current = null;\n            navigator.vibrate?.(10);\n            open(x, y);\n          }, LONG_PRESS),\n        };\n      }}\n      onPointerMove={(e) => {\n        const p = press.current;\n        if (!p || p.id !== e.pointerId) return;\n        if (Math.hypot(e.clientX - p.x, e.clientY - p.y) > LONG_PRESS_SLOP) {\n          cancelPress();\n        }\n      }}\n      onPointerUp={cancelPress}\n      onPointerCancel={cancelPress}\n      className={cn(\n        \"relative flex h-[320px] w-[min(520px,100%)] touch-manipulation flex-col items-center justify-center gap-1.5 rounded-3xl px-6 text-center border border-dashed border-border outline-hidden select-none [-webkit-touch-callout:none] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n        className,\n      )}\n    >\n      <span id={hintId} className=\"sr-only\">\n        Press Shift F10 for actions\n      </span>\n      {children}\n      <AnimatePresence>\n        {menu && (\n          <Menu\n            key={menu.id}\n            x={menu.x}\n            y={menu.y}\n            items={items}\n            onClose={close}\n            onSelect={(item) => {\n              close(true);\n              onSelect?.(item);\n            }}\n          />\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction Menu({\n  x,\n  y,\n  items,\n  onClose,\n  onSelect,\n}: {\n  x: number;\n  y: number;\n  items: MenuItem[];\n  onClose: (returnFocus: boolean) => void;\n  onSelect: (item: MenuItem) => void;\n}) {\n  const ref = useRef<HTMLDivElement>(null);\n  const isPresent = useIsPresent();\n  const reduceMotion = useReducedMotion();\n\n  // Measures the real menu before the first paint, then flips it to the\n  // other side of the pointer when it would overflow and shifts it inward\n  // if even that does not fit. Written straight to the node, so placement\n  // costs no extra render.\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    // offsetWidth ignores the entering scale, so this is the settled size.\n    const w = el.offsetWidth;\n    const h = el.offsetHeight;\n    const vw = document.documentElement.clientWidth;\n    const vh = document.documentElement.clientHeight;\n    const place = (at: number, size: number, max: number) => {\n      const flipped = at + size > max - EDGE ? at - size : at;\n      return Math.min(Math.max(flipped, EDGE), max - size - EDGE);\n    };\n    const left = place(x, w, vw);\n    const top = place(y, h, vh);\n    el.style.left = `${left}px`;\n    el.style.top = `${top}px`;\n    // Grows out of the pointer itself: the corner nearest it after a flip,\n    // or the exact point when the menu had to shift.\n    const ox = Math.min(Math.max(x - left, 0), w);\n    const oy = Math.min(Math.max(y - top, 0), h);\n    el.style.transformOrigin = `${ox}px ${oy}px`;\n    el.querySelector<HTMLElement>('[role=\"menuitem\"]')?.focus({\n      preventScroll: true,\n    });\n  }, [x, y]);\n\n  useEffect(() => {\n    if (!isPresent) return;\n    const outside = (e: PointerEvent) => {\n      if (!ref.current?.contains(e.target as Node)) onClose(false);\n    };\n    const dismiss = () => onClose(false);\n    document.addEventListener(\"pointerdown\", outside, true);\n    // Capture catches scrolling in any container, not just the page.\n    window.addEventListener(\"scroll\", dismiss, true);\n    window.addEventListener(\"resize\", dismiss);\n    window.addEventListener(\"blur\", dismiss);\n    return () => {\n      document.removeEventListener(\"pointerdown\", outside, true);\n      window.removeEventListener(\"scroll\", dismiss, true);\n      window.removeEventListener(\"resize\", dismiss);\n      window.removeEventListener(\"blur\", dismiss);\n    };\n  }, [isPresent, onClose]);\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const list = Array.from(\n      ref.current?.querySelectorAll<HTMLElement>('[role=\"menuitem\"]') ?? [],\n    );\n    const at = list.indexOf(document.activeElement as HTMLElement);\n    const go = (i: number) =>\n      list[(i + list.length) % list.length]?.focus({ preventScroll: true });\n    if (e.key === \"ArrowDown\") go(at + 1);\n    else if (e.key === \"ArrowUp\") go(at < 0 ? -1 : at - 1);\n    else if (e.key === \"Home\") go(0);\n    else if (e.key === \"End\") go(-1);\n    else if (e.key === \"Escape\" || e.key === \"Tab\") onClose(true);\n    else return;\n    e.preventDefault();\n    e.stopPropagation();\n  };\n\n  return createPortal(\n    <motion.div\n      ref={ref}\n      role=\"menu\"\n      tabIndex={-1}\n      aria-label=\"Actions\"\n      onKeyDown={onKeyDown}\n      onContextMenu={(e) => {\n        e.preventDefault();\n        e.stopPropagation();\n      }}\n      // Leaving the menu clears the highlight, like a native menu.\n      onPointerLeave={(e) => {\n        if (e.pointerType === \"mouse\")\n          ref.current?.focus({ preventScroll: true });\n      }}\n      // Opens in 150ms from the pointer, leaves in 100ms on opacity alone:\n      // the exit should never hold the eye.\n      initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.95 }}\n      animate={{\n        opacity: 1,\n        scale: 1,\n        transition: { duration: 0.15, ease: EASE_OUT },\n      }}\n      exit={{ opacity: 0, transition: { duration: 0.1, ease: \"easeOut\" } }}\n      className={cn(\n        \"fixed top-0 left-0 z-50 w-55 rounded-[14px] bg-background p-1.5 shadow-raised outline-hidden\",\n        !isPresent && \"pointer-events-none\",\n      )}\n    >\n      {items.map((item) => (\n        <div key={item.label}>\n          {item.separated && (\n            <div role=\"separator\" className=\"mx-2.5 my-1.5 h-px bg-border\" />\n          )}\n          <button\n            type=\"button\"\n            role=\"menuitem\"\n            tabIndex={-1}\n            aria-disabled={item.disabled || undefined}\n            onClick={() => {\n              if (!item.disabled) onSelect(item);\n            }}\n            // Hover and keyboard share one highlight, so there is never a\n            // second \"current\" item to reconcile.\n            onPointerMove={(e) => {\n              if (\n                e.pointerType !== \"touch\" &&\n                document.activeElement !== e.currentTarget\n              ) {\n                e.currentTarget.focus({ preventScroll: true });\n              }\n            }}\n            // 8px items inside 6px padding keep the corners concentric with\n            // the menu's 14px.\n            className={cn(\n              \"flex h-9 w-full items-center gap-2.5 rounded-lg px-2.5 text-left text-sm outline-hidden select-none\",\n              item.disabled\n                ? \"cursor-default text-muted-foreground opacity-60 focus:bg-foreground/[0.04]\"\n                : item.destructive\n                  ? \"text-destructive focus:bg-destructive/10\"\n                  : \"text-foreground focus:bg-foreground/[0.06]\",\n            )}\n          >\n            <span aria-hidden className=\"size-4 shrink-0\">\n              {item.icon}\n            </span>\n            {item.label}\n          </button>\n        </div>\n      ))}\n    </motion.div>,\n    document.body,\n  );\n}\n\nfunction Icon({ d }: { d: string }) {\n  return (\n    <svg\n      viewBox=\"0 0 16 16\"\n      className=\"size-4\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.5}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      <path d={d} />\n    </svg>\n  );\n}\n\nconst ITEMS: MenuItem[] = [\n  {\n    label: \"Open\",\n    done: \"Opened\",\n    icon: (\n      <Icon d=\"M6.5 3.25H4.25a1 1 0 0 0-1 1v7.5a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1V9.5M9.25 2.75h4v4M13 3 7.75 8.25\" />\n    ),\n  },\n  {\n    label: \"Rename\",\n    done: \"Renamed\",\n    icon: <Icon d=\"m10.5 3.25 2.25 2.25L6 12.25H3.75V10z\" />,\n  },\n  {\n    label: \"Duplicate\",\n    done: \"Duplicated\",\n    icon: (\n      <Icon d=\"M5.25 7a1.75 1.75 0 0 1 1.75-1.75h4.5A1.75 1.75 0 0 1 13.25 7v4.5a1.75 1.75 0 0 1-1.75 1.75H7a1.75 1.75 0 0 1-1.75-1.75zM10.75 5.25V4.5A1.75 1.75 0 0 0 9 2.75H4.5A1.75 1.75 0 0 0 2.75 4.5V9a1.75 1.75 0 0 0 1.75 1.75h.75\" />\n    ),\n  },\n  {\n    label: \"Share link\",\n    disabled: true,\n    icon: (\n      <Icon d=\"M7 9a2.5 2.5 0 0 0 3.54 0l2-2A2.5 2.5 0 0 0 9 3.46l-.75.75M9 7a2.5 2.5 0 0 0-3.54 0l-2 2A2.5 2.5 0 0 0 7 12.54l.75-.75\" />\n    ),\n  },\n  {\n    label: \"Delete\",\n    done: \"Deleted\",\n    destructive: true,\n    separated: true,\n    icon: (\n      <Icon d=\"M2.75 4.25h10.5M6.25 4.25v-1.5h3.5v1.5M4 4.25l.6 8.1a1 1 0 0 0 1 .9h4.8a1 1 0 0 0 1-.9l.6-8.1M6.75 7v3.75M9.25 7v3.75\" />\n    ),\n  },\n];\n\nexport default function ContextMenuDemo() {\n  const [chosen, setChosen] = useState<{ n: number; text: string } | null>(\n    null,\n  );\n\n  return (\n    <ContextMenuArea\n      items={ITEMS}\n      label=\"Canvas\"\n      onSelect={(item) =>\n        setChosen((c) => ({\n          n: (c?.n ?? 0) + 1,\n          text: item.done ?? item.label,\n        }))\n      }\n    >\n      <p className=\"text-base font-medium text-foreground\">\n        Right-click anywhere\n      </p>\n      <p className=\"text-sm text-pretty text-muted-foreground\">\n        Long-press on touch, Shift F10 from the keyboard\n      </p>\n      {/* A reserved line, so the hint above never moves when a result\n          appears. Keyed per choice so each one fades in fresh. */}\n      <p aria-live=\"polite\" className=\"mt-3 h-6 text-sm leading-6 text-muted-foreground\">\n        {chosen && (\n          <span\n            key={chosen.n}\n            className=\"inline-block transition-[opacity,filter,translate] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] starting:opacity-0 motion-safe:starting:translate-y-0.5 motion-safe:starting:blur-[4px]\"\n          >\n            {chosen.text}\n          </span>\n        )}\n      </p>\n    </ContextMenuArea>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["navigation"],"docs":"From ui lab: https://lab.xevrion.dev/lab/context-menu"}