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