{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "context-menu",
  "type": "registry:ui",
  "title": "Context Menu",
  "description": "Right-click / long-press menu. Wraps `@base-ui/react/context-menu` (same Menu compositional API as DropdownMenu, just with a Root that handles right-click / long-press / Shift+F10 as the open gesture).",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "overlay",
    "primitive"
  ],
  "dependencies": [
    "@base-ui/react",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json",
    "https://ds.interlace.tools/r/cn.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/context-menu.tsx",
      "target": "components/ui/context-menu.tsx",
      "type": "registry:ui",
      "content": "'use client';\n\nimport * as React from 'react';\n\n// @interlace/context-menu v1.1.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/context-menu\n// What changed since: https://ds.interlace.tools/c/context-menu#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — ContextMenu\n *\n * Right-click / long-press menu. Wraps `@base-ui/react/context-menu`\n * (same Menu compositional API as DropdownMenu, just with a Root that\n * handles right-click / long-press / Shift+F10 as the open gesture).\n *\n * For the standard click-button-to-open pattern, use `DropdownMenu`.\n * Use ContextMenu only when consumers expect a true OS-level\n * right-click affordance (file lists, editor canvases, image grids).\n *\n * ## Anatomy\n *\n *   ContextMenu                       (Root — Base UI manager)\n *     ├─ ContextMenuTrigger          (the right-clickable surface)\n *     └─ ContextMenuContent          (wraps Portal + Positioner + Popup\n *                                     internally — do NOT nest it in\n *                                     ContextMenuPortal, that portals twice)\n *         ├─ ContextMenuGroup → ContextMenuLabel + ContextMenuItem\n *         ├─ ContextMenuItem\n *         ├─ ContextMenuSeparator\n *         ├─ ContextMenuCheckboxItem\n *         └─ ContextMenuRadioGroup → ContextMenuRadioItem\n *\n * `ContextMenuPortal` is exported only for the `container=` override (render\n * the popup into a specific node instead of `document.body`); it is not part\n * of the normal tree. `ContextMenuLabel` is `Menu.GroupLabel` and MUST sit\n * inside a `ContextMenuGroup` — outside one it throws on open.\n *\n * ## MIN_VIEWPORT — 320\n *\n * Long-press support works at every viewport.\n *\n * | Rule | Concept                          | Where in this file                                          |\n * | ---- | -------------------------------- | ----------------------------------------------------------- |\n * | R4   | Extends Base UI part props       | Each wrapper extends `React.ComponentProps<typeof BaseContextMenu.X>` |\n * | R6   | data-slot per part               | `data-slot=\"context-menu-*\"`                                |\n * | R7   | className merged + ...rest       | `cn(BASE, className)` + `{...props}`                        |\n * | R13  | Ecosystem first                  | Wraps Base UI's context-menu — no bespoke right-click handling |\n * | R14  | Declares min viewport            | `data-min-viewport={String(MIN_VIEWPORT)}` + exported const |\n * | R18  | Tailwind only                    | Zero inline `style`; styling lifted from DropdownMenu       |\n * | R19  | Tokens only                      | popover / accent / border / muted-foreground tokens         |\n * | R20  | AA contrast                      | Inherits semantic tokens which clear AAA                    |\n * | R25  | Client component                 | Base UI Menu hooks require client tier                      |\n * | R26  | A11y from headless primitive     | role=\"menu\" + keyboard nav + focus management from Base UI; ContextMenuTrigger adds the Shift+F10 / Menu-key opener Base UI omits |\n */\n\nimport { ContextMenu as BaseContextMenu } from '@base-ui/react/context-menu';\nimport { CheckIcon, CircleIcon } from 'lucide-react';\n\nimport { cn } from '@/lib/utils';\n\nexport const MIN_VIEWPORT = 320 as const;\n\n/**\n * Base UI wraps the React event (`BaseUIEvent<…>`), so derive the trigger's\n * keydown signature from the component's own props rather than hand-typing it.\n */\ntype TriggerKeyDown = NonNullable<\n  React.ComponentProps<typeof BaseContextMenu.Trigger>['onKeyDown']\n>;\n\nfunction ContextMenu(\n  props: React.ComponentProps<typeof BaseContextMenu.Root>,\n) {\n  return <BaseContextMenu.Root {...props} />;\n}\n\n/**\n * Right-click surface.\n *\n * ## Keyboard opening (our addition — Base UI does not ship it)\n *\n * `@base-ui/react/context-menu` opens on `contextmenu` (right-click /\n * long-press) only. A menu with no keyboard path to open it is a WCAG 2.1.1\n * (Keyboard) failure, and it is invisible to axe because the menu simply\n * isn't in the DOM until a pointer event fires. So the trigger:\n *\n *   - joins the tab order (`tabIndex` defaults to `0`), and\n *   - synthesises a `contextmenu` event on **Shift+F10** and on the\n *     dedicated **Menu / ContextMenu** key — the two bindings the WAI-ARIA\n *     APG names for this pattern.\n *\n * Pass `tabIndex={-1}` to opt a decorative trigger out of the tab order; the\n * key handler then only fires if something else focuses it.\n */\nfunction ContextMenuTrigger({\n  onKeyDown,\n  tabIndex = 0,\n  ...props\n}: React.ComponentProps<typeof BaseContextMenu.Trigger>) {\n  const handleKeyDown: TriggerKeyDown = (event) => {\n    onKeyDown?.(event);\n    if (event.defaultPrevented) return;\n\n    const isMenuKey = event.key === 'ContextMenu';\n    const isShiftF10 = event.key === 'F10' && event.shiftKey;\n    if (!isMenuKey && !isShiftF10) return;\n\n    event.preventDefault();\n    // Open at the element's own centre so the popup is anchored to the\n    // thing the user focused, not to wherever the pointer happens to rest.\n    // Browsers deliver `contextmenu` as a PointerEvent, and Base UI reads\n    // pointer fields off it — a plain MouseEvent throws inside the handler.\n    const target = event.currentTarget;\n    const box = target.getBoundingClientRect();\n    const init: PointerEventInit = {\n      bubbles: true,\n      cancelable: true,\n      button: 2,\n      buttons: 0,\n      pointerType: 'mouse',\n      clientX: box.left + box.width / 2,\n      clientY: box.top + box.height / 2,\n    };\n    target.dispatchEvent(\n      typeof PointerEvent === 'function'\n        ? new PointerEvent('contextmenu', init)\n        : new MouseEvent('contextmenu', init),\n    );\n  };\n\n  return (\n    <BaseContextMenu.Trigger\n      data-slot=\"context-menu-trigger\"\n      data-min-viewport={String(MIN_VIEWPORT)}\n      tabIndex={tabIndex}\n      onKeyDown={handleKeyDown}\n      {...props}\n    />\n  );\n}\n\nfunction ContextMenuPortal(\n  props: React.ComponentProps<typeof BaseContextMenu.Portal>,\n) {\n  return <BaseContextMenu.Portal data-slot=\"context-menu-portal\" {...props} />;\n}\n\nfunction ContextMenuContent({\n  className,\n  side,\n  sideOffset = 4,\n  align = 'start',\n  children,\n  ...props\n}: React.ComponentProps<typeof BaseContextMenu.Popup> & {\n  side?: 'top' | 'right' | 'bottom' | 'left' | 'inline-start' | 'inline-end';\n  sideOffset?: number;\n  align?: 'start' | 'center' | 'end';\n}) {\n  return (\n    <BaseContextMenu.Portal>\n      <BaseContextMenu.Positioner\n        side={side}\n        sideOffset={sideOffset}\n        align={align}\n        className=\"z-50\"\n      >\n        <BaseContextMenu.Popup\n          data-slot=\"context-menu-content\"\n          className={cn(\n            'bg-popover text-popover-foreground data-[open]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[open]:fade-in-0 data-[closed]:zoom-out-95 data-[open]:zoom-in-95 z-50 min-w-32 origin-(--transform-origin) overflow-hidden rounded-md border p-1 shadow-md outline-hidden',\n            className,\n          )}\n          {...props}\n        >\n          {children}\n        </BaseContextMenu.Popup>\n      </BaseContextMenu.Positioner>\n    </BaseContextMenu.Portal>\n  );\n}\n\nfunction ContextMenuGroup(\n  props: React.ComponentProps<typeof BaseContextMenu.Group>,\n) {\n  return <BaseContextMenu.Group data-slot=\"context-menu-group\" {...props} />;\n}\n\nfunction ContextMenuItem({\n  className,\n  ...props\n}: React.ComponentProps<typeof BaseContextMenu.Item>) {\n  return (\n    <BaseContextMenu.Item\n      data-slot=\"context-menu-item\"\n      className={cn(\n        'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none',\n        '[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n        'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction ContextMenuCheckboxItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof BaseContextMenu.CheckboxItem>) {\n  return (\n    <BaseContextMenu.CheckboxItem\n      data-slot=\"context-menu-checkbox-item\"\n      className={cn(\n        'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none',\n        'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n        className,\n      )}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <BaseContextMenu.CheckboxItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </BaseContextMenu.CheckboxItemIndicator>\n      </span>\n      {children}\n    </BaseContextMenu.CheckboxItem>\n  );\n}\n\nfunction ContextMenuRadioGroup(\n  props: React.ComponentProps<typeof BaseContextMenu.RadioGroup>,\n) {\n  return <BaseContextMenu.RadioGroup data-slot=\"context-menu-radio-group\" {...props} />;\n}\n\nfunction ContextMenuRadioItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof BaseContextMenu.RadioItem>) {\n  return (\n    <BaseContextMenu.RadioItem\n      data-slot=\"context-menu-radio-item\"\n      className={cn(\n        'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none',\n        'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n        className,\n      )}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <BaseContextMenu.RadioItemIndicator>\n          <CircleIcon className=\"size-2 fill-current\" />\n        </BaseContextMenu.RadioItemIndicator>\n      </span>\n      {children}\n    </BaseContextMenu.RadioItem>\n  );\n}\n\nfunction ContextMenuLabel({\n  className,\n  ...props\n}: React.ComponentProps<typeof BaseContextMenu.GroupLabel>) {\n  return (\n    <BaseContextMenu.GroupLabel\n      data-slot=\"context-menu-label\"\n      className={cn('text-muted-foreground px-2 py-1.5 text-xs font-semibold', className)}\n      {...props}\n    />\n  );\n}\n\nfunction ContextMenuSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof BaseContextMenu.Separator>) {\n  return (\n    <BaseContextMenu.Separator\n      data-slot=\"context-menu-separator\"\n      className={cn('bg-border -mx-1 my-1 h-px', className)}\n      {...props}\n    />\n  );\n}\n\nfunction ContextMenuShortcut({\n  className,\n  ...props\n}: React.ComponentProps<'span'>) {\n  return (\n    <span\n      data-slot=\"context-menu-shortcut\"\n      className={cn(\n        'text-muted-foreground ml-auto text-xs tracking-widest',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n/* ─────────────────────────────────────────────────────────────────\n * ContextMenuCompose — convenience composition. Same items API as\n * DropdownMenuCompose; only the trigger semantics differ.\n *\n *   <ContextMenuCompose\n *     trigger={<div className=\"w-64 h-32\">Right-click me</div>}\n *     items={[\n *       { label: 'Open', onSelect: handleOpen, shortcut: '↩' },\n *       { type: 'separator' },\n *       { label: 'Delete', onSelect: handleDel, tone: 'destructive' },\n *     ]}\n *   />\n * ──────────────────────────────────────────────────────────────── */\ntype ContextMenuComposeItem =\n  | {\n      type?: 'item';\n      label: React.ReactNode;\n      onSelect?: () => void;\n      shortcut?: React.ReactNode;\n      disabled?: boolean;\n      tone?: 'default' | 'destructive';\n    }\n  | { type: 'separator' }\n  /**\n   * Opens a labelled group that runs until the next label or separator.\n   * A label with no items after it is dropped — see `renderComposeItems`.\n   */\n  | { type: 'label'; label: React.ReactNode };\n\ninterface ContextMenuComposeProps {\n  trigger: React.ReactNode;\n  items: ContextMenuComposeItem[];\n  className?: string;\n}\n\nfunction ContextMenuCompose({\n  trigger,\n  items,\n  className,\n}: ContextMenuComposeProps) {\n  return (\n    <ContextMenu>\n      <ContextMenuTrigger render={trigger as React.ReactElement} />\n      <ContextMenuContent className={className}>\n        {renderComposeItems(items)}\n      </ContextMenuContent>\n    </ContextMenu>\n  );\n}\n\n/**\n * Renders the flat `items` array into the nested tree Base UI requires.\n *\n * A `label` item is `Menu.GroupLabel`, which THROWS unless it sits inside a\n * `Menu.Group` (\"Base UI error #31\"). The crash is invisible until the menu\n * actually opens — every closed-by-default story renders fine — so a flat\n * `<ContextMenuLabel>` shipped happily and blew up on first right-click.\n *\n * A label therefore opens a group that runs until the next label or\n * separator. That's also the correct semantics: the group is what the label\n * labels, so assistive tech announces \"Danger zone, group\" instead of a\n * floating string.\n */\nfunction renderComposeItems(items: ContextMenuComposeItem[]) {\n  const out: React.ReactNode[] = [];\n  let group: React.ReactNode[] = [];\n  let groupLabel: React.ReactNode = null;\n  let groupKey = 0;\n\n  const flushGroup = () => {\n    // A group with no members is dropped, label and all. `Menu.Group` won't\n    // crash on empty children, but a `role=\"group\"` whose `aria-labelledby`\n    // points at a heading with nothing beneath it announces a section that\n    // isn't there — worse than omitting it. Reachable via two consecutive\n    // labels, or a label immediately followed by a separator.\n    if (group.length === 0) {\n      groupLabel = null;\n      return;\n    }\n    out.push(\n      <ContextMenuGroup key={`group-${groupKey++}`}>\n        {groupLabel !== null ? (\n          <ContextMenuLabel>{groupLabel}</ContextMenuLabel>\n        ) : null}\n        {group}\n      </ContextMenuGroup>,\n    );\n    group = [];\n    groupLabel = null;\n  };\n\n  items.forEach((item, i) => {\n    if (item.type === 'separator') {\n      flushGroup();\n      out.push(<ContextMenuSeparator key={i} />);\n      return;\n    }\n    if (item.type === 'label') {\n      flushGroup();\n      groupLabel = item.label;\n      return;\n    }\n    const node = (\n      <ContextMenuItem\n        key={i}\n        onClick={item.onSelect}\n        disabled={item.disabled}\n        data-tone={item.tone === 'destructive' ? 'destructive' : undefined}\n        className={\n          item.tone === 'destructive'\n            ? 'text-destructive data-[highlighted]:text-destructive'\n            : undefined\n        }\n      >\n        {item.label}\n        {item.shortcut ? (\n          <ContextMenuShortcut>{item.shortcut}</ContextMenuShortcut>\n        ) : null}\n      </ContextMenuItem>\n    );\n    // Items before any label stay ungrouped — wrapping them in an unlabelled\n    // group would add a meaningless `role=\"group\"` to the a11y tree.\n    if (groupLabel === null && group.length === 0) out.push(node);\n    else group.push(node);\n  });\n\n  flushGroup();\n  return out;\n}\n\n// Dotted access — `<ContextMenu.Compose ...>`. See dialog.tsx for pattern.\nconst ContextMenuWithDot = Object.assign(ContextMenu, {\n  Compose: ContextMenuCompose,\n}) as typeof ContextMenu & { Compose: typeof ContextMenuCompose };\n\nexport {\n  ContextMenuWithDot as ContextMenu,\n  ContextMenuTrigger,\n  ContextMenuPortal,\n  ContextMenuContent,\n  ContextMenuGroup,\n  ContextMenuItem,\n  ContextMenuCheckboxItem,\n  ContextMenuRadioGroup,\n  ContextMenuRadioItem,\n  ContextMenuLabel,\n  ContextMenuSeparator,\n  ContextMenuShortcut,\n  ContextMenuCompose,\n};\nexport type { ContextMenuComposeProps, ContextMenuComposeItem };\n"
    }
  ],
  "meta": {
    "tier": "primitive",
    "client": true,
    "minViewport": 320,
    "loading": false,
    "version": "1.1.0",
    "since": "1.0.0"
  },
  "docs": "## @interlace/context-menu\n\nInstalled to `components/ui/context-menu.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/context-menu';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/context-menu\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
