/** * Stage 4.4 port of Base UI React's `useHoverReferenceInteraction`. * * React source: * `base-ui/packages/react/src/floating-ui-react/hooks/useHoverReferenceInteraction.ts` * * Attaches the hover event handlers that belong on the reference (trigger) * element. Coordinates with the floating-side counterpart (below) via * `HoverInteraction`, the shared mutable bag on `dataRef`. * * Returns a prop bag (`onPointerDown`, `onPointerEnter`, `onMouseMove`) * applied via the `reference` slot of `useInteractions`. The heavy * `mouseenter` / `mouseleave` wiring is installed imperatively with * `addEventListener` so disabled-element gaps don't swallow the events * (React's delegation system has the same quirk). * * ### React → Solid deviations * * 1. `useRef` → plain `MutableRef` objects or closure `let`s. The * `HoverInteraction` shared bag is pure mutables so no signals are * involved — the hover logic runs entirely in event callbacks and * effect bodies. * 2. `useValueAsRef` → not needed for options read inside event * handlers — we capture them at setup. Options are read at event * time off the captured `props` reference so late-bound changes * observed by callers still apply (plain JS behaviour). * 3. `useStableCallback` → `createStableCallback` from * `src/internals/stable-callback.ts`. Same identity-stability * guarantee — ensures `addEventListener` / `removeEventListener` * pair up across re-installations. * 4. React's `useEffect` with dep arrays → `createEffect` in the * split `(compute, apply)` form. The compute branch returns a * tracked snapshot keyed on `enabled` / the signals we want to * re-run on; the apply branch installs listeners and registers * `onCleanup`. * 5. `ReactDOM.flushSync` (used on the touch open path) → Solid's * `flush()` from `solid-js`. Same semantics: apply all pending * updates before the next tick, so downstream reactive readers * see the touch-opened state synchronously. * 6. React returns `HTMLProps | undefined` from `useMemo`; the Solid * port returns a plain object since the body runs once. */ import { isElement } from "@floating-ui/utils/dom"; import { createEffect, flush, onCleanup, untrack } from "solid-js"; import { type BaseUIChangeEventDetails, createChangeEventDetails, REASONS, } from "../../change-event-details"; import { ownerDocument } from "../../dom/owner-document"; import { type MaybeAccessor, readOption } from "../../maybe-accessor"; import { createStableCallback } from "../../stable-callback"; import type { FloatingUIOpenChangeDetails } from "../components/floating-root-store"; import { useFloatingTree } from "../components/floating-tree"; import type { FloatingTreeStore } from "../components/floating-tree-store"; import type { Delay, ElementPropsBag, FloatingContext, FloatingRootContext, MutableRef, } from "../types"; import { contains, getTarget, isTargetInsideEnabledTrigger, } from "../utils/element"; import { isMouseLikePointerType } from "../utils/event"; import { applySafePolygonPointerEventsMutation, clearSafePolygonPointerEventsMutation, useHoverInteractionSharedState, } from "./use-hover-interaction-shared-state"; import type { HandleClose, HandleCloseContextBase } from "./use-hover-shared"; import { getDelay, getRestMs, isClickLikeOpenEvent as isClickLikeOpenEventShared, } from "./use-hover-shared"; const EMPTY_REF: Readonly> = { current: null }; export interface UseHoverReferenceInteractionProps { enabled?: MaybeAccessor | undefined; handleClose?: HandleClose | null | undefined; restMs?: number | (() => number) | undefined; delay?: Delay | (() => Delay) | undefined; move?: boolean | undefined; mouseOnly?: boolean | undefined; externalTree?: FloatingTreeStore | undefined; /** * Whether the hook controls the active trigger. When `false`, consumers * spread the returned props on an inactive trigger via * `getTriggerProps`. * @default true */ isActiveTrigger?: MaybeAccessor | undefined; /** * Reactive trigger element — re-arms `mouseenter` / `mouseleave` listeners * when the ref callback mounts (split-effect compute phase). */ triggerElement?: MaybeAccessor | undefined; triggerElementRef?: Readonly> | undefined; getHandleCloseContext?: (() => HandleCloseContextBase | null) | undefined; isClosing?: (() => boolean) | undefined; } /** * Provides hover interactions that should be attached to reference or trigger * elements. */ export function useHoverReferenceInteraction( context: FloatingRootContext | FloatingContext, props: UseHoverReferenceInteractionProps = {}, ): ElementPropsBag | undefined { const store = "rootStore" in context ? context.rootStore : context; const { dataRef, events } = store.context; // Untrack the body-time prop destructure: parents may pass // signal-derived values via JSX, which makes each `props.foo` // access a tracked read and fires `[STRICT_READ_UNTRACKED]` under // the strict-read scope set up by `devComponent(...)`. These fields // are typed as plain values (not `MaybeAccessor`), so a one-shot // snapshot here matches the React fork's semantics. const { delay = 0, handleClose = null, mouseOnly = false, restMs = 0, move = true, triggerElementRef = EMPTY_REF, externalTree, getHandleCloseContext, isClosing, } = untrack(() => ({ delay: props.delay, handleClose: props.handleClose, mouseOnly: props.mouseOnly, restMs: props.restMs, move: props.move, triggerElementRef: props.triggerElementRef, externalTree: props.externalTree, getHandleCloseContext: props.getHandleCloseContext, isClosing: props.isClosing, })); const readEnabled = (): boolean => readOption(props.enabled) ?? true; const readIsActiveTrigger = (): boolean => readOption(props.isActiveTrigger) ?? true; const readTriggerElement = (): Element | null => readOption(props.triggerElement) ?? null; const tree = useFloatingTree(externalTree); const instance = useHoverInteractionSharedState(store); const isHoverCloseActiveRef: MutableRef = { current: false }; createEffect( () => readIsActiveTrigger(), (active) => { if (active) { instance.handleCloseOptions = handleClose?.__options; } }, ); const isClickLikeOpenEvent = createStableCallback<() => boolean>( () => () => isClickLikeOpenEventShared( dataRef.current.openEvent?.type, instance.interactedInside, ), ); const isRelatedTargetInsideEnabledTrigger = createStableCallback< (target: EventTarget | null) => boolean >(() => (target: EventTarget | null) => { return isTargetInsideEnabledTrigger(target, store.context.triggerElements); }); const isOverInactiveTrigger = createStableCallback< ( currentDomReference: Element | null, currentTarget: Element, target: EventTarget | null, ) => boolean >( () => ( currentDomReference: Element | null, currentTarget: Element, target: EventTarget | null, ) => { const allTriggers = store.context.triggerElements; if (allTriggers.hasElement(currentTarget)) { return ( !currentDomReference || !contains(currentDomReference, currentTarget) ); } if (!isElement(target)) { return false; } const targetElement = target as Element; return ( allTriggers.hasMatchingElement((trigger) => contains(trigger, targetElement), ) && (!currentDomReference || !contains(currentDomReference, targetElement)) ); }, ); const closeWithDelay = createStableCallback< (event: MouseEvent, runElseBranch?: boolean) => void >(() => (event: MouseEvent, runElseBranch = true) => { const closeDelay = getDelay(delay, "close", instance.pointerType); if (closeDelay) { instance.openChangeTimeout.start(closeDelay, () => { store.setOpen( false, createChangeEventDetails( REASONS.triggerHover, event, ) as BaseUIChangeEventDetails, ); tree?.events.emit("floating.closed", event); }); } else if (runElseBranch) { instance.openChangeTimeout.clear(); store.setOpen( false, createChangeEventDetails( REASONS.triggerHover, event, ) as BaseUIChangeEventDetails, ); tree?.events.emit("floating.closed", event); } }); const cleanupMouseMoveHandler = createStableCallback<() => void>(() => () => { if (!instance.handler) { return; } const doc = ownerDocument(store.state.domReferenceElement); doc.removeEventListener("mousemove", instance.handler); instance.handler = undefined; }); const clearPointerEvents = createStableCallback<() => void>(() => () => { clearSafePolygonPointerEventsMutation(instance); }); // Single-shot unmount-style cleanup — matches React's // `React.useEffect(() => cleanupMouseMoveHandler, [...])`. onCleanup(() => { cleanupMouseMoveHandler(); }); // `openchange` listener: clears pending hover timers whenever the // popup closes so a delayed hover-open can't fight an external // `setOpen(false)`. Matches React's subscriber. createEffect( () => readEnabled(), (isEnabled) => { if (!isEnabled) { return; } function onOpenChangeLocal(details: FloatingUIOpenChangeDetails) { if (!details.open) { isHoverCloseActiveRef.current = details.reason === REASONS.triggerHover; cleanupMouseMoveHandler(); instance.openChangeTimeout.clear(); instance.restTimeout.clear(); instance.blockMouseMove = true; instance.restTimeoutPending = false; } else { isHoverCloseActiveRef.current = false; } } events.on("openchange", onOpenChangeLocal); return () => { events.off("openchange", onOpenChangeLocal); }; }, ); // Main reference-element listener effect. Re-runs when the reference // element identity changes (so `addEventListener` latches on the new // node), when the trigger ref signal updates, or when `enabled` flips. createEffect( () => { const isActiveTrigger = readIsActiveTrigger(); const trigger = readTriggerElement() ?? (triggerElementRef.current as HTMLElement | null) ?? (isActiveTrigger ? (store.state.domReferenceElement as HTMLElement | null) : null); return { trigger, enabled: readEnabled() }; }, ({ trigger, enabled }) => { if (!enabled) { return; } if (!isElement(trigger)) { return; } // `isElement` narrows to `Element`, but the listeners below use the // `HTMLElementEventMap` overloads; bind a non-null `HTMLElement` so the // typed handlers resolve and the narrowing survives nested closures. const triggerEl = trigger as HTMLElement; let referenceHasPointer = false; function onPointerEnterNative(event: PointerEvent) { instance.pointerType = event.pointerType; } function onMouseEnter(event: MouseEvent) { referenceHasPointer = true; instance.openChangeTimeout.clear(); instance.blockMouseMove = false; // `mouseenter` can fire before JSX `onPointerEnter` runs (or when // only imperative listeners are active). Treat it as mouse-like. if ( mouseOnly && !isMouseLikePointerType(instance.pointerType) && event.type === "mouseenter" ) { instance.pointerType = "mouse"; } if (mouseOnly && !isMouseLikePointerType(instance.pointerType)) { return; } // Only rest delay is set; there's no fallback delay. // This will be handled by `onMouseMove`. const restMsValue = getRestMs(restMs); const openDelay = getDelay(delay, "open", instance.pointerType); const eventTarget = getTarget(event); const currentTarget = (event.currentTarget as HTMLElement) ?? null; const currentDomReference = store.state.domReferenceElement; let triggerNode = currentTarget; // Wrapper/delegated mode: resolve the actual trigger from the event target. if ( isElement(eventTarget) && !store.context.triggerElements.hasElement(eventTarget as Element) ) { for (const triggerElement of store.context.triggerElements.elements()) { if (contains(triggerElement, eventTarget as Element)) { triggerNode = triggerElement as HTMLElement; break; } } } // Wrapper/delegated mode fallback. if ( isElement(currentTarget) && isElement(currentDomReference) && !store.context.triggerElements.hasElement(currentTarget) && contains(currentTarget, currentDomReference as Element) ) { triggerNode = currentDomReference as HTMLElement; } const isOverInactive = triggerNode == null ? false : isOverInactiveTrigger( currentDomReference, triggerNode, eventTarget, ); const isOpen = store.state.open; const isInClosingTransition = isClosing?.() ?? store.state.transitionStatus === "ending"; const isHoverCloseTransition = !isOpen && isInClosingTransition && isHoverCloseActiveRef.current; const isReenteringSameTriggerDuringCloseTransition = !isOverInactive && isElement(triggerNode) && isElement(currentDomReference) && contains(currentDomReference as Element, triggerNode) && isHoverCloseTransition; const isRestOnlyDelay = restMsValue > 0 && !openDelay; const shouldOpenImmediately = (isOverInactive && (isOpen || isHoverCloseTransition)) || isReenteringSameTriggerDuringCloseTransition; const shouldOpen = !isOpen || isOverInactive; if (shouldOpenImmediately) { store.setOpen( true, createChangeEventDetails( REASONS.triggerHover, event, triggerNode ?? undefined, ) as BaseUIChangeEventDetails, ); return; } if (isRestOnlyDelay) { return; } if (openDelay) { instance.openChangeTimeout.start(openDelay, () => { if (!shouldOpen || !referenceHasPointer) { return; } if (!store.state.open) { store.setOpen( true, createChangeEventDetails( REASONS.triggerHover, event, triggerNode ?? undefined, ) as BaseUIChangeEventDetails, ); } }); } else if (shouldOpen) { store.setOpen( true, createChangeEventDetails( REASONS.triggerHover, event, triggerNode ?? undefined, ) as BaseUIChangeEventDetails, ); } } function onMouseLeave(event: MouseEvent) { referenceHasPointer = false; if (isClickLikeOpenEvent()) { clearPointerEvents(); return; } // Cancel a pending hover-open when the pointer leaves before the // delay elapses. This must run even when `floatingContext` is not // wired yet (popup still mounting) — otherwise the timer fires // after leave and the surface blips visible briefly. if (!store.state.open) { instance.openChangeTimeout.clear(); } cleanupMouseMoveHandler(); const domReferenceElement = store.state.domReferenceElement; const doc = ownerDocument(domReferenceElement); instance.restTimeout.clear(); instance.restTimeoutPending = false; const handleCloseContextBase = dataRef.current.floatingContext ?? getHandleCloseContext?.(); const ignoreRelatedTargetTrigger = isRelatedTargetInsideEnabledTrigger( event.relatedTarget, ); if (ignoreRelatedTargetTrigger) { return; } if (handleClose && handleCloseContextBase) { if (!store.state.open) { instance.openChangeTimeout.clear(); } const currentTrigger = triggerElementRef.current; instance.handler = handleClose({ ...(handleCloseContextBase as HandleCloseContextBase), tree, x: event.clientX, y: event.clientY, onClose() { clearPointerEvents(); cleanupMouseMoveHandler(); if ( readEnabled() && !isClickLikeOpenEvent() && currentTrigger === store.state.domReferenceElement ) { closeWithDelay(event, true); } }, }); doc.addEventListener("mousemove", instance.handler); instance.handler(event); return; } if (!store.state.open) { return; } const shouldClose = instance.pointerType === "touch" ? !contains( store.state.floatingElement, event.relatedTarget as Element | null, ) : true; if (shouldClose) { closeWithDelay(event); } } function syncHoverWhileAlreadyOver(): void { if (store.state.open || instance.openChangeTimeout.isStarted()) { return; } let hovered = false; try { hovered = triggerEl.matches(":hover"); } catch { hovered = false; } if (!hovered) { return; } onMouseEnter( new MouseEvent("mouseenter", { bubbles: true, cancelable: true }), ); } const cleanups: Array<() => void> = []; if (move) { const onceHandler = (evt: MouseEvent) => onMouseEnter(evt); triggerEl.addEventListener("mousemove", onceHandler, { once: true }); cleanups.push(() => { triggerEl.removeEventListener("mousemove", onceHandler); }); } triggerEl.addEventListener("pointerenter", onPointerEnterNative); triggerEl.addEventListener("mouseenter", onMouseEnter); triggerEl.addEventListener("mouseleave", onMouseLeave); cleanups.push(() => { triggerEl.removeEventListener("pointerenter", onPointerEnterNative); triggerEl.removeEventListener("mouseenter", onMouseEnter); triggerEl.removeEventListener("mouseleave", onMouseLeave); }); queueMicrotask(syncHoverWhileAlreadyOver); return () => { for (const cleanup of cleanups) { cleanup(); } }; }, ); function setPointerRef(event: PointerEvent) { instance.pointerType = event.pointerType; } return { onPointerDown: setPointerRef, onPointerEnter: setPointerRef, onMouseMove(event: MouseEvent) { const nativeEvent = event; const trigger = event.currentTarget as HTMLElement; const currentDomReference = store.state.domReferenceElement; const currentOpen = store.state.open; const isOverInactive = isOverInactiveTrigger( currentDomReference, trigger, event.target, ); if (mouseOnly && !isMouseLikePointerType(instance.pointerType)) { return; } if ( currentOpen && isOverInactive && instance.handleCloseOptions?.blockPointerEvents ) { const floatingElement = store.state.floatingElement; if (floatingElement) { const scopeElement = instance.handleCloseOptions?.getScope?.() ?? trigger.ownerDocument.body; applySafePolygonPointerEventsMutation(instance, { scopeElement, referenceElement: trigger, floatingElement, }); } } const restMsValue = getRestMs(restMs); if ((currentOpen && !isOverInactive) || restMsValue === 0) { return; } const movementX = (event as MouseEvent & { movementX?: number }) .movementX; const movementY = (event as MouseEvent & { movementY?: number }) .movementY; if ( !isOverInactive && instance.restTimeoutPending && (movementX ?? 0) ** 2 + (movementY ?? 0) ** 2 < 2 ) { return; } instance.restTimeout.clear(); function handleMouseMove() { instance.restTimeoutPending = false; // A delayed hover open should not override a click-like open that // happened while the hover delay was pending. if (isClickLikeOpenEvent()) { return; } const latestOpen = store.state.open; if (!instance.blockMouseMove && (!latestOpen || isOverInactive)) { store.setOpen( true, createChangeEventDetails( REASONS.triggerHover, nativeEvent, trigger, ) as BaseUIChangeEventDetails, ); } } if (instance.pointerType === "touch") { // React uses `ReactDOM.flushSync` to apply the touch-open // synchronously so downstream reactive readers see it in // the same tick. Solid's `flush()` is the direct analogue. handleMouseMove(); flush(); } else if (isOverInactive && currentOpen) { handleMouseMove(); } else { instance.restTimeoutPending = true; instance.restTimeout.start(restMsValue, handleMouseMove); } }, }; }