diff --git a/packages/react/src/floating-ui-react/hooks/useHover.ts b/packages/react/src/floating-ui-react/hooks/useHover.ts index 332c49d5af5..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,478 +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() || !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; - }, [ + 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 5f183df312f..a64ff5a6810 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; } @@ -235,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 e55b646d8a7..67fedad6026 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 }; /** @@ -43,7 +38,7 @@ const EMPTY_REF: Readonly> = { current: null }; export function useHoverReferenceInteraction( context: FloatingRootContext | FloatingContext, props: UseHoverReferenceInteractionProps = {}, -): HTMLProps | undefined { +) { const store = 'rootStore' in context ? context.rootStore : context; const { dataRef, events } = store.context; @@ -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; } }); @@ -304,6 +267,10 @@ export function useHoverReferenceInteraction( trigger.addEventListener('mouseleave', onMouseLeave); return () => { + cleanupMouseMoveHandler(); + openChangeTimeout.clear(); + restTimeout.clear(); + trigger.removeEventListener('mouseleave', onScrollMouseLeave); if (move) { @@ -388,9 +355,7 @@ export function useHoverReferenceInteraction( } if (pointerTypeRef.current === 'touch') { - ReactDOM.flushSync(() => { - handleMouseMove(); - }); + ReactDOM.flushSync(handleMouseMove); } else if (isOverInactiveTrigger && currentOpen) { handleMouseMove(); } else { diff --git a/packages/react/src/navigation-menu/trigger/NavigationMenuTrigger.tsx b/packages/react/src/navigation-menu/trigger/NavigationMenuTrigger.tsx index 6f0bd1d8d35..0ee152561c2 100644 --- a/packages/react/src/navigation-menu/trigger/NavigationMenuTrigger.tsx +++ b/packages/react/src/navigation-menu/trigger/NavigationMenuTrigger.tsx @@ -12,8 +12,7 @@ import { useClick, useFloatingRootContext, useFloatingTree, - useHover, - useInteractions, + useHoverReferenceInteraction, } from '../../floating-ui-react'; import { contains, @@ -43,6 +42,7 @@ import { getCssDimensions } from '../../utils/getCssDimensions'; import { NavigationMenuRoot } from '../root/NavigationMenuRoot'; import { NAVIGATION_MENU_TRIGGER_IDENTIFIER } from '../utils/constants'; import { useNavigationMenuDismissContext } from '../list/NavigationMenuDismissContext'; +import { mergeProps } from '../../merge-props'; const DEFAULT_SIZE = { width: 0, height: 0 }; @@ -286,17 +286,24 @@ export const NavigationMenuTrigger = React.forwardRef(function NavigationMenuTri }, }); - const hover = useHover(context, { + const triggerElementRef = React.useMemo>( + () => ({ current: triggerElement }), + [triggerElement], + ); + + const hoverProps = useHoverReferenceInteraction(context, { move: false, handleClose: safePolygon({ blockPointerEvents: pointerType !== 'touch' }), restMs: mounted && positionerElement ? 0 : delay, delay: { close: closeDelay }, + triggerElementRef, }); const click = useClick(context, { enabled: interactionsEnabled, stickIfOpen, toggle: isActiveItem, }); + useIsoLayoutEffect(() => { if (isActiveItem) { setFloatingRootContext(context); @@ -304,7 +311,7 @@ export const NavigationMenuTrigger = React.forwardRef(function NavigationMenuTri } }, [isActiveItem, context, setFloatingRootContext, prevTriggerElementRef, triggerElement]); - const { getReferenceProps } = useInteractions([hover, click]); + const triggerProps = mergeProps(click.reference, hoverProps); function handleActivation(event: React.MouseEvent | React.KeyboardEvent) { ReactDOM.flushSync(() => { @@ -442,7 +449,7 @@ export const NavigationMenuTrigger = React.forwardRef(function NavigationMenuTri stateAttributesMapping={pressableTriggerOpenStateMapping} refs={[forwardedRef, setTriggerElement, buttonRef]} props={[ - getReferenceProps, + triggerProps, dismissProps?.reference || EMPTY_ARRAY, defaultProps, elementProps, diff --git a/packages/react/src/navigation-menu/viewport/NavigationMenuViewport.tsx b/packages/react/src/navigation-menu/viewport/NavigationMenuViewport.tsx index dae21f40c98..d83133597df 100644 --- a/packages/react/src/navigation-menu/viewport/NavigationMenuViewport.tsx +++ b/packages/react/src/navigation-menu/viewport/NavigationMenuViewport.tsx @@ -13,6 +13,7 @@ import { isOutsideEvent, contains, } from '../../floating-ui-react/utils'; +import { useHoverFloatingInteraction } from '../../floating-ui-react'; import { getEmptyRootContext } from '../../floating-ui-react/utils/getEmptyRootContext'; import { useNavigationMenuPositionerContext } from '../positioner/NavigationMenuPositionerContext'; @@ -85,11 +86,17 @@ export const NavigationMenuViewport = React.forwardRef(function NavigationMenuVi prevTriggerElementRef, viewportInert, setViewportInert, + closeDelay, } = useNavigationMenuRootContext(); const hasPositioner = Boolean(useNavigationMenuPositionerContext(true)); const domReference = (floatingRootContext || EMPTY_ROOT_CONTEXT).useState('domReferenceElement'); + useHoverFloatingInteraction(floatingRootContext ?? EMPTY_ROOT_CONTEXT, { + enabled: floatingRootContext != null, + closeDelay, + }); + useIsoLayoutEffect(() => { if (domReference) { prevTriggerElementRef.current = domReference; diff --git a/packages/react/src/preview-card/root/PreviewCardRoot.tsx b/packages/react/src/preview-card/root/PreviewCardRoot.tsx index 843bee3eac5..2c39a635af9 100644 --- a/packages/react/src/preview-card/root/PreviewCardRoot.tsx +++ b/packages/react/src/preview-card/root/PreviewCardRoot.tsx @@ -6,7 +6,8 @@ import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { safePolygon, useDismiss, - useHover, + useHoverFloatingInteraction, + useHoverReferenceInteraction, useInteractions, useFloatingRootContext, } from '../../floating-ui-react'; @@ -124,17 +125,25 @@ export function PreviewCardRoot(props: PreviewCardRoot.Props) { const getDelayValue = () => delayRef.current; const getCloseDelayValue = () => closeDelayRef.current; - const hover = useHover(context, { + useHoverFloatingInteraction(context, { closeDelay: getCloseDelayValue }); + + const triggerElementRef = React.useMemo>( + () => ({ current: triggerElement }), + [triggerElement], + ); + + const hoverProps = useHoverReferenceInteraction(context, { mouseOnly: true, move: false, handleClose: safePolygon(), restMs: getDelayValue, delay: () => ({ close: getCloseDelayValue() }), + triggerElementRef, }); const focus = useFocusWithDelay(context, { delay: getDelayValue }); const dismiss = useDismiss(context); - const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss]); + const { getReferenceProps, getFloatingProps } = useInteractions([focus, dismiss]); const contextValue = React.useMemo( () => ({ @@ -146,7 +155,7 @@ export function PreviewCardRoot(props: PreviewCardRoot.Props) { positionerElement, setPositionerElement, popupRef, - triggerProps: getReferenceProps(), + triggerProps: getReferenceProps(hoverProps), popupProps: getFloatingProps(), floatingRootContext: context, instantType, @@ -161,6 +170,7 @@ export function PreviewCardRoot(props: PreviewCardRoot.Props) { setMounted, positionerElement, getReferenceProps, + hoverProps, getFloatingProps, context, instantType,