@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
JavaScript
'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]);
}