Glrk UI

Context Menu

A wrapper for Base UI Context Menu — same options API as Menu, triggered by right-click on any element.

Default
Groups, submenus, separators
Right-click here
Checkbox
Indicator right (default)
Right-click here
Indicator left
Right-click here
With group labels
Right-click here
Radio
Controlled
Right-click here
Indicator left
Right-click here
With group labels
Right-click here

Installation

npx shadcn@latest add @glrk-ui/context-menu

If you haven't set up the prerequisites yet, check out Prerequest section.

Copy and paste the following code into your project.

types/general.d.ts
type allowedPrimitiveT = string | number | boolean

type optionT = {
  label: React.ReactNode
  value: allowedPrimitiveT
  className?: string
  disabled?: boolean
}

type groupT = {
  group: string
  options: (allowedPrimitiveT | optionT)[]
  className?: string
}

type optionsT = (allowedPrimitiveT | optionT | groupT)[]

type indicatorAtT = 'right' | 'left'
types/menu.d.ts
type menuOptionT =
  | allowedPrimitiveT
  | optionT
  | (optionT & {
      variant?: 'default' | 'destructive'
      shortcut?: string
      disabled?: boolean
    })

type menuGroupT = {
  group: string
  options: menuOptionT[]
  className?: string
  groupLabelCls?: string
}

type subMenuT = {
  submenu: string
  options: (menuOptionT | menuGroupT)[]
  triggerCls?: string
  contentCls?: string
}

type menuOptionsT = (menuOptionT | menuGroupT | subMenuT)[]

type menuInputOptionT =
  | allowedPrimitiveT
  | optionT
  | (optionT & {
      disabled?: boolean
    })

type menuInputGroupT = {
  group: string
  options: menuInputOptionT[]
  className?: string
  groupLabelCls?: string
}

type inputSubMenuT = {
  submenu: string
  options: (menuInputOptionT | menuInputGroupT)[]
  triggerCls?: string
  contentCls?: string
}

type menuInputOptionsT = (menuInputOptionT | menuInputGroupT | inputSubMenuT)[]
lib/utils.ts
import { isValidElement, type ReactNode } from 'react'
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

export function isAllowedPrimitive(value: unknown): value is allowedPrimitiveT {
  return ['string', 'number', 'boolean'].includes(typeof value)
}

export function parseAllowedPrimitive(value: allowedPrimitiveT): allowedPrimitiveT {
  if (typeof value !== 'string') return value

  const trimmed = value.trim()

  if (trimmed === 'true') return true
  if (trimmed === 'false') return false
  if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed)

  return trimmed
}

export function optionTypeChecker<T>(key: keyof T) {
  return (option: any): option is T => !!option && typeof option === 'object' && key in option
}

export const isSeparator = (item: any) => item === '---'
export const isOption = optionTypeChecker<optionT>('value')
export const isGroup = optionTypeChecker<groupT>('group')

export const getValue = (item: allowedPrimitiveT | optionT) =>
  typeof item === 'object' ? item.value : item
export const getLabel = (item: allowedPrimitiveT | optionT) =>
  typeof item === 'object' ? item.label : `${item}`

export function getKey(item: allowedPrimitiveT | optionT, i: number): string {
  const val = getValue(item)
  if (typeof val === 'boolean') return `key-${val}`
  if (val === '---') return `---${i}`
  return `${val}`
}

export function extractText(node: ReactNode): string {
  if (node == null || typeof node === 'boolean') return ''
  if (typeof node === 'string' || typeof node === 'number') return String(node)
  if (isValidElement(node)) return extractText((node.props as { children?: ReactNode }).children)
  if (Array.isArray(node)) return node.map(extractText).join('')
  return ''
}
lib/menu.ts
import { optionTypeChecker } from './utils'

export const isSubMenu = optionTypeChecker<subMenuT>('submenu')
export const isGroupMenu = optionTypeChecker<menuGroupT>('group')
export const isInputSubMenu = optionTypeChecker<inputSubMenuT>('submenu')
export const isInputGroupMenu = optionTypeChecker<menuInputGroupT>('group')
ui/context-menu.tsx
'use client'

