import type { JSX, ValidComponent } from "@solidjs/web"; /** * `` — anchors the popup to the trigger via floating-ui. * Mirrors `packages/react/src/menu/positioner/MenuPositioner.tsx`. * * Renders no visible UI — wraps the popup in a floating-ui positioned * `
`. Installs an `InternalBackdrop` when modal. Installs a * `CompositeList` so items + submenu-triggers register into the root's * `listRef` / `labelsRef` for keyboard navigation + typeahead. * * ### Port deviations * * - `useAnchorPositioning`'s full collision-avoidance / sticky / viewport * integration is not wired — uses low-level `useFloating` + flip/shift/ * size/arrow like ``. Full math needs browser * coverage. */ import { flip, offset, type Placement, shift, size } from "@floating-ui/dom"; import { dynamic } from "@solidjs/web"; import { createEffect, createMemo, createSignal, omit, onCleanup, Show, untrack, } from "solid-js"; import { createChangeEventDetails, REASONS, } from "../../internals/change-event-details"; import { composeRefs, userRef } from "../../internals/compose-refs"; import { CompositeList } from "../../internals/composite/composite-list"; import { FloatingNode, useFloatingNodeId, } from "../../internals/floating-ui/components/floating-tree"; import { useFloating } from "../../internals/floating-ui/hooks/use-floating"; import { arrow } from "../../internals/floating-ui/middleware/arrow"; import type { MutableRef } from "../../internals/floating-ui/types"; import { mergeRootProps } from "../../internals/merge-root-props"; import { InternalBackdrop } from "../../internals/popups/internal-backdrop"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import type { BaseUIComponentProps } from "../../internals/types"; import { Timeout } from "../../internals/use-timeout"; import { useMenubarContext } from "../../menubar/menubar-context"; import { useMenuPortalContext } from "../portal/menu-portal-context"; import { type MenuRootChangeEventDetails, useMenuRootContext, } from "../root/menu-root-context"; import type { MenuItemHoverEvent } from "../utils/item-hover"; import { type MenuAlign, MenuPositionerContext, type MenuPositionerContextValue, type MenuSide, } from "./menu-positioner-context"; export interface MenuPositionerState { open: boolean; side: MenuSide; align: MenuAlign; anchorHidden: boolean; nested: boolean; instant: string | undefined; } export interface MenuPositionerProps extends BaseUIComponentProps<"div", MenuPositionerState> { /** * Tag/component to render. Defaults to `"div"`. Replaces the * pre-spike `render` prop (axis G). */ as?: ValidComponent; side?: Exclude | undefined; align?: MenuAlign | undefined; sideOffset?: number | undefined; alignOffset?: number | undefined; arrowPadding?: number | undefined; collisionPadding?: number | undefined; positionMethod?: "absolute" | "fixed" | undefined; } function composePlacement( side: Exclude, align: MenuAlign, ): Placement { const physicalSide: "top" | "right" | "bottom" | "left" = side === "inline-start" ? "left" : side === "inline-end" ? "right" : side; if (align === "center") return physicalSide as Placement; return `${physicalSide}-${align}` as Placement; } function parsePlacement(placement: Placement): { side: MenuSide; align: MenuAlign; } { const [rawSide, rawAlign] = placement.split("-") as [ "top" | "right" | "bottom" | "left", "start" | "end" | undefined, ]; return { side: rawSide, align: rawAlign ?? "center", }; } export function MenuPositioner(props: MenuPositionerProps): JSX.Element { const root = useMenuRootContext(); const menubar = useMenubarContext(true); const nodeId = useFloatingNodeId(); const keepMounted = useMenuPortalContext(); const nested = (): boolean => root.nested(); const side = (): Exclude => props.side ?? (nested() ? "inline-end" : "bottom"); const align = (): MenuAlign => props.align ?? (nested() ? "start" : "start"); const sideOffset = (): number => props.sideOffset ?? 0; const alignOffset = (): number => props.alignOffset ?? 0; const arrowPadding = (): number => props.arrowPadding ?? 5; const collisionPadding = (): number => props.collisionPadding ?? 5; const strategy = (): "absolute" | "fixed" => props.positionMethod ?? "absolute"; const arrowRef: MutableRef = { current: null }; const floating = useFloating({ rootContext: root.floatingRootContext, nodeId, get placement() { return composePlacement(side(), align()); }, get strategy() { return strategy(); }, get middleware() { return [ offset({ mainAxis: sideOffset(), alignmentAxis: alignOffset(), }), flip({ padding: collisionPadding() }), shift({ padding: collisionPadding() }), size({ padding: collisionPadding(), apply({ rects, availableWidth, availableHeight, elements: els }) { const s = els.floating.style; s.setProperty("--anchor-width", `${rects.reference.width}px`); s.setProperty("--anchor-height", `${rects.reference.height}px`); s.setProperty("--available-width", `${availableWidth}px`); s.setProperty("--available-height", `${availableHeight}px`); }, }), arrow({ element: arrowRef.current, padding: arrowPadding(), offsetParent: "real", }), ]; }, }); let positionerNode: HTMLElement | null = null; const setPositionerElement = (el: HTMLElement | null): void => { positionerNode = el; // opacity:0 before first paint (ref runs during DOM construction); the // reveal effect can run after the first paint in Solid 2. See // popover-positioner. if (el) el.style.opacity = "0"; floating.refs.setFloating(el); root.positionerRef.current = el; }; // Solid 2 DOM spread misses the first `floating.x`/`y` frame — // imperatively mirror the transform so the popup doesn't flash at (0, 0). // Transform + reveal in ONE imperative write so the popup is never visible // at the stale (0,0) transform (the teleport). `opacity:0` until // `isPositioned`, matching Base UI's `useAnchorPositioning`. See // popover-positioner for the full rationale. createEffect( () => { if (!root.mounted()) return null; return { transform: `translate(${Math.round(floating.x)}px, ${Math.round(floating.y)}px)`, positioned: floating.isPositioned || !root.open(), }; }, (data) => { if (!data || !positionerNode) return; positionerNode.style.transform = data.transform; positionerNode.style.opacity = data.positioned ? "" : "0"; }, ); const computedSideAlign = createMemo<{ side: MenuSide; align: MenuAlign; }>(() => parsePlacement(floating.placement)); const computedSide = (): MenuSide => computedSideAlign().side; const computedAlign = (): MenuAlign => computedSideAlign().align; const [anchorHidden] = createSignal(false); const arrowStyles = createMemo(() => { const data = floating.middlewareData.arrow as | { x?: number; y?: number; centerOffset?: number } | undefined; if (!data) return { position: "absolute" }; const styles: JSX.CSSProperties = { position: "absolute" }; if (data.x != null) styles.left = `${data.x}px`; if (data.y != null) styles.top = `${data.y}px`; return styles; }); const arrowUncentered = createMemo(() => { const data = floating.middlewareData.arrow as | { centerOffset?: number } | undefined; return (data?.centerOffset ?? 0) !== 0; }); const positionerContext: MenuPositionerContextValue = { side: computedSide, align: computedAlign, anchorHidden, arrowRef, arrowStyles, arrowUncentered, nodeId: () => nodeId, }; const state = createMemo(() => ({ open: root.open(), side: computedSide(), align: computedAlign(), anchorHidden: anchorHidden(), nested: nested(), instant: root.instantType(), })); createEffect( () => root.mounted(), () => { floating.update(); }, ); // Close this (sub)menu when a different item in its parent menu is // hovered, so hovering a sibling closes other branches. Re-hovering this // menu's own trigger cancels a pending delayed close. Mirrors React's // `itemhover` listener in `MenuPositioner`. const closeTimeout = Timeout.create(); const tree = root.floatingTreeRoot(); if (tree != null) { const onItemHover = (event: MenuItemHoverEvent): void => { const parentNodeId = untrack(root.floatingParentNodeId); if (!untrack(root.open) || event.nodeId !== parentNodeId) return; const trigger = untrack(root.activeTriggerElement); if (event.target == null || trigger == null || trigger === event.target) { closeTimeout.clear(); return; } const close = (): void => root.setOpen( false, createChangeEventDetails( REASONS.siblingOpen, ) as MenuRootChangeEventDetails, ); const delay = root.closeDelayRef.current; if (delay <= 0) close(); else if (!closeTimeout.isStarted()) closeTimeout.start(delay, close); }; tree.events.on("itemhover", onItemHover); onCleanup(() => tree.events.off("itemhover", onItemHover)); } onCleanup(() => closeTimeout.clear()); createEffect( () => root.open(), (isOpen) => { if (!isOpen) closeTimeout.clear(); }, ); // `transform` is written imperatively in the `createEffect` above // because Solid 2's DOM spread subscribes `get style()` late and misses // the first `floating.x` / `floating.y` frame. const positionerStyle = createMemo(() => { const openNow = root.open(); const mountedNow = root.mounted(); return { position: strategy(), top: "0", left: "0", visibility: mountedNow ? undefined : "hidden", "pointer-events": openNow ? undefined : "none", }; }); // Inside a menubar the menubar's `modal` decides, and the backdrop cuts // out the whole menubar so its other triggers stay clickable. const shouldRenderBackdrop = createMemo( () => root.mounted() && !nested() && (menubar != null ? menubar.modal() : root.modal()), ); const backdropCutout = (): Element | undefined => (menubar != null ? menubar.contentElement() : root.activeTriggerElement()) ?? undefined; const internal = { get style() { return positionerStyle(); }, get hidden() { return !root.open(); }, get "data-open"() { return state().open ? "" : undefined; }, get "data-closed"() { return state().open ? undefined : ""; }, get "data-side"() { return state().side; }, get "data-align"() { return state().align; }, get "data-anchor-hidden"() { return state().anchorHidden ? "" : undefined; }, get "data-nested"() { return state().nested ? "" : undefined; }, get "data-instant"() { return state().instant ? "" : undefined; }, }; const userClassStyle = resolveClassStyle(props, () => state()); const userProps = omit( props, "as", "class", "style", "attrs", "side", "align", "sideOffset", "alignOffset", "arrowPadding", "collisionPadding", "positionMethod", ); let merged: Record = mergeRootProps( internal, userClassStyle, ); merged = mergeRootProps(merged, userProps); const Tag = dynamic(() => props.as ?? "div"); return ( ); }