@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.
315 lines (305 loc) • 14.5 kB
JavaScript
"use strict";
'use client';
var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard").default;
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.useHoverReferenceInteraction = useHoverReferenceInteraction;
var React = _interopRequireWildcard(require("react"));
var ReactDOM = _interopRequireWildcard(require("react-dom"));
var _dom = require("@floating-ui/utils/dom");
var _addEventListener = require("@base-ui/utils/addEventListener");
var _mergeCleanups = require("@base-ui/utils/mergeCleanups");
var _useValueAsRef = require("@base-ui/utils/useValueAsRef");
var _useStableCallback = require("@base-ui/utils/useStableCallback");
var _owner = require("@base-ui/utils/owner");
var _element = require("../utils/element");
var _event = require("../utils/event");
var _createBaseUIEventDetails = require("../../internals/createBaseUIEventDetails");
var _reasons = require("../../internals/reasons");
var _FloatingTree = require("../components/FloatingTree");
var _useHoverInteractionSharedState = require("./useHoverInteractionSharedState");
var _useHoverShared = require("./useHoverShared");
const EMPTY_REF = {
current: null
};
/**
* Provides hover interactions that should be attached to reference or trigger
* elements.
*/
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 = (0, _FloatingTree.useFloatingTree)(externalTree);
const instance = (0, _useHoverInteractionSharedState.useHoverInteractionSharedState)(store);
const isHoverCloseActiveRef = React.useRef(false);
const handleCloseRef = (0, _useValueAsRef.useValueAsRef)(handleClose);
const delayRef = (0, _useValueAsRef.useValueAsRef)(delay);
const restMsRef = (0, _useValueAsRef.useValueAsRef)(restMs);
const enabledRef = (0, _useValueAsRef.useValueAsRef)(enabled);
const isClosingRef = (0, _useValueAsRef.useValueAsRef)(isClosing);
if (isActiveTrigger) {
// eslint-disable-next-line no-underscore-dangle
instance.handleCloseOptions = handleCloseRef.current?.__options;
}
const isClickLikeOpenEvent = (0, _useStableCallback.useStableCallback)(() => {
return (0, _useHoverShared.isClickLikeOpenEvent)(dataRef.current.openEvent?.type, instance.interactedInside);
});
const isRelatedTargetInsideEnabledTrigger = (0, _useStableCallback.useStableCallback)(target => {
return (0, _element.isTargetInsideEnabledTrigger)(target, store.context.triggerElements);
});
const isOverInactiveTrigger = (0, _useStableCallback.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 || !(0, _element.contains)(currentDomReference, currentTarget);
}
// Fallback for delegated/wrapper usage where currentTarget may be outside the trigger map.
if (!(0, _dom.isElement)(target)) {
return false;
}
const targetElement = target;
return allTriggers.hasMatchingElement(trigger => (0, _element.contains)(trigger, targetElement)) && (!currentDomReference || !(0, _element.contains)(currentDomReference, targetElement));
});
const closeWithDelay = (0, _useStableCallback.useStableCallback)((event, runElseBranch = true) => {
const closeDelay = (0, _useHoverShared.getDelay)(delayRef.current, 'close', instance.pointerType);
if (closeDelay) {
instance.openChangeTimeout.start(closeDelay, () => {
store.setOpen(false, (0, _createBaseUIEventDetails.createChangeEventDetails)(_reasons.REASONS.triggerHover, event));
tree?.events.emit('floating.closed', event);
});
} else if (runElseBranch) {
instance.openChangeTimeout.clear();
store.setOpen(false, (0, _createBaseUIEventDetails.createChangeEventDetails)(_reasons.REASONS.triggerHover, event));
tree?.events.emit('floating.closed', event);
}
});
const cleanupMouseMoveHandler = (0, _useStableCallback.useStableCallback)(() => {
if (!instance.handler) {
return;
}
const doc = (0, _owner.ownerDocument)(store.select('domReferenceElement'));
doc.removeEventListener('mousemove', instance.handler);
instance.handler = undefined;
});
const clearPointerEvents = (0, _useStableCallback.useStableCallback)(() => {
(0, _useHoverInteractionSharedState.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.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 (!(0, _dom.isElement)(trigger)) {
return undefined;
}
function onMouseEnter(event) {
instance.openChangeTimeout.clear();
instance.blockMouseMove = false;
if (mouseOnly && !(0, _event.isMouseLikePointerType)(instance.pointerType)) {
return;
}
// Only rest delay is set; there's no fallback delay.
// This will be handled by `onMouseMove`.
const restMsValue = (0, _useHoverShared.getRestMs)(restMsRef.current);
const openDelay = (0, _useHoverShared.getDelay)(delayRef.current, 'open', instance.pointerType);
const eventTarget = (0, _element.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 ((0, _dom.isElement)(eventTarget) && !store.context.triggerElements.hasElement(eventTarget)) {
for (const triggerElement of store.context.triggerElements.elements()) {
if ((0, _element.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 ((0, _dom.isElement)(currentTarget) && (0, _dom.isElement)(currentDomReference) && !store.context.triggerElements.hasElement(currentTarget) && (0, _element.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 && (0, _dom.isElement)(triggerNode) && (0, _dom.isElement)(currentDomReference) && (0, _element.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, (0, _createBaseUIEventDetails.createChangeEventDetails)(_reasons.REASONS.triggerHover, event, triggerNode));
return;
}
if (isRestOnlyDelay) {
return;
}
if (openDelay) {
instance.openChangeTimeout.start(openDelay, () => {
if (shouldOpen) {
store.setOpen(true, (0, _createBaseUIEventDetails.createChangeEventDetails)(_reasons.REASONS.triggerHover, event, triggerNode));
}
});
} else if (shouldOpen) {
store.setOpen(true, (0, _createBaseUIEventDetails.createChangeEventDetails)(_reasons.REASONS.triggerHover, event, triggerNode));
}
}
function onMouseLeave(event) {
if (isClickLikeOpenEvent()) {
clearPointerEvents();
return;
}
cleanupMouseMoveHandler();
const domReferenceElement = store.select('domReferenceElement');
const doc = (0, _owner.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' ? !(0, _element.contains)(store.select('floatingElement'), event.relatedTarget) : true;
if (shouldClose) {
closeWithDelay(event);
}
}
if (move) {
return (0, _mergeCleanups.mergeCleanups)((0, _addEventListener.addEventListener)(trigger, 'mousemove', onMouseEnter, {
once: true
}), (0, _addEventListener.addEventListener)(trigger, 'mouseenter', onMouseEnter), (0, _addEventListener.addEventListener)(trigger, 'mouseleave', onMouseLeave));
}
return (0, _mergeCleanups.mergeCleanups)((0, _addEventListener.addEventListener)(trigger, 'mouseenter', onMouseEnter), (0, _addEventListener.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 && !(0, _event.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;
(0, _useHoverInteractionSharedState.applySafePolygonPointerEventsMutation)(instance, {
scopeElement,
referenceElement: trigger,
floatingElement
});
}
}
const restMsValue = (0, _useHoverShared.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, (0, _createBaseUIEventDetails.createChangeEventDetails)(_reasons.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]);
}