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 { useComboboxRootContext } from "../root/combobox-root-context";
import {
type ComboboxAlign,
ComboboxPositionerContext,
type ComboboxPositionerContextValue,
type ComboboxSide,
} from "./positioner-context";
export interface ComboboxPositionerState {
open: boolean;
side: ComboboxSide;
align: ComboboxAlign;
anchorHidden: boolean;
empty: boolean;
}
export interface ComboboxPositionerProps
extends BaseUIComponentProps<"div", ComboboxPositionerState> {
as?: ValidComponent;
side?: ComboboxSide | undefined;
align?: ComboboxAlign | undefined;
sideOffset?: number | undefined;
alignOffset?: number | undefined;
arrowPadding?: number | undefined;
collisionPadding?: number | undefined;
positionMethod?: "absolute" | "fixed" | undefined;
}
function composePlacement(side: ComboboxSide, align: ComboboxAlign): Placement {
if (align === "center") return side as Placement;
return `${side}-${align}` as Placement;
}
function parsePlacement(placement: Placement): {
side: ComboboxSide;
align: ComboboxAlign;
} {
const [rawSide, rawAlign] = placement.split("-") as [
ComboboxSide,
"start" | "end" | undefined,
];
return { side: rawSide, align: rawAlign ?? "center" };
}
/**
* Positions the popup against the input. Renders a `
` element.
*
* happy-dom returns 0 for all layout rects, so pixel-offset positioning
* math is not unit-testable. Real browser coverage required.
*/
export function ComboboxPositioner(
props: ComboboxPositionerProps,
): JSX.Element {
const root = useComboboxRootContext();
const nodeId = useFloatingNodeId();
const side = (): ComboboxSide => props.side ?? "bottom";
const align = (): ComboboxAlign => props.align ?? "start";
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";
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: 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",
}),
];
},
});
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.setPositionerElement(el);
};
// Solid 2's DOM spread subscribes `get style()` late, so the first
// `floating.x` / `floating.y` write after mount can be missed and the popup
// renders at (0, 0). Imperatively mirror the transform here — the
// createEffect is guaranteed to re-run on every `x` / `y` change.
// 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: ComboboxSide;
align: ComboboxAlign;
}>(() => parsePlacement(floating.placement));
const computedSide = (): ComboboxSide => computedSideAlign().side;
const computedAlign = (): ComboboxAlign => computedSideAlign().align;
// Keep root.popupSide in sync for sibling parts.
createEffect(
() => computedSide(),
(s) => {
root.setPopupSide(s);
},
);
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: ComboboxPositionerContextValue = {
side: computedSide,
align: computedAlign,
anchorHidden,
arrowRef,
arrowStyles,
arrowUncentered,
};
const state = createMemo(() => ({
open: root.open(),
side: computedSide(),
align: computedAlign(),
anchorHidden: anchorHidden(),
empty: root.flatFilteredItems().length === 0,
}));
createEffect(
() => root.mounted(),
() => {
floating.update();
},
);
// `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",
};
});
const internal = {
get hidden() {
return !root.open();
},
// popupStateMapping projection.
get "data-open"() {
return state().open ? "" : undefined;
},
get "data-closed"() {
return state().open ? undefined : "";
},
get "data-anchor-hidden"() {
return state().anchorHidden ? "" : undefined;
},
get "data-side"() {
return state().side;
},
get "data-align"() {
return state().align;
},
};
const internalStyle = {
get style() {
return positionerStyle();
},
};
const userClassStyle = resolveClassStyle(props, () => state());
const userProps = omit(
props,
"as",
"class",
"style",
"attrs",
"side",
"align",
"sideOffset",
"alignOffset",
"arrowPadding",
"collisionPadding",
"positionMethod",
);
let merged: Record = mergeRootProps(internalStyle, internal);
merged = mergeRootProps(merged, userClassStyle);
merged = mergeRootProps(merged, userProps);
const Tag = dynamic(() => props.as ?? "div");
return (
);
}