Glrk UI

Menu

A wrapper for Base UI Menu — supports plain items, checkbox, radio, groups, submenus, and separators with a unified options API.

Default
Basic
Groups, submenus, separators
Controlled open
Checkbox
Indicator right (default)
Indicator left
With group labels
Radio
Controlled value
Indicator left
With group labels

Installation

npx shadcn@latest add @glrk-ui/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/menu.tsx
'use client'

import * as React from 'react'
import { ChevronRightIcon, CheckIcon } from 'lucide-react'
import { Menu as MenuPrimitive } from '@base-ui/react/menu'

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

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

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

function MenuTrigger(props: MenuPrimitive.Trigger.Props) {
  return <MenuPrimitive.Trigger data-slot="menu-trigger" {...props} />
}

function MenuContent({
  align = 'start',
  alignOffset = 0,
  side = 'bottom',
  sideOffset = 4,
  className,
  ...props
}: MenuPrimitive.Popup.Props &
  Pick<MenuPrimitive.Positioner.Props, 'align' | 'alignOffset' | 'side' | 'sideOffset'>) {
  return (
    <MenuPrimitive.Portal>
      <MenuPrimitive.Positioner
        className="outline-none"
        align={align}
        alignOffset={alignOffset}
        side={side}
        sideOffset={sideOffset}
      >
        <MenuPrimitive.Popup
          data-slot="menu-content"
          className={cn(
            'max-h-(--available-height) w-(--anchor-width) min-w-32 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:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95',
            className,
          )}
          {...props}
        />
      </MenuPrimitive.Positioner>
    </MenuPrimitive.Portal>
  )
}

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

function MenuLabel({
  className,
  inset,
  ...props
}: MenuPrimitive.GroupLabel.Props & {
  inset?: boolean
}) {
  return (
    <MenuPrimitive.GroupLabel
      data-slot="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 MenuItem({
  className,
  inset,
  variant = 'default',
  ...props
}: MenuPrimitive.Item.Props & {
  inset?: boolean
  variant?: 'default' | 'destructive'
}) {
  return (
    <MenuPrimitive.Item
      data-slot="menu-item"
      data-inset={inset}
      data-variant={variant}
      className={cn(
        "group/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 not-data-[variant=destructive]: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 data-[variant=destructive]:*:[svg]:text-destructive",
        className,
      )}
      {...props}
    />
  )
}

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

function MenuSubTrigger({
  className,
  inset,
  children,
  ...props
}: MenuPrimitive.SubmenuTrigger.Props & {
  inset?: boolean
}) {
  return (
    <MenuPrimitive.SubmenuTrigger
      data-slot="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 not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground 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" />
    </MenuPrimitive.SubmenuTrigger>
  )
}

function MenuSubContent({
  align = 'start',
  alignOffset = -3,
  side = 'right',
  sideOffset = 0,
  className,
  ...props
}: React.ComponentProps<typeof MenuContent>) {
  return (
    <MenuContent
      data-slot="menu-sub-content"
      className={cn(
        'w-auto min-w-24 rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-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,
      )}
      align={align}
      alignOffset={alignOffset}
      side={side}
      sideOffset={sideOffset}
      {...props}
    />
  )
}

function MenuCheckboxItem({
  className,
  children,
  checked,
  inset,
  indicatorAt = 'right',
  ...props
}: MenuPrimitive.CheckboxItem.Props & {
  inset?: boolean
  indicatorAt?: indicatorAtT
}) {
  return (
    <MenuPrimitive.CheckboxItem
      data-slot="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 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 flex items-center justify-center',
          indicatorAt === 'right' ? 'right-2' : 'left-2',
        )}
        data-slot="menu-checkbox-item-indicator"
      >
        <MenuPrimitive.CheckboxItemIndicator>
          <CheckIcon />
        </MenuPrimitive.CheckboxItemIndicator>
      </span>
      {children}
    </MenuPrimitive.CheckboxItem>
  )
}

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

function MenuRadioItem({
  className,
  children,
  inset,
  indicatorAt = 'right',
  ...props
}: MenuPrimitive.RadioItem.Props & {
  inset?: boolean
  indicatorAt?: indicatorAtT
}) {
  return (
    <MenuPrimitive.RadioItem
      data-slot="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 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 flex items-center justify-center',
          indicatorAt === 'right' ? 'right-2' : 'left-2',
        )}
        data-slot="menu-radio-item-indicator"
      >
        <MenuPrimitive.RadioItemIndicator>
          <CheckIcon />
        </MenuPrimitive.RadioItemIndicator>
      </span>
      {children}
    </MenuPrimitive.RadioItem>
  )
}

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

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

export {
  Menu,
  MenuPortal,
  MenuTrigger,
  MenuContent,
  MenuGroup,
  MenuLabel,
  MenuItem,
  MenuCheckboxItem,
  MenuRadioGroup,
  MenuRadioItem,
  MenuSeparator,
  MenuShortcut,
  MenuSub,
  MenuSubTrigger,
  MenuSubContent,
}
ui/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 {
  Menu,
  MenuTrigger,
  MenuContent,
  MenuGroup,
  MenuLabel,
  MenuItem,
  MenuCheckboxItem,
  MenuRadioGroup,
  MenuRadioItem,
  MenuSeparator,
  MenuShortcut,
  MenuSub,
  MenuSubTrigger,
  MenuSubContent,
} from '@/components/ui/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 = {
  trigger: React.ReactNode
  triggerCls?: string
  triggerProps?: Omit<React.ComponentProps<typeof MenuTrigger>, 'children' | 'className'>
  itemCls?: string
  groupCls?: string
  groupLabelCls?: string
  contentProps?: React.ComponentProps<typeof MenuContent>
  onSelect?: (value: allowedPrimitiveT) => void
} & React.ComponentProps<typeof Menu>

