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