import * as React from 'react'
import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react/context-menu'
import { ChevronRightIcon, CheckIcon } from 'lucide-react'

import { cn } from '@/lib/utils'

function ContextMenu(props: ContextMenuPrimitive.Root.Props) {
  return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}

function ContextMenuPortal(props: ContextMenuPrimitive.Portal.Props) {
  return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
}

function ContextMenuTrigger({ className, ...props }: ContextMenuPrimitive.Trigger.Props) {
  return (
    <ContextMenuPrimitive.Trigger
      data-slot="context-menu-trigger"
      className={cn('select-none', className)}
      {...props}
    />
  )
}

function ContextMenuContent({
  className,
  align = 'start',
  alignOffset = 4,
  side = 'right',
  sideOffset = 0,
  ...props
}: ContextMenuPrimitive.Popup.Props &
  Pick<ContextMenuPrimitive.Positioner.Props, 'align' | 'alignOffset' | 'side' | 'sideOffset'>) {
  return (
    <ContextMenuPrimitive.Portal>
      <ContextMenuPrimitive.Positioner
        className="outline-none"
        align={align}
        alignOffset={alignOffset}
        side={side}
        sideOffset={sideOffset}
      >
        <ContextMenuPrimitive.Popup
          data-slot="context-menu-content"
          className={cn(
            'max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
            className,
          )}
          {...props}
        />
      </ContextMenuPrimitive.Positioner>
    </ContextMenuPrimitive.Portal>
  )
}

function ContextMenuGroup(props: ContextMenuPrimitive.Group.Props) {
  return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
}

function ContextMenuLabel({
  className,
  inset,
  ...props
}: ContextMenuPrimitive.GroupLabel.Props & {
  inset?: boolean
}) {
  return (
    <ContextMenuPrimitive.GroupLabel
      data-slot="context-menu-label"
      data-inset={inset}
      className={cn(
        'px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7',
        className,
      )}
      {...props}
    />
  )
}

function ContextMenuItem({
  className,
  inset,
  variant = 'default',
  ...props
}: ContextMenuPrimitive.Item.Props & {
  inset?: boolean
  variant?: 'default' | 'destructive'
}) {
  return (
    <ContextMenuPrimitive.Item
      data-slot="context-menu-item"
      data-inset={inset}
      data-variant={variant}
      className={cn(
        "group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
        className,
      )}
      {...props}
    />
  )
}

function ContextMenuSub(props: ContextMenuPrimitive.SubmenuRoot.Props) {
  return <ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />
}

function ContextMenuSubTrigger({
  className,
  inset,
  children,
  ...props
}: ContextMenuPrimitive.SubmenuTrigger.Props & {
  inset?: boolean
}) {
  return (
    <ContextMenuPrimitive.SubmenuTrigger
      data-slot="context-menu-sub-trigger"
      data-inset={inset}
      className={cn(
        "flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className,
      )}
      {...props}
    >
      {children}
      <ChevronRightIcon className="ml-auto" />
    </ContextMenuPrimitive.SubmenuTrigger>
  )
}

function ContextMenuSubContent(props: React.ComponentProps<typeof ContextMenuContent>) {
  return (
    <ContextMenuContent
      data-slot="context-menu-sub-content"
      className="shadow-lg"
      side="right"
      {...props}
    />
  )
}

function ContextMenuCheckboxItem({
  className,
  children,
  checked,
  inset,
  indicatorAt = 'right',
  ...props
}: ContextMenuPrimitive.CheckboxItem.Props & {
  inset?: boolean
  indicatorAt?: indicatorAtT
}) {
  return (
    <ContextMenuPrimitive.CheckboxItem
      data-slot="context-menu-checkbox-item"
      data-inset={inset}
      className={cn(
        "relative flex cursor-default items-center gap-1.5 rounded-md py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className,
        indicatorAt === 'right' ? 'pr-8 pl-2' : 'pr-2 pl-8',
      )}
      checked={checked}
      {...props}
    >
      <span
        className={cn(
          'pointer-events-none absolute',
          indicatorAt === 'right' ? 'right-2' : 'left-2',
        )}
      >
        <ContextMenuPrimitive.CheckboxItemIndicator>
          <CheckIcon />
        </ContextMenuPrimitive.CheckboxItemIndicator>
      </span>
      {children}
    </ContextMenuPrimitive.CheckboxItem>
  )
}

