@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.
176 lines (173 loc) • 7.03 kB
JavaScript
'use client';
import * as React from 'react';
import { isElement } from '@floating-ui/utils/dom';
import { useStableCallback } from '@base-ui/utils/useStableCallback';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { ownerDocument } from '@base-ui/utils/owner';
import { getTarget, isMouseLikePointerType, isTargetInsideEnabledTrigger } from "../utils.js";
import { createChangeEventDetails } from "../../utils/createBaseUIEventDetails.js";
import { REASONS } from "../../utils/reasons.js";
import { useFloatingParentNodeId, useFloatingTree } from "../components/FloatingTree.js";
import { isInteractiveElement, safePolygonIdentifier, useHoverInteractionSharedState } from "./useHoverInteractionSharedState.js";
const clickLikeEvents = new Set(['click', 'mousedown']);
/**
* 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
} = parameters;
const instance = useHoverInteractionSharedState(store);
const tree = useFloatingTree();
const parentId = useFloatingParentNodeId();
const isClickLikeOpenEvent = useStableCallback(() => {
if (instance.interactedInside) {
return true;
}
return dataRef.current.openEvent ? clickLikeEvents.has(dataRef.current.openEvent.type) : false;
});
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, runElseBranch = true) => {
const closeDelay = getDelay(closeDelayProp, instance.pointerType);
if (closeDelay && !instance.handler) {
instance.openChangeTimeout.start(closeDelay, () => store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event)));
} else if (runElseBranch) {
instance.openChangeTimeout.clear();
store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event));
}
}, [closeDelayProp, store, instance]);
const cleanupMouseMoveHandler = useStableCallback(() => {
instance.unbindMouseMove();
instance.handler = undefined;
});
const clearPointerEvents = useStableCallback(() => {
if (instance.performedPointerEventsMutation) {
const body = ownerDocument(floatingElement).body;
body.style.pointerEvents = '';
body.removeAttribute(safePolygonIdentifier);
instance.performedPointerEventsMutation = false;
}
});
const handleInteractInside = useStableCallback(event => {
const target = getTarget(event);
if (!isInteractiveElement(target)) {
instance.interactedInside = false;
return;
}
instance.interactedInside = true;
});
useIsoLayoutEffect(() => {
if (!open) {
instance.pointerType = undefined;
instance.restTimeoutPending = false;
instance.interactedInside = false;
cleanupMouseMoveHandler();
clearPointerEvents();
}
}, [open, instance, cleanupMouseMoveHandler, clearPointerEvents]);
React.useEffect(() => {
return () => {
cleanupMouseMoveHandler();
};
}, [cleanupMouseMoveHandler]);
React.useEffect(() => {
return clearPointerEvents;
}, [clearPointerEvents]);
useIsoLayoutEffect(() => {
if (!enabled) {
return undefined;
}
if (open && instance.handleCloseOptions?.blockPointerEvents && isHoverOpen() && isElement(domReferenceElement) && floatingElement) {
instance.performedPointerEventsMutation = true;
const body = ownerDocument(floatingElement).body;
body.setAttribute(safePolygonIdentifier, '');
const ref = domReferenceElement;
const floatingEl = floatingElement;
const parentFloating = tree?.nodesRef.current.find(node => node.id === parentId)?.context?.elements.floating;
if (parentFloating) {
parentFloating.style.pointerEvents = '';
}
body.style.pointerEvents = 'none';
ref.style.pointerEvents = 'auto';
floatingEl.style.pointerEvents = 'auto';
return () => {
body.style.pointerEvents = '';
ref.style.pointerEvents = '';
floatingEl.style.pointerEvents = '';
};
}
return undefined;
}, [enabled, open, domReferenceElement, floatingElement, instance, isHoverOpen, tree, parentId]);
React.useEffect(() => {
if (!enabled) {
return undefined;
}
// Ensure the floating element closes after scrolling even if the pointer
// did not move.
// https://github.com/floating-ui/floating-ui/discussions/1692
function onScrollMouseLeave(event) {
if (isClickLikeOpenEvent() || !dataRef.current.floatingContext || !store.select('open')) {
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;
}
clearPointerEvents();
cleanupMouseMoveHandler();
if (!isClickLikeOpenEvent()) {
closeWithDelay(event);
}
}
function onFloatingMouseEnter(event) {
instance.openChangeTimeout.clear();
clearPointerEvents();
instance.handler?.(event);
cleanupMouseMoveHandler();
}
function onFloatingMouseLeave(event) {
if (!isClickLikeOpenEvent()) {
closeWithDelay(event, false);
}
}
const floating = floatingElement;
if (floating) {
floating.addEventListener('mouseleave', onScrollMouseLeave);
floating.addEventListener('mouseenter', onFloatingMouseEnter);
floating.addEventListener('mouseleave', onFloatingMouseLeave);
floating.addEventListener('pointerdown', handleInteractInside, true);
}
return () => {
if (floating) {
floating.removeEventListener('mouseleave', onScrollMouseLeave);
floating.removeEventListener('mouseenter', onFloatingMouseEnter);
floating.removeEventListener('mouseleave', onFloatingMouseLeave);
floating.removeEventListener('pointerdown', handleInteractInside, true);
}
};
}, [enabled, floatingElement, store, dataRef, isClickLikeOpenEvent, isRelatedTargetInsideEnabledTrigger, closeWithDelay, clearPointerEvents, cleanupMouseMoveHandler, handleInteractInside, instance]);
}
export function getDelay(value, pointerType) {
if (pointerType && !isMouseLikePointerType(pointerType)) {
return 0;
}
if (typeof value === 'function') {
return value();
}
return value;
}