import type { JSX, ValidComponent } from "@solidjs/web"; /** * `` — container for top-level menus with arrow-key roving focus * between triggers. Solid 2 port of Base UI React's `Menubar` * (`packages/react/src/menubar/Menubar.tsx`). * * Wraps the contained ``s in a shared ``, provides * a `MenubarContext` that each child menu reads, and owns a keyboard * handler on the `role="menubar"` element so arrow keys navigate between * the contained `` elements. * * ### Port deviations from React * * - React uses `useListNavigation` inside each menu with `externalTree` * + `parentOrientation` so arrow-keys flow across menus through the * floating tree. The Solid port discovers triggers by querying the * menubar's content element for `button[aria-haspopup="menu"]`. * `useListNavigation` already accepts `parentOrientation` as an * accessor (P3 landed) — now needs structural plumbing * (MenubarContext → child Menu.Root → listnav) and top-level menus * must consume the parent-axis main-key to suppress the menu's own * open-on-arrow path in vertical menubars. Deferred until the * browser harness can verify vertical-menubar key mapping. * - `menuopenchange` events ARE consumed from the shared `FloatingTree`. * The menubar wraps children in * `` so contained ``s' * `floatingParentNodeId` equals the menubar's id — that's the key the * event handler filters on. The older direct-through-context path * (`MenubarContext.setHasSubmenuOpen` + `registerOpenMenu`) is * preserved; both paths are idempotent. * - `allowMouseUpTriggerRef` is ported as a shared `MutableRef`; * consumers (child menus) can read it when the mouseup-on-item window * lands. */ import { dynamic } from "@solidjs/web"; import { type Accessor, createSignal, omit, onCleanup, untrack, } from "solid-js"; import { useDirection } from "../direction-provider/direction-provider-context.ts"; import { useBaseUiId } from "../internals/base-ui-id"; import { FloatingNode, FloatingTree, useFloatingNodeId, } from "../internals/floating-ui/components/floating-tree.tsx"; import { FloatingTreeStore } from "../internals/floating-ui/components/floating-tree-store.ts"; import type { MutableRef } from "../internals/floating-ui/types.ts"; import { type MaybeAccessor, readOption } from "../internals/maybe-accessor"; import { mergeRootProps } from "../internals/merge-root-props"; import { resolveClassStyle } from "../internals/resolve-class-style"; import type { BaseUIComponentProps } from "../internals/types"; import type { MenuRootOrientation } from "../menu/root/menu-root-context"; import { createMenubar } from "./create-menubar"; import { MenubarContext, type MenubarContextValue } from "./menubar-context.ts"; import { MenubarDataAttributes } from "./menubar-data-attributes.ts"; export interface MenubarState { /** The orientation of the menubar. */ orientation: MenuRootOrientation; /** Whether the menubar is modal. */ modal: boolean; /** Whether any submenu within the menubar is open. */ hasSubmenuOpen: boolean; } export interface MenubarProps extends BaseUIComponentProps<"div", MenubarState> { /** Tag/component to render. Defaults to `"div"`. */ as?: ValidComponent; /** * Whether the menubar is modal. * @default true */ modal?: MaybeAccessor; /** * Whether the whole menubar is disabled. * @default false */ disabled?: MaybeAccessor; /** * The orientation of the menubar. * @default 'horizontal' */ orientation?: MaybeAccessor; /** * Whether to loop keyboard focus back to the first item when the end of * the list is reached while using the arrow keys. * @default true */ loopFocus?: MaybeAccessor; } /** Collect the top-level menu triggers rendered inside the menubar. */ function getTriggers(content: HTMLElement | null): HTMLElement[] { if (!content) return []; // Each `` renders `aria-haspopup="menu"`; a submenu trigger // sets the same but lives inside a nested menu popup that is portalled — // so it won't match our container-scoped query. const nodes = content.querySelectorAll( 'button[aria-haspopup="menu"]', ); return Array.from(nodes).filter( (el) => !el.hasAttribute("disabled") && !el.hasAttribute("aria-disabled"), ); } /** * Inner body — runs INSIDE the `` so `useFloatingNodeId` can * allocate a node id bound to the menubar's tree. Child ``s * mounted under this node read `floatingParentNodeId` as the menubar's id, * which is the key the events path filters on. */ function MenubarInner( props: MenubarProps & { tree: FloatingTreeStore }, ): JSX.Element { // `tree` is a stable store instance passed once from ``; read it // under `untrack` so the body-time access doesn't trip STRICT_READ. const tree = untrack(() => props.tree); // Compose the headless Menubar primitive — owns orientation, modal, disabled, // loopFocus, hasSubmenuOpen, and the single-open coordinator. The wrapper // layers FloatingTree, FloatingNode, role="menubar", and the keyboard // handler on top. const menubarPrimitive = createMenubar({ orientation: () => readOption(props.orientation) ?? "horizontal", modal: () => readOption(props.modal) ?? true, disabled: () => readOption(props.disabled) ?? false, loopFocus: () => readOption(props.loopFocus) ?? true, }); const modal = menubarPrimitive.modal; const disabled = menubarPrimitive.disabled; const orientation = menubarPrimitive.orientation; const loopFocus = menubarPrimitive.loopFocus; const hasSubmenuOpen = menubarPrimitive.hasSubmenuOpen; const setHasSubmenuOpen = menubarPrimitive.setHasSubmenuOpen; const registerOpenMenu = menubarPrimitive.registerOpenMenu; const fallbackId = useBaseUiId(); const resolvedId = (): string | undefined => typeof props.id === "string" ? props.id : fallbackId; const [contentElement, setContentElement] = createSignal( null, { ownedWrite: true }, ); const allowMouseUpTriggerRef: MutableRef = { current: false }; // Allocate a floating-tree node id for the menubar itself. Every child // `` inside reads this as its `floatingParentNodeId` — that's // what distinguishes a top-level menubar child from a submenu when the // events path filters `menuopenchange`. const menubarNodeId = useFloatingNodeId(); // Events-path consumption of `menuopenchange`. Child menus // emit through `floatingTreeRoot.events`; we filter for ones whose // `parentNodeId === menubarNodeId` and track `hasSubmenuOpen` from that // stream in parallel with the existing direct-`setHasSubmenuOpen` path. // The two are idempotent — a child menu both calls `setHasSubmenuOpen` // directly and emits an event; `hasSubmenuOpen` is the disjunction. let openFromEvents = 0; const handleMenuOpenChange = (data: { open: boolean; nodeId: string | undefined; parentNodeId: string | null; }): void => { if (data.parentNodeId !== menubarNodeId) return; if (data.open) { openFromEvents += 1; setHasSubmenuOpen(true); } else { openFromEvents = Math.max(0, openFromEvents - 1); if (openFromEvents === 0) { setHasSubmenuOpen(false); } } }; tree.events.on("menuopenchange", handleMenuOpenChange); onCleanup(() => { tree.events.off("menuopenchange", handleMenuOpenChange); }); const direction = useDirection(); // Keyboard handler — arrow keys traverse top-level triggers. We discover // them by DOM query at event time (the trigger set is stable per render // so re-queries are cheap + avoid registration plumbing). const handleKeyDown = (event: KeyboardEvent): void => { const content = contentElement(); if (!content) return; const triggers = getTriggers(content); if (triggers.length === 0) return; const target = event.target as Node | null; const active = target instanceof HTMLElement ? triggers.find((t) => t === target || t.contains(target)) : undefined; if (!active) return; const isRtl = direction() === "rtl"; const forwardKey = orientation() === "horizontal" ? isRtl ? "ArrowLeft" : "ArrowRight" : "ArrowDown"; const backwardKey = orientation() === "horizontal" ? isRtl ? "ArrowRight" : "ArrowLeft" : "ArrowUp"; const firstKey = "Home"; const lastKey = "End"; const currentIndex = triggers.indexOf(active); const max = triggers.length - 1; let nextIndex: number | null = null; if (event.key === forwardKey) { if (currentIndex < max) { nextIndex = currentIndex + 1; } else if (loopFocus()) { nextIndex = 0; } } else if (event.key === backwardKey) { if (currentIndex > 0) { nextIndex = currentIndex - 1; } else if (loopFocus()) { nextIndex = max; } } else if (event.key === firstKey) { nextIndex = 0; } else if (event.key === lastKey) { nextIndex = max; } if (nextIndex == null || nextIndex === currentIndex) return; event.preventDefault(); event.stopPropagation(); triggers[nextIndex]?.focus(); }; const contextValue: MenubarContextValue = { modal, disabled, contentElement, setContentElement, hasSubmenuOpen, setHasSubmenuOpen: (open) => { setHasSubmenuOpen(open); }, orientation, allowMouseUpTriggerRef, rootId: resolvedId as Accessor, registerOpenMenu, }; const setRef = (el: HTMLElement | null): void => { setContentElement(el); }; const userStyling = resolveClassStyle(props, () => ({ orientation: orientation(), modal: modal(), hasSubmenuOpen: hasSubmenuOpen(), })); const userHtmlProps = omit( props, "class", "style", "attrs", "as", "children", "modal", "disabled", "orientation", "loopFocus", "id", "tree", ) as Record; const internalProps = { ref: setRef, role: "menubar" as const, onKeyDown: handleKeyDown, get id() { return resolvedId(); }, get "aria-orientation"() { return orientation(); }, get [MenubarDataAttributes.orientation]() { return orientation(); }, get [MenubarDataAttributes.modal]() { return modal() ? "" : undefined; }, get [MenubarDataAttributes.hasSubmenuOpen]() { return hasSubmenuOpen() ? "" : undefined; }, }; const styledUserProps = mergeRootProps(userStyling, userHtmlProps); const merged = mergeRootProps(internalProps, styledUserProps); const Tag = dynamic(() => props.as ?? "div"); return ( {props.children} ); } /** * The container for menus. Renders a `
` that roving-focuses * across contained `` elements via arrow keys. */ export function Menubar(props: MenubarProps): JSX.Element { const tree = new FloatingTreeStore(); // Render `MenubarInner` as a component (not a plain call) so its body runs // INSIDE the `` provider's owner — otherwise `useFloatingNodeId` // reads a null tree context and the menubar's own node never registers. return ( ); }