shadimage

DropdownMenu + ContextMenu

registry/pouf/menu.tsx

import * as RMenu from '@radix-ui/react-dropdown-menu'
import * as RContext from '@radix-ui/react-context-menu'
import clsx from 'clsx'
import type { ReactElement, ReactNode } from 'react'
import { motion, useReducedMotion } from 'framer-motion'
import { renderIcon, type IconLike } from './Icon'

export interface MenuEntry {
  label: string
  icon?: IconLike
  onClick?: () => void
  disabled?: boolean
  tone?: 'down'
}

/* ------------------------------------------------------------------ */
/* DropdownMenu                                                        */
/* ------------------------------------------------------------------ */

interface MenuItem {
  label: string
  icon?: IconLike
  onClick?: () => void
  disabled?: boolean
  tone?: 'down'
}

interface DropdownMenuProps {
  /** A single element that can receive a ref and native button attributes. */
  children: ReactElement
  items: (MenuItem | 'separator')[]
  label?: string
}

export function DropdownMenu({ children, items, label }: DropdownMenuProps) {
  const reduceMotion = useReducedMotion()
  return (
    <RMenu.Root>
      {/* asChild places Radix's ARIA state, handlers, and ref on the actual
          focusable trigger. 1st-Pouf Button forwards those native attributes. */}
      <RMenu.Trigger asChild aria-label={label}>
        {children}
      </RMenu.Trigger>
      <RMenu.Portal>
        <RMenu.Content className="pouf-menu" sideOffset={8} align="start" asChild>
          <motion.div
            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: -4 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            transition={{ duration: reduceMotion ? 0.01 : 0.14, ease: 'easeOut' }}
          >
            {items.map((item, i) => {
              if (item === 'separator') return <RMenu.Separator key={i} className="pouf-menu__sep" />
              return (
                <RMenu.Item
                  key={i}
                  className={clsx('pouf-menu__item', item.tone === 'down' && 'pouf-menu__item--down')}
                  onClick={item.onClick}
                  disabled={item.disabled}
                >
                  {item.icon && renderIcon(item.icon, 'sm')}
                  {item.label}
                </RMenu.Item>
              )
            })}
          </motion.div>
        </RMenu.Content>
      </RMenu.Portal>
    </RMenu.Root>
  )
}

/* ------------------------------------------------------------------ */
/* ContextMenu — the right-click menu                                 */
/* ------------------------------------------------------------------ */

interface ContextMenuProps {
  /** The surface you right-click. */
  children: ReactNode
  items: (MenuEntry | 'separator')[]
}

/** A right-click menu on the same clay skin as DropdownMenu. Wrap any region;
 * a secondary click (or long-press on touch) opens it at the pointer. */
export function ContextMenu({ children, items }: ContextMenuProps) {
  const reduceMotion = useReducedMotion()
  return (
    <RContext.Root>
      <RContext.Trigger className="pouf-context-anchor">{children}</RContext.Trigger>
      <RContext.Portal>
        <RContext.Content className="pouf-menu" asChild>
          <motion.div
            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.95 }}
            animate={{ opacity: 1, scale: 1 }}
            transition={{ duration: reduceMotion ? 0.01 : 0.14, ease: 'easeOut' }}
          >
            {items.map((item, i) => {
              if (item === 'separator') return <RContext.Separator key={i} className="pouf-menu__sep" />
              return (
                <RContext.Item
                  key={i}
                  className={clsx('pouf-menu__item', item.tone === 'down' && 'pouf-menu__item--down')}
                  onClick={item.onClick}
                  disabled={item.disabled}
                >
                  {item.icon && renderIcon(item.icon, 'sm')}
                  {item.label}
                </RContext.Item>
              )
            })}
          </motion.div>
        </RContext.Content>
      </RContext.Portal>
    </RContext.Root>
  )
}
Interaction
dropdown

DependenciesBundle weight · medium

ui-primitives
@radix-ui/react-context-menu@radix-ui/react-dropdown-menu
animation
framer-motion