// -------

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

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

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

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

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 <MenuSeparator className={cn(className)} />

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

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

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

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

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

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

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

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

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

          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))}
            />
          )
        })}
      </MenuSubContent>
    </MenuSub>
  )
}

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

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

                {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)}
                    />
                  )
                })}
              </MenuGroup>
            )
          }

          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)}
            />
          )
        })}
      </MenuSubContent>
    </MenuSub>
  )
}

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

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

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

            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}
              />
            )
          })}
        </MenuRadioGroup>
      </MenuSubContent>
    </MenuSub>
  )
}

type MenuWrapperProps = commonPropsT & {
  options: menuOptionsT
}
function MenuWrapper({
  trigger,
  options,
  triggerCls,
  triggerProps,
  itemCls,
  groupCls,
  groupLabelCls,
  contentProps,
  onSelect,
  ...props
}: MenuWrapperProps) {
  return (
    <Menu {...props}>
      <MenuTrigger className={cn(triggerCls)} {...triggerProps}>{trigger}</MenuTrigger>

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

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

          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))}
            />
          )
        })}
      </MenuContent>
    </Menu>
  )
}

type MenuCheckboxWrapperProps = commonPropsT &
  commonCheckboxProps & {
    options: menuInputOptionsT
  }
function MenuCheckboxWrapper({
  trigger,
  options,
  triggerCls,
  triggerProps,
  contentProps,
  itemCls,
  groupCls,
  groupLabelCls,
  checked: o_checked,
  onCheckedChange: o_onCheckedChange,
  indicatorAt,
  ...props
}: MenuCheckboxWrapperProps) {
  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 (
    <Menu {...props}>
      <MenuTrigger className={cn(triggerCls)} {...triggerProps}>{trigger}</MenuTrigger>

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

                {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)}
                    />
                  )
                })}
              </MenuGroup>
            )
          }

          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)}
            />
          )
        })}
      </MenuContent>
    </Menu>
  )
}

type MenuRadioWrapperProps = commonPropsT &
  commonRadioProps & {
    options: menuInputOptionsT
  }
function MenuRadioWrapper({
  trigger,
  options,
  triggerCls,
  triggerProps,
  itemCls,
  groupCls,
  groupLabelCls,
  contentProps,
  value: o_value,
  onValueChange: o_onValueChange,
  indicatorAt,
  ...props
}: MenuRadioWrapperProps) {
  const [i_value, setIValue] = useState<allowedPrimitiveT>('')

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

  return (
    <Menu {...props}>
      <MenuTrigger className={cn(triggerCls)} {...triggerProps}>{trigger}</MenuTrigger>

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

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

            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}
              />
            )
          })}
        </MenuRadioGroup>
      </MenuContent>
    </Menu>
  )
}

export { MenuWrapper, MenuCheckboxWrapper, MenuRadioWrapper }

Usage

Basic

import { MenuWrapper } from "@/components/ui/menu-wrapper"
import { buttonVariants } from "@/components/ui/button"

<MenuWrapper
  options={["Edit", "Duplicate", "---", "Delete"]}
  trigger="Actions"
  triggerCls={buttonVariants({ variant: "outline" })}
/>

Shortcuts and destructive variant

<MenuWrapper
  options={[
    { label: "Edit", value: "edit", shortcut: "⌘E" },
    { label: "Delete", value: "delete", variant: "destructive", shortcut: "⌘⌫" },
  ]}
  trigger="Actions"
  triggerCls={buttonVariants({ variant: "outline" })}
  onSelect={v => console.log(v)}
/>

Checkbox

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

<MenuCheckboxWrapper
  options={["Auto-save", "Spellcheck", "Dark mode"]}
  checked={checked}
  onCheckedChange={(v, c) =>
    setChecked(prev => (c ? [...prev, v] : prev.filter(x => x !== v)))
  }
  trigger="Features"
  triggerCls={buttonVariants({ variant: "outline" })}
/>

Radio

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

<MenuRadioWrapper
  options={["Light", "Dark", "System"]}
  value={val}
  onValueChange={setVal}
  trigger={`Theme: ${val}`}
  triggerCls={buttonVariants({ variant: "outline" })}
/>

Indicator position

<MenuCheckboxWrapper options={options} indicatorAt="right" trigger="..." />
<MenuRadioWrapper options={options} indicatorAt="right" trigger="..." />

Groups, submenus, separators

const options: menuOptionsT = [
  { label: "New File", value: "new", shortcut: "⌘N" },
  "Save",
  "---",
  {
    group: "Settings",
    options: [{ label: "Appearance", value: "appearance" }, "Shortcuts"],
  },
  {
    submenu: "Export",
    options: ["PDF", "HTML", { group: "Advanced", options: ["LaTeX"] }],
  },
]

Reference

Prop

Type

Prop

Type

Prop

Type