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