/** * Stage 2 port of Base UI React's `useFloating` hook. * * React source: `base-ui/packages/react/src/floating-ui-react/hooks/useFloating.ts`. * That file wraps `@floating-ui/react-dom`'s `useFloating` (the positioning * engine) and grafts on a root-store context, a domReference ref, and a * `FloatingContext` value that later-stage interaction hooks consume. * * The Solid port: * - Replaces `@floating-ui/react-dom`'s `useFloating` with an inline * Solid-native port of the same algorithm built on `@floating-ui/dom`'s * `computePosition` + `autoUpdate`. We can't reuse the React helper * because it is gated on `React.useState` / `ReactDOM.flushSync`. * - Swaps `React.useRef` for the `MutableRef` pattern from Stage 1. * - Swaps `React.useState` / `useEffect` for Solid's `createSignal` / * `createEffect` + `onCleanup`. * - Uses `useIsoLayoutEffect` (alias of `createRenderEffect`) where React * uses `useIsoLayoutEffect`. * * `FloatingTree` / `useFloatingTree` is ported in Stage 3. The * tree-sync block at the end of React's `useFloating` that writes back * `node.context` is now wired: it's required for * `FloatingFocusManager.handleFocusOutside` to detect focus moves into * an ancestor floating element as "related" rather than closing the * current surface. */ import { autoUpdate, computePosition, type VirtualElement, } from "@floating-ui/dom"; import { isElement } from "@floating-ui/utils/dom"; import { createEffect, createSignal, onCleanup, untrack } from "solid-js"; import { useIsoLayoutEffect } from "../../use-iso-layout-effect"; import type { FloatingRootStore } from "../components/floating-root-store"; import { useFloatingTree } from "../components/floating-tree"; import type { ContextData, ExtendedElements, ExtendedRefs, FloatingContext, FloatingEvents, MutableRef, NarrowedElement, ReferenceType, UseFloatingOptions, UseFloatingReturn, } from "../types"; import { useFloatingRootContext } from "./use-floating-root-context"; // --------------------------------------------------------------------------- // Low-level Solid port of `@floating-ui/react-dom`'s `useFloating`. // // This reimplements the positioning engine: drive `computePosition` from the // latest reference/floating/middleware/strategy/placement, and use // `autoUpdate` to re-run it when layout changes. Output signals feed the // outer `UseFloatingReturn`. Kept in this file because no other hook needs // it; Stage 5+ may extract it if components start reaching for the raw // positioning engine. // --------------------------------------------------------------------------- interface UsePositionOptions { placement: UseFloatingOptions["placement"]; strategy: UseFloatingOptions["strategy"]; middleware: UseFloatingOptions["middleware"]; platform: UseFloatingOptions["platform"]; elements: { reference: ReferenceType | null; floating: HTMLElement | null; }; open: boolean | undefined; } interface UsePositionInternal { x: () => number; y: () => number; placement: () => NonNullable; strategy: () => NonNullable; middlewareData: () => Record; isPositioned: () => boolean; update: () => void; refs: { reference: MutableRef; floating: MutableRef; setReference: (node: ReferenceType | null) => void; setFloating: (node: HTMLElement | null) => void; }; elements: () => { reference: ReferenceType | null; floating: HTMLElement | null; }; } function createPosition( getOpts: () => UsePositionOptions, ): UsePositionInternal { const [x, setX] = createSignal(0, { ownedWrite: true }); const [y, setY] = createSignal(0, { ownedWrite: true }); // Initial signal values are taken as snapshots — `getOpts()` reads // reactive sources (the parent's options bag) inside this hook's // component-body scope, which strict-read flags. The seeded value is // only the initial; subsequent updates flow through the apply effect // further below. const [placement, setPlacement] = createSignal< NonNullable >( untrack(() => getOpts().placement ?? "bottom"), { ownedWrite: true }, ); const [strategy, setStrategy] = createSignal< NonNullable >( untrack(() => getOpts().strategy ?? "absolute"), { ownedWrite: true }, ); const [middlewareData, setMiddlewareData] = createSignal< Record >({}, { ownedWrite: true }); const [isPositioned, setIsPositioned] = createSignal(false, { ownedWrite: true, }); const referenceRef: MutableRef = { current: null }; const floatingRef: MutableRef = { current: null }; const [referenceSignal, setReferenceSignal] = createSignal(null, { ownedWrite: true }); const [floatingSignal, setFloatingSignal] = createSignal( null, { ownedWrite: true }, ); // Track mount state so `computePosition` callbacks don't touch signals // after disposal. In Solid there is no `onMount` export; we flip the // flag in a split-effect's apply (which fires after setup completes) // and clear it via `onCleanup`. let isMounted = false; createEffect( () => undefined, () => { isMounted = true; }, ); onCleanup(() => { isMounted = false; }); const update = () => { // `update` fires from effect-apply callbacks and `autoUpdate`'s // resize/scroll listeners — neither is a tracking scope in Solid 2. // `getOpts` reads store signals (for `elements.reference`/`.floating` // in particular); wrap in `untrack` so those reads return the current // values without emitting the dev "read directly in an effect" warning. const opts = untrack(getOpts); const reference = referenceRef.current ?? opts.elements.reference; const floating = floatingRef.current ?? opts.elements.floating; if (!reference || !floating) { return; } const config: Parameters[2] = { placement: opts.placement ?? "bottom", strategy: opts.strategy ?? "absolute", middleware: opts.middleware ?? [], }; if (opts.platform) { config.platform = opts.platform; } computePosition(reference, floating, config).then((next) => { if (!isMounted) { return; } setX(next.x); setY(next.y); setPlacement(next.placement); setStrategy(next.strategy); setMiddlewareData(next.middlewareData); // Matches React: don't flip isPositioned to `true` while open is // explicitly false (transition-out case). setIsPositioned(opts.open !== false); }); }; const setReference = (node: ReferenceType | null) => { if (node !== referenceRef.current) { referenceRef.current = node; setReferenceSignal(node); } }; const setFloating = (node: HTMLElement | null) => { if (node !== floatingRef.current) { floatingRef.current = node; setFloatingSignal(node); } }; // Reset isPositioned when open flips to false. Mirrors React's layout // effect. Split-effect: compute tracks `opts.open`, apply runs the // side effect. We check `isPositioned()` untracked inside apply since // we only want to flip it on the open-value change. createEffect( () => getOpts().open, (openValue) => { if (openValue === false) { setIsPositioned(false); } }, ); // Drive `autoUpdate` whenever both reference + floating are attached. // `autoUpdate` itself handles scroll/resize listening — we just need // to re-subscribe if either element changes. Compute produces the // [reference, floating] pair (tracking `referenceSignal`, // `floatingSignal`, and the opts' elements), apply (re)binds // `autoUpdate` + hooks a cleanup. createEffect( () => { const opts = getOpts(); return { reference: opts.elements.reference ?? referenceSignal(), // The locally-set floating element (`refs.setFloating`, i.e. the // positioner) must take precedence over the root store's // `floatingElement` for POSITIONING. Roots that sync the popup // element into the store (tooltip, preview-card — for interaction // hooks like safe-polygon) would otherwise re-target `autoUpdate` // + `computePosition` at the popup a frame after mount, and the // popup-relative coordinates would corrupt the positioner's // transform (the tooltip visibly flies toward the document // origin). React's `useFloating` has the same split: positioning // follows `position.refs.setFloating`, the store serves hooks. floating: floatingSignal() ?? opts.elements.floating, }; }, ({ reference, floating }) => { if (reference) { referenceRef.current = reference; } if (floating) { floatingRef.current = floating; } if (!reference || !floating) { return; } const cleanup = autoUpdate(reference, floating, update); return cleanup; }, ); // Re-run the position calc when placement/strategy/middleware change. createEffect( () => { const o = getOpts(); return [o.placement, o.strategy, o.middleware, o.platform] as const; }, () => { update(); }, ); return { x, y, placement, strategy, middlewareData, isPositioned, update, refs: { reference: referenceRef, floating: floatingRef, setReference, setFloating, }, elements: () => { const opts = getOpts(); return { reference: opts.elements.reference ?? referenceSignal(), floating: opts.elements.floating ?? floatingSignal(), }; }, }; } // --------------------------------------------------------------------------- // Public `useFloating` hook // --------------------------------------------------------------------------- /** * Provides data to position a floating element and context to add interactions. * * The `externalTree` option is consumed via `useFloatingTree(externalTree)` * to resolve the tree that owns `nodeId`; when omitted, the ambient * `FloatingTreeContext` supplies it. Both the `dataRef.current.floatingContext` * write and the tree-node back-ref (`node.context = context`) are wired. * * @see https://floating-ui.com/docs/useFloating */ export function useFloating( options: UseFloatingOptions = {}, ): UseFloatingReturn { const { nodeId } = options; const internalRootStore = useFloatingRootContext({ onOpenChange: options.onOpenChange, elements: options.elements, }); const rootContext: FloatingRootStore = (options.rootContext as FloatingRootStore | undefined) ?? internalRootStore; // Position-reference override (settable via `setPositionReference`). const [positionReference, setPositionReferenceRaw] = createSignal(null, { ownedWrite: true }); const domReferenceRef: MutableRef | null> = { current: null, }; // Sync rootContext's domReferenceElement into the local ref. React does // this inside a useIsoLayoutEffect; Solid uses the same primitive. useIsoLayoutEffect( () => rootContext.state.domReferenceElement, (domReference) => { if (domReference) { domReferenceRef.current = domReference as NarrowedElement | null; } }, ); const position = createPosition(() => ({ placement: options.placement, strategy: options.strategy, middleware: options.middleware, platform: options.platform, elements: { reference: positionReference() ?? ((rootContext.state.positionReference ?? rootContext.state.referenceElement) as ReferenceType | null), floating: rootContext.state.floatingElement as HTMLElement | null, }, open: rootContext.state.open as boolean, })); const setPositionReference = (node: ReferenceType | null) => { const computedPositionReference = isElement(node) ? ({ getBoundingClientRect: () => node.getBoundingClientRect(), getClientRects: () => node.getClientRects(), contextElement: node, } satisfies VirtualElement) : node; setPositionReferenceRaw(computedPositionReference); position.refs.setReference(computedPositionReference); }; // Local mirrors synced into the root store — mirrors React's // `localDomReference` / `localFloatingElement` state + `useSyncedValue` // calls. We implement them as plain signals + a Solid effect so the // writes happen reactively. const [localDomReference, setLocalDomReference] = createSignal< NarrowedElement | null | undefined >(undefined, { ownedWrite: true }); const [localFloatingElement, setLocalFloatingElement] = createSignal(null, { ownedWrite: true }); createEffect( () => ({ localRef: localDomReference(), localFloating: localFloatingElement(), }), ({ localRef, localFloating }) => { // Apply callback is untracked in Solid 2 (`createEffect(compute, // effectFn)` only tracks the compute fn). We want "current value, // no subscription" for the rootContext store reads — `untrack` // keeps the strict-read context from flagging the snapshot reads // while preserving identical semantics (rootContext writes still // reactively notify their own subscribers). untrack(() => { rootContext.sync( "referenceElement", localRef === undefined ? (rootContext.state.referenceElement as ReferenceType | null) : (localRef ?? null), ); const localDomElement = isElement(localRef) ? (localRef as Element) : null; rootContext.sync( "domReferenceElement", localRef === undefined ? rootContext.state.domReferenceElement : localDomElement, ); rootContext.sync("floatingElement", localFloating); }); }, ); const setReference = (node: ReferenceType | null) => { if (isElement(node) || node === null) { (domReferenceRef as MutableRef).current = node; setLocalDomReference(node as NarrowedElement | null); } // Backwards-compat: passing a virtual element to `reference` AFTER // a DOM reference has been set preserves the virtual-ref path. // See matching comment in React source. if ( isElement(position.refs.reference.current) || position.refs.reference.current === null || (node !== null && !isElement(node)) ) { position.refs.setReference(node); } }; const setFloating = (node: HTMLElement | null) => { setLocalFloatingElement(node); position.refs.setFloating(node); }; const refs: ExtendedRefs = { reference: position.refs.reference, floating: position.refs.floating, domReference: domReferenceRef, setReference, setFloating, setPositionReference, }; // `elements` exposes current values. React used `useMemo`; Solid // inlines the read and relies on consumers to track if needed. // Defined as a getter-style object so the values stay live when read // from inside a tracking context (e.g. an effect in a consumer). const elements: ExtendedElements = { get reference() { return position.elements().reference; }, get floating() { return position.elements().floating; }, get domReference() { return rootContext.state .domReferenceElement as NarrowedElement | null; }, }; // FloatingContext — live getters over the underlying signals so that // interaction hooks (Stage 4+) observe current values inside effects. // Each getter subscribes to exactly one signal — a single `data()` function // that read all six signals per call caused // every reader to subscribe to all six, amplifying `middlewareData` // churn into ResizeObserver feedback via the `size` middleware's // `--anchor-width` writes. const context: FloatingContext = { get x() { return position.x(); }, get y() { return position.y(); }, get placement() { return position.placement(); }, get strategy() { return position.strategy(); }, get middlewareData() { return position.middlewareData(); }, get isPositioned() { return position.isPositioned(); }, update: position.update, get open() { return rootContext.state.open as boolean; }, onOpenChange: rootContext.setOpen, events: rootContext.context.events as FloatingEvents, dataRef: rootContext.context.dataRef as MutableRef, get floatingId() { return rootContext.state.floatingId as string | undefined; }, refs, elements, nodeId, rootStore: rootContext, } as FloatingContext; // Stash the context on the dataRef so legacy call sites can fish it // back out (matches React). Also sync the back-ref on the floating // tree node so ancestor / descendant walks (used by // `FloatingFocusManager.handleFocusOutside`, `useDismiss`, nested // hover interaction) can reach this context via `node.context`. // Mirrors React's `node.context = context` assignment at the tail of // `useFloating`. const tree = useFloatingTree(options.externalTree); useIsoLayoutEffect( () => undefined, () => { rootContext.context.dataRef.current.floatingContext = context; if (tree && nodeId != null) { const node = tree.nodesRef.current.find((n) => n.id === nodeId); if (node) { node.context = context; } } }, ); // Return shape: merge positioning data + context + refs + elements. // Each getter reads exactly one signal — see the note above the // `context` builder. Building the object with // getters (not via `{...result}` spread) preserves reactivity; spread // would evaluate getters once and freeze the initial `0`/`false` values. return { get x() { return position.x(); }, get y() { return position.y(); }, get placement() { return position.placement(); }, get strategy() { return position.strategy(); }, get middlewareData() { return position.middlewareData(); }, get isPositioned() { return position.isPositioned(); }, update: position.update, context, refs, elements, } as UseFloatingReturn; }