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.

417 lines (409 loc) 15.9 kB
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]); }