function ContextMenuRadioGroup(props: ContextMenuPrimitive.RadioGroup.Props) {
  return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />
}

function ContextMenuRadioItem({
  className,
  children,
  inset,
  indicatorAt = 'right',
  ...props
}: ContextMenuPrimitive.RadioItem.Props & {
  inset?: boolean
  indicatorAt?: indicatorAtT
}) {
  return (
    <ContextMenuPrimitive.RadioItem
      data-slot="context-menu-radio-item"
      data-inset={inset}
      className={cn(
        "relative flex cursor-default items-center gap-1.5 rounded-md py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className,
        indicatorAt === 'right' ? 'pr-8 pl-2' : 'pr-2 pl-8',
      )}
      {...props}
    >
      <span
        className={cn(
          'pointer-events-none absolute',
          indicatorAt === 'right' ? 'right-2' : 'left-2',
        )}
      >
        <ContextMenuPrimitive.RadioItemIndicator>
          <CheckIcon />
        </ContextMenuPrimitive.RadioItemIndicator>
      </span>
      {children}
    </ContextMenuPrimitive.RadioItem>
  )
}

function ContextMenuSeparator({ className, ...props }: ContextMenuPrimitive.Separator.Props) {
  return (
    <ContextMenuPrimitive.Separator
      data-slot="context-menu-separator"
      className={cn('-mx-1 my-1 h-px bg-border', className)}
      {...props}
    />
  )
}

function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
  return (
    <span
      data-slot="context-menu-shortcut"
      className={cn(
        'ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground',
        className,
      )}
      {...props}
    />
  )
}

export {
  ContextMenu,
  ContextMenuTrigger,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuCheckboxItem,
  ContextMenuRadioItem,
  ContextMenuLabel,
  ContextMenuSeparator,
  ContextMenuShortcut,
  ContextMenuGroup,
  ContextMenuPortal,
  ContextMenuSub,
  ContextMenuSubContent,
  ContextMenuSubTrigger,
  ContextMenuRadioGroup,
}
ui/context-menu-wrapper.tsx
'use client'

import { useState } from 'react'
import {
  cn,
  getKey,
  getLabel,
  getValue,
  isSeparator,
  parseAllowedPrimitive,
} from '@/lib/utils'
import {
  isSubMenu,
  isGroupMenu,
  isInputSubMenu,
  isInputGroupMenu
} from '@/lib/menu'

import {
  ContextMenu,
  ContextMenuTrigger,
  ContextMenuContent,
  ContextMenuGroup,
  ContextMenuLabel,
  ContextMenuItem,
  ContextMenuCheckboxItem,
  ContextMenuRadioGroup,
  ContextMenuRadioItem,
  ContextMenuSeparator,
  ContextMenuShortcut,
  ContextMenuSub,
  ContextMenuSubTrigger,
  ContextMenuSubContent,
} from '@/components/ui/context-menu'

type commonCheckboxProps = {
  checked?: allowedPrimitiveT[]
  indicatorAt?: indicatorAtT
  onCheckedChange?: (value: allowedPrimitiveT, checked: boolean) => void
}

type commonRadioProps = {
  value?: allowedPrimitiveT
  indicatorAt?: indicatorAtT
  onValueChange?: (value: allowedPrimitiveT) => void
}

type commonSubMenuT = {
  itemCls?: string
  groupCls?: string
  groupLabelCls?: string
}

type commonPropsT = {
  children: React.ReactNode
  itemCls?: string
  groupCls?: string
  groupLabelCls?: string
  contentProps?: React.ComponentProps<typeof ContextMenuContent>
  onSelect?: (value: allowedPrimitiveT) => void
} & React.ComponentProps<typeof ContextMenu>

// -------

