/* Copyright 2026 Marimo. All rights reserved. */ import type { JSX, PropsWithChildren } from "react"; import React from "react"; import { createPortal } from "react-dom"; import { z } from "zod"; import { withFullScreenAsRoot } from "@/components/ui/fullscreen"; import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger, NavigationMenuViewport, navigationMenuTriggerStyle, } from "@/components/ui/navigation"; import { Tooltip } from "@/components/ui/tooltip"; import { useResizeObserver } from "@/hooks/useResizeObserver"; import { renderHTML } from "@/plugins/core/RenderHTML"; import { cn } from "@/utils/cn"; import { appendQueryParams } from "@/utils/urls"; import type { IStatelessPlugin, IStatelessPluginProps, } from "../stateless-plugin"; import "./navigation-menu.css"; import { KnownQueryParams } from "@/core/constants"; import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui"; interface MenuItem { label: string; href: string; description?: string | null; } interface MenuItemGroup { label: string; items: MenuItem[]; } interface Data { /** * The labels for each item; raw HTML. */ items: (MenuItem | MenuItemGroup)[]; /** * The orientation of the menu. */ orientation: "horizontal" | "vertical"; } interface PortalPosition { left: number; top: number; } interface HorizontalMenuGroupProps { item: MenuItemGroup; preserveQueryParams: (href: string) => string; target: (href: string) => string; } const HorizontalMenuGroup = ({ item, preserveQueryParams, target, }: HorizontalMenuGroupProps): JSX.Element => { const triggerRef = React.useRef | null>(null); const [open, setOpen] = React.useState(false); return ( setOpen(value !== "")} > {renderHTML({ html: item.label })}
    {item.items.map((subItem) => ( {subItem.description && renderHTML({ html: subItem.description })} ))}
); }; // Portal into the fullscreen element (or VSCode output container) when // present; `position: fixed` elements portaled to document.body render // behind a fullscreened cell. const NavigationMenuPortal = withFullScreenAsRoot( ({ container, children, }: PropsWithChildren<{ container?: Element | DocumentFragment | null; }>) => createPortal(children, container ?? document.body), ); const VIEWPORT_MARGIN = 8; const NavigationMenuViewportPortal = ({ anchorRef, open, children, }: PropsWithChildren<{ anchorRef: React.RefObject; open: boolean; }>): React.ReactElement | null => { const [position, setPosition] = React.useState(null); const contentRef = React.useRef(null); const updatePosition = React.useCallback(() => { if (!anchorRef.current) { return; } const rect = anchorRef.current.getBoundingClientRect(); // Clamp so a wide dropdown doesn't run off the right edge of the viewport. const contentWidth = contentRef.current?.firstElementChild?.scrollWidth ?? 0; const maxLeft = window.innerWidth - contentWidth - VIEWPORT_MARGIN; setPosition({ left: Math.max(VIEWPORT_MARGIN, Math.min(rect.left, maxLeft)), top: rect.bottom, }); }, [anchorRef]); // Recompute whenever the menu opens: the trigger may have moved since mount // due to layout shifts that emit no scroll/resize events. The rAF re-measures // after the dropdown content has rendered, so clamping sees its real width. React.useLayoutEffect(() => { updatePosition(); if (open) { const raf = requestAnimationFrame(updatePosition); return () => cancelAnimationFrame(raf); } }, [open, updatePosition]); useResizeObserver({ ref: anchorRef, onResize: updatePosition, }); React.useEffect(() => { if (typeof window === "undefined") { return; } window.addEventListener("resize", updatePosition); document.addEventListener("scroll", updatePosition, { capture: true }); return () => { window.removeEventListener("resize", updatePosition); document.removeEventListener("scroll", updatePosition, { capture: true }); }; }, [updatePosition]); if (!position || typeof document === "undefined") { return null; } return (
{children}
); }; export class NavigationMenuPlugin implements IStatelessPlugin { public tagName = "marimo-nav-menu"; private menuItemValidator = z.object({ label: z.string(), href: z.string(), description: z.string().nullish(), }); private menuItemGroupValidator = z.object({ label: z.string(), items: z.array(this.menuItemValidator), }); public validator = z.object({ items: z.array( z.union([this.menuItemValidator, this.menuItemGroupValidator]), ), orientation: z.enum(["horizontal", "vertical"]), }); public render(props: IStatelessPluginProps): JSX.Element { return ; } } type NavMenuComponentProps = Data; const NavMenuComponent = ({ items, orientation, }: PropsWithChildren): JSX.Element => { const maybeWithTooltip = ( component: JSX.Element, description?: string | null, ) => { return description ? ( {component} ) : ( component ); }; const target = (href: string) => { if (href.startsWith("http")) { return "_blank"; } return "_self"; }; const preserveQueryParams = (href: string) => { const currentUrl = new URL(globalThis.location.href); return appendQueryParams({ href, queryParams: currentUrl.search, keys: [KnownQueryParams.filePath], }); }; const renderMenuItem = (item: MenuItem | MenuItemGroup) => { if ("items" in item) { return orientation === "horizontal" ? ( ) : (
{renderHTML({ html: item.label })}
{item.items.map((subItem) => ( {maybeWithTooltip( {renderHTML({ html: subItem.label })} , subItem.description, )} ))}
); } return ( {renderHTML({ html: item.label })} ); }; return ( {items.map((item) => renderMenuItem(item))} ); }; const ListItem = React.forwardRef< React.ComponentRef<"a">, React.ComponentPropsWithoutRef<"a"> & { label: string; } >(({ className, label, children, ...props }, ref) => { return (
  • {renderHTML({ html: label })}
    {children && (

    {children}

    )}
  • ); }); ListItem.displayName = "ListItem";