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.

160 lines (158 loc) 7.22 kB
'use client'; import * as React from 'react'; import { isElement } from '@floating-ui/utils/dom'; import { addEventListener } from '@base-ui/utils/addEventListener'; import { mergeCleanups } from '@base-ui/utils/mergeCleanups'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { useTimeout } from '@base-ui/utils/useTimeout'; import { ownerDocument } from '@base-ui/utils/owner'; import { contains, getTarget, isTargetInsideEnabledTrigger } from "../utils/element.js"; import { getNodeChildren } from "../utils/nodes.js"; import { createChangeEventDetails } from "../../internals/createBaseUIEventDetails.js"; import { REASONS } from "../../internals/reasons.js"; import { useFloatingParentNodeId, useFloatingTree } from "../components/FloatingTree.js"; import { applySafePolygonPointerEventsMutation, clearSafePolygonPointerEventsMutation, isInteractiveElement, useHoverInteractionSharedState } from "./useHoverInteractionSharedState.js"; import { getDelay, isClickLikeOpenEvent as isClickLikeOpenEventShared } from "./useHoverShared.js"; /** * Provides hover interactions that should be attached to the floating element. */ export function useHoverFloatingInteraction(context, 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 { enabled = true, closeDelay: closeDelayProp = 0, nodeId: nodeIdProp } = parameters; const instance = useHoverInteractionSharedState(store); const tree = useFloatingTree(); const parentId = useFloatingParentNodeId(); const isClickLikeOpenEvent = useStableCallback(() => { return isClickLikeOpenEventShared(dataRef.current.openEvent?.type, instance.interactedInside); }); const isHoverOpen = useStableCallback(() => { const type = dataRef.current.openEvent?.type; return type?.includes('mouse') && type !== 'mousedown'; }); const isRelatedTargetInsideEnabledTrigger = useStableCallback(target => { return isTargetInsideEnabledTrigger(target, store.context.triggerElements); }); const closeWithDelay = React.useCallback(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(); } }, [closeDelayProp, store, instance, tree]); const clearPointerEvents = useStableCallback(() => { clearSafePolygonPointerEventsMutation(instance); }); const handleInteractInside = useStableCallback(event => { const target = getTarget(event); if (!isInteractiveElement(target)) { instance.interactedInside = false; return; } instance.interactedInside = target?.closest('[aria-haspopup]') != null; }); 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 = ''; } const scopeElement = instance.handleCloseOptions?.getScope?.() ?? instance.pointerEventsScopeElement ?? parentFloating ?? 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]); const childClosedTimeout = useTimeout(); React.useEffect(() => { if (!enabled) { return undefined; } function onFloatingMouseEnter() { instance.openChangeTimeout.clear(); childClosedTimeout.clear(); tree?.events.off('floating.closed', onNodeClosed); clearPointerEvents(); } function onFloatingMouseLeave(event) { if (tree && parentId && getNodeChildren(tree.nodesRef.current, parentId).length > 0) { tree.events.on('floating.closed', onNodeClosed); return; } if (isRelatedTargetInsideEnabledTrigger(event.relatedTarget)) { // 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 || getNodeChildren(tree.nodesRef.current, parentId).length > 0) { 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, nodeIdProp, isClickLikeOpenEvent, isRelatedTargetInsideEnabledTrigger, closeWithDelay, clearPointerEvents, handleInteractInside, instance, tree, parentId, childClosedTimeout]); }