@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.
309 lines (299 loc) • 13.3 kB
JavaScript
'use client';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { isElement } from '@floating-ui/utils/dom';
import { addEventListener } from '@base-ui/utils/addEventListener';
import { mergeCleanups } from '@base-ui/utils/mergeCleanups';
import { useValueAsRef } from '@base-ui/utils/useValueAsRef';
import { useStableCallback } from '@base-ui/utils/useStableCallback';
import { ownerDocument } from '@base-ui/utils/owner';
import { contains, getTarget, isTargetInsideEnabledTrigger } from "../utils/element.js";
import { isMouseLikePointerType } from "../utils/event.js";
import { createChangeEventDetails } from "../../internals/createBaseUIEventDetails.js";
import { REASONS } from "../../internals/reasons.js";
import { useFloatingTree } from "../components/FloatingTree.js";
import { applySafePolygonPointerEventsMutation, clearSafePolygonPointerEventsMutation, useHoverInteractionSharedState } from "./useHoverInteractionSharedState.js";
import { getDelay, getRestMs, isClickLikeOpenEvent as isClickLikeOpenEventShared } from "./useHoverShared.js";
const EMPTY_REF = {
current: null
};
/**
* Provides hover interactions that should be attached to reference or trigger
* elements.
*/
export function useHoverReferenceInteraction(context, props = {}) {
const store = 'rootStore' in context ? context.rootStore : context;
const {
dataRef,
events
} = store.context;
const {
enabled = true,
delay = 0,
handleClose = null,
mouseOnly = false,
restMs = 0,
move = true,
triggerElementRef = EMPTY_REF,
externalTree,
isActiveTrigger = true,
getHandleCloseContext,
isClosing
} = props;
const tree = useFloatingTree(externalTree);
const instance = useHoverInteractionSharedState(store);
const isHoverCloseActiveRef = React.useRef(false);
const handleCloseRef = useValueAsRef(handleClose);
const delayRef = useValueAsRef(delay);
const restMsRef = useValueAsRef(restMs);
const enabledRef = useValueAsRef(enabled);
const isClosingRef = useValueAsRef(isClosing);
if (isActiveTrigger) {
// eslint-disable-next-line no-underscore-dangle
instance.handleCloseOptions = handleCloseRef.current?.__options;
}
const isClickLikeOpenEvent = useStableCallback(() => {
return isClickLikeOpenEventShared(dataRef.current.openEvent?.type, instance.interactedInside);
});
const isRelatedTargetInsideEnabledTrigger = useStableCallback(target => {
return isTargetInsideEnabledTrigger(target, store.context.triggerElements);
});
const isOverInactiveTrigger = useStableCallback((currentDomReference, currentTarget, target) => {
const allTriggers = store.context.triggerElements;
// Fast path for normal usage where handlers are attached directly to triggers.
if (allTriggers.hasElement(currentTarget)) {
return !currentDomReference || !contains(currentDomReference, currentTarget);
}
// Fallback for delegated/wrapper usage where currentTarget may be outside the trigger map.
if (!isElement(target)) {
return false;
}
const targetElement = target;
return allTriggers.hasMatchingElement(trigger => contains(trigger, targetElement)) && (!currentDomReference || !contains(currentDomReference, targetElement));
});
const closeWithDelay = useStableCallback((event, runElseBranch = true) => {
const closeDelay = getDelay(delayRef.current, 'close', instance.pointerType);
if (closeDelay) {
instance.openChangeTimeout.start(closeDelay, () => {
store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event));
tree?.events.emit('floating.closed', event);
});
} else if (runElseBranch) {
instance.openChangeTimeout.clear();
store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event));
tree?.events.emit('floating.closed', event);
}
});
const cleanupMouseMoveHandler = useStableCallback(() => {
if (!instance.handler) {
return;
}
const doc = ownerDocument(store.select('domReferenceElement'));
doc.removeEventListener('mousemove', instance.handler);
instance.handler = undefined;
});
const clearPointerEvents = useStableCallback(() => {
clearSafePolygonPointerEventsMutation(instance);
});
React.useEffect(() => cleanupMouseMoveHandler, [cleanupMouseMoveHandler]);
// When closing before opening, clear the delay timeouts to cancel it
// from showing.
React.useEffect(() => {
if (!enabled) {
return undefined;
}
function onOpenChangeLocal(details) {
if (!details.open) {
isHoverCloseActiveRef.current = details.reason === REASONS.triggerHover;
cleanupMouseMoveHandler();
instance.openChangeTimeout.clear();
instance.restTimeout.clear();
instance.blockMouseMove = true;
instance.restTimeoutPending = false;
} else {
isHoverCloseActiveRef.current = false;
}
}
events.on('openchange', onOpenChangeLocal);
return () => {
events.off('openchange', onOpenChangeLocal);
};
}, [enabled, events, instance, cleanupMouseMoveHandler]);
React.useEffect(() => {
if (!enabled) {
return undefined;
}
const trigger = triggerElementRef.current ?? (isActiveTrigger ? store.select('domReferenceElement') : null);
if (!isElement(trigger)) {
return undefined;
}
function onMouseEnter(event) {
instance.openChangeTimeout.clear();
instance.blockMouseMove = false;
if (mouseOnly && !isMouseLikePointerType(instance.pointerType)) {
return;
}
// Only rest delay is set; there's no fallback delay.
// This will be handled by `onMouseMove`.
const restMsValue = getRestMs(restMsRef.current);
const openDelay = getDelay(delayRef.current, 'open', instance.pointerType);
const eventTarget = getTarget(event);
const currentTarget = event.currentTarget ?? null;
const currentDomReference = store.select('domReferenceElement');
let triggerNode = currentTarget;
// Wrapper/delegated mode: resolve the actual trigger from the event target.
if (isElement(eventTarget) && !store.context.triggerElements.hasElement(eventTarget)) {
for (const triggerElement of store.context.triggerElements.elements()) {
if (contains(triggerElement, eventTarget)) {
triggerNode = triggerElement;
break;
}
}
}
// Wrapper/delegated mode fallback: if the wrapper contains the active trigger,
// treat this as re-entering that active trigger.
if (isElement(currentTarget) && isElement(currentDomReference) && !store.context.triggerElements.hasElement(currentTarget) && contains(currentTarget, currentDomReference)) {
triggerNode = currentDomReference;
}
const isOverInactive = triggerNode == null ? false : isOverInactiveTrigger(currentDomReference, triggerNode, eventTarget);
const isOpen = store.select('open');
const isInClosingTransition = isClosingRef.current?.() ?? store.select('transitionStatus') === 'ending';
const isHoverCloseTransition = !isOpen && isInClosingTransition && isHoverCloseActiveRef.current;
const isReenteringSameTriggerDuringCloseTransition = !isOverInactive && isElement(triggerNode) && isElement(currentDomReference) && contains(currentDomReference, triggerNode) && isHoverCloseTransition;
const isRestOnlyDelay = restMsValue > 0 && !openDelay;
const shouldOpenImmediately = isOverInactive && (isOpen || isHoverCloseTransition) || isReenteringSameTriggerDuringCloseTransition;
const shouldOpen = !isOpen || isOverInactive;
// Open immediately when moving between triggers while open, or during
// a hover-driven close transition (including same-trigger re-entry).
if (shouldOpenImmediately) {
store.setOpen(true, createChangeEventDetails(REASONS.triggerHover, event, triggerNode));
return;
}
if (isRestOnlyDelay) {
return;
}
if (openDelay) {
instance.openChangeTimeout.start(openDelay, () => {
if (shouldOpen) {
store.setOpen(true, createChangeEventDetails(REASONS.triggerHover, event, triggerNode));
}
});
} else if (shouldOpen) {
store.setOpen(true, createChangeEventDetails(REASONS.triggerHover, event, triggerNode));
}
}
function onMouseLeave(event) {
if (isClickLikeOpenEvent()) {
clearPointerEvents();
return;
}
cleanupMouseMoveHandler();
const domReferenceElement = store.select('domReferenceElement');
const doc = ownerDocument(domReferenceElement);
instance.restTimeout.clear();
instance.restTimeoutPending = false;
const handleCloseContextBase = dataRef.current.floatingContext ?? getHandleCloseContext?.();
const ignoreRelatedTargetTrigger = isRelatedTargetInsideEnabledTrigger(event.relatedTarget);
if (ignoreRelatedTargetTrigger) {
return;
}
if (handleCloseRef.current && handleCloseContextBase) {
if (!store.select('open')) {
instance.openChangeTimeout.clear();
}
const currentTrigger = triggerElementRef.current;
instance.handler = handleCloseRef.current({
...handleCloseContextBase,
tree,
x: event.clientX,
y: event.clientY,
onClose() {
clearPointerEvents();
cleanupMouseMoveHandler();
if (enabledRef.current && !isClickLikeOpenEvent() && currentTrigger === store.select('domReferenceElement')) {
closeWithDelay(event, true);
}
}
});
doc.addEventListener('mousemove', instance.handler);
instance.handler(event);
return;
}
const shouldClose = instance.pointerType === 'touch' ? !contains(store.select('floatingElement'), event.relatedTarget) : true;
if (shouldClose) {
closeWithDelay(event);
}
}
if (move) {
return mergeCleanups(addEventListener(trigger, 'mousemove', onMouseEnter, {
once: true
}), addEventListener(trigger, 'mouseenter', onMouseEnter), addEventListener(trigger, 'mouseleave', onMouseLeave));
}
return mergeCleanups(addEventListener(trigger, 'mouseenter', onMouseEnter), addEventListener(trigger, 'mouseleave', onMouseLeave));
}, [cleanupMouseMoveHandler, clearPointerEvents, dataRef, delayRef, closeWithDelay, store, enabled, handleCloseRef, instance, isActiveTrigger, isOverInactiveTrigger, isClickLikeOpenEvent, isRelatedTargetInsideEnabledTrigger, mouseOnly, move, restMsRef, triggerElementRef, tree, enabledRef, getHandleCloseContext, isClosingRef]);
return React.useMemo(() => {
if (!enabled) {
return undefined;
}
function setPointerRef(event) {
instance.pointerType = event.pointerType;
}
return {
onPointerDown: setPointerRef,
onPointerEnter: setPointerRef,
onMouseMove(event) {
const {
nativeEvent
} = event;
const trigger = event.currentTarget;
const currentDomReference = store.select('domReferenceElement');
const currentOpen = store.select('open');
const isOverInactive = isOverInactiveTrigger(currentDomReference, trigger, event.target);
if (mouseOnly && !isMouseLikePointerType(instance.pointerType)) {
return;
}
if (currentOpen && isOverInactive && instance.handleCloseOptions?.blockPointerEvents) {
const floatingElement = store.select('floatingElement');
if (floatingElement) {
const scopeElement = instance.handleCloseOptions?.getScope?.() ?? trigger.ownerDocument.body;
applySafePolygonPointerEventsMutation(instance, {
scopeElement,
referenceElement: trigger,
floatingElement
});
}
}
const restMsValue = getRestMs(restMsRef.current);
if (currentOpen && !isOverInactive || restMsValue === 0) {
return;
}
if (!isOverInactive && instance.restTimeoutPending && event.movementX ** 2 + event.movementY ** 2 < 2) {
return;
}
instance.restTimeout.clear();
function handleMouseMove() {
instance.restTimeoutPending = false;
// A delayed hover open should not override a click-like open that happened
// while the hover delay was pending.
if (isClickLikeOpenEvent()) {
return;
}
const latestOpen = store.select('open');
if (!instance.blockMouseMove && (!latestOpen || isOverInactive)) {
store.setOpen(true, createChangeEventDetails(REASONS.triggerHover, nativeEvent, trigger));
}
}
if (instance.pointerType === 'touch') {
ReactDOM.flushSync(() => {
handleMouseMove();
});
} else if (isOverInactive && currentOpen) {
handleMouseMove();
} else {
instance.restTimeoutPending = true;
instance.restTimeout.start(restMsValue, handleMouseMove);
}
}
};
}, [enabled, instance, isClickLikeOpenEvent, isOverInactiveTrigger, mouseOnly, store, restMsRef]);
}