/** * Stage 4.7 port of Base UI React's `useClientPoint` hook. * * React source: * `base-ui/packages/react/src/floating-ui-react/hooks/useClientPoint.ts` * * Positions the floating element at a viewport client point (e.g. the * cursor). Used for context menus and tooltips that follow the cursor. * The hook installs a document `mousemove` listener while the floating * element is open and writes a fresh `VirtualElement` whose * `getBoundingClientRect()` returns the current `(x, y)` into the * store's `positionReference` slot — which `useFloating` prefers over * the raw `referenceElement`, so positioning tracks the cursor. * * ### React → Solid deviations * * 1. `useRef` → `MutableRef` closures (matches sibling hooks). The * `initialRef` / `cleanupListenerRef` state is preserved 1:1. * * 2. `useState` (`pointerType`) → * `createSignal` so the listener-install effect * re-runs when the pointer type changes (React's dep array doesn't * actually include `pointerType`, but downstream reads inside the * virtual-element factory go through the signal which is read * lazily — correct either way). * * 3. `useState([])` (`reactive`) — React uses a tick-signal state to * force the listener-install effect to re-run without changing any * other dep. The Solid port uses a `createSignal(0)` + bump * counter with the same semantics. * * 4. `useStableCallback` → `createStableCallback`. Only used where the * React source stabilized handler identity — Solid's * non-re-rendering model doesn't strictly need it, but we preserve * the shape for parity. * * 5. `useEffect(() => addListener(), [addListener, reactive])` → a * single `createEffect(compute, apply)` that tracks the reactive * inputs (`open`, `floatingElement`, `domReferenceElement`, * `enabled`, `pointerType`, `reactiveTick`) and returns an * `onCleanup` callback that removes the mousemove listener. The * two smaller `useEffect`s that reset `initialRef` are collapsed * into an `useIsoLayoutEffect` that runs synchronously on reactive * changes. * * 6. React's `React.MouseEvent` → plain `MouseEvent`. The * port's handler receives native events (Solid has no synthetic * event wrapper). * * 7. Shadow-DOM-safe `contains()` from `src/internals/dom/contains.ts` * replaces the raw `node.contains(target)` calls. * * 8. `React.useMemo` for the `reference` bag collapses to a plain * object literal — Solid component bodies don't re-run, so the bag * identity is already stable. * * ### Reason strings * * None. `useClientPoint` doesn't call `store.setOpen` — it only writes * the `positionReference` slot, so no `REASONS.*` mapping applies. */ import { getWindow } from "@floating-ui/utils/dom"; import { createEffect, createMemo, createSignal } from "solid-js"; import { contains } from "../../dom/contains"; import { createStableCallback } from "../../stable-callback"; import { useIsoLayoutEffect } from "../../use-iso-layout-effect"; import type { ContextData, ElementProps, FloatingContext, FloatingRootContext, MutableRef, ReferenceType, } from "../types"; import { getTarget } from "../utils/element"; import { isMouseLikePointerType } from "../utils/event"; function createVirtualElement( domElement: Element | null | undefined, data: { axis: "x" | "y" | "both"; dataRef: MutableRef; pointerType: string | undefined; x: number | null; y: number | null; }, ): ReferenceType { let offsetX: number | null = null; let offsetY: number | null = null; let isAutoUpdateEvent = false; return { contextElement: domElement || undefined, getBoundingClientRect() { const domRect = domElement?.getBoundingClientRect() || { width: 0, height: 0, x: 0, y: 0, }; const isXAxis = data.axis === "x" || data.axis === "both"; const isYAxis = data.axis === "y" || data.axis === "both"; const canTrackCursorOnAutoUpdate = ["mouseenter", "mousemove"].includes( data.dataRef.current.openEvent?.type || "", ) && data.pointerType !== "touch"; let width = domRect.width; let height = domRect.height; let x = domRect.x; let y = domRect.y; if (offsetX == null && data.x && isXAxis) { offsetX = domRect.x - data.x; } if (offsetY == null && data.y && isYAxis) { offsetY = domRect.y - data.y; } x -= offsetX || 0; y -= offsetY || 0; width = 0; height = 0; if (!isAutoUpdateEvent || canTrackCursorOnAutoUpdate) { width = data.axis === "y" ? domRect.width : 0; height = data.axis === "x" ? domRect.height : 0; x = isXAxis && data.x != null ? data.x : x; y = isYAxis && data.y != null ? data.y : y; } else if (isAutoUpdateEvent && !canTrackCursorOnAutoUpdate) { height = data.axis === "x" ? domRect.height : height; width = data.axis === "y" ? domRect.width : width; } isAutoUpdateEvent = true; return { width, height, x, y, top: y, right: x + width, bottom: y + height, left: x, }; }, }; } function isMouseBasedEvent(event: Event | undefined): event is MouseEvent { return event != null && (event as MouseEvent).clientX != null; } export interface UseClientPointProps { /** * Whether the Hook is enabled, including all internal Effects and event * handlers. * @default true */ enabled?: boolean | undefined; /** * Whether to restrict the client point to an axis and use the reference * element (if it exists) as the other axis. This can be useful if the * floating element is also interactive. * @default 'both' */ axis?: "x" | "y" | "both" | undefined; /** * An explicitly provided `x` client coordinate. * @default null */ x?: number | null | undefined; /** * An explicitly provided `y` client coordinate. * @default null */ y?: number | null | undefined; } /** * Positions the floating element relative to a client point (in the viewport), * such as the mouse position. By default, it follows the mouse cursor. * @see https://floating-ui.com/docs/useClientPoint */ export function useClientPoint( context: FloatingRootContext | FloatingContext, props: UseClientPointProps = {}, ): ElementProps { const store = "rootStore" in context ? context.rootStore : context; const dataRef = store.context.dataRef; // Lazy accessors so consumer-passed `get enabled()` / `get axis()` // stay reactive AND don't trip `[STRICT_READ_UNTRACKED]` from this // hook's body (which runs inside the parent component's untracked // scope). Use sites read via the accessor; effects fold the value // into compute so apply stays clean. const isEnabled = (): boolean => props.enabled === undefined ? true : props.enabled; const axisOf = (): "x" | "y" | "both" => props.axis === undefined ? "both" : props.axis; const { x: xProp = null, y: yProp = null } = props; // React `useRef` equivalents. const initialRef: MutableRef = { current: false }; const cleanupListenerRef: MutableRef void)> = { current: null }; const [pointerType, setPointerType] = createSignal( undefined, { name: "useClientPoint.pointerType", ownedWrite: true }, ); // React uses `useState([])` + `setReactive([])` as a tick-signal to // re-run the listener-install effect. Solid equivalent: a bumped counter. const [reactiveTick, setReactiveTick] = createSignal(0, { name: "useClientPoint.reactiveTick", ownedWrite: true, }); const setReference = createStableCallback< ( newX: number | null, newY: number | null, referenceElement?: Element | null, ) => void >( () => ( newX: number | null, newY: number | null, referenceElement?: Element | null, ) => { if (initialRef.current) { return; } // Prevent setting if the open event was not a mouse-like one // (e.g. focus to open, then hover over the reference element). // Only apply if the event exists. if ( dataRef.current.openEvent && !isMouseBasedEvent(dataRef.current.openEvent) ) { return; } const domReference = store.state.domReferenceElement; store.set( "positionReference", createVirtualElement(referenceElement ?? domReference, { x: newX, y: newY, axis: axisOf(), dataRef, pointerType: pointerType(), }), ); }, ); const handleReferenceEnterOrMove = createStableCallback< (event: MouseEvent) => void >(() => (event: MouseEvent) => { if (!store.state.open) { setReference( event.clientX, event.clientY, event.currentTarget as Element, ); } else if (!cleanupListenerRef.current) { // If there's no cleanup, there's no listener, but we want to ensure // we add the listener if the cursor landed on the floating element // and then back on the reference (i.e. it's interactive). setReactiveTick((t) => t + 1); } }); // Per-key memos de-dupe the store's coarse `equals: false` signal into // per-field identity-equal accessors. Without this, writes from inside // the main effect (below) would re-fire the compute with a fresh tuple // each time and loop. With the memos, compute only re-runs when an // individual key's identity changes. const openMemo = createMemo(() => store.state.open); const floatingMemo = createMemo(() => store.state.floatingElement); const domReferenceMemo = createMemo(() => store.state.domReferenceElement); // Main listener-install effect. React keyed this on // `[openCheck, enabled, floating, dataRef, domReference, store, // setReference]` plus a re-tick via `reactive`. createEffect( () => { // Tracked reads — mirrors React's dep array, through the // per-key memos so writes we make inside apply don't re-fire // the compute. Also captures `pointerType` here so the apply // callback never reads it directly (apply is unowned + strict). const open = openMemo(); const floatingElement = floatingMemo(); const domReferenceElement = domReferenceMemo(); reactiveTick(); const pointer = pointerType(); const enabled = isEnabled(); const axis = axisOf(); return { open, floatingElement, domReferenceElement, pointer, enabled, axis, }; }, ({ open, floatingElement, domReferenceElement, pointer, enabled, axis, }) => { // Mirror React's `openCheck`: for mouse-like pointers keep // tracking even while the floating element is transitioning // out; for touch gate strictly on `open`. const openCheck = isMouseLikePointerType(pointer) ? floatingElement : open; if (!openCheck || !enabled) { return; } // Controlled override: `x` / `y` props bypass cursor tracking. if (xProp != null || yProp != null) { initialRef.current = false; store.set( "positionReference", createVirtualElement(domReferenceElement, { x: xProp, y: yProp, axis, dataRef, pointerType: pointer, }), ); return; } const win = getWindow(floatingElement); function handleMouseMove(event: MouseEvent) { const target = getTarget(event) as Element | null; if (!contains(floatingElement, target)) { setReference(event.clientX, event.clientY); } else { cleanupListenerRef.current?.(); cleanupListenerRef.current = null; } } if ( !dataRef.current.openEvent || isMouseBasedEvent(dataRef.current.openEvent) ) { win.addEventListener("mousemove", handleMouseMove); const removeListener = () => { win.removeEventListener("mousemove", handleMouseMove); }; cleanupListenerRef.current = () => { removeListener(); cleanupListenerRef.current = null; }; return () => { removeListener(); cleanupListenerRef.current = null; }; } store.set("positionReference", domReferenceElement); return undefined; }, ); // Reset `initialRef` when the floating element detaches (React runs // this inside a dedicated `useEffect`). useIsoLayoutEffect( () => ({ floatingElement: store.state.floatingElement, enabled: isEnabled(), }), ({ floatingElement, enabled }) => { if (enabled && !floatingElement) { initialRef.current = false; } }, ); // Snapshot `initialRef` when disabled while open (React runs this // inside a dedicated `useEffect`). Prevents a stale cursor tracking // from overwriting the positionReference after `enabled` flips off. useIsoLayoutEffect( () => ({ open: store.state.open, enabled: isEnabled() }), ({ open, enabled }) => { if (!enabled && open) { initialRef.current = true; } }, ); const reference: ElementProps["reference"] = { onPointerDown(event: PointerEvent) { if (!isEnabled()) return; setPointerType(event.pointerType); }, onPointerEnter(event: PointerEvent) { if (!isEnabled()) return; setPointerType(event.pointerType); }, onMouseMove(event: MouseEvent) { if (!isEnabled()) return; handleReferenceEnterOrMove(event); }, onMouseEnter(event: MouseEvent) { if (!isEnabled()) return; handleReferenceEnterOrMove(event); }, }; return { reference, trigger: reference }; }