/** * Stage 4.4 port of Base UI React's `useHover` hook. * * React source: * `base-ui/packages/react/src/floating-ui-react/hooks/useHover.ts` * * Single-hook variant for the common case of one reference + one * floating element sharing hover handlers. The React source still ships * this distinct from the reference / floating interaction pair (above) * because the one-off shape has noticeably less bookkeeping — no tree * `floating.closed` events, simpler close-scroll-mouseleave handling, * and a merged `isHoverOpen` predicate that short-circuits most of the * nested-tree gates. * * Consumers that need separate trigger + floating hooks (for wrapper * trigger modes, delegation, etc.) should use the `use-hover-*` pair * directly. * * ### React → Solid deviations * * Same pattern as the sibling hover hooks + `useDismiss`: * - `useRef` → plain `MutableRef` objects / closure `let`s. * - `useValueAsRef(fn)` → read options inside handlers (no wrapping * needed in Solid's non-re-rendering model). * - `useStableCallback` → `createStableCallback`. * - `useEffect` / `useIsoLayoutEffect` → `createEffect` / * `useIsoLayoutEffect` in split-compute-apply form. * - `addEventListener` + `mergeCleanups` → direct DOM-level * `addEventListener` with array-based cleanup in `onCleanup`. * - `isReactEvent` branches dropped: Solid already receives native * events. * * ### Reason strings * * Every hover-driven `setOpen` emits `REASONS.triggerHover`. This * matches React's React-source literal (`'trigger-hover'`) exactly. */ import { isElement } from "@floating-ui/utils/dom"; import { createEffect, onCleanup, untrack } from "solid-js"; import { type BaseUIChangeEventDetails, createChangeEventDetails, REASONS, } from "../../change-event-details"; import { contains } from "../../dom/contains"; import { ownerDocument } from "../../dom/owner-document"; import { createStableCallback } from "../../stable-callback"; import { useIsoLayoutEffect } from "../../use-iso-layout-effect"; import { useTimeout } from "../../use-timeout"; import type { FloatingUIOpenChangeDetails } from "../components/floating-root-store"; import { useFloatingParentNodeId, useFloatingTree, } from "../components/floating-tree"; import type { Delay, ElementProps, FloatingContext, FloatingRootContext, MutableRef, } from "../types"; import { getTarget, isInteractiveElement } from "../utils/element"; import type { HandleClose, HandleCloseContext } from "./use-hover-shared"; import { getDelay, getRestMs } from "./use-hover-shared"; export type { HandleClose, HandleCloseContext }; import { type MaybeAccessor, readOption } from "../../maybe-accessor"; export interface UseHoverProps { /** * Accepts an event handler that runs on `mousemove` to control when the * floating element closes once the cursor leaves the reference element. * @default null */ handleClose?: HandleClose | null | undefined; /** * Waits until the user's cursor is at "rest" over the reference element * before changing the `open` state. * @default 0 */ restMs?: number | (() => number) | undefined; /** * Waits for the specified time when the event listener runs before changing * the `open` state. * @default 0 */ delay?: Delay | (() => Delay) | undefined; /** * Whether moving the cursor over the floating element will open it, without * a regular hover event required. * @default true */ move?: boolean | undefined; /** * Whether the hook is enabled. When `false`, no listeners are attached * and no state is tracked. Accepts an accessor so reactive callers * can flip mid-life (solidify.md P3). * @default true */ enabled?: MaybeAccessor | undefined; /** * Whether only mouse-like pointers (`mouse`, `pen`) trigger hover-open. * Touch input still dispatches `mouseenter` / `mouseleave` on most * platforms but rarely represents a hover intent. * @default false */ mouseOnly?: boolean | undefined; } /** * Opens the floating element while hovering over the reference element, like * CSS `:hover`. * @see https://floating-ui.com/docs/useHover */ export function useHover( context: FloatingRootContext | FloatingContext, props: UseHoverProps = {}, ): ElementProps { const store = "rootStore" in context ? context.rootStore : context; const { dataRef, events } = store.context; // Snapshot at hook-body-time. These options are captured in closures // fired by DOM events, so a body-time read is the correct semantic // (matches React's `useHover` where the destructure is on a memoized // hook input). `untrack` here just keeps `[STRICT_READ_UNTRACKED]` // quiet for consumers passing `get handleClose()` style accessors. // `enabled` is the only option that's deliberately reactive — see // `getEnabled` below. const { delay = 0, handleClose = null, restMs = 0, move = true, mouseOnly = false, } = untrack(() => ({ delay: props.delay, handleClose: props.handleClose, restMs: props.restMs, move: props.move, mouseOnly: props.mouseOnly, })); const getEnabled = (): boolean => props.enabled === undefined ? true : readOption(props.enabled); const tree = useFloatingTree(); const parentId = useFloatingParentNodeId(); // React `useRef` equivalents. const pointerTypeRef: MutableRef = { current: undefined }; const interactedInsideRef: MutableRef = { current: false }; const timeout = useTimeout(); const handlerRef: MutableRef<((event: MouseEvent) => void) | undefined> = { current: undefined, }; const restTimeout = useTimeout(); const blockMouseMoveRef: MutableRef = { current: true }; const performedPointerEventsMutationRef: MutableRef = { current: false, }; const unbindMouseMoveRef: MutableRef<() => void> = { current: () => {} }; const restTimeoutPendingRef: MutableRef = { current: false }; const isHoverOpen = createStableCallback<() => boolean>(() => () => { const type = dataRef.current.openEvent?.type; return Boolean(type?.includes("mouse")) && type !== "mousedown"; }); const isClickLikeOpenEvent = createStableCallback<() => boolean>(() => () => { if (interactedInsideRef.current) { return true; } const openEventType = dataRef.current.openEvent?.type; return openEventType === "click" || openEventType === "mousedown"; }); // Clear delay timeouts on close (if a close preempts an open). createEffect( () => undefined, () => { function onOpenChangeLocal(details: FloatingUIOpenChangeDetails) { if (!details.open) { timeout.clear(); restTimeout.clear(); blockMouseMoveRef.current = true; restTimeoutPendingRef.current = false; } } events.on("openchange", onOpenChangeLocal); return () => { events.off("openchange", onOpenChangeLocal); }; }, ); // When `handleClose` is set, listen for document-level `mouseleave` so // the floating element still closes when the cursor exits the window // entirely (safe-polygon alone can't detect this). createEffect( () => ({ open: store.state.open, floatingElement: store.state.floatingElement, enabled: getEnabled(), }), (snapshot) => { const { open, floatingElement, enabled } = snapshot; if (!enabled || !handleClose || !open) { return; } function onLeave(event: MouseEvent) { if (isClickLikeOpenEvent()) { return; } if (isHoverOpen()) { store.setOpen( false, createChangeEventDetails( REASONS.triggerHover, event, ((event.currentTarget as HTMLElement) ?? undefined) as | HTMLElement | undefined, ) as BaseUIChangeEventDetails, ); } } const html = ownerDocument(floatingElement).documentElement; html.addEventListener("mouseleave", onLeave); return () => { html.removeEventListener("mouseleave", onLeave); }; }, ); const closeWithDelay = createStableCallback< (event: MouseEvent, runElseBranch?: boolean) => void >(() => (event: MouseEvent, runElseBranch = true) => { const closeDelay = getDelay(delay, "close", pointerTypeRef.current); if (closeDelay && !handlerRef.current) { timeout.start(closeDelay, () => store.setOpen( false, createChangeEventDetails( REASONS.triggerHover, event, ) as BaseUIChangeEventDetails, ), ); } else if (runElseBranch) { timeout.clear(); store.setOpen( false, createChangeEventDetails( REASONS.triggerHover, event, ) as BaseUIChangeEventDetails, ); } }); const cleanupMouseMoveHandler = createStableCallback<() => void>(() => () => { unbindMouseMoveRef.current(); unbindMouseMoveRef.current = () => {}; handlerRef.current = undefined; }); const clearPointerEvents = createStableCallback<() => void>(() => () => { if (performedPointerEventsMutationRef.current) { const body = ownerDocument(store.state.floatingElement).body; body.style.pointerEvents = ""; performedPointerEventsMutationRef.current = false; } }); const handleInteractInside = createStableCallback< (event: PointerEvent) => void >(() => (event: PointerEvent) => { const target = getTarget(event) as Element | null; if (!isInteractiveElement(target)) { interactedInsideRef.current = false; return; } interactedInsideRef.current = true; }); // Main event-wiring effect. React uses a single `useEffect` keyed on the // reference / floating element identity; Solid re-runs when either // signal changes. createEffect( () => ({ domReferenceElement: store.state.domReferenceElement, floatingElement: store.state.floatingElement, isOpen: store.state.open, enabled: getEnabled(), }), (snapshot) => { const { domReferenceElement, floatingElement, isOpen, enabled } = snapshot; if (!enabled) { return; } const triggerNullable = domReferenceElement as HTMLElement | null; if (!isElement(triggerNullable)) { return; } // Re-bind the narrowed element so the non-null type survives inside the // nested closures below (control-flow narrowing of the original `const` // is otherwise lost across function boundaries). const trigger: HTMLElement = triggerNullable; let referenceHasPointer = false; function onReferenceMouseEnter(event: MouseEvent) { referenceHasPointer = true; timeout.clear(); blockMouseMoveRef.current = false; if (mouseOnly) { const pt = pointerTypeRef.current; if (pt !== undefined && pt !== "mouse" && pt !== "pen") { return; } } if (getRestMs(restMs) > 0 && !getDelay(delay, "open")) { return; } const openDelay = getDelay(delay, "open", pointerTypeRef.current); const triggerHtml = ((event.currentTarget as HTMLElement) ?? undefined) as HTMLElement | undefined; const domReference = store.state.domReferenceElement; const isOverInactiveTrigger = domReference && triggerHtml && !contains(domReference, triggerHtml); if (openDelay) { timeout.start(openDelay, () => { if (!store.state.open && referenceHasPointer) { store.setOpen( true, createChangeEventDetails( REASONS.triggerHover, event, triggerHtml, ) as BaseUIChangeEventDetails, ); } }); } else if (!isOpen || isOverInactiveTrigger) { store.setOpen( true, createChangeEventDetails( REASONS.triggerHover, event, triggerHtml, ) as BaseUIChangeEventDetails, ); } } function onReferenceMouseLeave(event: MouseEvent) { referenceHasPointer = false; if (isClickLikeOpenEvent()) { clearPointerEvents(); return; } // Cancel a pending hover-open when the pointer leaves before the // delay elapses — even when the floating node is not mounted yet. if (!store.state.open) { timeout.clear(); } unbindMouseMoveRef.current(); const doc = ownerDocument(floatingElement); restTimeout.clear(); restTimeoutPendingRef.current = false; const triggers = store.context.triggerElements; if ( event.relatedTarget && triggers.hasElement(event.relatedTarget as Element) ) { // Moving to another trigger — keep the popup open so the // sibling trigger can move it. return; } if (handleClose && dataRef.current.floatingContext) { if (!store.state.open) { timeout.clear(); } const ctx = { ...dataRef.current.floatingContext, tree, x: event.clientX, y: event.clientY, onClose() { clearPointerEvents(); cleanupMouseMoveHandler(); if (!isClickLikeOpenEvent()) { closeWithDelay(event, true); } }, } as HandleCloseContext; const handler = handleClose(ctx); handlerRef.current = handler; doc.addEventListener("mousemove", handler); unbindMouseMoveRef.current = () => { doc.removeEventListener("mousemove", handler); }; return; } // Allow interactivity without `safePolygon` on touch devices. // With a pointer, a short close delay is an alternative. if (!store.state.open) { return; } // Allow interactivity without `safePolygon` on touch devices. // With a pointer, a short close delay is an alternative. const shouldClose = pointerTypeRef.current === "touch" ? !contains(floatingElement, event.relatedTarget as Element | null) : true; if (shouldClose) { closeWithDelay(event); } } function syncHoverWhileAlreadyOver(): void { if (store.state.open || timeout.isStarted()) { return; } let hovered = false; try { hovered = trigger.matches(":hover"); } catch { hovered = false; } if (!hovered) { return; } onReferenceMouseEnter( new MouseEvent("mouseenter", { bubbles: true, cancelable: true }), ); } // Close the floating element after scrolling even if the pointer // did not move. // https://github.com/floating-ui/floating-ui/discussions/1692 function onScrollMouseLeave(event: MouseEvent) { if ( isClickLikeOpenEvent() || !dataRef.current.floatingContext || !store.state.open ) { return; } const triggers = store.context.triggerElements; if ( event.relatedTarget && triggers.hasElement(event.relatedTarget as Element) ) { return; } if (!handleClose) { return; } const ctx = { ...dataRef.current.floatingContext, tree, x: event.clientX, y: event.clientY, onClose() { clearPointerEvents(); cleanupMouseMoveHandler(); if (!isClickLikeOpenEvent()) { closeWithDelay(event); } }, } as HandleCloseContext; handleClose(ctx)(event); } function onFloatingMouseEnter() { timeout.clear(); clearPointerEvents(); } function onFloatingMouseLeave(event: MouseEvent) { if (!isClickLikeOpenEvent()) { closeWithDelay(event, false); } } const cleanups: Array<() => void> = []; if (isOpen) { trigger.addEventListener("mouseleave", onScrollMouseLeave); cleanups.push(() => { trigger.removeEventListener("mouseleave", onScrollMouseLeave); }); } if (move) { const onceHandler = (evt: MouseEvent) => onReferenceMouseEnter(evt); trigger.addEventListener("mousemove", onceHandler, { once: true }); cleanups.push(() => { trigger.removeEventListener("mousemove", onceHandler); }); } trigger.addEventListener("mouseenter", onReferenceMouseEnter); trigger.addEventListener("mouseleave", onReferenceMouseLeave); cleanups.push(() => { trigger.removeEventListener("mouseenter", onReferenceMouseEnter); trigger.removeEventListener("mouseleave", onReferenceMouseLeave); }); queueMicrotask(syncHoverWhileAlreadyOver); if (floatingElement) { floatingElement.addEventListener("mouseleave", onScrollMouseLeave); floatingElement.addEventListener("mouseenter", onFloatingMouseEnter); floatingElement.addEventListener("mouseleave", onFloatingMouseLeave); floatingElement.addEventListener( "pointerdown", handleInteractInside as EventListener, true, ); cleanups.push(() => { floatingElement.removeEventListener("mouseleave", onScrollMouseLeave); floatingElement.removeEventListener( "mouseenter", onFloatingMouseEnter, ); floatingElement.removeEventListener( "mouseleave", onFloatingMouseLeave, ); floatingElement.removeEventListener( "pointerdown", handleInteractInside as EventListener, true, ); }); } return () => { for (const cleanup of cleanups) { cleanup(); } }; }, ); // Block pointer-events of every element other than the reference and // floating while the floating element is open and has a `handleClose` // handler that opts in via `__options.blockPointerEvents`. Also handles // nested floating elements. // https://github.com/floating-ui/floating-ui/issues/1722 useIsoLayoutEffect( () => ({ open: store.state.open, domReferenceElement: store.state.domReferenceElement, floatingElement: store.state.floatingElement, enabled: getEnabled(), }), (snapshot) => { const { open, domReferenceElement, floatingElement, enabled } = snapshot; if (!enabled) { return; } if (open && handleClose?.__options?.blockPointerEvents && isHoverOpen()) { performedPointerEventsMutationRef.current = true; const floatingEl = floatingElement; if (isElement(domReferenceElement) && floatingEl) { const body = ownerDocument(floatingElement).body; const ref = domReferenceElement as HTMLElement | SVGSVGElement; const parentFloating = tree?.nodesRef.current.find( (node) => node.id === parentId, )?.context?.elements.floating; if (parentFloating) { parentFloating.style.pointerEvents = ""; } body.style.pointerEvents = "none"; ref.style.pointerEvents = "auto"; floatingEl.style.pointerEvents = "auto"; return () => { body.style.pointerEvents = ""; ref.style.pointerEvents = ""; floatingEl.style.pointerEvents = ""; }; } } return undefined; }, ); // Reset shared bookkeeping when the popup closes. useIsoLayoutEffect( () => store.state.open, (open) => { if (!open) { pointerTypeRef.current = undefined; restTimeoutPendingRef.current = false; interactedInsideRef.current = false; cleanupMouseMoveHandler(); clearPointerEvents(); } }, ); // Component-unmount cleanup — mirrors React's final `useEffect` returning // a function. onCleanup(() => { cleanupMouseMoveHandler(); timeout.clear(); restTimeout.clear(); interactedInsideRef.current = false; clearPointerEvents(); }); const reference: ElementProps["reference"] = { onPointerDown(event: PointerEvent) { pointerTypeRef.current = event.pointerType; }, onPointerEnter(event: PointerEvent) { pointerTypeRef.current = event.pointerType; }, onMouseMove(event: MouseEvent) { if (!getEnabled()) { return; } const nativeEvent = event; const trigger = event.currentTarget as HTMLElement; const domReference = store.state.domReferenceElement; const isOverInactiveTrigger = domReference && !contains(domReference, event.target as Element); function handleMouseMove() { if ( !blockMouseMoveRef.current && (!store.state.open || isOverInactiveTrigger) ) { store.setOpen( true, createChangeEventDetails( REASONS.triggerHover, nativeEvent, trigger, ) as BaseUIChangeEventDetails, ); } } if ( (store.state.open && !isOverInactiveTrigger) || getRestMs(restMs) === 0 ) { return; } const movementX = (event as MouseEvent & { movementX?: number }) .movementX; const movementY = (event as MouseEvent & { movementY?: number }) .movementY; // Ignore insignificant movements to account for tremors. if ( !isOverInactiveTrigger && restTimeoutPendingRef.current && (movementX ?? 0) ** 2 + (movementY ?? 0) ** 2 < 2 ) { return; } restTimeout.clear(); if (pointerTypeRef.current === "touch") { handleMouseMove(); } else if (isOverInactiveTrigger) { handleMouseMove(); } else { restTimeoutPendingRef.current = true; restTimeout.start(getRestMs(restMs), handleMouseMove); } }, }; // When `enabled` is a literal `false`, return empty bag. When it's // an accessor (or `undefined`), keep the bag and gate handlers // reactively. The structural check is intentional snapshot — wrap // in `untrack` so consumer-passed `get enabled()` getters don't // trip `[STRICT_READ_UNTRACKED]` from this body-time read. const sticksDisabled = untrack( () => typeof props.enabled !== "function" && !getEnabled(), ); if (sticksDisabled) { return {}; } return { reference }; }