type itemProps = {
  option: menuOptionT
  className?: string
  onSelect?: () => void
}
function Item({ option, className, onSelect }: itemProps) {
  const value = getValue(option)

  if (isSeparator(value)) return <ContextMenuSeparator className={cn(className)} />

  const label = getLabel(option)
  const opt: any = typeof option === 'object' ? option : {}
  const shortcut = opt?.shortcut

  return (
    <ContextMenuItem {...opt} onSelect={onSelect} className={cn(className, opt?.className)}>
      {label}
      {shortcut && <ContextMenuShortcut>{shortcut}</ContextMenuShortcut>}
    </ContextMenuItem>
  )
}

type checkboxItemProps = {
  option: menuInputOptionT
  className?: string
  checked?: boolean
  onCheckedChange?: (checked: boolean) => void
  indicatorAt?: indicatorAtT
}
function CheckboxItem({
  option,
  className,
  checked = false,
  indicatorAt,
  onCheckedChange = () => { },
}: checkboxItemProps) {
  const value = getValue(option)

  if (isSeparator(value)) return <ContextMenuSeparator className={cn(className)} />

  const label = getLabel(option)
  const disabled = (option as any)?.disabled

  return (
    <ContextMenuCheckboxItem
      checked={checked}
      disabled={disabled}
      className={cn(className)}
      indicatorAt={indicatorAt}
      onCheckedChange={onCheckedChange}
    >
      {label}
    </ContextMenuCheckboxItem>
  )
}

type radioItemProps = {
  option: menuInputOptionT
  className?: string
  indicatorAt?: indicatorAtT
}
function RadioItem({ option, className, indicatorAt }: radioItemProps) {
  const value = getValue(option)

  if (isSeparator(value)) return <ContextMenuSeparator className={cn(className)} />

  const label = getLabel(option)
  const disabled = (option as any)?.disabled

  return (
    <ContextMenuRadioItem
      value={`${value}`}
      disabled={disabled}
      className={cn(className)}
      indicatorAt={indicatorAt}
    >
      {label}
    </ContextMenuRadioItem>
  )
}

type SubMenuProps = commonSubMenuT & {
  submenu: subMenuT
  onSelect?: (value: allowedPrimitiveT) => void
}
function SubMenu({ submenu, itemCls, groupCls, groupLabelCls, onSelect }: SubMenuProps) {
  return (
    <ContextMenuSub>
      <ContextMenuSubTrigger className={cn(submenu.triggerCls)}>
        {submenu.submenu}
      </ContextMenuSubTrigger>

      <ContextMenuSubContent className={cn(submenu.contentCls)}>
        {submenu.options.map((option, i) => {
          if (isGroupMenu(option)) {
            return (
              <ContextMenuGroup key={option.group} className={cn(groupCls, option.className)}>
                <ContextMenuLabel
                  className={cn(
                    'pb-0.5 text-xs text-muted-foreground font-normal',
                    groupLabelCls,
                    option.groupLabelCls,
                  )}
                >
                  {option.group}
                </ContextMenuLabel>

                {option.options.map((grOpt, j) => (
                  <Item
                    key={getKey(grOpt, j)}
                    option={grOpt}
                    className={itemCls}
                    onSelect={() => onSelect?.(getValue(grOpt))}
                  />
                ))}
              </ContextMenuGroup>
            )
          }

          if (isSubMenu(option)) {
            return (
              <SubMenu
                key={getKey(option, i)}
                submenu={option}
                itemCls={itemCls}
                groupCls={groupCls}
                onSelect={onSelect}
              />
            )
          }

          return (
            <Item
              key={getKey(option, i)}
              option={option}
              className={itemCls}
              onSelect={() => onSelect?.(getValue(option))}
            />
          )
        })}
      </ContextMenuSubContent>
    </ContextMenuSub>
  )
}

type CheckboxSubMenuProps = commonSubMenuT &
  commonCheckboxProps & {
    submenu: inputSubMenuT
  }
