import { flip, offset, type Placement, shift, size } from "@floating-ui/dom"; import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createEffect, createMemo, createSignal, omit, Show } from "solid-js"; import { composeRefs, userRef } from "../../internals/compose-refs"; 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 { resolveClassStyle } from "../../internals/resolve-class-style"; import type { BaseUIComponentProps } from "../../internals/types"; import { usePopoverRootContext } from "../popover-root-context"; import { type PopoverAlign, PopoverPositionerContext, type PopoverPositionerContextValue, type PopoverSide, } from "./popover-positioner-context"; export interface PopoverPositionerState { /** Whether the popover is currently open. */ open: boolean; /** The side of the anchor the component is placed on. */ side: PopoverSide; /** The alignment of the component relative to the anchor. */ align: PopoverAlign; /** Whether the anchor element is hidden. */ anchorHidden: boolean; } export interface PopoverPositionerProps extends BaseUIComponentProps<"div", PopoverPositionerState> { /** * Tag/component to render. Defaults to `"div"`. Replaces the * pre-spike `render` prop (axis G). */ as?: ValidComponent; /** * Which side of the trigger to place the popover on. * @default "bottom" */ side?: PopoverSide | undefined; /** * How to align the popover along the chosen side. * @default "center" */ align?: PopoverAlign | undefined; /** * Gap (px) between the trigger and the popover. * @default 0 */ sideOffset?: number | undefined; /** * Shift (px) along the side for finer `start` / `end` alignment. * @default 0 */ alignOffset?: number | undefined; /** * Padding (px) between the arrow element and the positioner's edges. * @default 5 */ arrowPadding?: number | undefined; /** * Padding (px) between the positioner and the collision boundary. * @default 5 */ collisionPadding?: number | undefined; /** * `absolute` (default) or `fixed` CSS positioning strategy. * @default "absolute" */ positionMethod?: "absolute" | "fixed" | undefined; } // --------------------------------------------------------------------------- // Placement helpers // --------------------------------------------------------------------------- /** Compose floating-ui's `Placement` string from `side` + `align`. */ function composePlacement(side: PopoverSide, align: PopoverAlign): Placement { // Solid port: `inline-start` / `inline-end` are dir-sensitive aliases // provided for API parity; we map them to logical `left` / `right` here // and let consumers plug in a DirectionProvider reactive transform if // they need the full logical-property behavior. The React source does the // same mapping inside `useAnchorPositioning`. 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; } /** Split floating-ui's placement back into `side` + `align`. */ function parsePlacement(placement: Placement): { side: PopoverSide; align: PopoverAlign; } { const [rawSide, rawAlign] = placement.split("-") as [ "top" | "right" | "bottom" | "left", "start" | "end" | undefined, ]; return { side: rawSide, align: rawAlign ?? "center", }; } /** * Positions the popover against the trigger. Renders a `
` element. * * ### React → Solid deviations * * - **No `anchor` prop**. React's `useAnchorPositioning` supports custom * anchor elements / refs / virtual-element functions; the Solid port * anchors to the trigger element exclusively (matches the common-case * usage). Easy to extend later. * - **No `collisionBoundary` prop**. Uses the viewport by default via * `@floating-ui/dom`'s `shift`/`flip` middleware defaults. * - **No `sticky` / `disableAnchorTracking` / `collisionAvoidance`**. The * floating-ui `autoUpdate` loop is always enabled when the popup is * mounted. * - **No `adaptiveOrigin` / viewport integration**. Viewport is not a * ported component. * - **No `useAnchoredPopupScrollLock`**. Modal scroll-lock is deferred — * `modal={true}` still traps focus and blocks outside pointer events via * `FloatingFocusManager` + `useDismiss`, just without scroll locking. * - **No `InternalBackdrop` cutout**. The user-facing `` * still works; the internal non-visible backdrop used to block outside * pointer events during modal mode is skipped. * * `happy-dom` returns 0 for every geometry function, so assertions on the * computed `--anchor-width` / `left` / `top` values are not meaningful. Real * positioning correctness needs browser-level coverage. */ export function PopoverPositioner(props: PopoverPositionerProps): JSX.Element { const root = usePopoverRootContext(); const nodeId = useFloatingNodeId(); const side = (): PopoverSide => props.side ?? "bottom"; const align = (): PopoverAlign => props.align ?? "center"; const sideOffsetValue = (): number => props.sideOffset ?? 0; const alignOffsetValue = (): number => props.alignOffset ?? 0; const arrowPadding = (): number => props.arrowPadding ?? 5; const collisionPadding = (): number => props.collisionPadding ?? 5; const strategy = (): "absolute" | "fixed" => props.positionMethod ?? "absolute"; // Arrow ref slot — `` writes it on mount. We seed `null` // and the arrow middleware tolerates it (returns empty result). const arrowRef: MutableRef = { current: null }; // `useFloating` needs a reactive placement. We re-read `side` / `align` // inside a memo and feed it to the options as a plain value (no tracking // needed since `useFloating` reads every field through a getter-based // options object). Here the options go directly into `useFloating` so we // wrap them in getters. const floating = useFloating({ rootContext: root.floatingRootContext, nodeId, get placement() { return composePlacement(side(), align()); }, get strategy() { return strategy(); }, get middleware() { return [ offset({ mainAxis: sideOffsetValue(), alignmentAxis: alignOffsetValue(), }), 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", }), ]; }, }); // Positioner ref — writes into floating-ui's `setFloating` so the // positioning engine knows about our element. let positionerNode: HTMLElement | null = null; const setPositionerElement = (el: HTMLElement | null): void => { positionerNode = el; // Start hidden the instant the element exists — in the ref callback, // which runs during DOM construction (before first paint). The reveal // effect below alone is too late: in Solid 2 its apply can run AFTER the // first paint, so the popup flashes at (0,0) before opacity is set. This // guarantees opacity:0 is on the element from its very first frame. // `transition: none` for the same reason: `computePosition`'s // getBoundingClientRect forces a style/layout flush while the element is // still at `transform: none`, so a consumer transition on // `transform`/`top`/`left` would animate the first positioning write — // the popup visibly flies in from the document origin. Mirrors Base UI's // `getDisabledMountTransitionStyles` (transition: none while "starting"); // the reveal effect clears it when the status leaves "starting". if (el) { el.style.opacity = "0"; el.style.transition = "none"; } floating.refs.setFloating(el); }; // Mirror BOTH the transform and the reveal imperatively, in ONE effect, so // they land in the SAME DOM write (no frame where the popup is visible at // the stale (0,0) transform — the teleport). This is exactly Base UI's model // (`useAnchorPositioning`): it sets `opacity:0` until `isPositioned` and // applies the transform + opacity together as one reactive `floatingStyles` // unit. We must do it imperatively because Solid 2's `style` spread // subscribes `get style()` late and misses the async `isPositioned`/`x`/`y` // flips from `computePosition().then()`. `opacity` (not `visibility`) matches // Base UI and avoids fighting the reactive `visibility` in the style object. // `|| !open` keeps it opaque while closing (`isPositioned` resets on // `open=false`) so exit transitions aren't clipped. createEffect( () => { if (!root.mounted()) return null; return { transform: `translate(${Math.round(floating.x)}px, ${Math.round(floating.y)}px)`, positioned: floating.isPositioned || !root.open(), starting: root.transitionStatus() === "starting", }; }, (data) => { if (!data || !positionerNode) return; positionerNode.style.transform = data.transform; positionerNode.style.opacity = data.positioned ? "" : "0"; // Suppress consumer position transitions until the mount settles // (see the ref callback above); re-enable after "starting" so anchor // tracking still animates. positionerNode.style.transition = data.starting ? "none" : ""; }, ); // `side` / `align` parsed from the computed placement (may differ from // the requested one after `flip` kicks in). const computedSideAlign = createMemo<{ side: PopoverSide; align: PopoverAlign; }>(() => parsePlacement(floating.placement)); const computedSide = (): PopoverSide => computedSideAlign().side; const computedAlign = (): PopoverAlign => computedSideAlign().align; // `anchorHidden` reflects floating-ui's `hide` middleware, which we do // not currently include — default to `false`. Reserved here so the // public state shape + data-attrs match React. const [anchorHidden] = createSignal(false); // Arrow middleware data — the `arrow` middleware writes `x`/`y` into // `middlewareData.arrow`. Expose reactive styles / `uncentered` for the // arrow component. 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: PopoverPositionerContextValue = { side: computedSide, align: computedAlign, anchorHidden, arrowRef, arrowStyles, arrowUncentered, }; const state = createMemo(() => ({ open: root.open(), side: computedSide(), align: computedAlign(), anchorHidden: anchorHidden(), })); // Re-run arrow middleware when the arrow ref lands, otherwise the // floating-ui `arrow` middleware sees `element: null` forever. React's // `useAnchorPositioning` handles this through `useMergeRefs` + effect; // the Solid port uses a fresh recompute on `root.mounted()` transitions. createEffect( () => root.mounted(), () => { floating.update(); }, ); const positionerStyle = createMemo(() => { const openNow = root.open(); const mountedNow = root.mounted(); return { position: strategy(), top: "0", left: "0", // Visibility is owned by the imperative effect above (the reactive // spread can't track the `isPositioned` flip). Keep the not-mounted // hidden here for keep-mounted popups; when mounted, leave it unset // so the imperative effect controls it. visibility: mountedNow ? undefined : "hidden", "pointer-events": openNow ? undefined : "none", }; }); 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; }, }; 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 ( ); }