UNPKG

@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.

277 lines (274 loc) 10.6 kB
import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { isElement } from '@floating-ui/utils/dom'; import { useValueAsRef } from '@base-ui-components/utils/useValueAsRef'; import { useStableCallback } from '@base-ui-components/utils/useStableCallback'; import { contains, getDocument, isMouseLikePointerType } from "../utils.js"; import { createChangeEventDetails } from "../../utils/createBaseUIEventDetails.js"; import { REASONS } from "../../utils/reasons.js"; import { getDelay } from "./useHover.js"; import { useFloatingTree } from "../components/FloatingTree.js"; import { safePolygonIdentifier, useHoverInteractionSharedState } from "./useHoverInteractionSharedState.js"; function getRestMs(value) { if (typeof value === 'function') { return value(); } return value; } /** * 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, triggerElement = null, externalTree, isActiveTrigger = true } = props; const tree = useFloatingTree(externalTree); const { pointerTypeRef, interactedInsideRef, handlerRef: closeHandlerRef, blockMouseMoveRef, performedPointerEventsMutationRef, unbindMouseMoveRef, restTimeoutPendingRef, openChangeTimeout, restTimeout, handleCloseOptionsRef } = useHoverInteractionSharedState(store); const handleCloseRef = useValueAsRef(handleClose); const delayRef = useValueAsRef(delay); const restMsRef = useValueAsRef(restMs); if (isActiveTrigger) { // eslint-disable-next-line no-underscore-dangle handleCloseOptionsRef.current = handleCloseRef.current?.__options; } const isClickLikeOpenEvent = useStableCallback(() => { if (interactedInsideRef.current) { return true; } return dataRef.current.openEvent ? ['click', 'mousedown'].includes(dataRef.current.openEvent.type) : false; }); const closeWithDelay = React.useCallback((event, runElseBranch = true) => { const closeDelay = getDelay(delayRef.current, 'close', pointerTypeRef.current); if (closeDelay && !closeHandlerRef.current) { openChangeTimeout.start(closeDelay, () => store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event))); } else if (runElseBranch) { openChangeTimeout.clear(); store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event)); } }, [delayRef, closeHandlerRef, store, pointerTypeRef, openChangeTimeout]); const cleanupMouseMoveHandler = useStableCallback(() => { unbindMouseMoveRef.current(); closeHandlerRef.current = undefined; }); const clearPointerEvents = useStableCallback(() => { if (performedPointerEventsMutationRef.current) { const body = getDocument(store.select('domReferenceElement')).body; body.style.pointerEvents = ''; body.removeAttribute(safePolygonIdentifier); performedPointerEventsMutationRef.current = 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) { openChangeTimeout.clear(); restTimeout.clear(); blockMouseMoveRef.current = true; restTimeoutPendingRef.current = false; } } events.on('openchange', onOpenChangeLocal); return () => { events.off('openchange', onOpenChangeLocal); }; }, [enabled, events, openChangeTimeout, restTimeout, blockMouseMoveRef, restTimeoutPendingRef]); const handleScrollMouseLeave = useStableCallback(event => { if (isClickLikeOpenEvent()) { return; } if (!dataRef.current.floatingContext) { return; } const triggerElements = store.context.triggerElements; if (event.relatedTarget && triggerElements.hasElement(event.relatedTarget)) { return; } handleCloseRef.current?.({ ...dataRef.current.floatingContext, tree, x: event.clientX, y: event.clientY, onClose() { clearPointerEvents(); cleanupMouseMoveHandler(); if (!isClickLikeOpenEvent()) { closeWithDelay(event); } } })(event); }); React.useEffect(() => { if (!enabled) { return undefined; } const trigger = triggerElement ?? (isActiveTrigger ? store.select('domReferenceElement') : null); if (!isElement(trigger)) { return undefined; } function onMouseEnter(event) { openChangeTimeout.clear(); blockMouseMoveRef.current = false; if (mouseOnly && !isMouseLikePointerType(pointerTypeRef.current)) { return; } // Only rest delay is set; there's no fallback delay. // This will be handled by `onMouseMove`. if (getRestMs(restMsRef.current) > 0 && !getDelay(delayRef.current, 'open')) { return; } const openDelay = getDelay(delayRef.current, 'open', pointerTypeRef.current); const currentDomReference = store.select('domReferenceElement'); const allTriggers = store.context.triggerElements; const isOverInactiveTrigger = (allTriggers.hasElement(event.target) || allTriggers.hasMatchingElement(t => contains(t, event.target))) && (!currentDomReference || !contains(currentDomReference, event.target)); const triggerNode = event.currentTarget ?? null; if (openDelay) { openChangeTimeout.start(openDelay, () => { if (!store.select('open')) { store.setOpen(true, createChangeEventDetails(REASONS.triggerHover, event, triggerNode)); } }); } else if (!store.select('open') || isOverInactiveTrigger) { store.setOpen(true, createChangeEventDetails(REASONS.triggerHover, event, triggerNode)); } } function onMouseLeave(event) { if (isClickLikeOpenEvent()) { clearPointerEvents(); return; } unbindMouseMoveRef.current(); const domReferenceElement = store.select('domReferenceElement'); const doc = getDocument(domReferenceElement); restTimeout.clear(); restTimeoutPendingRef.current = false; const triggerElements = store.context.triggerElements; if (event.relatedTarget && triggerElements.hasElement(event.relatedTarget)) { return; } if (handleCloseRef.current && dataRef.current.floatingContext) { if (!store.select('open')) { openChangeTimeout.clear(); } closeHandlerRef.current = handleCloseRef.current({ ...dataRef.current.floatingContext, tree, x: event.clientX, y: event.clientY, onClose() { clearPointerEvents(); cleanupMouseMoveHandler(); if (!isClickLikeOpenEvent()) { closeWithDelay(event, true); } } }); const handler = closeHandlerRef.current; handler(event); doc.addEventListener('mousemove', handler); unbindMouseMoveRef.current = () => { doc.removeEventListener('mousemove', handler); }; return; } const shouldClose = pointerTypeRef.current === 'touch' ? !contains(store.select('floatingElement'), event.relatedTarget) : true; if (shouldClose) { closeWithDelay(event); } } function onScrollMouseLeave(event) { handleScrollMouseLeave(event); } if (store.select('open')) { trigger.addEventListener('mouseleave', onScrollMouseLeave); } if (move) { trigger.addEventListener('mousemove', onMouseEnter, { once: true }); } trigger.addEventListener('mouseenter', onMouseEnter); trigger.addEventListener('mouseleave', onMouseLeave); return () => { trigger.removeEventListener('mouseleave', onScrollMouseLeave); if (move) { trigger.removeEventListener('mousemove', onMouseEnter); } trigger.removeEventListener('mouseenter', onMouseEnter); trigger.removeEventListener('mouseleave', onMouseLeave); }; }, [cleanupMouseMoveHandler, clearPointerEvents, blockMouseMoveRef, dataRef, delayRef, closeWithDelay, store, enabled, handleCloseRef, handleScrollMouseLeave, isActiveTrigger, isClickLikeOpenEvent, mouseOnly, move, pointerTypeRef, restMsRef, restTimeout, restTimeoutPendingRef, openChangeTimeout, triggerElement, tree, unbindMouseMoveRef, closeHandlerRef]); return React.useMemo(() => { function setPointerRef(event) { pointerTypeRef.current = event.pointerType; } return { onPointerDown: setPointerRef, onPointerEnter: setPointerRef, onMouseMove(event) { const { nativeEvent } = event; const trigger = event.currentTarget; const currentDomReference = store.select('domReferenceElement'); const allTriggers = store.context.triggerElements; const currentOpen = store.select('open'); const isOverInactiveTrigger = (allTriggers.hasElement(event.target) || allTriggers.hasMatchingElement(t => contains(t, event.target))) && (!currentDomReference || !contains(currentDomReference, event.target)); if (mouseOnly && !isMouseLikePointerType(pointerTypeRef.current)) { return; } if (currentOpen && !isOverInactiveTrigger || getRestMs(restMsRef.current) === 0) { return; } if (!isOverInactiveTrigger && restTimeoutPendingRef.current && event.movementX ** 2 + event.movementY ** 2 < 2) { return; } restTimeout.clear(); function handleMouseMove() { if (!blockMouseMoveRef.current && (!currentOpen || isOverInactiveTrigger)) { store.setOpen(true, createChangeEventDetails(REASONS.triggerHover, nativeEvent, trigger)); } } if (pointerTypeRef.current === 'touch') { ReactDOM.flushSync(() => { handleMouseMove(); }); } else if (isOverInactiveTrigger && currentOpen) { handleMouseMove(); } else { restTimeoutPendingRef.current = true; restTimeout.start(getRestMs(restMsRef.current), handleMouseMove); } } }; }, [blockMouseMoveRef, mouseOnly, store, pointerTypeRef, restMsRef, restTimeout, restTimeoutPendingRef]); }