function CheckboxSubMenu({
  submenu,
  itemCls,
  groupCls,
  groupLabelCls,
  checked = [],
  indicatorAt,
  onCheckedChange = () => { },
}: CheckboxSubMenuProps) {
  return (
    <ContextMenuSub>
      <ContextMenuSubTrigger className={cn(submenu.triggerCls)}>
        {submenu.submenu}
      </ContextMenuSubTrigger>

      <ContextMenuSubContent className={cn(submenu.contentCls)}>
        {submenu.options.map((option, i) => {
          if (isInputGroupMenu(option)) {
            return (
              <ContextMenuGroup key={option.group} className={cn(groupCls, option.className)}>
                <ContextMenuLabel
                  className={cn(
                    'pb-0.5 text-xs text-muted-foreground font-normal',
                    groupLabelCls,
                    option.groupLabelCls,
                  )}
                >
                  {option.group}
                </ContextMenuLabel>

                {option.options.map((grOpt, j) => {
                  const v = getValue(grOpt)
                  return (
                    <CheckboxItem
                      key={getKey(grOpt, j)}
                      option={grOpt}
                      checked={checked.includes(v)}
                      className={itemCls}
                      indicatorAt={indicatorAt}
                      onCheckedChange={checked => onCheckedChange?.(v, checked)}
                    />
                  )
                })}
              </ContextMenuGroup>
            )
          }

          if (isInputSubMenu(option)) {
            return (
              <CheckboxSubMenu
                key={option.submenu}
                submenu={option}
                checked={checked}
                itemCls={itemCls}
                groupCls={groupCls}
                indicatorAt={indicatorAt}
                groupLabelCls={groupLabelCls}
                onCheckedChange={onCheckedChange}
              />
            )
          }

          const v = getValue(option)
          return (
            <CheckboxItem
              key={getKey(option, i)}
              option={option}
              checked={checked.includes(v)}
              className={itemCls}
              indicatorAt={indicatorAt}
              onCheckedChange={checked => onCheckedChange?.(v, checked)}
            />
          )
        })}
      </ContextMenuSubContent>
    </ContextMenuSub>
  )
}

type RadioSubMenuProps = commonSubMenuT &
  commonRadioProps & {
    submenu: inputSubMenuT
  }
function RadioSubMenu({
  submenu,
  itemCls,
  groupCls,
  groupLabelCls,
  value = '',
  indicatorAt,
  onValueChange = () => { },
}: RadioSubMenuProps) {
  return (
    <ContextMenuSub>
      <ContextMenuSubTrigger className={cn(submenu.triggerCls)}>
        {submenu.submenu}
      </ContextMenuSubTrigger>

      <ContextMenuSubContent className={cn(submenu.contentCls)}>
        <ContextMenuRadioGroup value={`${value}`} onValueChange={onValueChange}>
          {submenu.options.map((option, i) => {
            if (isInputGroupMenu(option)) {
              return (
                <ContextMenuGroup key={option.group} className={cn(groupCls, option.className)}>
                  <ContextMenuLabel
                    className={cn(
                      'pb-0.5 text-xs text-muted-foreground font-normal',
                      groupLabelCls,
                      option.groupLabelCls,
                    )}
                  >
                    {option.group}
                  </ContextMenuLabel>

                  {option.options.map((grOpt, j) => (
                    <RadioItem
                      key={getKey(grOpt, j)}
                      option={grOpt}
                      className={itemCls}
                      indicatorAt={indicatorAt}
                    />
                  ))}
                </ContextMenuGroup>
              )
            }

            if (isInputSubMenu(option)) {
              return (
                <RadioSubMenu
                  key={option.submenu}
                  value={value}
                  submenu={option}
                  itemCls={itemCls}
                  groupCls={groupCls}
                  indicatorAt={indicatorAt}
                  groupLabelCls={groupLabelCls}
                  onValueChange={onValueChange}
                />
              )
            }

            return (
              <RadioItem
                key={getKey(option, i)}
                option={option}
                className={itemCls}
                indicatorAt={indicatorAt}
              />
            )
          })}
        </ContextMenuRadioGroup>
      </ContextMenuSubContent>
    </ContextMenuSub>
  )
}

