Overlays

Context Menu

Right-click context menu built on the shared Menu overlay and styled with the quebi design system. Captures the pointer position to anchor the popover, reusing the dark surface, cyan hairlines, and dropdown item styling with sections, separators, shortcuts, and intents.

overlaymenucontext-menuright-clickactions

Right-click target

Right-click the surface to open the menu anchored at the pointer.

Icons and shortcuts

Items can carry leading icons and trailing keyboard shortcut hints.

Header and sections

Group related items under labelled sections with a leading header.

Source

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import { createContext, use, useRef, useState } from "react"
import {
  MenuContent,
  type MenuContentProps,
  MenuDescription,
  MenuHeader,
  MenuItem,
  MenuLabel,
  MenuSection,
  MenuSeparator,
  MenuShortcut,
} from "@/components/menu"
import { cn } from "@/lib/utils"

/**
 * ContextMenu — quebi design system
 *
 * A right-click (context) menu built on the shared Menu overlay. The trigger
 * captures the pointer position on `contextmenu` and anchors the popover there,
 * reusing the dark quebi-bg surface, cyan hairlines, and dropdown item styling.
 *
 * Restyled from the original onto quebi tokens — only the trigger's own classes
 * changed; all menu styling is inherited from `@/components/menu`.
 */

interface ContextMenuTriggerContextType {
  buttonRef: React.RefObject<HTMLButtonElement | null>
  contextMenuOffset: { offset: number; crossOffset: number } | null
  setContextMenuOffset: React.Dispatch<
    React.SetStateAction<{ offset: number; crossOffset: number } | null>
  >
}

const ContextMenuTriggerContext = createContext<ContextMenuTriggerContextType | undefined>(
  undefined,
)

const useContextMenuTrigger = () => {
  const context = use(ContextMenuTriggerContext)
  if (!context) {
    throw new Error("useContextMenuTrigger must be used within a ContextMenuTrigger")
  }
  return context
}

interface ContextMenuProps {
  children: React.ReactNode
}

const ContextMenu = ({ children }: ContextMenuProps) => {
  const [contextMenuOffset, setContextMenuOffset] = useState<{
    offset: number
    crossOffset: number
  } | null>(null)
  const buttonRef = useRef<HTMLButtonElement>(null)
  return (
    <ContextMenuTriggerContext.Provider
      value={{ buttonRef, contextMenuOffset, setContextMenuOffset }}
    >
      {children}
    </ContextMenuTriggerContext.Provider>
  )
}

type ContextMenuTriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement>

const ContextMenuTrigger = ({ className, ...props }: ContextMenuTriggerProps) => {
  const { buttonRef, setContextMenuOffset } = useContextMenuTrigger()

  const onContextMenu = (e: React.MouseEvent<HTMLButtonElement>) => {
    e.preventDefault()
    const rect = e.currentTarget.getBoundingClientRect()
    setContextMenuOffset({
      offset: e.clientY - rect.bottom,
      crossOffset: e.clientX - rect.left,
    })
  }
  return (
    <button
      className={cn(
        "cursor-default outline-hidden disabled:opacity-60 disabled:forced-colors:disabled:text-[GrayText]",
        "focus-visible:ring-2 focus-visible:ring-quebi-brand/50 focus-visible:ring-offset-2 focus-visible:ring-offset-quebi-bg",
        className,
      )}
      ref={buttonRef}
      aria-haspopup="menu"
      onContextMenu={onContextMenu}
      {...props}
    />
  )
}

type ContextMenuContentProps<T> = Omit<
  MenuContentProps<T>,
  "arrow" | "isOpen" | "onOpenChange" | "triggerRef" | "placement" | "shouldFlip"
>

const ContextMenuContent = <T extends object>(props: ContextMenuContentProps<T>) => {
  const { contextMenuOffset, setContextMenuOffset, buttonRef } = useContextMenuTrigger()
  return contextMenuOffset ? (
    <MenuContent
      popover={{
        isOpen: !!contextMenuOffset,
        shouldFlip: false,
        triggerRef: buttonRef,
        onOpenChange: () => setContextMenuOffset(null),
        placement: "bottom left",
        offset: contextMenuOffset.offset,
        crossOffset: contextMenuOffset.crossOffset,
      }}
      onClose={() => setContextMenuOffset(null)}
      {...props}
    />
  ) : null
}

const ContextMenuItem = MenuItem
const ContextMenuSeparator = MenuSeparator
const ContextMenuDescription = MenuDescription
const ContextMenuSection = MenuSection
const ContextMenuHeader = MenuHeader
const ContextMenuShortcut = MenuShortcut
const ContextMenuLabel = MenuLabel

export type { ContextMenuProps }
export {
  ContextMenu,
  ContextMenuContent,
  ContextMenuDescription,
  ContextMenuHeader,
  ContextMenuItem,
  ContextMenuLabel,
  ContextMenuSection,
  ContextMenuSeparator,
  ContextMenuShortcut,
  ContextMenuTrigger,
}