// ~/src/siteplane/components/context-menu/context-menu.tsx "use client"; import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"; import { ChevronRightIcon } from "lucide-react"; import * 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 { switchMenuTrackClassName, switchThumbClassName, switchVariants, } from "../switch/switch"; import { menuGroupLabelClassName, menuItemClassName, menuPopupVariantClassNames, menuPopupWidthClassNames, menuSeparatorClassName, menuShortcutClassName, menuSubTriggerClassName, type MenuPopupVariant, type MenuPopupWidth, } from "../menu/menu-shared"; type ContextMenuTriggerArea = { element: HTMLElement | null; setElement: (element: HTMLElement | null) => void; }; // Base UI anchors the popup to the pointer, which has no width. The root // shares the trigger area so the trigger-based popup widths can measure it. const ContextMenuTriggerAreaContext = React.createContext(null); export function ContextMenu( props: ContextMenuPrimitive.Root.Props, ): React.ReactElement { const [element, setElement] = React.useState(null); const triggerArea = React.useMemo(() => ({ element, setElement }), [element]); return ( ); } // Base UI's own Portal type, with className narrowed to a string. export const ContextMenuPortal: React.ForwardRefExoticComponent< WithStringClassName> & React.RefAttributes > = ContextMenuPrimitive.Portal; export function ContextMenuTrigger({ className, children, ref, ...props }: WithStringClassName) { const setTriggerArea = React.useContext( ContextMenuTriggerAreaContext, )?.setElement; const triggerRef = React.useCallback( (element: HTMLDivElement | null) => { setTriggerArea?.(element); if (typeof ref === "function") ref(element); else if (ref) ref.current = element; }, [ref, setTriggerArea], ); return ( {children} ); } function useElementWidth(element: HTMLElement | null): number | undefined { const [width, setWidth] = React.useState(); React.useEffect(() => { if (!element) return; const measure = () => setWidth(element.getBoundingClientRect().width); measure(); const observer = new ResizeObserver(measure); observer.observe(element); return () => observer.disconnect(); }, [element]); return element ? width : undefined; } const contextMenuTriggerAreaWidthClassNames: Record = { ...menuPopupWidthClassNames, trigger: "min-w-0 w-(--context-menu-trigger-width) max-w-(--available-width)", "at-least-trigger": "min-w-(--context-menu-trigger-width) w-max max-w-[min(max(--spacing(80),var(--context-menu-trigger-width)),var(--available-width))]", }; export function ContextMenuPopup({ children, className, sideOffset = 0, align = "start", alignOffset, side = "bottom", anchor, portalProps, width = "auto", variant = "default", ...props }: WithStringClassName & { align?: ContextMenuPrimitive.Positioner.Props["align"]; sideOffset?: ContextMenuPrimitive.Positioner.Props["sideOffset"]; alignOffset?: ContextMenuPrimitive.Positioner.Props["alignOffset"]; side?: ContextMenuPrimitive.Positioner.Props["side"]; anchor?: ContextMenuPrimitive.Positioner.Props["anchor"]; portalProps?: WithStringClassName; width?: MenuPopupWidth; variant?: MenuPopupVariant; }) { // A custom anchor or a submenu trigger has a real width; only the pointer // anchor falls back to the trigger area. const triggerArea = React.useContext(ContextMenuTriggerAreaContext); const measuresTriggerArea = triggerArea !== null && anchor === undefined && (width === "trigger" || width === "at-least-trigger"); const triggerAreaWidth = useElementWidth( measuresTriggerArea ? triggerArea.element : null, ); return (
{children}
); } export function ContextMenuGroup( props: WithStringClassName, ) { return ( ); } export function ContextMenuItem({ className, children, inset, variant = "default", ...props }: WithStringClassName & { inset?: boolean; variant?: "default" | "destructive"; }) { return ( {menuRowLabel(children)} ); } export function ContextMenuLinkItem({ className, children, inset, variant = "default", closeOnClick = true, ...props }: WithStringClassName & { inset?: boolean; variant?: "default" | "destructive"; }) { return ( {menuRowLabel(children)} ); } export function ContextMenuCheckboxItem({ 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 ContextMenuRadioGroup( props: WithStringClassName, ) { return ( ); } export function ContextMenuRadioItem({ 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. */ indicatorPosition?: "start" | "end" | undefined; }) { const end = indicatorPosition === "end"; return ( {end ? ( <> {menuRowLabel(children)} ) : ( <> {children} )} ); } export function ContextMenuGroupLabel({ className, inset, ...props }: WithStringClassName & { inset?: boolean; }) { return ( ); } export function ContextMenuSeparator({ className, ...props }: WithStringClassName) { return ( ); } export function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"kbd">) { return ( ); } export function ContextMenuSub(props: ContextMenuPrimitive.SubmenuRoot.Props) { return ( ); } export function ContextMenuSubTrigger({ className, inset, children, ...props }: WithStringClassName & { inset?: boolean; }) { return ( {menuRowLabel(children)} ); } export function ContextMenuSubPopup({ className, sideOffset = 0, alignOffset, align = "start", ...props }: WithStringClassName & { align?: ContextMenuPrimitive.Positioner.Props["align"]; sideOffset?: ContextMenuPrimitive.Positioner.Props["sideOffset"]; alignOffset?: ContextMenuPrimitive.Positioner.Props["alignOffset"]; width?: MenuPopupWidth; variant?: MenuPopupVariant; }) { const defaultAlignOffset = align !== "center" ? -5 : undefined; // A submenu is anchored to its trigger row, which Base UI measures. return ( ); } export { ContextMenuPrimitive }; // ~/src/siteplane/components/context-menu/index.ts export * from "./context-menu";