type ContextWrapperProps = commonPropsT & {
  options: menuOptionsT
}
function ContextWrapper({
  children,
  options,
  itemCls,
  groupCls,
  groupLabelCls,
  contentProps,
  onSelect,
  ...props
}: ContextWrapperProps) {
  return (
    <ContextMenu {...props}>
      <ContextMenuTrigger>{children}</ContextMenuTrigger>

      <ContextMenuContent {...contentProps}>
        {options.map((option, i) => {
          if (isGroupMenu(option)) {
            return (
              <ContextMenuGroup key={option.group} className={cn(groupCls, option.className)}>
                <ContextMenuLabel
                  className={cn(
                    'pb-0.5 text-xs text-muted-foreground font-normal',
                    groupLabelCls,
                    option.groupLabelCls,
                  )}
                >
                  {option.group}
                </ContextMenuLabel>

                {option.options.map((grOpt, j) => (
                  <Item
                    key={getKey(grOpt, j)}
                    option={grOpt}
                    className={itemCls}
                    onSelect={() => onSelect?.(getValue(grOpt))}
                  />
                ))}
              </ContextMenuGroup>
            )
          }

          if (isSubMenu(option)) {
            return (
              <SubMenu
                key={option.submenu}
                submenu={option}
                itemCls={itemCls}
                groupCls={groupCls}
                groupLabelCls={groupLabelCls}
                onSelect={onSelect}
              />
            )
          }

          return (
            <Item
              key={getKey(option, i)}
              option={option}
              className={itemCls}
              onSelect={() => onSelect?.(getValue(option))}
            />
          )
        })}
      </ContextMenuContent>
    </ContextMenu>
  )
}

type ContextCheckboxWrapperProps = commonPropsT &
  commonCheckboxProps & {
    options: menuInputOptionsT
  }
function ContextCheckboxWrapper({
  children,
  options,

  contentProps,
  itemCls,
  groupCls,
  groupLabelCls,

  checked: o_checked,
  onCheckedChange: o_onCheckedChange,

  indicatorAt,
  ...props
}: ContextCheckboxWrapperProps) {
  const [i_checked, setIChecked] = useState<allowedPrimitiveT[]>([])

  function i_Checked(v: allowedPrimitiveT, c: boolean) {
    setIChecked(prev => (!c ? prev.filter(p => p !== v) : [...prev, v]))
  }

  const checked = o_checked ?? i_checked
  const onCheckedChange = o_onCheckedChange ?? i_Checked

  return (
    <ContextMenu {...props}>
      <ContextMenuTrigger>{children}</ContextMenuTrigger>

      <ContextMenuContent {...contentProps}>
        {options.map((option, i) => {
          if (isInputGroupMenu(option)) {
            return (
              <ContextMenuGroup key={option.group} className={cn(groupCls, option.className)}>
                <ContextMenuLabel
                  className={cn(
                    'pb-0.5 text-xs text-muted-foreground font-normal',
                    groupLabelCls,
                    option.groupLabelCls,
                  )}
                >
                  {option.group}
                </ContextMenuLabel>

                {option.options.map((grOpt, j) => {
                  const v = getValue(grOpt)
                  return (
                    <CheckboxItem
                      key={getKey(grOpt, j)}
                      option={grOpt}
                      checked={checked.includes(v)}
                      className={itemCls}
                      indicatorAt={indicatorAt}
                      onCheckedChange={checked => onCheckedChange?.(v, checked)}
                    />
                  )
                })}
              </ContextMenuGroup>
            )
          }

          if (isInputSubMenu(option)) {
            return (
              <CheckboxSubMenu
                key={option.submenu}
                submenu={option}
                checked={checked}
                itemCls={itemCls}
                groupCls={groupCls}
                indicatorAt={indicatorAt}
                groupLabelCls={groupLabelCls}
                onCheckedChange={onCheckedChange}
              />
            )
          }

          const v = getValue(option)
          return (
            <CheckboxItem
              key={getKey(option, i)}
              option={option}
              checked={checked.includes(v)}
              className={itemCls}
              indicatorAt={indicatorAt}
              onCheckedChange={checked => onCheckedChange?.(v, checked)}
            />
          )
        })}
      </ContextMenuContent>
    </ContextMenu>
  )
}

type ContextRadioWrapperProps = commonPropsT &
  commonRadioProps & {
    options: menuInputOptionsT
  }
