{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "context-menu",
  "type": "registry:component",
  "title": "Context menu",
  "description": "Opens actions at the pointer or with Shift+F10. Escape and outside click close the menu.",
  "registryDependencies": [
    "https://vlak.dev/r/vlak-base.json",
    "https://vlak.dev/r/inter.json",
    "https://vlak.dev/r/dropdown-menu.json",
    "https://vlak.dev/r/vlak-lib.json"
  ],
  "dependencies": [
    "@stylexjs/stylex"
  ],
  "devDependencies": [
    "@stylexjs/babel-plugin"
  ],
  "docs": "Vlak leaves are StyleX. Compile them with @stylexjs/babel-plugin (Vite: @stylexjs/unplugin, Next: @stylexjs/nextjs-plugin). If you would rather not run a compiler, import @noorddev/vlak-react instead: it ships precompiled with one stylesheet.",
  "files": [
    {
      "path": "vlak/context-menu.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { vlak } from \"./tokens.stylex\";\nimport { rs } from \"./rs\";\nimport { useOverlayPosition } from \"./use-overlay-position\";\nimport type { DropdownMenuItem, MenuCloseReason } from \"./dropdown-menu\";\nimport { MenuPanel, menuStyles } from \"./dropdown-menu\";\n\nconst styles = stylex.create({\n  trigger: {\n    outlineWidth: {\n      default: null,\n      \":focus-visible\": 2,\n    },\n    outlineStyle: {\n      default: null,\n      \":focus-visible\": \"solid\",\n    },\n    outlineColor: {\n      default: null,\n      \":focus-visible\": vlak.ink,\n    },\n    outlineOffset: {\n      default: null,\n      \":focus-visible\": 2,\n    },\n  },\n  pin: {\n    position: \"fixed\",\n    zIndex: vlak.zFloat,\n    backgroundColor: vlak.paper,\n  },\n});\n\nexport interface ContextMenuProps extends React.HTMLAttributes<HTMLDivElement> {\n  items: DropdownMenuItem[];\n}\n\n/**\n * Menu at the pointer on right-click, or anchored to the trigger on\n * Shift+F10 / the ContextMenu key. The wrapper is tabbable (pass\n * `tabIndex={-1}` when the child is focusable itself); focus returns to\n * whatever had it when the menu closes.\n */\nexport const ContextMenu = React.forwardRef<HTMLDivElement, ContextMenuProps>(function ContextMenu(\n  { items, className, style, children, onContextMenu, onKeyDown, ...props },\n  ref,\n) {\n  const idBase = React.useId();\n  const menuId = `${idBase}-menu`;\n  const [at, setAt] = React.useState<{ x: number; y: number } | null>(null);\n  const menuRef = React.useRef<HTMLDivElement>(null);\n  const restoreTo = React.useRef<HTMLElement | null>(null);\n  const placement = useOverlayPosition(at !== null, menuRef, restoreTo, at);\n\n  React.useEffect(() => {\n    if (!at) return;\n    const onPointerDown = (e: PointerEvent) => {\n      if (!menuRef.current?.contains(e.target as Node)) setAt(null);\n    };\n    document.addEventListener(\"pointerdown\", onPointerDown);\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown);\n  }, [at]);\n\n  const openAt = (x: number, y: number) => {\n    restoreTo.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n    setAt({ x, y });\n  };\n\n  const openFromRect = (el: HTMLElement) => {\n    const r = el.getBoundingClientRect();\n    openAt(r.left, r.bottom);\n  };\n\n  const close = (reason: MenuCloseReason) => {\n    setAt(null);\n    if (reason !== \"outside\") restoreTo.current?.focus();\n  };\n\n  const trigger = rs([\"rs-context-menu-trigger\", className], styles.trigger);\n  const menu = rs([\"rs-menu\", \"rs-context-menu-pin\"], menuStyles.menu, menuStyles.menuFixed, styles.pin);\n  return (\n    <div\n      ref={ref}\n      tabIndex={0}\n      aria-keyshortcuts=\"Shift+F10\"\n      className={trigger.className}\n      style={{ ...trigger.style, ...style }}\n      onContextMenu={(e) => {\n        onContextMenu?.(e);\n        if (e.defaultPrevented) return;\n        e.preventDefault();\n        /* A keyboard-synthesised contextmenu event carries no pointer position. */\n        if (e.clientX === 0 && e.clientY === 0) openFromRect(e.currentTarget);\n        else openAt(e.clientX, e.clientY);\n      }}\n      onKeyDown={(e) => {\n        onKeyDown?.(e);\n        if (e.defaultPrevented || at) return;\n        if ((e.key === \"F10\" && e.shiftKey) || e.key === \"ContextMenu\") {\n          e.preventDefault();\n          openFromRect(e.currentTarget);\n        }\n      }}\n      {...props}\n    >\n      {children}\n      {at && (\n        <MenuPanel\n          panelRef={menuRef}\n          id={menuId}\n          items={items}\n          className={menu.className}\n          style={{ ...menu.style, ...placement }}\n          onClose={close}\n        />\n      )}\n    </div>\n  );\n});\n",
      "type": "registry:component",
      "target": "components/vlak/context-menu.tsx"
    },
    {
      "path": "vlak/use-overlay-position.ts",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\n/** Keep lightweight overlays inside the visual viewport. The native top layer avoids clipping. */\nexport function useOverlayPosition(\n  open: boolean,\n  panel: React.RefObject<HTMLElement | null>,\n  anchor: React.RefObject<HTMLElement | null>,\n  point?: { x: number; y: number } | null,\n  placement: \"bottom\" | \"inline-end\" = \"bottom\",\n  options?: {\n    popover?: \"auto\" | \"manual\";\n    edge?: number;\n    matchAnchorWidth?: boolean;\n    /** Preferred block side. Collision handling may use the opposite side. */\n    side?: \"auto\" | \"top\" | \"bottom\";\n  },\n) {\n  const [position, setPosition] = React.useState<React.CSSProperties>({ visibility: \"hidden\" });\n  React.useLayoutEffect(() => {\n    const element = panel.current;\n    if (!open || !element) return;\n    const native = typeof element.showPopover === \"function\";\n    if (native) {\n      element.setAttribute(\"popover\", options?.popover ?? \"manual\");\n      if (!element.matches(\":popover-open\")) element.showPopover();\n    }\n    const place = () => {\n      const viewport = window.visualViewport;\n      const edge = Math.max(0, options?.edge ?? 8);\n      const leftEdge = (viewport?.offsetLeft ?? 0) + edge;\n      const topEdge = (viewport?.offsetTop ?? 0) + edge;\n      const viewportWidth = viewport?.width ?? window.innerWidth;\n      const viewportHeight = viewport?.height ?? window.innerHeight;\n      const rightEdge = leftEdge + viewportWidth - edge * 2;\n      const bottomEdge = topEdge + viewportHeight - edge * 2;\n      const target = anchor.current?.getBoundingClientRect();\n      const paint = getComputedStyle(element);\n      const borderX = (Number.parseFloat(paint.borderLeftWidth) || 0) + (Number.parseFloat(paint.borderRightWidth) || 0);\n      const borderY = (Number.parseFloat(paint.borderTopWidth) || 0) + (Number.parseFloat(paint.borderBottomWidth) || 0);\n      const matchAnchor = options?.matchAnchorWidth !== false;\n      let panelWidth = element.scrollWidth + borderX;\n      if (!matchAnchor) {\n        // Measure the authored width without the last placement’s viewport clamp.\n        // Restoring it here also keeps resize observation tied to the final layout.\n        const { width, minWidth, maxWidth } = element.style;\n        element.style.width = \"\";\n        element.style.minWidth = \"\";\n        element.style.maxWidth = \"\";\n        panelWidth = element.getBoundingClientRect().width;\n        Object.assign(element.style, { width, minWidth, maxWidth });\n      }\n      const width = Math.min(Math.max(panelWidth, matchAnchor ? target?.width ?? 0 : 0), viewportWidth - edge * 2);\n      const height = Math.min(element.scrollHeight + borderY, viewportHeight - edge * 2);\n      let left = point?.x ?? target?.left ?? leftEdge;\n      let top = point?.y ?? (target ? target.bottom + 6 : topEdge);\n      if (placement === \"inline-end\" && target) {\n        const rtl = getComputedStyle(anchor.current!).direction === \"rtl\";\n        left = rtl ? target.left - width - 4 : target.right + 4;\n        if (left + width > rightEdge) left = target.left - width - 4;\n        if (left < leftEdge) left = target.right + 4;\n        top = target.top;\n        if (top + height > bottomEdge) top = bottomEdge - height;\n      } else if (target && point?.y === undefined) {\n        const below = target.bottom + 6;\n        const above = target.top - height - 6;\n        if (options?.side === \"top\") {\n          top = above >= topEdge || below + height > bottomEdge ? above : below;\n        } else {\n          top = below + height <= bottomEdge || above < topEdge ? below : above;\n        }\n      } else if (top + height > bottomEdge) {\n        top = point?.y !== undefined ? point.y - height : bottomEdge - height;\n      }\n      left = Math.max(leftEdge, Math.min(left, rightEdge - width));\n      top = Math.max(topEdge, Math.min(top, bottomEdge - height));\n      const next: React.CSSProperties = {\n        position: \"fixed\", inset: \"auto\", left, top, margin: 0,\n        minWidth: matchAnchor ? Math.min(target?.width ?? 0, viewportWidth - edge * 2) : 0,\n        maxWidth: viewportWidth - edge * 2, maxHeight: viewportHeight - edge * 2,\n        width, overflow: \"auto\", visibility: \"visible\",\n      };\n      setPosition((previous) => JSON.stringify(previous) === JSON.stringify(next) ? previous : next);\n    };\n    place();\n    const observer = typeof ResizeObserver === \"undefined\" ? undefined : new ResizeObserver(place);\n    observer?.observe(element);\n    if (anchor.current) observer?.observe(anchor.current);\n    window.addEventListener(\"resize\", place);\n    window.addEventListener(\"scroll\", place, true);\n    window.visualViewport?.addEventListener(\"resize\", place);\n    window.visualViewport?.addEventListener(\"scroll\", place);\n    return () => {\n      observer?.disconnect();\n      window.removeEventListener(\"resize\", place);\n      window.removeEventListener(\"scroll\", place, true);\n      window.visualViewport?.removeEventListener(\"resize\", place);\n      window.visualViewport?.removeEventListener(\"scroll\", place);\n    };\n  }, [open, panel, anchor, point?.x, point?.y, placement, options?.popover, options?.edge, options?.matchAnchorWidth, options?.side]);\n  return position;\n}\n",
      "type": "registry:file",
      "target": "components/vlak/use-overlay-position.ts"
    },
    {
      "path": "vlak/styles/context-menu.css",
      "content": "/* ── context-menu: generated from packages/react/src/components/context-menu.tsx ── */\n.rs-context-menu-trigger:focus-visible{outline-width:2px;outline-style:solid;outline-color:var(--text);outline-offset:2px}\n.rs-menu.rs-context-menu-pin{position:fixed;z-index:var(--z-float);background-color:var(--bg)}\n",
      "type": "registry:file",
      "target": "styles/vlak/context-menu.css"
    }
  ],
  "meta": {
    "vlak": {
      "category": "actions",
      "classes": [
        "rs-context-menu-trigger",
        "rs-context-menu-pin"
      ],
      "snippet": "<div class=\"rs-menu\" role=\"menu\"><button class=\"rs-menu-item\" role=\"menuitem\">Copy</button><button class=\"rs-menu-item\" role=\"menuitem\">Paste</button><hr class=\"rs-menu-sep\" /><button class=\"rs-menu-item\" role=\"menuitem\">Inspect</button></div>",
      "cssOnly": false,
      "registryDependencies": [
        "dropdown-menu",
        "vlak-lib"
      ],
      "aliases": [
        "Context menu",
        "Right-click menu",
        "Contextual menu"
      ],
      "example": "import { ContextMenu } from \"@noorddev/vlak-react\";\n\n<ContextMenu items={[{ label: \"Copy\", onSelect: copy }, { label: \"Paste\", onSelect: paste }, { separator: true }, { label: \"Inspect\" }]}>\n  <Canvas />\n</ContextMenu>",
      "usage": {
        "use": [
          "Actions on a region or object that already has a primary interaction: a canvas, a row, a file.",
          "The same items shape as DropdownMenu."
        ],
        "avoid": [
          "Actions with no other way in; add a visible DropdownMenu too.",
          "Touch-only surfaces without a long-press alternative."
        ]
      },
      "keyboard": [
        {
          "keys": "Shift + F10, Context menu key",
          "does": "Opens the menu at the wrapper"
        },
        {
          "keys": "Arrow down, Arrow up, Home, End, Type letters",
          "does": "Move between items"
        },
        {
          "keys": "Enter, Space",
          "does": "Selects the item and closes"
        },
        {
          "keys": "Escape",
          "does": "Closes and returns focus"
        }
      ],
      "a11y": [
        "The wrapper is a tab stop with aria-keyshortcuts=\"Shift+F10\"; pass tabIndex={-1} when the child is focusable itself.",
        "The panel is role=\"menu\" with menuitem buttons, roving focus, and type-ahead; focus returns to whatever had it when the menu closes.",
        "Right-click opens at the pointer; the keyboard opens at the trigger's edge."
      ]
    }
  }
}
