shadimage

Menu

A comprehensive menu component with submenus and sections

registry/preskok/ui/preskok-ui/menu.tsx

"use client"

import { twJoin, twMerge } from "cn"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
import type { ButtonProps } from "react-aria-components/Button"
import { Button } from "react-aria-components/Button"
import { composeRenderProps } from "react-aria-components/composeRenderProps"
import type {
  MenuItemProps as MenuItemPrimitiveProps,
  MenuProps as MenuPrimitiveProps,
  MenuSectionProps as MenuSectionPrimitiveProps,
  MenuTriggerProps as MenuTriggerPrimitiveProps,
} from "react-aria-components/Menu"
import {
  Collection,
  Header,
  MenuItem as MenuItemPrimitive,
  Menu as MenuPrimitive,
  MenuSection as MenuSectionPrimitive,
  MenuTrigger as MenuTriggerPrimitive,
  SubmenuTrigger as SubmenuTriggerPrimitive,
} from "react-aria-components/Menu"
import { tv, type VariantProps } from "tailwind-variants"

import { cx } from "@/lib/primitive"

import {
  DropdownDescription,
  dropdownItemStyles,
  DropdownKeyboard,
  DropdownLabel,
  dropdownSectionStyles,
  DropdownSeparator,
} from "./dropdown"
import { PopoverContent, type PopoverContentProps } from "./popover"

const Menu = (props: MenuTriggerPrimitiveProps) => (
  <MenuTriggerPrimitive {...props} />
)

const MenuSubMenu = ({ delay = 0, ...props }) => (
  <SubmenuTriggerPrimitive {...props} delay={delay}>
    {props.children}
  </SubmenuTriggerPrimitive>
)

interface MenuTriggerProps extends ButtonProps {
  ref?: React.Ref<HTMLButtonElement>
}

const MenuTrigger = ({ className, ref, ...props }: MenuTriggerProps) => (
  <Button
    ref={ref}
    data-slot="menu-trigger"
    className={cx(
      "focus-visible:ring-primary relative inline text-left outline-hidden focus-visible:ring-1",
      "*:data-[slot=chevron]:size-5 sm:*:data-[slot=chevron]:size-4",
      className
    )}
    {...props}
  />
)

interface MenuContentProps<T>
  extends MenuPrimitiveProps<T>, Pick<PopoverContentProps, "placement"> {
  className?: string
  popover?: Pick<
    PopoverContentProps,
    | "arrow"
    | "className"
    | "placement"
    | "offset"
    | "crossOffset"
    | "arrowBoundaryOffset"
    | "triggerRef"
    | "isOpen"
    | "onOpenChange"
    | "shouldFlip"
  >
}

const menuContentStyles = tv({
  base: "grid max-h-[inherit] grid-cols-[auto_1fr] gap-y-1 overflow-x-hidden overflow-y-auto overscroll-contain p-1 outline-hidden [clip-path:inset(0_0_0_0_round_calc(var(--radius-xl)-(--spacing(1))))] *:[[role='group']+[role=group]]:mt-3",
})

const MenuContent = <T extends object>({
  className,
  placement,
  popover,
  ...props
}: MenuContentProps<T>) => {
  return (
    <PopoverContent
      className={cx("min-w-32", popover?.className)}
      placement={placement}
      {...popover}
    >
      <MenuPrimitive
        data-slot="menu-content"
        className={menuContentStyles({ className })}
        {...props}
      />
    </PopoverContent>
  )
}

interface MenuItemProps
  extends MenuItemPrimitiveProps, VariantProps<typeof dropdownItemStyles> {}

const MenuItem = ({ className, intent, children, ...props }: MenuItemProps) => {
  const textValue =
    props.textValue || (typeof children === "string" ? children : undefined)
  return (
    <MenuItemPrimitive
      data-slot="menu-item"
      className={composeRenderProps(
        className,
        (className, { hasSubmenu, ...renderProps }) =>
          dropdownItemStyles({
            ...renderProps,
            intent,
            className: hasSubmenu
              ? twMerge(
                  intent === "danger" &&
                    "open:bg-destructive/10 open:text-destructive",
                  intent === "warning" &&
                    "open:bg-warning/10 open:text-warning",
                  intent === undefined && "open:bg-accent",
                  className
                )
              : className,
          })
      )}
      textValue={textValue}
      {...props}
    >
      {(values) => (
        <>
          {values.isSelected && (
            <span
              className={twJoin(
                "group-has-data-[slot=avatar]:absolute group-has-data-[slot=avatar]:right-0",
                "group-has-data-[slot=icon]:absolute group-has-data-[slot=icon]:right-0"
              )}
            >
              {values.selectionMode === "single" && (
                <CheckIcon
                  className="-mx-0.5 mr-2 size-4"
                  data-slot="check-indicator"
                />
              )}
              {values.selectionMode === "multiple" && (
                <CheckIcon
                  className="-mx-0.5 mr-2 size-4"
                  data-slot="check-indicator"
                />
              )}
            </span>
          )}

          {typeof children === "function" ? children(values) : children}

          {values.hasSubmenu && (
            <ChevronRightIcon
              data-slot="chevron"
              className="absolute right-2 size-3.5"
            />
          )}
        </>
      )}
    </MenuItemPrimitive>
  )
}

export interface MenuHeaderProps extends React.ComponentProps<typeof Header> {
  separator?: boolean
}

const MenuHeader = ({
  className,
  separator = false,
  ...props
}: MenuHeaderProps) => (
  <Header
    className={twMerge(
      "col-span-full px-2.5 py-2 text-base font-medium sm:text-sm",
      separator && "-mx-1 border-b sm:px-3 sm:pb-[0.625rem]",
      className
    )}
    {...props}
  />
)

const { section, header } = dropdownSectionStyles()

interface MenuSectionProps<T> extends MenuSectionPrimitiveProps<T> {
  ref?: React.Ref<HTMLDivElement>
  label?: string
}

const MenuSection = <T extends object>({
  className,
  ref,
  ...props
}: MenuSectionProps<T>) => {
  return (
    <MenuSectionPrimitive
      ref={ref}
      className={section({ className })}
      {...props}
    >
      {"label" in props && <Header className={header()}>{props.label}</Header>}
      <Collection items={props.items}>{props.children}</Collection>
    </MenuSectionPrimitive>
  )
}

const MenuSeparator = DropdownSeparator
const MenuShortcut: typeof DropdownKeyboard = DropdownKeyboard
const MenuLabel = DropdownLabel
const MenuDescription = DropdownDescription

export {
  Menu,
  MenuContent,
  menuContentStyles,
  MenuDescription,
  MenuHeader,
  MenuItem,
  MenuLabel,
  MenuSection,
  MenuSeparator,
  MenuShortcut,
  MenuSubMenu,
  MenuTrigger,
}
export type {
  MenuContentProps,
  MenuItemProps,
  MenuSectionProps,
  MenuTriggerProps,
}
Interaction
dropdown

DependenciesBundle weight · light

icons
lucide-react