From 34faba46c48a22cec4b345e88f5682519cd9bf76 Mon Sep 17 00:00:00 2001 From: atomiks Date: Wed, 10 Dec 2025 07:55:02 +1100 Subject: [PATCH 1/3] [internal] Fix useHover interaction hook duplication --- .../src/floating-ui-react/hooks/useHover.ts | 523 +----------------- .../hooks/useHoverFloatingInteraction.ts | 145 ++--- .../hooks/useHoverInteractionSharedState.ts | 110 +++- .../hooks/useHoverReferenceInteraction.ts | 81 +-- 4 files changed, 202 insertions(+), 657 deletions(-) diff --git a/packages/react/src/floating-ui-react/hooks/useHover.ts b/packages/react/src/floating-ui-react/hooks/useHover.ts index b49ab5f8678..9c86b961ed1 100644 --- a/packages/react/src/floating-ui-react/hooks/useHover.ts +++ b/packages/react/src/floating-ui-react/hooks/useHover.ts @@ -1,13 +1,4 @@ import * as React from 'react'; -import { isElement } from '@floating-ui/utils/dom'; -import { useTimeout } from '@base-ui/utils/useTimeout'; -import { useValueAsRef } from '@base-ui/utils/useValueAsRef'; -import { useStableCallback } from '@base-ui/utils/useStableCallback'; -import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; -import { contains, getDocument, getTarget, isMouseLikePointerType } from '../utils'; - -import { useFloatingParentNodeId, useFloatingTree } from '../components/FloatingTree'; -import { FloatingTreeStore } from '../components/FloatingTreeStore'; import type { Delay, ElementProps, @@ -16,18 +7,10 @@ import type { FloatingTreeType, SafePolygonOptions, } from '../types'; -import { createChangeEventDetails } from '../../utils/createBaseUIEventDetails'; -import { REASONS } from '../../utils/reasons'; -import { createAttribute } from '../utils/createAttribute'; -import { FloatingUIOpenChangeDetails } from '../../utils/types'; -import { TYPEABLE_SELECTOR } from '../utils/constants'; - -const safePolygonIdentifier = createAttribute('safe-polygon'); -const interactiveSelector = `button,[role="button"],select,[tabindex]:not([tabindex="-1"]),${TYPEABLE_SELECTOR}`; - -function isInteractiveElement(element: Element | null) { - return element ? Boolean(element.closest(interactiveSelector)) : false; -} +import { isMouseLikePointerType } from '../utils'; +import { FloatingTreeStore } from '../components/FloatingTreeStore'; +import { useHoverReferenceInteraction } from './useHoverReferenceInteraction'; +import { useHoverFloatingInteraction } from './useHoverFloatingInteraction'; export interface HandleCloseContext extends FloatingContext { onClose: () => void; @@ -64,13 +47,6 @@ export function getDelay( return value?.[prop]; } -function getRestMs(value: number | (() => number)) { - if (typeof value === 'function') { - return value(); - } - return value; -} - export interface UseHoverProps { /** * Whether the Hook is enabled, including all internal Effects and event @@ -130,10 +106,8 @@ export function useHover( props: UseHoverProps = {}, ): ElementProps { 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, @@ -145,481 +119,36 @@ export function useHover( 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<(event: MouseEvent) => void>(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: FloatingUIOpenChangeDetails) { - 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: MouseEvent) { - if (isClickLikeOpenEvent()) { - return; - } - - if (isHoverOpen()) { - store.setOpen( - false, - createChangeEventDetails( - REASONS.triggerHover, - event, - (event.currentTarget as HTMLElement) ?? 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: MouseEvent, 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 resolvedTrigger = triggerElement ?? domReferenceElement; + const triggerElementRef = React.useMemo>( + () => ({ current: resolvedTrigger }), + [resolvedTrigger], ); - 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: PointerEvent) => { - const target = getTarget(event) as Element | null; - if (!isInteractiveElement(target)) { - interactedInsideRef.current = false; - return; + const closeDelay = React.useCallback(() => { + const resolved = typeof delay === 'function' ? delay() : delay; + if (typeof resolved === 'number') { + return resolved; } + return resolved?.close ?? 0; + }, [delay]); - interactedInsideRef.current = true; + useHoverFloatingInteraction(context, { + enabled, + closeDelay, + externalTree, }); - // 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: MouseEvent) { - 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 as HTMLElement) ?? 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: MouseEvent) { - 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 as Element)) { - // 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 as Element | null) - : 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: MouseEvent) { - if (isClickLikeOpenEvent()) { - return; - } - if (!dataRef.current.floatingContext) { - return; - } - - const triggers = store.context.triggerElements; - - if (event.relatedTarget && triggers.hasElement(event.relatedTarget as Element)) { - // 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: MouseEvent) { - if (!isClickLikeOpenEvent()) { - closeWithDelay(event, false); - } - } - - const trigger = (triggerElement ?? domReferenceElement) as HTMLElement | null; - - 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; - }, [ + const reference = useHoverReferenceInteraction(context, { enabled, + delay, + handleClose, mouseOnly, + restMs, 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 as HTMLElement | SVGSVGElement; - - 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: ElementProps['reference'] = React.useMemo(() => { - function setPointerRef(event: React.PointerEvent) { - pointerTypeRef.current = event.pointerType; - } - - return { - onPointerDown: setPointerRef, - onPointerEnter: setPointerRef, - onMouseMove(event) { - const { nativeEvent } = event; - const trigger = event.currentTarget as HTMLElement; - - // `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 as Element); - - 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]); + triggerElementRef, + externalTree, + }); return React.useMemo(() => (enabled ? { reference } : {}), [enabled, reference]); } diff --git a/packages/react/src/floating-ui-react/hooks/useHoverFloatingInteraction.ts b/packages/react/src/floating-ui-react/hooks/useHoverFloatingInteraction.ts index 38124d44a5a..e356cd5f9b6 100644 --- a/packages/react/src/floating-ui-react/hooks/useHoverFloatingInteraction.ts +++ b/packages/react/src/floating-ui-react/hooks/useHoverFloatingInteraction.ts @@ -1,19 +1,17 @@ import * as React from 'react'; import { isElement } from '@floating-ui/utils/dom'; -import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import type { FloatingContext, FloatingRootContext } from '../types'; -import { getDocument, getTarget, isMouseLikePointerType } from '../utils'; +import { getDocument } from '../utils'; -import { createChangeEventDetails } from '../../utils/createBaseUIEventDetails'; -import { REASONS } from '../../utils/reasons'; import { useFloatingParentNodeId, useFloatingTree } from '../components/FloatingTree'; import { FloatingTreeStore } from '../components/FloatingTreeStore'; import { - isInteractiveElement, + getCloseDelay, safePolygonIdentifier, useHoverInteractionSharedState, + useHoverInteractionSharedMethods, } from './useHoverInteractionSharedState'; export type UseHoverFloatingInteractionProps = { @@ -35,8 +33,6 @@ export type UseHoverFloatingInteractionProps = { externalTree?: FloatingTreeStore; }; -const clickLikeEvents = new Set(['click', 'mousedown']); - /** * Provides hover interactions that should be attached to the floating element. */ @@ -52,71 +48,30 @@ export function useHoverFloatingInteraction( const { enabled = true, closeDelay: closeDelayProp = 0, externalTree } = parameters; + const sharedState = useHoverInteractionSharedState(store); const { pointerTypeRef, interactedInsideRef, handlerRef, performedPointerEventsMutationRef, - unbindMouseMoveRef, restTimeoutPendingRef, - openChangeTimeout: openChangeTimeout, + openChangeTimeout, handleCloseOptionsRef, - } = useHoverInteractionSharedState(store); - - const tree = useFloatingTree(externalTree); - const parentId = useFloatingParentNodeId(); - - const isClickLikeOpenEvent = useStableCallback(() => { - if (interactedInsideRef.current) { - return true; - } + } = sharedState; - return dataRef.current.openEvent ? clickLikeEvents.has(dataRef.current.openEvent.type) : false; - }); - - const isHoverOpen = useStableCallback(() => { - const type = dataRef.current.openEvent?.type; - return type?.includes('mouse') && type !== 'mousedown'; - }); - - const closeWithDelay = React.useCallback( - (event: MouseEvent, runElseBranch = true) => { - const closeDelay = getDelay(closeDelayProp, pointerTypeRef.current); - if (closeDelay && !handlerRef.current) { - openChangeTimeout.start(closeDelay, () => - store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event)), - ); - } else if (runElseBranch) { - openChangeTimeout.clear(); - store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event)); - } - }, - [closeDelayProp, handlerRef, store, pointerTypeRef, openChangeTimeout], + const { + isClickLikeOpenEvent, + isHoverOpen, + handleInteractInside, + closeWithDelay, + cleanupMouseMoveHandler, + clearPointerEvents, + } = useHoverInteractionSharedMethods(store, sharedState, () => + getCloseDelay(closeDelayProp, pointerTypeRef.current), ); - 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: PointerEvent) => { - const target = getTarget(event) as Element | null; - if (!isInteractiveElement(target)) { - interactedInsideRef.current = false; - return; - } - - interactedInsideRef.current = true; - }); + const tree = useFloatingTree(externalTree); + const parentId = useFloatingParentNodeId(); useIsoLayoutEffect(() => { if (!open) { @@ -138,12 +93,9 @@ export function useHoverFloatingInteraction( React.useEffect(() => { return () => { cleanupMouseMoveHandler(); + clearPointerEvents(); }; - }, [cleanupMouseMoveHandler]); - - React.useEffect(() => { - return clearPointerEvents; - }, [clearPointerEvents]); + }, [clearPointerEvents, cleanupMouseMoveHandler]); useIsoLayoutEffect(() => { if (!enabled) { @@ -196,7 +148,7 @@ export function useHoverFloatingInteraction( ]); React.useEffect(() => { - if (!enabled) { + if (!enabled || !floatingElement) { return undefined; } @@ -204,10 +156,7 @@ export function useHoverFloatingInteraction( // did not move. // https://github.com/floating-ui/floating-ui/discussions/1692 function onScrollMouseLeave(event: MouseEvent) { - if (isClickLikeOpenEvent()) { - return; - } - if (!dataRef.current.floatingContext) { + if (isClickLikeOpenEvent() || !dataRef.current.floatingContext) { return; } @@ -238,36 +187,30 @@ export function useHoverFloatingInteraction( } } - const floating = floatingElement; - if (floating) { - floating.addEventListener('mouseleave', onScrollMouseLeave); - floating.addEventListener('mouseenter', onFloatingMouseEnter); - floating.addEventListener('mouseleave', onFloatingMouseLeave); - floating.addEventListener('pointerdown', handleInteractInside, true); - } + floatingElement.addEventListener('mouseleave', onScrollMouseLeave); + floatingElement.addEventListener('mouseenter', onFloatingMouseEnter); + floatingElement.addEventListener('mouseleave', onFloatingMouseLeave); + floatingElement.addEventListener('pointerdown', handleInteractInside, true); return () => { - if (floating) { - floating.removeEventListener('mouseleave', onScrollMouseLeave); - floating.removeEventListener('mouseenter', onFloatingMouseEnter); - floating.removeEventListener('mouseleave', onFloatingMouseLeave); - floating.removeEventListener('pointerdown', handleInteractInside, true); - } - }; - }); -} - -export function getDelay( - value: number | (() => number), - pointerType?: PointerEvent['pointerType'], -) { - if (pointerType && !isMouseLikePointerType(pointerType)) { - return 0; - } - - if (typeof value === 'function') { - return value(); - } + openChangeTimeout.clear(); - return value; + floatingElement.removeEventListener('mouseleave', onScrollMouseLeave); + floatingElement.removeEventListener('mouseenter', onFloatingMouseEnter); + floatingElement.removeEventListener('mouseleave', onFloatingMouseLeave); + floatingElement.removeEventListener('pointerdown', handleInteractInside, true); + }; + }, [ + enabled, + floatingElement, + isClickLikeOpenEvent, + dataRef, + store, + clearPointerEvents, + cleanupMouseMoveHandler, + closeWithDelay, + openChangeTimeout, + handlerRef, + handleInteractInside, + ]); } diff --git a/packages/react/src/floating-ui-react/hooks/useHoverInteractionSharedState.ts b/packages/react/src/floating-ui-react/hooks/useHoverInteractionSharedState.ts index 6a07406cbcf..321c134f268 100644 --- a/packages/react/src/floating-ui-react/hooks/useHoverInteractionSharedState.ts +++ b/packages/react/src/floating-ui-react/hooks/useHoverInteractionSharedState.ts @@ -1,17 +1,43 @@ import * as React from 'react'; import { useTimeout } from '@base-ui/utils/useTimeout'; - +import { useStableCallback } from '@base-ui/utils/useStableCallback'; import type { ContextData, FloatingRootContext, SafePolygonOptions } from '../types'; +import { getDocument, getTarget, isMouseLikePointerType } from '../utils'; import { createAttribute } from '../utils/createAttribute'; import { TYPEABLE_SELECTOR } from '../utils/constants'; +import { createChangeEventDetails } from '../../utils/createBaseUIEventDetails'; +import { REASONS } from '../../utils/reasons'; + +export function getRestMs(value: number | (() => number)) { + if (typeof value === 'function') { + return value(); + } + return value; +} export const safePolygonIdentifier = createAttribute('safe-polygon'); const interactiveSelector = `button,a,[role="button"],select,[tabindex]:not([tabindex="-1"]),${TYPEABLE_SELECTOR}`; +const clickLikeEvents = new Set(['click', 'mousedown']); export function isInteractiveElement(element: Element | null) { return element ? Boolean(element.closest(interactiveSelector)) : false; } +export function getCloseDelay( + value: number | (() => number), + pointerType?: PointerEvent['pointerType'], +) { + if (pointerType && !isMouseLikePointerType(pointerType)) { + return 0; + } + + if (typeof value === 'function') { + return value(); + } + + return value; +} + export interface HoverInteractionSharedState { pointerTypeRef: React.RefObject; interactedInsideRef: React.RefObject; @@ -76,3 +102,85 @@ export function useHoverInteractionSharedState( handleCloseOptionsRef, ]); } + +export interface HoverInteractionSharedMethods { + isClickLikeOpenEvent: () => boolean; + isHoverOpen: () => boolean; + handleInteractInside: (event: PointerEvent) => void; + closeWithDelay: (event: MouseEvent, runElseBranch?: boolean) => void; + cleanupMouseMoveHandler: () => void; + clearPointerEvents: () => void; +} + +export function useHoverInteractionSharedMethods( + store: FloatingRootContext, + sharedState: HoverInteractionSharedState, + getCloseDelayValue: () => number | undefined, +): HoverInteractionSharedMethods { + const { + interactedInsideRef, + handlerRef, + performedPointerEventsMutationRef, + unbindMouseMoveRef, + openChangeTimeout, + } = sharedState; + const { dataRef } = store.context; + + const isClickLikeOpenEvent = useStableCallback(() => { + if (interactedInsideRef.current) { + return true; + } + + return dataRef.current.openEvent ? clickLikeEvents.has(dataRef.current.openEvent.type) : false; + }); + + const closeWithDelay = useStableCallback((event: MouseEvent, runElseBranch = true) => { + const closeDelay = getCloseDelayValue(); + if (closeDelay && !handlerRef.current) { + openChangeTimeout.start(closeDelay, () => { + store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event)); + }); + } else if (runElseBranch) { + openChangeTimeout.clear(); + store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event)); + } + }); + + const cleanupMouseMoveHandler = useStableCallback(() => { + unbindMouseMoveRef.current(); + handlerRef.current = undefined; + }); + + const clearPointerEvents = useStableCallback(() => { + if (performedPointerEventsMutationRef.current) { + const body = getDocument(store.select('domReferenceElement')).body; + body.style.pointerEvents = ''; + body.removeAttribute(safePolygonIdentifier); + performedPointerEventsMutationRef.current = false; + } + }); + + const isHoverOpen = useStableCallback(() => { + const type = dataRef.current.openEvent?.type; + return !!type?.includes('mouse') && type !== 'mousedown'; + }); + + const handleInteractInside = useStableCallback((event: PointerEvent) => { + const target = getTarget(event) as Element | null; + if (!isInteractiveElement(target)) { + interactedInsideRef.current = false; + return; + } + + interactedInsideRef.current = true; + }); + + return { + isClickLikeOpenEvent, + isHoverOpen, + handleInteractInside, + closeWithDelay, + cleanupMouseMoveHandler, + clearPointerEvents, + }; +} diff --git a/packages/react/src/floating-ui-react/hooks/useHoverReferenceInteraction.ts b/packages/react/src/floating-ui-react/hooks/useHoverReferenceInteraction.ts index 974c5c2dc8a..9f29378e5e6 100644 --- a/packages/react/src/floating-ui-react/hooks/useHoverReferenceInteraction.ts +++ b/packages/react/src/floating-ui-react/hooks/useHoverReferenceInteraction.ts @@ -3,6 +3,7 @@ import * as ReactDOM from 'react-dom'; import { isElement } from '@floating-ui/utils/dom'; import { useValueAsRef } from '@base-ui/utils/useValueAsRef'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; +import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import type { FloatingContext, FloatingRootContext } from '../types'; import { contains, getDocument, isMouseLikePointerType } from '../utils'; import { createChangeEventDetails } from '../../utils/createBaseUIEventDetails'; @@ -11,8 +12,9 @@ import type { UseHoverProps } from './useHover'; import { getDelay } from './useHover'; import { useFloatingTree } from '../components/FloatingTree'; import { - safePolygonIdentifier, + getRestMs, useHoverInteractionSharedState, + useHoverInteractionSharedMethods, } from './useHoverInteractionSharedState'; import { FloatingUIOpenChangeDetails, HTMLProps } from '../../utils/types'; @@ -27,13 +29,6 @@ export interface UseHoverReferenceInteractionProps extends Omit>; } -function getRestMs(value: number | (() => number)) { - if (typeof value === 'function') { - return value(); - } - return value; -} - const EMPTY_REF: Readonly> = { current: null }; /** @@ -61,64 +56,32 @@ export function useHoverReferenceInteraction( const tree = useFloatingTree(externalTree); + const handleCloseRef = useValueAsRef(handleClose); + const delayRef = useValueAsRef(delay); + const restMsRef = useValueAsRef(restMs); + + const sharedState = useHoverInteractionSharedState(store); const { pointerTypeRef, - interactedInsideRef, - handlerRef: closeHandlerRef, blockMouseMoveRef, - performedPointerEventsMutationRef, unbindMouseMoveRef, restTimeoutPendingRef, openChangeTimeout, restTimeout, handleCloseOptionsRef, - } = useHoverInteractionSharedState(store); + } = sharedState; - const handleCloseRef = useValueAsRef(handleClose); - const delayRef = useValueAsRef(delay); - const restMsRef = useValueAsRef(restMs); + const closeHandlerRef = sharedState.handlerRef; - if (isActiveTrigger) { - // eslint-disable-next-line no-underscore-dangle - handleCloseOptionsRef.current = handleCloseRef.current?.__options; - } + const { isClickLikeOpenEvent, closeWithDelay, cleanupMouseMoveHandler, clearPointerEvents } = + useHoverInteractionSharedMethods(store, sharedState, () => + getDelay(delayRef.current, 'close', pointerTypeRef.current), + ); - 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: MouseEvent, 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; + useIsoLayoutEffect(() => { + if (isActiveTrigger) { + // eslint-disable-next-line no-underscore-dangle + handleCloseOptionsRef.current = handleCloseRef.current?.__options; } }); @@ -300,6 +263,10 @@ export function useHoverReferenceInteraction( trigger.addEventListener('mouseleave', onMouseLeave); return () => { + cleanupMouseMoveHandler(); + openChangeTimeout.clear(); + restTimeout.clear(); + trigger.removeEventListener('mouseleave', onScrollMouseLeave); if (move) { @@ -384,9 +351,7 @@ export function useHoverReferenceInteraction( } if (pointerTypeRef.current === 'touch') { - ReactDOM.flushSync(() => { - handleMouseMove(); - }); + ReactDOM.flushSync(handleMouseMove); } else if (isOverInactiveTrigger && currentOpen) { handleMouseMove(); } else { From 4de998a3a19426edaa93428ba2f1a82fe4408801 Mon Sep 17 00:00:00 2001 From: atomiks Date: Wed, 10 Dec 2025 09:17:52 +1100 Subject: [PATCH 2/3] [menu] Fix focus returning to trigger on Esc while pointer rests on popup --- .../src/floating-ui-react/hooks/useHoverFloatingInteraction.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/floating-ui-react/hooks/useHoverFloatingInteraction.ts b/packages/react/src/floating-ui-react/hooks/useHoverFloatingInteraction.ts index e356cd5f9b6..a64ff5a6810 100644 --- a/packages/react/src/floating-ui-react/hooks/useHoverFloatingInteraction.ts +++ b/packages/react/src/floating-ui-react/hooks/useHoverFloatingInteraction.ts @@ -156,7 +156,7 @@ export function useHoverFloatingInteraction( // did not move. // https://github.com/floating-ui/floating-ui/discussions/1692 function onScrollMouseLeave(event: MouseEvent) { - if (isClickLikeOpenEvent() || !dataRef.current.floatingContext) { + if (isClickLikeOpenEvent() || !dataRef.current.floatingContext || !store.select('open')) { return; } From d221083958127444e69a9c05eec3eabe0279f695 Mon Sep 17 00:00:00 2001 From: atomiks Date: Wed, 10 Dec 2025 23:24:13 +1100 Subject: [PATCH 3/3] lint --- .../src/floating-ui-react/hooks/useHover.ts | 520 +++++++++++++++++- .../hooks/useHoverFloatingInteraction.ts | 140 +++-- .../hooks/useHoverInteractionSharedState.ts | 110 +--- .../hooks/useHoverReferenceInteraction.ts | 81 ++- 4 files changed, 650 insertions(+), 201 deletions(-) diff --git a/packages/react/src/floating-ui-react/hooks/useHover.ts b/packages/react/src/floating-ui-react/hooks/useHover.ts index 9c86b961ed1..332c49d5af5 100644 --- a/packages/react/src/floating-ui-react/hooks/useHover.ts +++ b/packages/react/src/floating-ui-react/hooks/useHover.ts @@ -1,4 +1,13 @@ import * as React from 'react'; +import { isElement } from '@floating-ui/utils/dom'; +import { useTimeout } from '@base-ui/utils/useTimeout'; +import { useValueAsRef } from '@base-ui/utils/useValueAsRef'; +import { useStableCallback } from '@base-ui/utils/useStableCallback'; +import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; +import { contains, getDocument, getTarget, isMouseLikePointerType } from '../utils'; + +import { useFloatingParentNodeId, useFloatingTree } from '../components/FloatingTree'; +import { FloatingTreeStore } from '../components/FloatingTreeStore'; import type { Delay, ElementProps, @@ -7,10 +16,18 @@ import type { FloatingTreeType, SafePolygonOptions, } from '../types'; -import { isMouseLikePointerType } from '../utils'; -import { FloatingTreeStore } from '../components/FloatingTreeStore'; -import { useHoverReferenceInteraction } from './useHoverReferenceInteraction'; -import { useHoverFloatingInteraction } from './useHoverFloatingInteraction'; +import { createChangeEventDetails } from '../../utils/createBaseUIEventDetails'; +import { REASONS } from '../../utils/reasons'; +import { createAttribute } from '../utils/createAttribute'; +import { FloatingUIOpenChangeDetails } from '../../utils/types'; +import { TYPEABLE_SELECTOR } from '../utils/constants'; + +const safePolygonIdentifier = createAttribute('safe-polygon'); +const interactiveSelector = `button,[role="button"],select,[tabindex]:not([tabindex="-1"]),${TYPEABLE_SELECTOR}`; + +function isInteractiveElement(element: Element | null) { + return element ? Boolean(element.closest(interactiveSelector)) : false; +} export interface HandleCloseContext extends FloatingContext { onClose: () => void; @@ -47,6 +64,13 @@ export function getDelay( return value?.[prop]; } +function getRestMs(value: number | (() => number)) { + if (typeof value === 'function') { + return value(); + } + return value; +} + export interface UseHoverProps { /** * Whether the Hook is enabled, including all internal Effects and event @@ -106,8 +130,10 @@ export function useHover( props: UseHoverProps = {}, ): ElementProps { 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, @@ -119,36 +145,478 @@ export function useHover( externalTree, } = props; - const resolvedTrigger = triggerElement ?? domReferenceElement; - const triggerElementRef = React.useMemo>( - () => ({ current: resolvedTrigger }), - [resolvedTrigger], + 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<(event: MouseEvent) => void>(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: FloatingUIOpenChangeDetails) { + 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: MouseEvent) { + if (isClickLikeOpenEvent()) { + return; + } + + if (isHoverOpen()) { + store.setOpen( + false, + createChangeEventDetails( + REASONS.triggerHover, + event, + (event.currentTarget as HTMLElement) ?? 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: MouseEvent, 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 closeDelay = React.useCallback(() => { - const resolved = typeof delay === 'function' ? delay() : delay; - if (typeof resolved === 'number') { - return resolved; + 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; } - return resolved?.close ?? 0; - }, [delay]); + }); - useHoverFloatingInteraction(context, { - enabled, - closeDelay, - externalTree, + const handleInteractInside = useStableCallback((event: PointerEvent) => { + const target = getTarget(event) as Element | null; + if (!isInteractiveElement(target)) { + interactedInsideRef.current = false; + return; + } + + interactedInsideRef.current = true; }); - const reference = useHoverReferenceInteraction(context, { + // 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: MouseEvent) { + 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 as HTMLElement) ?? 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: MouseEvent) { + 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 as Element)) { + // 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 as Element | null) + : 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: MouseEvent) { + if (isClickLikeOpenEvent() || !dataRef.current.floatingContext || !store.select('open')) { + return; + } + + const triggers = store.context.triggerElements; + + if (event.relatedTarget && triggers.hasElement(event.relatedTarget as Element)) { + // 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: MouseEvent) { + if (!isClickLikeOpenEvent()) { + closeWithDelay(event, false); + } + } + + const trigger = (triggerElement ?? domReferenceElement) as HTMLElement | null; + + 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, - delay, - handleClose, mouseOnly, - restMs, move, - triggerElementRef, - externalTree, - }); + 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 as HTMLElement | SVGSVGElement; + + 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: ElementProps['reference'] = React.useMemo(() => { + function setPointerRef(event: React.PointerEvent) { + pointerTypeRef.current = event.pointerType; + } + + return { + onPointerDown: setPointerRef, + onPointerEnter: setPointerRef, + onMouseMove(event) { + const { nativeEvent } = event; + const trigger = event.currentTarget as HTMLElement; + + // `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 as Element); + + 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]); } diff --git a/packages/react/src/floating-ui-react/hooks/useHoverFloatingInteraction.ts b/packages/react/src/floating-ui-react/hooks/useHoverFloatingInteraction.ts index a64ff5a6810..5f183df312f 100644 --- a/packages/react/src/floating-ui-react/hooks/useHoverFloatingInteraction.ts +++ b/packages/react/src/floating-ui-react/hooks/useHoverFloatingInteraction.ts @@ -1,17 +1,19 @@ import * as React from 'react'; import { isElement } from '@floating-ui/utils/dom'; +import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import type { FloatingContext, FloatingRootContext } from '../types'; -import { getDocument } from '../utils'; +import { getDocument, getTarget, isMouseLikePointerType } from '../utils'; +import { createChangeEventDetails } from '../../utils/createBaseUIEventDetails'; +import { REASONS } from '../../utils/reasons'; import { useFloatingParentNodeId, useFloatingTree } from '../components/FloatingTree'; import { FloatingTreeStore } from '../components/FloatingTreeStore'; import { - getCloseDelay, + isInteractiveElement, safePolygonIdentifier, useHoverInteractionSharedState, - useHoverInteractionSharedMethods, } from './useHoverInteractionSharedState'; export type UseHoverFloatingInteractionProps = { @@ -33,6 +35,8 @@ export type UseHoverFloatingInteractionProps = { externalTree?: FloatingTreeStore; }; +const clickLikeEvents = new Set(['click', 'mousedown']); + /** * Provides hover interactions that should be attached to the floating element. */ @@ -48,31 +52,72 @@ export function useHoverFloatingInteraction( const { enabled = true, closeDelay: closeDelayProp = 0, externalTree } = parameters; - const sharedState = useHoverInteractionSharedState(store); const { pointerTypeRef, interactedInsideRef, handlerRef, performedPointerEventsMutationRef, + unbindMouseMoveRef, restTimeoutPendingRef, - openChangeTimeout, + openChangeTimeout: openChangeTimeout, handleCloseOptionsRef, - } = sharedState; - - const { - isClickLikeOpenEvent, - isHoverOpen, - handleInteractInside, - closeWithDelay, - cleanupMouseMoveHandler, - clearPointerEvents, - } = useHoverInteractionSharedMethods(store, sharedState, () => - getCloseDelay(closeDelayProp, pointerTypeRef.current), - ); + } = useHoverInteractionSharedState(store); const tree = useFloatingTree(externalTree); const parentId = useFloatingParentNodeId(); + const isClickLikeOpenEvent = useStableCallback(() => { + if (interactedInsideRef.current) { + return true; + } + + return dataRef.current.openEvent ? clickLikeEvents.has(dataRef.current.openEvent.type) : false; + }); + + const isHoverOpen = useStableCallback(() => { + const type = dataRef.current.openEvent?.type; + return type?.includes('mouse') && type !== 'mousedown'; + }); + + const closeWithDelay = React.useCallback( + (event: MouseEvent, runElseBranch = true) => { + const closeDelay = getDelay(closeDelayProp, pointerTypeRef.current); + if (closeDelay && !handlerRef.current) { + openChangeTimeout.start(closeDelay, () => + store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event)), + ); + } else if (runElseBranch) { + openChangeTimeout.clear(); + store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event)); + } + }, + [closeDelayProp, handlerRef, store, pointerTypeRef, openChangeTimeout], + ); + + 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: PointerEvent) => { + const target = getTarget(event) as Element | null; + if (!isInteractiveElement(target)) { + interactedInsideRef.current = false; + return; + } + + interactedInsideRef.current = true; + }); + useIsoLayoutEffect(() => { if (!open) { pointerTypeRef.current = undefined; @@ -93,9 +138,12 @@ export function useHoverFloatingInteraction( React.useEffect(() => { return () => { cleanupMouseMoveHandler(); - clearPointerEvents(); }; - }, [clearPointerEvents, cleanupMouseMoveHandler]); + }, [cleanupMouseMoveHandler]); + + React.useEffect(() => { + return clearPointerEvents; + }, [clearPointerEvents]); useIsoLayoutEffect(() => { if (!enabled) { @@ -148,7 +196,7 @@ export function useHoverFloatingInteraction( ]); React.useEffect(() => { - if (!enabled || !floatingElement) { + if (!enabled) { return undefined; } @@ -187,30 +235,36 @@ export function useHoverFloatingInteraction( } } - floatingElement.addEventListener('mouseleave', onScrollMouseLeave); - floatingElement.addEventListener('mouseenter', onFloatingMouseEnter); - floatingElement.addEventListener('mouseleave', onFloatingMouseLeave); - floatingElement.addEventListener('pointerdown', handleInteractInside, true); + const floating = floatingElement; + if (floating) { + floating.addEventListener('mouseleave', onScrollMouseLeave); + floating.addEventListener('mouseenter', onFloatingMouseEnter); + floating.addEventListener('mouseleave', onFloatingMouseLeave); + floating.addEventListener('pointerdown', handleInteractInside, true); + } return () => { - openChangeTimeout.clear(); - - floatingElement.removeEventListener('mouseleave', onScrollMouseLeave); - floatingElement.removeEventListener('mouseenter', onFloatingMouseEnter); - floatingElement.removeEventListener('mouseleave', onFloatingMouseLeave); - floatingElement.removeEventListener('pointerdown', handleInteractInside, true); + if (floating) { + floating.removeEventListener('mouseleave', onScrollMouseLeave); + floating.removeEventListener('mouseenter', onFloatingMouseEnter); + floating.removeEventListener('mouseleave', onFloatingMouseLeave); + floating.removeEventListener('pointerdown', handleInteractInside, true); + } }; - }, [ - enabled, - floatingElement, - isClickLikeOpenEvent, - dataRef, - store, - clearPointerEvents, - cleanupMouseMoveHandler, - closeWithDelay, - openChangeTimeout, - handlerRef, - handleInteractInside, - ]); + }); +} + +export function getDelay( + value: number | (() => number), + pointerType?: PointerEvent['pointerType'], +) { + if (pointerType && !isMouseLikePointerType(pointerType)) { + return 0; + } + + if (typeof value === 'function') { + return value(); + } + + return value; } diff --git a/packages/react/src/floating-ui-react/hooks/useHoverInteractionSharedState.ts b/packages/react/src/floating-ui-react/hooks/useHoverInteractionSharedState.ts index 321c134f268..6a07406cbcf 100644 --- a/packages/react/src/floating-ui-react/hooks/useHoverInteractionSharedState.ts +++ b/packages/react/src/floating-ui-react/hooks/useHoverInteractionSharedState.ts @@ -1,43 +1,17 @@ import * as React from 'react'; import { useTimeout } from '@base-ui/utils/useTimeout'; -import { useStableCallback } from '@base-ui/utils/useStableCallback'; + import type { ContextData, FloatingRootContext, SafePolygonOptions } from '../types'; -import { getDocument, getTarget, isMouseLikePointerType } from '../utils'; import { createAttribute } from '../utils/createAttribute'; import { TYPEABLE_SELECTOR } from '../utils/constants'; -import { createChangeEventDetails } from '../../utils/createBaseUIEventDetails'; -import { REASONS } from '../../utils/reasons'; - -export function getRestMs(value: number | (() => number)) { - if (typeof value === 'function') { - return value(); - } - return value; -} export const safePolygonIdentifier = createAttribute('safe-polygon'); const interactiveSelector = `button,a,[role="button"],select,[tabindex]:not([tabindex="-1"]),${TYPEABLE_SELECTOR}`; -const clickLikeEvents = new Set(['click', 'mousedown']); export function isInteractiveElement(element: Element | null) { return element ? Boolean(element.closest(interactiveSelector)) : false; } -export function getCloseDelay( - value: number | (() => number), - pointerType?: PointerEvent['pointerType'], -) { - if (pointerType && !isMouseLikePointerType(pointerType)) { - return 0; - } - - if (typeof value === 'function') { - return value(); - } - - return value; -} - export interface HoverInteractionSharedState { pointerTypeRef: React.RefObject; interactedInsideRef: React.RefObject; @@ -102,85 +76,3 @@ export function useHoverInteractionSharedState( handleCloseOptionsRef, ]); } - -export interface HoverInteractionSharedMethods { - isClickLikeOpenEvent: () => boolean; - isHoverOpen: () => boolean; - handleInteractInside: (event: PointerEvent) => void; - closeWithDelay: (event: MouseEvent, runElseBranch?: boolean) => void; - cleanupMouseMoveHandler: () => void; - clearPointerEvents: () => void; -} - -export function useHoverInteractionSharedMethods( - store: FloatingRootContext, - sharedState: HoverInteractionSharedState, - getCloseDelayValue: () => number | undefined, -): HoverInteractionSharedMethods { - const { - interactedInsideRef, - handlerRef, - performedPointerEventsMutationRef, - unbindMouseMoveRef, - openChangeTimeout, - } = sharedState; - const { dataRef } = store.context; - - const isClickLikeOpenEvent = useStableCallback(() => { - if (interactedInsideRef.current) { - return true; - } - - return dataRef.current.openEvent ? clickLikeEvents.has(dataRef.current.openEvent.type) : false; - }); - - const closeWithDelay = useStableCallback((event: MouseEvent, runElseBranch = true) => { - const closeDelay = getCloseDelayValue(); - if (closeDelay && !handlerRef.current) { - openChangeTimeout.start(closeDelay, () => { - store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event)); - }); - } else if (runElseBranch) { - openChangeTimeout.clear(); - store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event)); - } - }); - - const cleanupMouseMoveHandler = useStableCallback(() => { - unbindMouseMoveRef.current(); - handlerRef.current = undefined; - }); - - const clearPointerEvents = useStableCallback(() => { - if (performedPointerEventsMutationRef.current) { - const body = getDocument(store.select('domReferenceElement')).body; - body.style.pointerEvents = ''; - body.removeAttribute(safePolygonIdentifier); - performedPointerEventsMutationRef.current = false; - } - }); - - const isHoverOpen = useStableCallback(() => { - const type = dataRef.current.openEvent?.type; - return !!type?.includes('mouse') && type !== 'mousedown'; - }); - - const handleInteractInside = useStableCallback((event: PointerEvent) => { - const target = getTarget(event) as Element | null; - if (!isInteractiveElement(target)) { - interactedInsideRef.current = false; - return; - } - - interactedInsideRef.current = true; - }); - - return { - isClickLikeOpenEvent, - isHoverOpen, - handleInteractInside, - closeWithDelay, - cleanupMouseMoveHandler, - clearPointerEvents, - }; -} diff --git a/packages/react/src/floating-ui-react/hooks/useHoverReferenceInteraction.ts b/packages/react/src/floating-ui-react/hooks/useHoverReferenceInteraction.ts index 9f29378e5e6..974c5c2dc8a 100644 --- a/packages/react/src/floating-ui-react/hooks/useHoverReferenceInteraction.ts +++ b/packages/react/src/floating-ui-react/hooks/useHoverReferenceInteraction.ts @@ -3,7 +3,6 @@ import * as ReactDOM from 'react-dom'; import { isElement } from '@floating-ui/utils/dom'; import { useValueAsRef } from '@base-ui/utils/useValueAsRef'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; -import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import type { FloatingContext, FloatingRootContext } from '../types'; import { contains, getDocument, isMouseLikePointerType } from '../utils'; import { createChangeEventDetails } from '../../utils/createBaseUIEventDetails'; @@ -12,9 +11,8 @@ import type { UseHoverProps } from './useHover'; import { getDelay } from './useHover'; import { useFloatingTree } from '../components/FloatingTree'; import { - getRestMs, + safePolygonIdentifier, useHoverInteractionSharedState, - useHoverInteractionSharedMethods, } from './useHoverInteractionSharedState'; import { FloatingUIOpenChangeDetails, HTMLProps } from '../../utils/types'; @@ -29,6 +27,13 @@ export interface UseHoverReferenceInteractionProps extends Omit>; } +function getRestMs(value: number | (() => number)) { + if (typeof value === 'function') { + return value(); + } + return value; +} + const EMPTY_REF: Readonly> = { current: null }; /** @@ -56,32 +61,64 @@ export function useHoverReferenceInteraction( const tree = useFloatingTree(externalTree); - const handleCloseRef = useValueAsRef(handleClose); - const delayRef = useValueAsRef(delay); - const restMsRef = useValueAsRef(restMs); - - const sharedState = useHoverInteractionSharedState(store); const { pointerTypeRef, + interactedInsideRef, + handlerRef: closeHandlerRef, blockMouseMoveRef, + performedPointerEventsMutationRef, unbindMouseMoveRef, restTimeoutPendingRef, openChangeTimeout, restTimeout, handleCloseOptionsRef, - } = sharedState; + } = useHoverInteractionSharedState(store); - const closeHandlerRef = sharedState.handlerRef; + const handleCloseRef = useValueAsRef(handleClose); + const delayRef = useValueAsRef(delay); + const restMsRef = useValueAsRef(restMs); - const { isClickLikeOpenEvent, closeWithDelay, cleanupMouseMoveHandler, clearPointerEvents } = - useHoverInteractionSharedMethods(store, sharedState, () => - getDelay(delayRef.current, 'close', pointerTypeRef.current), - ); + if (isActiveTrigger) { + // eslint-disable-next-line no-underscore-dangle + handleCloseOptionsRef.current = handleCloseRef.current?.__options; + } - useIsoLayoutEffect(() => { - 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: MouseEvent, 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; } }); @@ -263,10 +300,6 @@ export function useHoverReferenceInteraction( trigger.addEventListener('mouseleave', onMouseLeave); return () => { - cleanupMouseMoveHandler(); - openChangeTimeout.clear(); - restTimeout.clear(); - trigger.removeEventListener('mouseleave', onScrollMouseLeave); if (move) { @@ -351,7 +384,9 @@ export function useHoverReferenceInteraction( } if (pointerTypeRef.current === 'touch') { - ReactDOM.flushSync(handleMouseMove); + ReactDOM.flushSync(() => { + handleMouseMove(); + }); } else if (isOverInactiveTrigger && currentOpen) { handleMouseMove(); } else {