import type { JSX, ValidComponent } from "@solidjs/web"; /** * `` — anchors the popup to the trigger via floating-ui. * Mirrors `packages/react/src/select/positioner/SelectPositioner.tsx`. * * ### Port deviations * * - `alignItemWithTrigger` uses `position: fixed` on the positioner (no * floating-ui transform) while Select.Popup writes viewport coordinates. * Standard anchor placement uses the thin `useFloating` transform path. * - Uses low-level `useFloating` + `flip` / `shift` / `size` / `arrow` * middleware directly, matching ``. The full * `useAnchorPositioning` helper is available but not needed for this * thin port. * - `InternalBackdrop` is rendered when `modal && open` to block outside * pointer interaction. */ import { flip, offset, type Placement, shift, size } from "@floating-ui/dom"; import { dynamic } from "@solidjs/web"; import { createEffect, createMemo, createSignal, omit, Show, untrack, } from "solid-js"; 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 { useSelectRootContext } from "../select-root-context"; import { type SelectAlign, SelectPositionerContext, type SelectPositionerContextValue, type SelectSide, } from "./select-positioner-context"; export interface SelectPositionerState { open: boolean; side: SelectSide; align: SelectAlign; anchorHidden: boolean; } export interface SelectPositionerProps extends BaseUIComponentProps<"div", SelectPositionerState> { as?: ValidComponent; /** Which side of the trigger to place the popup on. @default "bottom" */ side?: Exclude | undefined; /** Alignment along the chosen side. @default "start" */ align?: SelectAlign | undefined; /** Px gap between the trigger and the popup. @default 0 */ sideOffset?: number | undefined; /** Px shift along the side. @default 0 */ alignOffset?: number | undefined; /** Padding between the arrow and the positioner edges. @default 5 */ arrowPadding?: number | undefined; /** Padding between the positioner and collision boundary. @default 5 */ collisionPadding?: number | undefined; /** CSS positioning strategy. @default "absolute" */ positionMethod?: "absolute" | "fixed" | undefined; /** * When true (default), the popup overlaps the trigger so the selected * item's text aligns with the trigger value. Uses fixed positioning * and viewport coordinates instead of floating-ui transform placement. * @default true */ alignItemWithTrigger?: boolean | undefined; } function composePlacement( side: Exclude, align: SelectAlign, ): 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: SelectSide; align: SelectAlign; } { const [rawSide, rawAlign] = placement.split("-") as [ "top" | "right" | "bottom" | "left", "start" | "end" | undefined, ]; return { side: rawSide, align: rawAlign ?? "center", }; } export function SelectPositioner(props: SelectPositionerProps): JSX.Element { const root = useSelectRootContext(); const nodeId = useFloatingNodeId(); const side = (): Exclude => props.side ?? "bottom"; const align = (): SelectAlign => props.align ?? "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); }; const scrollUpArrowRef: MutableRef = { current: null }; const scrollDownArrowRef: MutableRef = { current: null }; // Trigger-aligned popup placement state. The controlled flag mirrors // React's `useState(alignItemWithTrigger)` — it lets the popup // auto-fallback to standard anchor placement when space is tight. When // `mounted` flips to false we resync with the prop for the next open. const [controlledAlignItemWithTrigger, setControlledAlignItemWithTrigger] = createSignal(props.alignItemWithTrigger ?? true, { ownedWrite: true, }); createEffect( () => ({ mountedNow: root.mounted(), prop: props.alignItemWithTrigger ?? true, }), ({ mountedNow, prop }) => untrack(() => { if (!mountedNow && controlledAlignItemWithTrigger() !== prop) { setControlledAlignItemWithTrigger(prop); } }), ); const alignItemWithTriggerActive = createMemo(() => { return ( root.mounted() && controlledAlignItemWithTrigger() && root.openMethod() !== "touch" ); }); // Solid 2 DOM spread misses the first `floating.x`/`y` frame — // imperatively mirror the transform so the popup doesn't flash at (0, 0). // Skip when trigger-aligned: popup geometry writes viewport coords and // React uses `position: fixed` only (no transform) in that mode. // 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`. No transform in // trigger-aligned mode (popup writes viewport coords). See popover-positioner // for the full rationale. createEffect( () => { if (!root.mounted()) return null; return { transform: alignItemWithTriggerActive() ? null : `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: SelectSide; align: SelectAlign; }>(() => parsePlacement(floating.placement)); const computedAlign = (): SelectAlign => computedSideAlign().align; const [anchorHidden] = createSignal(false); // Reported `side` flips to 'none' when the popup is trigger-aligned — // the popup isn't anchored to one edge; it overlaps the trigger. const computedSide = (): SelectSide => alignItemWithTriggerActive() ? "none" : computedSideAlign().side; // `isPositioned` gates trigger-aligned geometry in Select.Popup — wait for // floating-ui's first successful compute while open, not the initial x/y of 0. const isPositioned = createMemo(() => { return root.mounted() && root.open() && floating.isPositioned; }); 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: SelectPositionerContextValue = { side: computedSide, align: computedAlign, anchorHidden, arrowRef, arrowStyles, arrowUncentered, alignItemWithTriggerActive, setControlledAlignItemWithTrigger, isPositioned, scrollUpArrowRef, scrollDownArrowRef, }; const state = createMemo(() => ({ open: root.open(), side: computedSide(), align: computedAlign(), anchorHidden: anchorHidden(), })); createEffect( () => ({ mountedNow: root.mounted(), openNow: root.open(), trigger: root.triggerElement(), }), ({ trigger }) => { if (trigger) { floating.refs.setReference(trigger); } 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(); const base: JSX.CSSProperties = { visibility: mountedNow ? undefined : "hidden", "pointer-events": openNow ? undefined : "none", }; if (alignItemWithTriggerActive()) { return { ...base, position: "fixed" }; } return { ...base, position: strategy(), top: "0", left: "0", }; }); 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; }, }; 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", "alignItemWithTrigger", ); let merged: Record = mergeRootProps(internalStyle, internal); merged = mergeRootProps(merged, userClassStyle); merged = mergeRootProps(merged, userProps); const Tag = dynamic(() => props.as ?? "div"); return ( ); }