function ContextRadioWrapper({
  children,
  options,

  itemCls,
  groupCls,
  groupLabelCls,
  contentProps,

  value: o_value,
  onValueChange: o_onValueChange,

  indicatorAt,
  ...props
}: ContextRadioWrapperProps) {
  const [i_value, setIValue] = useState<allowedPrimitiveT>('')

  const value = o_value ?? i_value
  const onValueChange = o_onValueChange ?? setIValue

  return (
    <ContextMenu {...props}>
      <ContextMenuTrigger>{children}</ContextMenuTrigger>

      <ContextMenuContent {...contentProps}>
        <ContextMenuRadioGroup
          value={`${value}`}
          onValueChange={v => onValueChange(parseAllowedPrimitive(v))}
        >
          {options.map((option, i) => {
            if (isInputGroupMenu(option)) {
              return (
                <ContextMenuGroup key={option.group} className={cn(groupCls, option.className)}>
                  <ContextMenuLabel
                    className={cn(
                      'pb-0.5 text-xs text-muted-foreground font-normal',
                      groupLabelCls,
                      option.groupLabelCls,
                    )}
                  >
                    {option.group}
                  </ContextMenuLabel>

                  {option.options.map((grOpt, j) => (
                    <RadioItem
                      key={getKey(grOpt, j)}
                      option={grOpt}
                      className={itemCls}
                      indicatorAt={indicatorAt}
                    />
                  ))}
                </ContextMenuGroup>
              )
            }

            if (isInputSubMenu(option)) {
              return (
                <RadioSubMenu
                  key={option.submenu}
                  value={value}
                  submenu={option}
                  itemCls={itemCls}
                  groupCls={groupCls}
                  indicatorAt={indicatorAt}
                  groupLabelCls={groupLabelCls}
                  onValueChange={v => onValueChange(parseAllowedPrimitive(v))}
                />
              )
            }

            return (
              <RadioItem
                key={getKey(option, i)}
                option={option}
                className={itemCls}
                indicatorAt={indicatorAt}
              />
            )
          })}
        </ContextMenuRadioGroup>
      </ContextMenuContent>
    </ContextMenu>
  )
}

export { ContextWrapper, ContextCheckboxWrapper, ContextRadioWrapper }

Usage

Basic

import { ContextWrapper } from "@/components/ui/context-menu-wrapper"

<ContextWrapper options={["Cut", "Copy", "Paste"]}>
  <div className="border border-dashed rounded-lg p-8">
    Right-click here
  </div>
</ContextWrapper>

With shortcuts and destructive item

<ContextWrapper
  options={[
    { label: "Edit", value: "edit", shortcut: "⌘E" },
    { label: "Delete", value: "delete", variant: "destructive", shortcut: "⌘⌫" },
  ]}
  onSelect={v => console.log(v)}
>
  <div>Right-click here</div>
</ContextWrapper>

Checkbox

const [checked, setChecked] = useState<allowedPrimitiveT[]>([])

<ContextCheckboxWrapper
  options={["Auto-save", "Spellcheck", "Dark mode"]}
  checked={checked}
  onCheckedChange={(v, c) =>
    setChecked(prev => (c ? [...prev, v] : prev.filter(x => x !== v)))
  }
>
  <div>Right-click here</div>
</ContextCheckboxWrapper>

Radio

const [val, setVal] = useState<allowedPrimitiveT>("Light")

<ContextRadioWrapper
  options={["Light", "Dark", "System"]}
  value={val}
  onValueChange={setVal}
>
  <div>Right-click here</div>
</ContextRadioWrapper>

Indicator position

<ContextCheckboxWrapper options={options} indicatorAt="right">
  <div>Right-click here</div>
</ContextCheckboxWrapper>

Groups, submenus, separators

const options: menuOptionsT = [
  { label: "Cut", value: "cut", shortcut: "⌘X" },
  { label: "Copy", value: "copy", shortcut: "⌘C" },
  "---",
  {
    group: "Actions",
    options: ["Save", "Export"],
  },
  {
    submenu: "Share",
    options: ["Email", "Link"],
  },
]

Reference

ContextWrapper

Prop

Type

ContextCheckboxWrapper

Prop

Type

ContextRadioWrapper

Prop

Type