// ~/src/siteplane/components/menu/menu.tsx "use client"; import { Menu as MenuPrimitive } from "@base-ui/react/menu"; import { ChevronRightIcon } from "lucide-react"; import type * as React from "react"; import { type SiteplaneControlSize, SiteplaneUIPortalScope, } from "../../system/provider/siteplane-ui-provider"; import { ChoiceMenuItemCheck, choiceMenuItemCheckGridClassName, choiceMenuItemClassName, choiceMenuItemEndGridClassName, choiceMenuItemEndLabelClassName, choiceMenuItemLabelClassName, choiceMenuItemSwitchGridClassName, choiceMenuItemSwitchLabelClassName, choiceMenuSwitchSize, menuRowLabel, } from "../../system/styles/choice-menu-item"; import { cn, type WithStringClassName } from "../../system/utils/cn"; import { slotClass } from "../../system/utils/slot-class"; import { menuGroupLabelClassName, menuItemClassName, menuPopupVariantClassNames, menuPopupWidthClassNames, menuSeparatorClassName, menuShortcutClassName, menuSubTriggerClassName, type MenuPopupVariant, type MenuPopupWidth, } from "./menu-shared"; import { switchMenuTrackClassName, switchThumbClassName, switchVariants, } from "../switch/switch"; export const MenuCreateHandle: typeof MenuPrimitive.createHandle = MenuPrimitive.createHandle; export const Menu: typeof MenuPrimitive.Root = MenuPrimitive.Root; // Base UI's own Portal type, with className narrowed to a string. export const MenuPortal: React.ForwardRefExoticComponent< WithStringClassName> & React.RefAttributes > = MenuPrimitive.Portal; export function MenuTrigger({ className, children, ...props }: WithStringClassName) { return ( {children} ); } export function MenuPopup({ children, className, sideOffset = 4, align = "center", alignOffset, side = "bottom", anchor, portalProps, width = "auto", variant = "default", ...props }: WithStringClassName & { align?: MenuPrimitive.Positioner.Props["align"]; sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"]; alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"]; side?: MenuPrimitive.Positioner.Props["side"]; anchor?: MenuPrimitive.Positioner.Props["anchor"]; portalProps?: WithStringClassName; width?: MenuPopupWidth; variant?: MenuPopupVariant; }) { return (
{children}
); } export function MenuGroup( props: WithStringClassName, ) { return ( ); } export function MenuItem({ className, children, inset, variant = "default", ...props }: WithStringClassName & { inset?: boolean; variant?: "default" | "destructive"; }) { return ( {menuRowLabel(children)} ); } export function MenuLinkItem({ className, children, inset, variant = "default", closeOnClick = true, ...props }: WithStringClassName & { inset?: boolean; variant?: "default" | "destructive"; }) { return ( {menuRowLabel(children)} ); } export function MenuCheckboxItem({ className, children, checked, size, variant = "default", ...props }: WithStringClassName & { size?: SiteplaneControlSize; variant?: "default" | "switch"; }) { // The switch keeps the size that fits the compact row. const resolvedSize = size ?? choiceMenuSwitchSize; return ( {variant === "switch" ? ( <> {menuRowLabel(children)} ) : ( <> {children} )} ); } export function MenuRadioGroup( props: WithStringClassName, ) { return ( ); } export function MenuRadioItem({ className, children, indicatorPosition = "start", ...props }: WithStringClassName & { /** * Where the row shows its check: in the leading slot (`start`), or at the * end of the row (`end`) for identity rows that lead with a logo or an * avatar, such as the workspaces of a switcher. */ indicatorPosition?: "start" | "end" | undefined; }) { const end = indicatorPosition === "end"; return ( {end ? ( <> {menuRowLabel(children)} ) : ( <> {children} )} ); } export function MenuGroupLabel({ className, inset, ...props }: WithStringClassName & { inset?: boolean }) { return ( ); } export function MenuSeparator({ className, ...props }: WithStringClassName) { return ( ); } export function MenuShortcut({ className, ...props }: React.ComponentProps<"kbd">) { return ( ); } export function MenuSub(props: MenuPrimitive.SubmenuRoot.Props) { return ; } export function MenuSubTrigger({ className, inset, children, ...props }: WithStringClassName & { inset?: boolean; }) { return ( {menuRowLabel(children)} ); } export function MenuSubPopup({ className, sideOffset = 0, alignOffset, align = "start", ...props }: WithStringClassName & { align?: MenuPrimitive.Positioner.Props["align"]; sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"]; alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"]; width?: MenuPopupWidth; variant?: MenuPopupVariant; }) { const defaultAlignOffset = align !== "center" ? -5 : undefined; return ( ); } // The DropdownMenu names are the shadcn names of the Menu parts. They differ // where shadcn names a part differently: DropdownMenuContent is MenuPopup, // DropdownMenuLabel is MenuGroupLabel and DropdownMenuSubContent is // MenuSubPopup. export { MenuPrimitive, MenuCreateHandle as DropdownMenuCreateHandle, Menu as DropdownMenu, MenuPortal as DropdownMenuPortal, MenuTrigger as DropdownMenuTrigger, MenuPopup as DropdownMenuContent, MenuGroup as DropdownMenuGroup, MenuItem as DropdownMenuItem, MenuCheckboxItem as DropdownMenuCheckboxItem, MenuRadioGroup as DropdownMenuRadioGroup, MenuRadioItem as DropdownMenuRadioItem, MenuGroupLabel as DropdownMenuLabel, MenuSeparator as DropdownMenuSeparator, MenuShortcut as DropdownMenuShortcut, MenuSub as DropdownMenuSub, MenuSubTrigger as DropdownMenuSubTrigger, MenuSubPopup as DropdownMenuSubContent, }; // ~/src/siteplane/components/menu/menu-shared.tsx import { surfaceClassNames } from "../../system/styles/surface"; import { cn } from "../../system/utils/cn"; const shortcutClassName = "ms-auto font-medium font-sans text-muted-foreground/72 text-xs"; // cn() drops the default min-w-32 when a width mode or className sets a min width. const menuPopupLayoutClassName = "flex min-w-32 origin-(--transform-origin) outline-none before:rounded-[inherit] focus:outline-none"; export const menuPopupClassName = cn( surfaceClassNames.floatingOverlay, menuPopupLayoutClassName, ); export type MenuPopupWidth = "auto" | "trigger" | "content" | "at-least-trigger"; // `glass` lays the shared glass surface over whatever sits behind the menu. export type MenuPopupVariant = "default" | "glass"; // Glass keeps the popup's frame and layout and takes the glass surface in // place of the opaque one, whose fill, edge and shadow the glass rule draws. export const menuPopupVariantClassNames: Record = { default: menuPopupClassName, glass: cn( surfaceClassNames.floatingOverlayFrame, surfaceClassNames.glass, menuPopupLayoutClassName, ), }; // Every popup stays within the space beside its anchor and is at most 20rem // wide, a step of the spacing scale like its minimum (min-w-32); a longer row // label ends in an ellipsis instead (rulebook K5). A trigger-wide popup keeps // its trigger's width, and one at least as wide as its trigger grows to it. // ContextMenu repeats these classes for its trigger area: Tailwind reads // class names only as written. export const menuPopupWidthClassNames: Record = { auto: "max-w-[min(--spacing(80),var(--available-width))]", content: "w-max max-w-[min(--spacing(80),var(--available-width))]", trigger: "min-w-0 w-(--anchor-width) max-w-(--available-width)", "at-least-trigger": "min-w-(--anchor-width) w-max max-w-[min(max(--spacing(80),var(--anchor-width)),var(--available-width))]", }; // Menus stay compact at every control size: rows, group labels and separators // keep the default look, and the shared menu-row recipe in design-rework.css // sets their height, text and icon. export const menuItemClassName = "flex cursor-pointer select-none items-center whitespace-nowrap outline-none data-disabled:cursor-not-allowed data-disabled:opacity-(--sp-rw-disabled-opacity-action) [&>svg]:pointer-events-none [&>svg]:shrink-0"; // A group label wraps a long unbroken value, such as an email address, within // the popup's width (rulebook K5); only rows stay on one line. export const menuGroupLabelClassName = "font-(--sp-rw-font-weight-label) wrap-anywhere"; export const menuSeparatorClassName = ""; export const menuShortcutClassName = shortcutClassName; export const menuSubTriggerClassName = "flex cursor-pointer items-center whitespace-nowrap outline-none data-disabled:cursor-not-allowed data-disabled:opacity-(--sp-rw-disabled-opacity-action) [&_svg]:pointer-events-none"; // ~/src/siteplane/components/menu/index.ts export * from "./menu";