UNPKG

@base-ui/react

Version:

Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.

165 lines (162 loc) 7.41 kB
'use client'; import * as React from 'react'; import { addEventListener } from '@base-ui/utils/addEventListener'; import { mergeCleanups } from '@base-ui/utils/mergeCleanups'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { ownerDocument } from '@base-ui/utils/owner'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { useTimeout } from '@base-ui/utils/useTimeout'; import { isElement } from '@floating-ui/utils/dom'; import { createChangeEventDetails } from "../../internals/createBaseUIEventDetails.js"; import { REASONS } from "../../internals/reasons.js"; import { useFloatingParentNodeId, useFloatingTree } from "../components/FloatingTree.js"; import { contains, getTarget } from "../utils/element.js"; import { getNodeChildren } from "../utils/nodes.js"; import { applySafePolygonPointerEventsMutation, clearSafePolygonPointerEventsMutation, isInteractiveElement, useHoverInteractionSharedState } from "./useHoverInteractionSharedState.js"; import { getDelay, isClickLikeOpenEvent as isClickLikeOpenEventShared, isHoverOpenEvent, isInsideEnabledTrigger } from "./useHoverShared.js"; /** * Provides hover interactions that should be attached to the floating element. */ export function useHoverFloatingInteraction(context, parameters = {}) { const { enabled = true, closeDelay: closeDelayProp = 0, nodeId: nodeIdProp } = parameters; const store = 'rootStore' in context ? context.rootStore : context; const open = store.useState('open'); const floatingElement = store.useState('floatingElement'); const domReferenceElement = store.useState('domReferenceElement'); const { dataRef } = store.context; const tree = useFloatingTree(); const parentId = useFloatingParentNodeId(); const instance = useHoverInteractionSharedState(store); const childClosedTimeout = useTimeout(); const isClickLikeOpenEvent = useStableCallback(() => { return isClickLikeOpenEventShared(dataRef.current.openEvent?.type, instance.interactedInside); }); const isHoverOpen = useStableCallback(() => { return isHoverOpenEvent(dataRef.current.openEvent?.type); }); const clearPointerEvents = useStableCallback(() => { clearSafePolygonPointerEventsMutation(instance); }); useIsoLayoutEffect(() => { if (!open) { instance.pointerType = undefined; instance.restTimeoutPending = false; instance.interactedInside = false; clearPointerEvents(); } }, [open, instance, clearPointerEvents]); React.useEffect(() => { return clearPointerEvents; }, [clearPointerEvents]); useIsoLayoutEffect(() => { if (!enabled) { return undefined; } if (open && instance.handleCloseOptions?.blockPointerEvents && isHoverOpen() && isElement(domReferenceElement) && floatingElement) { const ref = domReferenceElement; const floatingEl = floatingElement; const doc = ownerDocument(floatingElement); const parentFloating = tree?.nodesRef.current.find(node => node.id === parentId)?.context?.elements.floating; if (parentFloating) { parentFloating.style.pointerEvents = ''; } // A keep-mounted submenu can appear in the tree before it opens, so a // cached scope or parent lookup may resolve to the submenu itself. That // would not shield sibling items in the parent menu. const cachedScopeElement = instance.pointerEventsScopeElement !== floatingEl ? instance.pointerEventsScopeElement : null; const parentScopeElement = parentFloating !== floatingEl ? parentFloating : null; const scopeElement = instance.handleCloseOptions?.getScope?.() ?? cachedScopeElement ?? parentScopeElement ?? ref.closest('[data-rootownerid]') ?? doc.body; applySafePolygonPointerEventsMutation(instance, { scopeElement, referenceElement: ref, floatingElement: floatingEl }); return () => { clearPointerEvents(); }; } return undefined; }, [enabled, open, domReferenceElement, floatingElement, instance, isHoverOpen, tree, parentId, clearPointerEvents]); React.useEffect(() => { if (!enabled) { return undefined; } function hasParentChildren() { return !!(tree && parentId && getNodeChildren(tree.nodesRef.current, parentId).length > 0); } function closeWithDelay(event) { const closeDelay = getDelay(closeDelayProp, 'close', instance.pointerType); const close = () => { store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event)); tree?.events.emit('floating.closed', event); }; if (closeDelay) { instance.openChangeTimeout.start(closeDelay, close); } else { instance.openChangeTimeout.clear(); close(); } } function handleInteractInside(event) { const target = getTarget(event); if (!isInteractiveElement(target)) { instance.interactedInside = false; return; } instance.interactedInside = target?.closest('[aria-haspopup]') != null; } function onFloatingMouseEnter() { instance.openChangeTimeout.clear(); childClosedTimeout.clear(); tree?.events.off('floating.closed', onNodeClosed); clearPointerEvents(); } function onFloatingMouseLeave(event) { if (hasParentChildren() && tree) { tree.events.on('floating.closed', onNodeClosed); return; } if (isInsideEnabledTrigger(event.relatedTarget, store.context.triggerElements)) { // If the mouse is leaving the reference element to another trigger, don't explicitly close the popup // as it will be moved. 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, relatedTarget)); if (isMovingIntoDescendantFloating) { return; } // If the safePolygon handler is active, let it handle the close logic. if (instance.handler) { instance.handler(event); return; } clearPointerEvents(); if (!isClickLikeOpenEvent()) { closeWithDelay(event); } } function onNodeClosed(event) { if (!tree || !parentId || hasParentChildren()) { return; } // Allow the mouseenter event to fire in case child was closed because mouse moved into parent. childClosedTimeout.start(0, () => { tree.events.off('floating.closed', onNodeClosed); store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event)); tree.events.emit('floating.closed', event); }); } const floating = floatingElement; return mergeCleanups(floating && addEventListener(floating, 'mouseenter', onFloatingMouseEnter), floating && addEventListener(floating, 'mouseleave', onFloatingMouseLeave), floating && addEventListener(floating, 'pointerdown', handleInteractInside, true), () => { tree?.events.off('floating.closed', onNodeClosed); }); }, [enabled, floatingElement, store, dataRef, closeDelayProp, nodeIdProp, isClickLikeOpenEvent, clearPointerEvents, instance, tree, parentId, childClosedTimeout]); }