@base-ui-components/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.
417 lines (409 loc) • 15.9 kB
JavaScript
import * as React from 'react';
import { isElement } from '@floating-ui/utils/dom';
import { useTimeout } from '@base-ui-components/utils/useTimeout';
import { useValueAsRef } from '@base-ui-components/utils/useValueAsRef';
import { useStableCallback } from '@base-ui-components/utils/useStableCallback';
import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect';
import { contains, getDocument, getTarget, isMouseLikePointerType } from "../utils.js";
import { useFloatingParentNodeId, useFloatingTree } from "../components/FloatingTree.js";
import { createChangeEventDetails } from "../../utils/createBaseUIEventDetails.js";
import { REASONS } from "../../utils/reasons.js";
import { createAttribute } from "../utils/createAttribute.js";
import { TYPEABLE_SELECTOR } from "../utils/constants.js";
const safePolygonIdentifier = createAttribute('safe-polygon');
const interactiveSelector = `button,[role="button"],select,[tabindex]:not([tabindex="-1"]),${TYPEABLE_SELECTOR}`;
function isInteractiveElement(element) {
return element ? Boolean(element.closest(interactiveSelector)) : false;
}
export function getDelay(value, prop, pointerType) {
if (pointerType && !isMouseLikePointerType(pointerType)) {
return 0;
}
if (typeof value === 'number') {
return value;
}
if (typeof value === 'function') {
const result = value();
if (typeof result === 'number') {
return result;
}
return result?.[prop];
}
return value?.[prop];
}
function getRestMs(value) {
if (typeof value === 'function') {
return value();
}
return value;
}
/**
* Opens the floating element while hovering over the reference element, like
* CSS `:hover`.
* @see https://floating-ui.com/docs/useHover
*/
export function useHover(context, props = {}) {
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,
events
} = store.context;
const {
enabled = true,
delay = 0,
handleClose = null,
mouseOnly = false,
restMs = 0,
move = true,
triggerElement = null,
externalTree
} = props;
const tree = useFloatingTree(externalTree);
const parentId = useFloatingParentNodeId();
const handleCloseRef = useValueAsRef(handleClose);
const delayRef = useValueAsRef(delay);
const restMsRef = useValueAsRef(restMs);
const pointerTypeRef = React.useRef(undefined);
const interactedInsideRef = React.useRef(false);
const timeout = useTimeout();
const handlerRef = React.useRef(undefined);
const restTimeout = useTimeout();
const blockMouseMoveRef = React.useRef(true);
const performedPointerEventsMutationRef = React.useRef(false);
const unbindMouseMoveRef = React.useRef(() => {});
const restTimeoutPendingRef = React.useRef(false);
const isHoverOpen = useStableCallback(() => {
const type = dataRef.current.openEvent?.type;
return type?.includes('mouse') && type !== 'mousedown';
});
const isClickLikeOpenEvent = useStableCallback(() => {
if (interactedInsideRef.current) {
return true;
}
return dataRef.current.openEvent ? ['click', 'mousedown'].includes(dataRef.current.openEvent.type) : false;
});
// 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) {
timeout.clear();
restTimeout.clear();
blockMouseMoveRef.current = true;
restTimeoutPendingRef.current = false;
}
}
events.on('openchange', onOpenChangeLocal);
return () => {
events.off('openchange', onOpenChangeLocal);
};
}, [enabled, events, timeout, restTimeout]);
React.useEffect(() => {
if (!enabled) {
return undefined;
}
if (!handleCloseRef.current) {
return undefined;
}
if (!open) {
return undefined;
}
function onLeave(event) {
if (isClickLikeOpenEvent()) {
return;
}
if (isHoverOpen()) {
store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event, event.currentTarget ?? undefined));
}
}
const html = getDocument(floatingElement).documentElement;
html.addEventListener('mouseleave', onLeave);
return () => {
html.removeEventListener('mouseleave', onLeave);
};
}, [floatingElement, open, store, enabled, handleCloseRef, isHoverOpen, isClickLikeOpenEvent]);
const closeWithDelay = React.useCallback((event, runElseBranch = true) => {
const closeDelay = getDelay(delayRef.current, 'close', pointerTypeRef.current);
if (closeDelay && !handlerRef.current) {
timeout.start(closeDelay, () => store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event)));
} else if (runElseBranch) {
timeout.clear();
store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event));
}
}, [delayRef, store, timeout]);
const cleanupMouseMoveHandler = useStableCallback(() => {
unbindMouseMoveRef.current();
handlerRef.current = undefined;
});
const clearPointerEvents = useStableCallback(() => {
if (performedPointerEventsMutationRef.current) {
const body = getDocument(floatingElement).body;
body.style.pointerEvents = '';
body.removeAttribute(safePolygonIdentifier);
performedPointerEventsMutationRef.current = false;
}
});
const handleInteractInside = useStableCallback(event => {
const target = getTarget(event);
if (!isInteractiveElement(target)) {
interactedInsideRef.current = false;
return;
}
interactedInsideRef.current = true;
});
// Registering the mouse events on the reference directly to bypass React's
// delegation system. If the cursor was on a disabled element and then entered
// the reference (no gap), `mouseenter` doesn't fire in the delegation system.
React.useEffect(() => {
if (!enabled) {
return undefined;
}
function onReferenceMouseEnter(event) {
timeout.clear();
blockMouseMoveRef.current = false;
if (mouseOnly && !isMouseLikePointerType(pointerTypeRef.current) || getRestMs(restMsRef.current) > 0 && !getDelay(delayRef.current, 'open')) {
return;
}
const openDelay = getDelay(delayRef.current, 'open', pointerTypeRef.current);
const trigger = event.currentTarget ?? undefined;
const domReference = store.select('domReferenceElement');
const isOverInactiveTrigger = domReference && trigger && !contains(domReference, trigger);
if (openDelay) {
timeout.start(openDelay, () => {
if (!store.select('open')) {
store.setOpen(true, createChangeEventDetails(REASONS.triggerHover, event, trigger));
}
});
} else if (!open || isOverInactiveTrigger) {
store.setOpen(true, createChangeEventDetails(REASONS.triggerHover, event, trigger));
}
}
function onReferenceMouseLeave(event) {
if (isClickLikeOpenEvent()) {
clearPointerEvents();
return;
}
unbindMouseMoveRef.current();
const doc = getDocument(floatingElement);
restTimeout.clear();
restTimeoutPendingRef.current = false;
const triggers = store.context.triggerElements;
if (event.relatedTarget && triggers.hasElement(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;
}
if (handleCloseRef.current && dataRef.current.floatingContext) {
// Prevent clearing `onScrollMouseLeave` timeout.
if (!open) {
timeout.clear();
}
handlerRef.current = handleCloseRef.current({
...dataRef.current.floatingContext,
tree,
x: event.clientX,
y: event.clientY,
onClose() {
clearPointerEvents();
cleanupMouseMoveHandler();
if (!isClickLikeOpenEvent()) {
closeWithDelay(event, true);
}
}
});
const handler = handlerRef.current;
doc.addEventListener('mousemove', handler);
unbindMouseMoveRef.current = () => {
doc.removeEventListener('mousemove', handler);
};
return;
}
// Allow interactivity without `safePolygon` on touch devices. With a
// pointer, a short close delay is an alternative, so it should work
// consistently.
const shouldClose = pointerTypeRef.current === 'touch' ? !contains(floatingElement, event.relatedTarget) : true;
if (shouldClose) {
closeWithDelay(event);
}
}
// 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()) {
return;
}
if (!dataRef.current.floatingContext) {
return;
}
const triggers = store.context.triggerElements;
if (event.relatedTarget && triggers.hasElement(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;
}
handleCloseRef.current?.({
...dataRef.current.floatingContext,
tree,
x: event.clientX,
y: event.clientY,
onClose() {
clearPointerEvents();
cleanupMouseMoveHandler();
if (!isClickLikeOpenEvent()) {
closeWithDelay(event);
}
}
})(event);
}
function onFloatingMouseEnter() {
timeout.clear();
clearPointerEvents();
}
function onFloatingMouseLeave(event) {
if (!isClickLikeOpenEvent()) {
closeWithDelay(event, false);
}
}
const trigger = triggerElement ?? domReferenceElement;
if (isElement(trigger)) {
const floating = floatingElement;
if (open) {
trigger.addEventListener('mouseleave', onScrollMouseLeave);
}
if (move) {
trigger.addEventListener('mousemove', onReferenceMouseEnter, {
once: true
});
}
trigger.addEventListener('mouseenter', onReferenceMouseEnter);
trigger.addEventListener('mouseleave', onReferenceMouseLeave);
if (floating) {
floating.addEventListener('mouseleave', onScrollMouseLeave);
floating.addEventListener('mouseenter', onFloatingMouseEnter);
floating.addEventListener('mouseleave', onFloatingMouseLeave);
floating.addEventListener('pointerdown', handleInteractInside, true);
}
return () => {
if (open) {
trigger.removeEventListener('mouseleave', onScrollMouseLeave);
}
if (move) {
trigger.removeEventListener('mousemove', onReferenceMouseEnter);
}
trigger.removeEventListener('mouseenter', onReferenceMouseEnter);
trigger.removeEventListener('mouseleave', onReferenceMouseLeave);
if (floating) {
floating.removeEventListener('mouseleave', onScrollMouseLeave);
floating.removeEventListener('mouseenter', onFloatingMouseEnter);
floating.removeEventListener('mouseleave', onFloatingMouseLeave);
floating.removeEventListener('pointerdown', handleInteractInside, true);
}
};
}
return undefined;
}, [enabled, mouseOnly, move, domReferenceElement, floatingElement, triggerElement, store, closeWithDelay, cleanupMouseMoveHandler, clearPointerEvents, open, tree, delayRef, handleCloseRef, dataRef, isClickLikeOpenEvent, restMsRef, timeout, restTimeout, handleInteractInside]);
// Block pointer-events of every element other than the reference and floating
// while the floating element is open and has a `handleClose` handler. Also
// handles nested floating elements.
// https://github.com/floating-ui/floating-ui/issues/1722
useIsoLayoutEffect(() => {
if (!enabled) {
return undefined;
}
// eslint-disable-next-line no-underscore-dangle
if (open && handleCloseRef.current?.__options?.blockPointerEvents && isHoverOpen()) {
performedPointerEventsMutationRef.current = true;
const floatingEl = floatingElement;
if (isElement(domReferenceElement) && floatingEl) {
const body = getDocument(floatingElement).body;
body.setAttribute(safePolygonIdentifier, '');
const ref = domReferenceElement;
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, parentId, tree, handleCloseRef, isHoverOpen, domReferenceElement, floatingElement]);
useIsoLayoutEffect(() => {
if (!open) {
pointerTypeRef.current = undefined;
restTimeoutPendingRef.current = false;
interactedInsideRef.current = false;
cleanupMouseMoveHandler();
clearPointerEvents();
}
}, [open, cleanupMouseMoveHandler, clearPointerEvents]);
React.useEffect(() => {
return () => {
cleanupMouseMoveHandler();
timeout.clear();
restTimeout.clear();
interactedInsideRef.current = false;
};
}, [enabled, domReferenceElement, cleanupMouseMoveHandler, timeout, restTimeout]);
React.useEffect(() => {
return clearPointerEvents;
}, [clearPointerEvents]);
const reference = React.useMemo(() => {
function setPointerRef(event) {
pointerTypeRef.current = event.pointerType;
}
return {
onPointerDown: setPointerRef,
onPointerEnter: setPointerRef,
onMouseMove(event) {
const {
nativeEvent
} = event;
const trigger = event.currentTarget;
// `true` when there are multiple triggers per floating element and user hovers over the one that
// wasn't used to open the floating element.
const isOverInactiveTrigger = store.select('domReferenceElement') && !contains(store.select('domReferenceElement'), event.target);
function handleMouseMove() {
if (!blockMouseMoveRef.current && (!store.select('open') || isOverInactiveTrigger)) {
store.setOpen(true, createChangeEventDetails(REASONS.triggerHover, nativeEvent, trigger));
}
}
if (mouseOnly && !isMouseLikePointerType(pointerTypeRef.current)) {
return;
}
if (store.select('open') && !isOverInactiveTrigger || getRestMs(restMsRef.current) === 0) {
return;
}
// Ignore insignificant movements to account for tremors.
if (!isOverInactiveTrigger && restTimeoutPendingRef.current && event.movementX ** 2 + event.movementY ** 2 < 2) {
return;
}
restTimeout.clear();
if (pointerTypeRef.current === 'touch') {
handleMouseMove();
} else if (isOverInactiveTrigger) {
handleMouseMove();
} else {
restTimeoutPendingRef.current = true;
restTimeout.start(getRestMs(restMsRef.current), handleMouseMove);
}
}
};
}, [mouseOnly, store, restMsRef, restTimeout]);
return React.useMemo(() => enabled ? {
reference
} : {}, [enabled, reference]);
}