/** * Stage 4.4 port of Base UI React's `useHoverFloatingInteraction`. * * React source: * `base-ui/packages/react/src/floating-ui-react/hooks/useHoverFloatingInteraction.ts` * * Attaches the hover event handlers that belong on the floating element. * Handles: * * - Cancelling the reference-side close-timer when the cursor enters * the floating surface (so the popup stays open while the user is * inside it). * - Closing the popup when the cursor leaves — respecting * `safePolygon` handlers, tree-aware nesting (don't close while a * child menu is open), and enabled-sibling triggers. * - Pointer-events blocking for `blockPointerEvents: true` * safe-polygons (covers menu nesting), including scope detection * via `getScope` or a `[data-rootownerid]` ancestor. * - `pointerdown` interaction-inside tracking used by the shared * `isClickLikeOpenEvent` predicate. * * ### React → Solid deviations * * Same shape as the reference-side port above: * - `useRef` → plain variables / `MutableRef`. * - `useStableCallback` → `createStableCallback`. * - `useEffect` / `useIsoLayoutEffect` → `createEffect` / * `useIsoLayoutEffect` (split-compute form). * - `addEventListener` + `mergeCleanups` → direct * `addEventListener` + an array-based cleanup in `onCleanup`. */ import { isElement } from "@floating-ui/utils/dom"; import { createEffect, 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 { useIsoLayoutEffect } from "../../use-iso-layout-effect"; import { useTimeout } from "../../use-timeout"; import type { FloatingRootStore } from "../components/floating-root-store"; import { useFloatingParentNodeId, useFloatingTree, } from "../components/floating-tree"; import type { FloatingContext, FloatingRootContext } from "../types"; import { contains, getTarget, isTargetInsideEnabledTrigger, } from "../utils/element"; import { getNodeChildren } from "../utils/nodes"; import { applySafePolygonPointerEventsMutation, clearSafePolygonPointerEventsMutation, isInteractiveElement, useHoverInteractionSharedState, } from "./use-hover-interaction-shared-state"; import { getDelay, isClickLikeOpenEvent as isClickLikeOpenEventShared, } from "./use-hover-shared"; export interface UseHoverFloatingInteractionProps { /** * Whether the Hook is enabled, including all internal Effects and event * handlers. * @default true */ enabled?: MaybeAccessor | undefined; /** * Waits for the specified time when the event listener runs before changing * the `open` state. * @default 0 */ closeDelay?: number | (() => number) | undefined; /** * Tree node id override for floating elements that participate in the tree * without a `FloatingContext`, such as inline nested navigation menus. */ nodeId?: string | undefined; } /** * Provides hover interactions that should be attached to the floating element. */ export function useHoverFloatingInteraction( context: | FloatingRootContext | FloatingContext | (() => FloatingRootContext | FloatingContext), parameters: UseHoverFloatingInteractionProps = {}, ): void { /** * Resolve the active floating root store. Accepts either a bare context or * an accessor so navigation-menu List can swap the active trigger context * without re-installing the hook. */ const resolveStore = (): FloatingRootStore => { const ctx = typeof context === "function" ? (context as () => FloatingRootContext | FloatingContext)() : context; return "rootStore" in ctx ? ctx.rootStore : ctx; }; const getEnabled = (): boolean => parameters.enabled === undefined ? true : readOption(parameters.enabled); const { closeDelay: closeDelayProp = 0, nodeId: nodeIdProp } = parameters; // One-shot snapshot used to seed shared state. `resolveStore` reads the // reactive `floatingRootContext` accessor (navigation-menu List passes an // accessor), so untrack the body-time read to avoid `[STRICT_READ_UNTRACKED]`. const initialStore = untrack(resolveStore); const instance = useHoverInteractionSharedState(initialStore); const tree = useFloatingTree(); const parentId = useFloatingParentNodeId(); const isClickLikeOpenEvent = createStableCallback<() => boolean>(() => () => { const store = resolveStore(); const { dataRef } = store.context; return isClickLikeOpenEventShared( dataRef.current.openEvent?.type, instance.interactedInside, ); }); const isHoverOpen = createStableCallback<() => boolean>(() => () => { const store = resolveStore(); const type = store.context.dataRef.current.openEvent?.type; return Boolean(type?.includes("mouse")) && type !== "mousedown"; }); const isRelatedTargetInsideEnabledTrigger = createStableCallback< (target: EventTarget | null) => boolean >(() => (target: EventTarget | null) => { const store = resolveStore(); return isTargetInsideEnabledTrigger(target, store.context.triggerElements); }); const closeWithDelay = createStableCallback<(event: MouseEvent) => void>( () => (event: MouseEvent) => { const store = resolveStore(); const closeDelay = getDelay( closeDelayProp, "close", instance.pointerType, ); const close = () => { store.setOpen( false, createChangeEventDetails( REASONS.triggerHover, event, ) as BaseUIChangeEventDetails, ); tree?.events.emit("floating.closed", event); }; if (closeDelay) { instance.openChangeTimeout.start(closeDelay, close); } else { instance.openChangeTimeout.clear(); close(); } }, ); const clearPointerEvents = createStableCallback<() => void>(() => () => { clearSafePolygonPointerEventsMutation(instance); }); const handleInteractInside = createStableCallback< (event: PointerEvent) => void >(() => (event: PointerEvent) => { const target = getTarget(event) as Element | null; if (!isInteractiveElement(target)) { instance.interactedInside = false; return; } instance.interactedInside = target?.closest("[aria-haspopup]") != null; }); // When the popup closes, reset shared state + clear any pointer-events // mutation set by the safe-polygon path. useIsoLayoutEffect( () => ({ enabled: getEnabled(), open: resolveStore().state.open, }), ({ enabled, open }) => { if (!enabled) { return; } if (!open) { instance.pointerType = undefined; instance.restTimeoutPending = false; instance.interactedInside = false; clearPointerEvents(); } }, ); onCleanup(() => { clearPointerEvents(); }); // Pointer-events mutation for `blockPointerEvents: true` safe-polygon. useIsoLayoutEffect( () => ({ enabled: getEnabled(), open: resolveStore().state.open, domReferenceElement: resolveStore().state.domReferenceElement, floatingElement: resolveStore().state.floatingElement, }), (snapshot) => { if (!snapshot.enabled) { return; } const { open, domReferenceElement, floatingElement } = snapshot; if ( open && instance.handleCloseOptions?.blockPointerEvents && isHoverOpen() && isElement(domReferenceElement) && floatingElement ) { const ref = domReferenceElement as HTMLElement | SVGSVGElement; const floatingEl = floatingElement; const doc = ownerDocument(floatingElement); const parentFloating = tree?.nodesRef.current.find( (node) => node.id === parentId, )?.context?.elements.floating as HTMLElement | null; if (parentFloating) { parentFloating.style.pointerEvents = ""; } const scopeElement = instance.handleCloseOptions?.getScope?.() ?? instance.pointerEventsScopeElement ?? parentFloating ?? (ref.closest("[data-rootownerid]") as | HTMLElement | SVGSVGElement | null) ?? doc.body; applySafePolygonPointerEventsMutation(instance, { scopeElement, referenceElement: ref, floatingElement: floatingEl, }); return () => { clearPointerEvents(); }; } return undefined; }, ); const childClosedTimeout = useTimeout(); createEffect( () => ({ enabled: getEnabled(), floating: resolveStore().state.floatingElement, }), ({ enabled, floating }) => { if (!enabled) { return; } // Effect-apply scope: the compute above already tracks // `resolveStore().state.floatingElement` (and therefore the // `floatingRootContext` accessor), so this re-read is a snapshot — // untrack to avoid `[STRICT_READ_UNTRACKED]`. const store = untrack(resolveStore); const { dataRef } = store.context; function onFloatingMouseEnter() { instance.openChangeTimeout.clear(); childClosedTimeout.clear(); tree?.events.off("floating.closed", onNodeClosed); clearPointerEvents(); } function onFloatingMouseLeave(event: MouseEvent) { if ( tree && parentId && getNodeChildren(tree.nodesRef.current, parentId).length > 0 ) { tree.events.on("floating.closed", onNodeClosed); return; } if (isRelatedTargetInsideEnabledTrigger(event.relatedTarget)) { return; } const currentNodeId = dataRef.current.floatingContext?.nodeId ?? nodeIdProp; const relatedTarget = event.relatedTarget; const isMovingIntoDescendantFloating = tree && currentNodeId && isElement(relatedTarget) && getNodeChildren(tree.nodesRef.current, currentNodeId, false).some( (node) => contains( node.context?.elements.floating ?? null, relatedTarget as Element, ), ); if (isMovingIntoDescendantFloating) { return; } if (instance.handler) { instance.handler(event); return; } clearPointerEvents(); if (!isClickLikeOpenEvent()) { closeWithDelay(event); } } function onNodeClosed(event: MouseEvent) { if ( !tree || !parentId || getNodeChildren(tree.nodesRef.current, parentId).length > 0 ) { return; } childClosedTimeout.start(0, () => { tree.events.off("floating.closed", onNodeClosed); resolveStore().setOpen( false, createChangeEventDetails( REASONS.triggerHover, event, ) as BaseUIChangeEventDetails, ); tree.events.emit("floating.closed", event); }); } if (!floating) { return; } floating.addEventListener("mouseenter", onFloatingMouseEnter); floating.addEventListener("mouseleave", onFloatingMouseLeave); floating.addEventListener( "pointerdown", handleInteractInside as EventListener, true, ); return () => { floating.removeEventListener("mouseenter", onFloatingMouseEnter); floating.removeEventListener("mouseleave", onFloatingMouseLeave); floating.removeEventListener( "pointerdown", handleInteractInside as EventListener, true, ); tree?.events.off("floating.closed", onNodeClosed); }; }, ); }