/** * Port of Base UI React's `useAnchorPositioning` hook * (`packages/react/src/utils/useAnchorPositioning.ts`). * * This is the heavy-weight orchestrator that threads `anchor`, * `collisionBoundary`, `sticky`, `disableAnchorTracking`, * `collisionAvoidance`, `adaptiveOrigin`, `side`, `align`, offsets and * arrow padding through `computePosition` + middleware. It composes the * Solid port's `useFloating` — the low-level positioning engine lives * there. This file's job is purely middleware assembly + anchor wiring. * * ## React → Solid deviations * * - Accessor-based reactive inputs. Every prop that React allowed to * change between renders is modeled here as `Accessor`; callers * pass getters. Primitive-style values (e.g. numeric `sideOffset`) may * be passed as a plain number because components usually compute them * inside a reactive memo and pass the memo. The orchestration below * wraps all inputs with `() => ...` at read time so either shape works. * - `React.useState` → `createSignal`, `React.useRef` → `{ current }`, * `useLayoutEffect` → `createRenderEffect` (via `useIsoLayoutEffect`), * `useEffect` → `createEffect`. * - `offset` / `flip` / `shift` middleware don't accept a `deps` array in * `@floating-ui/dom` the way `@floating-ui/react-dom` did — Solid's * reactive reads inside the middleware functions handle the same * contract (middleware rebuilds on each `computePosition` call). * - React gates the initial autoUpdate-less path on `keepMounted`; Solid * `useFloating` already wires `autoUpdate` when reference + floating * are attached, so we thread `disableAnchorTracking` through by * conditionally returning `false` from `useFloating`'s `open` read. * (Not quite — we can't short-circuit `autoUpdate` from here without * patching `useFloating`. Instead we skip registering the anchor * entirely while `disableAnchorTracking` is true after first * position.) See the `registerAnchor` effect below. * - The React source computes `middleware` as a fresh array on every * render. Solid runs setup once; we rebuild the array via a getter so * `useFloating` sees the latest reactive values. */ import { type AutoUpdateOptions, autoUpdate, flip, limitShift, type Middleware, type MiddlewareState, hide as nativeHide, offset, type Padding, type Side as PhysicalSide, type Placement, shift, size, type VirtualElement, } from "@floating-ui/dom"; import { getAlignment, getSide, getSideAxis, type Rect, } from "@floating-ui/utils"; import type { JSX } from "@solidjs/web"; import { type Accessor, createEffect, createMemo, createSignal, } from "solid-js"; import { useDirection } from "../../direction-provider/direction-provider-context"; import { ownerDocument } from "../dom/owner-document"; import { ownerWindow } from "../dom/owner-window"; import { useFloating } from "../floating-ui/hooks/use-floating"; import { arrow } from "../floating-ui/middleware/arrow"; import type { FloatingContext, FloatingRootContext, FloatingTreeStoreType as FloatingTreeStore, MutableRef, } from "../floating-ui/types"; import { useIsoLayoutEffect } from "../use-iso-layout-effect"; import { useLatestRef } from "../use-latest-ref"; import { DEFAULT_SIDES } from "./adaptive-origin-middleware"; // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- export type Side = | "top" | "bottom" | "left" | "right" | "inline-end" | "inline-start"; export type Align = "start" | "center" | "end"; export type Boundary = "clipping-ancestors" | Element | Element[] | Rect; export type OffsetFunction = (data: { side: Side; align: Align; anchor: { width: number; height: number }; positioner: { width: number; height: number }; }) => number; interface SideFlipMode { side?: "flip" | "none" | undefined; align?: "flip" | "shift" | "none" | undefined; fallbackAxisSide?: "start" | "end" | "none" | undefined; } interface SideShiftMode { side?: "shift" | "none" | undefined; align?: "shift" | "none" | undefined; fallbackAxisSide?: "start" | "end" | "none" | undefined; } export type CollisionAvoidance = SideFlipMode | SideShiftMode; /** Ref-like shape matching the React source; accepted for the `anchor` prop. */ export interface AnchorRefObject { current: T; } export type AnchorInput = | Element | VirtualElement | AnchorRefObject | (() => Element | VirtualElement | null) | null | undefined; export interface UseAnchorPositioningSharedParameters { anchor?: Accessor | AnchorInput | undefined; positionMethod?: | Accessor<"absolute" | "fixed"> | "absolute" | "fixed" | undefined; side?: Accessor | Side | undefined; sideOffset?: | Accessor | number | OffsetFunction | undefined; align?: Accessor | Align | undefined; alignOffset?: | Accessor | number | OffsetFunction | undefined; collisionBoundary?: Accessor | Boundary | undefined; collisionPadding?: Accessor | Padding | undefined; sticky?: Accessor | boolean | undefined; arrowPadding?: Accessor | number | undefined; disableAnchorTracking?: Accessor | boolean | undefined; collisionAvoidance?: | Accessor | CollisionAvoidance | undefined; } export interface UseAnchorPositioningParameters extends UseAnchorPositioningSharedParameters { keepMounted?: Accessor | boolean | undefined; floatingRootContext?: FloatingRootContext | undefined; mounted: Accessor | boolean; disableAnchorTracking: Accessor | boolean; collisionAvoidance: Accessor | CollisionAvoidance; shiftCrossAxis?: Accessor | boolean | undefined; nodeId?: string | undefined; adaptiveOrigin?: Accessor | Middleware | undefined; lazyFlip?: Accessor | boolean | undefined; externalTree?: FloatingTreeStore | undefined; } export interface UseAnchorPositioningReturnValue { readonly positionerStyles: JSX.CSSProperties; readonly arrowStyles: JSX.CSSProperties; readonly arrowRef: MutableRef; readonly arrowUncentered: boolean; readonly side: Side; readonly align: Align; readonly physicalSide: PhysicalSide; readonly anchorHidden: boolean; readonly refs: ReturnType["refs"]; readonly context: FloatingContext; readonly isPositioned: boolean; readonly update: () => void; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function resolve(input: T | Accessor): T { return typeof input === "function" ? (input as Accessor)() : input; } function getLogicalSide( sideParam: Side, renderedSide: PhysicalSide, isRtl: boolean, ): Side { const isLogicalSideParam = sideParam === "inline-start" || sideParam === "inline-end"; const logicalRight: Side = isRtl ? "inline-start" : "inline-end"; const logicalLeft: Side = isRtl ? "inline-end" : "inline-start"; const map: Record = { top: "top", right: isLogicalSideParam ? logicalRight : "right", bottom: "bottom", left: isLogicalSideParam ? logicalLeft : "left", }; return map[renderedSide]; } function getOffsetData( state: MiddlewareState, sideParam: Side, isRtl: boolean, ): { side: Side; align: Align; anchor: { width: number; height: number }; positioner: { width: number; height: number }; } { const { rects, placement } = state; return { side: getLogicalSide(sideParam, getSide(placement), isRtl), align: (getAlignment(placement) || "center") as Align, anchor: { width: rects.reference.width, height: rects.reference.height }, positioner: { width: rects.floating.width, height: rects.floating.height, }, }; } function isRef( param: Element | VirtualElement | AnchorRefObject | null | undefined, ): param is AnchorRefObject { return param != null && typeof param === "object" && "current" in param; } // --------------------------------------------------------------------------- // Default `hide` middleware — Solid port inlines the React source's // `hideMiddleware.ts`. `@floating-ui/dom` exports a `hide` middleware that // reports reference-hidden status; we wrap it with a zero-rect short-circuit // so a freshly-mounted anchor with no layout yet still flags as hidden. // --------------------------------------------------------------------------- const hideMiddleware: Middleware = { name: "hide", async fn(state) { const { width, height, x, y } = state.rects.reference; const anchorHidden = width === 0 && height === 0 && x === 0 && y === 0; const nativeResult = await nativeHide().fn(state); return { data: { referenceHidden: (nativeResult.data as { referenceHidden?: boolean } | undefined) ?.referenceHidden || anchorHidden, }, }; }, }; // --------------------------------------------------------------------------- // The hook // --------------------------------------------------------------------------- /** * Orchestrates anchor positioning for Base UI popup compounds. Composes * `useFloating` + the `offset` / `flip` / `shift` / `size` / `arrow` / * `hide` / `adaptiveOrigin` middleware chain. * * All reactive inputs can be passed as accessors. Primitive-typed props * accept both forms — the hook normalizes internally. The returned * object is a collection of getters backed by signals, suitable for * reactive spreads in JSX or reads inside memos. */ export function useAnchorPositioning( params: UseAnchorPositioningParameters, ): UseAnchorPositioningReturnValue { const { anchor, positionMethod, side: sideParamInput, sideOffset: sideOffsetInput, align: alignInput, alignOffset: alignOffsetInput, collisionBoundary: collisionBoundaryInput, collisionPadding: collisionPaddingInput, sticky: stickyInput, arrowPadding: arrowPaddingInput, disableAnchorTracking: disableAnchorTrackingInput, keepMounted: keepMountedInput, floatingRootContext, mounted: mountedInput, collisionAvoidance: collisionAvoidanceInput, shiftCrossAxis: shiftCrossAxisInput, nodeId, adaptiveOrigin: adaptiveOriginInput, lazyFlip: lazyFlipInput, externalTree, } = params; const getSideParam = (): Side => resolve(sideParamInput) ?? "bottom"; const getAlign = (): Align => resolve(alignInput) ?? "center"; const getSideOffset = (): number | OffsetFunction => resolve(sideOffsetInput) ?? 0; const getAlignOffset = (): number | OffsetFunction => resolve(alignOffsetInput) ?? 0; const getCollisionBoundary = (): Boundary | undefined => resolve(collisionBoundaryInput); const getCollisionPadding = (): Padding | undefined => resolve(collisionPaddingInput) ?? 5; const getSticky = (): boolean => resolve(stickyInput) ?? false; const getArrowPadding = (): number => resolve(arrowPaddingInput) ?? 5; const getDisableAnchorTracking = (): boolean => resolve(disableAnchorTrackingInput) ?? false; const getMounted = (): boolean => resolve(mountedInput); const getKeepMounted = (): boolean => resolve(keepMountedInput) ?? false; const getShiftCrossAxis = (): boolean => resolve(shiftCrossAxisInput) ?? false; const getLazyFlip = (): boolean => resolve(lazyFlipInput) ?? false; const getPositionMethod = (): "absolute" | "fixed" => resolve(positionMethod) ?? "absolute"; const getAdaptiveOrigin = (): Middleware | undefined => resolve(adaptiveOriginInput); const getCollisionAvoidance = (): CollisionAvoidance => resolve(collisionAvoidanceInput) ?? {}; // `mountSide` freezes the physical side after the first successful // position when `lazyFlip` is true, so filter lists etc. don't flip // eagerly while typing. const [mountSide, setMountSide] = createSignal(null, { ownedWrite: true, }); const direction = useDirection(); const isRtl = (): boolean => direction() === "rtl"; // Side / placement are reactive. React computes them via render math; we // wrap in a memo so the middleware getter below and the `placement` input // to `useFloating` see the same value. const resolvedSide = createMemo(() => { const mounted = mountSide(); if (mounted) return mounted; const sideParam = getSideParam(); const rtl = isRtl(); switch (sideParam) { case "inline-end": return rtl ? "left" : "right"; case "inline-start": return rtl ? "right" : "left"; default: return sideParam as PhysicalSide; } }); const placement = createMemo(() => { const s = resolvedSide(); const a = getAlign(); return a === "center" ? s : (`${s}-${a}` as Placement); }); // Arrow ref shared with the `arrow` middleware. Consumers write through // `arrowRef.current = el` from a ref callback. const arrowRef: MutableRef = { current: null }; // Latest-value refs for offsets so function-shaped offsets re-evaluate // each `computePosition` call against current closures. React uses // `useValueAsRef`; Solid's `useLatestRef` is the direct analog. const sideOffsetRef = useLatestRef(() => getSideOffset(), ); const alignOffsetRef = useLatestRef(() => getAlignOffset(), ); const mountedRef = useLatestRef(() => getMounted()); // --- Collision padding with "iOS bias" — copies React's logic exactly. const computeCollisionPadding = (): { padding: { top: number; right: number; bottom: number; left: number }; bias: number; } => { const bias = 1; const sideParam = getSideParam(); const biasTop = sideParam === "bottom" ? bias : 0; const biasBottom = sideParam === "top" ? bias : 0; const biasLeft = sideParam === "right" ? bias : 0; const biasRight = sideParam === "left" ? bias : 0; const raw = getCollisionPadding(); let padding: { top: number; right: number; bottom: number; left: number; }; if (typeof raw === "number") { padding = { top: raw + biasTop, right: raw + biasRight, bottom: raw + biasBottom, left: raw + biasLeft, }; } else if (raw) { const rawObj = raw as { top?: number; right?: number; bottom?: number; left?: number; }; padding = { top: (rawObj.top || 0) + biasTop, right: (rawObj.right || 0) + biasRight, bottom: (rawObj.bottom || 0) + biasBottom, left: (rawObj.left || 0) + biasLeft, }; } else { padding = { top: biasTop, right: biasRight, bottom: biasBottom, left: biasLeft, }; } return { padding, bias }; }; const middlewareMemo = createMemo(() => { const sideParam = getSideParam(); const rtl = isRtl(); const avoidance = getCollisionAvoidance(); const avoidanceSide = avoidance.side || "flip"; const avoidanceAlign = avoidance.align || "flip"; const avoidanceFallback = avoidance.fallbackAxisSide || "end"; const alignValue = getAlign(); const sticky = getSticky(); const shiftCrossAxis = getShiftCrossAxis(); const { padding: collisionPadding, bias } = computeCollisionPadding(); const boundary = getCollisionBoundary(); const commonCollisionProps = { boundary: boundary === "clipping-ancestors" ? "clippingAncestors" : boundary, padding: collisionPadding, } as const; const list: Middleware[] = [ offset((state) => { const data = getOffsetData(state, sideParam, rtl); const sideAxis = typeof sideOffsetRef.current === "function" ? sideOffsetRef.current(data) : sideOffsetRef.current; const alignAxis = typeof alignOffsetRef.current === "function" ? alignOffsetRef.current(data) : alignOffsetRef.current; return { mainAxis: sideAxis, crossAxis: alignAxis, alignmentAxis: alignAxis, }; }), ]; const shiftDisabled = avoidanceAlign === "none" && avoidanceSide !== "shift"; const crossAxisShiftEnabled = !shiftDisabled && (sticky || shiftCrossAxis || avoidanceSide === "shift"); const flipMiddleware: Middleware | null = avoidanceSide === "none" ? null : flip({ ...commonCollisionProps, padding: { top: collisionPadding.top + bias, right: collisionPadding.right + bias, bottom: collisionPadding.bottom + bias, left: collisionPadding.left + bias, }, mainAxis: !shiftCrossAxis && avoidanceSide === "flip", crossAxis: avoidanceAlign === "flip" ? "alignment" : false, fallbackAxisSideDirection: avoidanceFallback, }); const shiftMiddleware: Middleware | null = shiftDisabled ? null : shift((data) => { const html = ownerDocument(data.elements.floating).documentElement; return { ...commonCollisionProps, rootBoundary: shiftCrossAxis ? { x: 0, y: 0, width: html.clientWidth, height: html.clientHeight, } : undefined, mainAxis: avoidanceAlign !== "none", crossAxis: crossAxisShiftEnabled, limiter: sticky || shiftCrossAxis ? undefined : limitShift((limitData) => { if (!arrowRef.current) { return {}; } const { width, height } = arrowRef.current.getBoundingClientRect(); const sideAxis = getSideAxis(getSide(limitData.placement)); const arrowSize = sideAxis === "y" ? width : height; const offsetAmount = sideAxis === "y" ? collisionPadding.left + collisionPadding.right : collisionPadding.top + collisionPadding.bottom; return { offset: arrowSize / 2 + offsetAmount / 2, }; }), }; }); // Match React's middleware ordering for shift vs flip. See // https://floating-ui.com/docs/flip#combining-with-shift const pushMaybe = (mw: Middleware | null): void => { if (mw) list.push(mw); }; if ( avoidanceSide === "shift" || avoidanceAlign === "shift" || alignValue === "center" ) { pushMaybe(shiftMiddleware); pushMaybe(flipMiddleware); } else { pushMaybe(flipMiddleware); pushMaybe(shiftMiddleware); } list.push( size({ ...commonCollisionProps, apply({ elements: { floating }, availableWidth, availableHeight, rects, }) { if (!mountedRef.current) { return; } const floatingStyle = floating.style; floatingStyle.setProperty("--available-width", `${availableWidth}px`); floatingStyle.setProperty( "--available-height", `${availableHeight}px`, ); const dpr = ownerWindow(floating).devicePixelRatio || 1; const { x, y, width, height } = rects.reference; const anchorWidth = (Math.round((x + width) * dpr) - Math.round(x * dpr)) / dpr; const anchorHeight = (Math.round((y + height) * dpr) - Math.round(y * dpr)) / dpr; floatingStyle.setProperty("--anchor-width", `${anchorWidth}px`); floatingStyle.setProperty("--anchor-height", `${anchorHeight}px`); }, }), arrow(() => ({ // Fall back to a detached element so transform-origin math still // has something to read when the arrow isn't mounted yet. element: arrowRef.current || ownerDocument(arrowRef.current).createElement("div"), padding: getArrowPadding(), offsetParent: "floating", })), { name: "transformOrigin", fn(state) { const { elements, middlewareData, placement: renderedPlacement, rects, y, } = state; const currentRenderedSide = getSide(renderedPlacement); const currentRenderedAxis = getSideAxis(currentRenderedSide); const arrowEl = arrowRef.current as HTMLElement | null; const arrowData = middlewareData.arrow as | { x?: number; y?: number } | undefined; const shiftData = middlewareData.shift as | { x?: number; y?: number } | undefined; const arrowX = arrowData?.x || 0; const arrowY = arrowData?.y || 0; const arrowWidth = arrowEl?.clientWidth || 0; const arrowHeight = arrowEl?.clientHeight || 0; const transformX = arrowX + arrowWidth / 2; const transformY = arrowY + arrowHeight / 2; const shiftY = Math.abs(shiftData?.y || 0); const halfAnchorHeight = rects.reference.height / 2; const currentSideOffset = sideOffsetRef.current; const sideOffsetValue = typeof currentSideOffset === "function" ? currentSideOffset(getOffsetData(state, sideParam, rtl)) : currentSideOffset; const isOverlappingAnchor = shiftY > sideOffsetValue; const adjacent: Record = { top: `${transformX}px calc(100% + ${sideOffsetValue}px)`, bottom: `${transformX}px ${-sideOffsetValue}px`, left: `calc(100% + ${sideOffsetValue}px) ${transformY}px`, right: `${-sideOffsetValue}px ${transformY}px`, }; const adjacentTransformOrigin = adjacent[currentRenderedSide]; const overlapTransformOrigin = `${transformX}px ${rects.reference.y + halfAnchorHeight - y}px`; elements.floating.style.setProperty( "--transform-origin", crossAxisShiftEnabled && currentRenderedAxis === "y" && isOverlappingAnchor ? overlapTransformOrigin : adjacentTransformOrigin, ); return {}; }, }, hideMiddleware, ); const adaptive = getAdaptiveOrigin(); if (adaptive) list.push(adaptive); return list; }); // Reset local `mountSide` if `mounted` flipped to false, matching React's // render-phase guard that clears stale state. Compute returns a tuple so // `mountSide()` is read in tracked scope; apply receives both values // without further reactive reads. useIsoLayoutEffect( () => [getMounted(), mountSide()] as const, ([mounted, currentMountSide]) => { if (!mounted && currentMountSide !== null) { setMountSide(null); } }, ); // If the popup isn't mounted yet but we have a root context, clear // reference-related slots so `autoUpdate` doesn't attach to stale // elements (React does the same with a `useIsoLayoutEffect`). useIsoLayoutEffect( () => ({ mounted: getMounted(), root: floatingRootContext }), ({ mounted, root }) => { if (!mounted && root) { root.update({ referenceElement: null, floatingElement: null, domReferenceElement: null, positionReference: null, }); } }, ); // `autoUpdate` options — only track layout shift / resize when // `disableAnchorTracking` is false. const autoUpdateOptions = createMemo(() => { const disabled = getDisableAnchorTracking(); return { elementResize: !disabled && typeof ResizeObserver !== "undefined", layoutShift: !disabled && typeof IntersectionObserver !== "undefined", }; }); const floating = useFloating({ rootContext: floatingRootContext, get placement() { return placement(); }, get strategy() { return getPositionMethod(); }, get middleware() { return middlewareMemo(); }, nodeId, externalTree, }); // Track anchor element. Mirrors React's split-effect: a layout-phase // effect handles element / virtual-element anchors, a regular effect // handles ref-shaped anchors (since parent refs populate after layout). const registeredAnchorRef: MutableRef = { current: null, }; const resolveAnchor = (): Element | VirtualElement | null => { const raw = resolve(anchor); if (raw == null) return null; const val = typeof raw === "function" ? raw() : raw; if (val == null) return null; if (isRef(val)) return val.current as Element | null; return val as Element | VirtualElement; }; useIsoLayoutEffect( () => ({ mounted: getMounted(), anchor: resolveAnchor() }), ({ mounted, anchor: resolved }) => { if (!mounted) return; if (resolved !== registeredAnchorRef.current) { floating.refs.setPositionReference(resolved); registeredAnchorRef.current = resolved; } }, ); // `keepMounted` path: keep autoUpdate running while mounted. Solid's // `useFloating` already wires autoUpdate based on reference + // floating; we tack on a manual subscription for the keepMounted case // where consumers want to drive updates themselves. createEffect( () => ({ keep: getKeepMounted(), mounted: getMounted(), dom: floating.elements.domReference, float: floating.elements.floating, options: autoUpdateOptions(), }), ({ keep, mounted, dom, float, options }) => { if (!keep || !mounted || !dom || !float) return undefined; return autoUpdate(dom, float, floating.update, options); }, ); // Lazy flip: lock the rendered side once positioned. useIsoLayoutEffect( () => ({ lazy: getLazyFlip(), mounted: getMounted(), isPositioned: floating.isPositioned, placement: floating.placement, }), ({ lazy, mounted, isPositioned, placement: p }) => { if (lazy && mounted && isPositioned) { setMountSide(getSide(p)); } }, ); // --- Derived reactive outputs --------------------------------------- const renderedSide = (): PhysicalSide => getSide(floating.placement); const logicalRenderedSide = (): Side => getLogicalSide(getSideParam(), renderedSide(), isRtl()); const renderedAlign = (): Align => (getAlignment(floating.placement) || "center") as Align; const anchorHidden = (): boolean => { const data = floating.middlewareData.hide as | { referenceHidden?: boolean } | undefined; return Boolean(data?.referenceHidden); }; const arrowStyles = createMemo(() => { const data = floating.middlewareData.arrow as | { x?: number; y?: number } | undefined; const out: JSX.CSSProperties = { position: "absolute" }; if (data?.y !== undefined) out.top = `${data.y}px`; if (data?.x !== undefined) out.left = `${data.x}px`; return out; }); const arrowUncentered = (): boolean => { const data = floating.middlewareData.arrow as | { centerOffset?: number } | undefined; return (data?.centerOffset ?? 0) !== 0; }; const floatingStyles = createMemo(() => { const resolvedPosition: "absolute" | "fixed" = floating.isPositioned ? getPositionMethod() : "fixed"; const adaptive = getAdaptiveOrigin(); const adaptiveData = floating.middlewareData.adaptiveOrigin as | { sideX?: string; sideY?: string } | undefined; const sideX = adaptiveData?.sideX || DEFAULT_SIDES.sideX; const sideY = adaptiveData?.sideY || DEFAULT_SIDES.sideY; const base: JSX.CSSProperties = adaptive ? { position: resolvedPosition, [sideX]: `${floating.x}px`, [sideY]: `${floating.y}px`, } : { position: resolvedPosition, top: "0", left: "0", transform: `translate(${Math.round(floating.x)}px, ${Math.round(floating.y)}px)`, }; if (!floating.isPositioned) { base.opacity = 0; } return base; }); return { get positionerStyles() { return floatingStyles(); }, get arrowStyles() { return arrowStyles(); }, arrowRef, get arrowUncentered() { return arrowUncentered(); }, get side() { return logicalRenderedSide(); }, get align() { return renderedAlign(); }, get physicalSide() { return renderedSide(); }, get anchorHidden() { return anchorHidden(); }, refs: floating.refs, context: floating.context, get isPositioned() { return floating.isPositioned; }, update: floating.update, }; }