`. 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 (
);
}