'use client'; /* eslint-disable no-underscore-dangle */ import * as React from 'react'; import { addEventListener } from '@base-ui/utils/addEventListener'; import { mergeCleanups } from '@base-ui/utils/mergeCleanups'; import { ownerDocument } from '@base-ui/utils/owner'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { Timeout, useTimeout } from '@base-ui/utils/useTimeout'; import { getComputedStyle, getParentNode, isElement, isHTMLElement, isLastTraversableNode, isShadowRoot, } from '@floating-ui/utils/dom'; import { platform } from '@base-ui/utils/platform'; import { useFloatingTree } from '../components/FloatingTree'; import type { FloatingTreeStore } from '../components/FloatingTreeStore'; import type { ElementProps, FloatingRootContext } from '../types'; import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails'; import type { FloatingUIOpenChangeDetails } from '../../internals/types'; import { REASONS } from '../../internals/reasons'; import { createAttribute } from '../utils/createAttribute'; import { contains, getTarget, isEventTargetWithin, isRootElement } from '../utils/element'; import { isReactEvent, isVirtualClick } from '../utils/event'; import { getNodeChildren } from '../utils/nodes'; type PressType = 'intentional' | 'sloppy'; function alwaysFalse() { return false; } export function normalizeProp( normalizable?: boolean | { escapeKey?: boolean | undefined; outsidePress?: boolean | undefined }, ) { return { escapeKey: typeof normalizable === 'boolean' ? normalizable : (normalizable?.escapeKey ?? false), outsidePress: typeof normalizable === 'boolean' ? normalizable : (normalizable?.outsidePress ?? true), }; } export interface UseDismissProps { /** * Whether the Hook is enabled, including all internal Effects and event * handlers. * @default true */ enabled?: boolean | undefined; /** * Whether to dismiss the floating element upon pressing the `esc` key. * @default true */ escapeKey?: boolean | undefined; /** * Whether to dismiss the floating element upon pressing the reference * element. You likely want to ensure the `move` option in the `useHover()` * Hook has been disabled when this is in use. * * A lazy getter invoked when handling reference press events. * @default false */ referencePress?: (() => boolean) | undefined; /** * Whether to dismiss the floating element upon pressing outside of the * floating element. * If you have another element, like a toast, that is rendered outside the * floating element's React tree and don't want the floating element to close * when pressing it, you can guard the check like so: * ```jsx * useDismiss(context, { * outsidePress: (event) => !event.target.closest('.toast'), * }); * ``` * @default true */ outsidePress?: boolean | ((event: MouseEvent | TouchEvent) => boolean) | undefined; /** * The type of event to use to determine an outside "press". * - `intentional` dismisses on an outside `click` whose press began while the floating element was open, ignoring the trailing click of a press that started before it opened. Touch requires minimal `touchmove`s, and press-less clicks (keyboard, assistive technology) are always accepted. * - `sloppy` fires on `pointerdown` for mouse, while for touch it fires on `touchend` (within 1 second) or while scrolling away after `touchstart`. */ outsidePressEvent?: | PressType | { mouse: PressType; touch: PressType; } | (() => | PressType | { mouse: PressType; touch: PressType; }) | undefined; /** * Determines whether event listeners bubble upwards through a tree of * floating elements. */ bubbles?: boolean | { escapeKey?: boolean | undefined; outsidePress?: boolean | undefined } | undefined; /** * External FloatingTree to use when the one provided by context can't be used. */ externalTree?: FloatingTreeStore | undefined; } /** * Closes the floating element when a dismissal is requested — by default, when * the user presses the `escape` key or outside of the floating element. * @see https://floating-ui.com/docs/useDismiss */ export function useDismiss(store: FloatingRootContext, props: UseDismissProps = {}): ElementProps { const { enabled = true, escapeKey = true, outsidePress: outsidePressProp = true, outsidePressEvent = 'sloppy', referencePress = alwaysFalse, bubbles, externalTree, } = props; const open = store.useState('open'); const floatingElement = store.useState('floatingElement'); const { dataRef, events } = store.context; const tree = useFloatingTree(externalTree); const outsidePressFn = useStableCallback( typeof outsidePressProp === 'function' ? outsidePressProp : () => false, ); const outsidePress = typeof outsidePressProp === 'function' ? outsidePressFn : outsidePressProp; const outsidePressEnabled = outsidePress !== false; const getOutsidePressEventProp = useStableCallback(() => outsidePressEvent); const { escapeKey: escapeKeyBubbles, outsidePress: outsidePressBubbles } = normalizeProp(bubbles); const pressStartedInsideRef = React.useRef(false); const pressStartPreventedRef = React.useRef(false); // Ignore only the very next outside click after dragging from inside to outside. const suppressNextOutsideClickRef = React.useRef(false); // A click whose press began before the floating element opened is the tail of that // gesture (e.g. the drag-release that opened it), not a new outside press. const sawPressWhileOpenRef = React.useRef(false); const isComposingRef = React.useRef(false); const currentPointerTypeRef = React.useRef(''); const touchStateRef = React.useRef<{ startTime: number; startX: number; startY: number; dismissOnTouchEnd: boolean; dismissOnMouseDown: boolean; } | null>(null); const cancelDismissOnEndTimeout = useTimeout(); const clearInsideReactTreeTimeout = useTimeout(); // Outlives effect re-runs so a pending reset can't leave `isComposingRef` stuck. const compositionTimeout = useTimeout(); const clearInsideReactTree = useStableCallback(() => { clearInsideReactTreeTimeout.clear(); dataRef.current.insideReactTree = false; }); const hasBlockingChild = useStableCallback( (bubbleKey: '__escapeKeyBubbles' | '__outsidePressBubbles') => { const nodeId = dataRef.current.floatingContext?.nodeId; const children = tree ? getNodeChildren(tree.nodesRef.current, nodeId) : []; return children.some( (child) => child.context?.open && !child.context.dataRef.current[bubbleKey], ); }, ); const isEventWithinOwnElements = useStableCallback((event: Event) => { return ( isEventTargetWithin(event, store.select('floatingElement')) || isEventTargetWithin(event, store.select('domReferenceElement')) ); }); const closeOnReferencePress = useStableCallback((event: React.SyntheticEvent) => { if (!referencePress()) { return; } store.setOpen( false, createChangeEventDetails( REASONS.triggerPress, event.nativeEvent as MouseEvent | PointerEvent | TouchEvent | KeyboardEvent, ), ); }); const closeOnEscapeKeyDown = useStableCallback( (event: React.KeyboardEvent | KeyboardEvent) => { if (!open || !enabled || !escapeKey || event.key !== 'Escape') { return; } const native = isReactEvent(event) ? event.nativeEvent : event; // Wait until IME is settled. Pressing `Escape` while composing should // close the compose menu, but not the floating element. The ref covers Safari, which fires // `compositionend` before `keydown`; `isComposing` covers compositions that began while closed. if (isComposingRef.current || native.isComposing) { return; } if (!escapeKeyBubbles && hasBlockingChild('__escapeKeyBubbles')) { return; } const eventDetails = createChangeEventDetails(REASONS.escapeKey, native); store.setOpen(false, eventDetails); if (!eventDetails.isCanceled) { event.preventDefault(); } if (!escapeKeyBubbles && !eventDetails.isPropagationAllowed) { event.stopPropagation(); } }, ); const markInsideReactTree = useStableCallback(() => { dataRef.current.insideReactTree = true; clearInsideReactTreeTimeout.start(0, clearInsideReactTree); }); const markPressStartedInsideReactTree = useStableCallback( (event: React.PointerEvent | React.MouseEvent) => { if (!open || !enabled || event.button !== 0) { return; } const target = getTarget(event.nativeEvent) as Element | null; // Only treat presses that start within the floating DOM subtree as inside. // This avoids suppressing parent dismissal when interacting with nested portals. if (!contains(store.select('floatingElement'), target)) { return; } if (!pressStartedInsideRef.current) { pressStartedInsideRef.current = true; pressStartPreventedRef.current = false; } }, ); const markInsidePressStartPrevented = useStableCallback( (event: React.PointerEvent | React.MouseEvent) => { if (!open || !enabled) { return; } if (!(event.defaultPrevented || event.nativeEvent.defaultPrevented)) { return; } if (pressStartedInsideRef.current) { pressStartPreventedRef.current = true; } }, ); // A same-batch close+reopen never renders `open === false`, so only `openchange` can // observe that session boundary. The effect below covers controlled flips. React.useEffect(() => { function handleOpenChange(details: FloatingUIOpenChangeDetails) { // Only the closing half ends the session: `setOpen(true)` on an already-open // element (hovering an inactive trigger) must not drop a press mid-gesture. if (!details.open) { sawPressWhileOpenRef.current = false; } } events.on('openchange', handleOpenChange); return () => { events.off('openchange', handleOpenChange); }; }, [events]); // Not cleared in the listener effect's cleanup, which can run mid-press when a dependency // changes. In a closed shadow root, the marker is the only inside-press signal. React.useEffect(() => clearInsideReactTree, [clearInsideReactTree]); React.useEffect(() => { if (!open || !enabled) { // Reset in the effect body, not the cleanup, which also runs when a dependency // changes mid-gesture. if (!open) { sawPressWhileOpenRef.current = false; isComposingRef.current = false; currentPointerTypeRef.current = ''; touchStateRef.current = null; } return clearInsideReactTree; } dataRef.current.__escapeKeyBubbles = escapeKeyBubbles; dataRef.current.__outsidePressBubbles = outsidePressBubbles; const preventedPressSuppressionTimeout = new Timeout(); const doc = ownerDocument(floatingElement); function handleCompositionStart() { compositionTimeout.clear(); isComposingRef.current = true; } function handleCompositionEnd() { // Safari fires `compositionend` before `keydown`, so we need to wait // until the next tick to set `isComposing` to `false`. Set it here too, since closing // resets it while a composition can continue into the next open session. // https://bugs.webkit.org/show_bug.cgi?id=165004 isComposingRef.current = true; compositionTimeout.start( // 0ms or 1ms don't work in Safari. 5ms appears to consistently work. // Only apply to WebKit for the test to remain 0ms. platform.engine.webkit ? 5 : 0, () => { isComposingRef.current = false; }, ); } function suppressImmediateOutsideClickAfterPreventedStart() { suppressNextOutsideClickRef.current = true; // Firefox can emit the synthetic outside click in a later task after // pointer lock exit, so microtask clearing is too early here. preventedPressSuppressionTimeout.start(0, () => { suppressNextOutsideClickRef.current = false; }); } function resetPressStartState() { pressStartedInsideRef.current = false; pressStartPreventedRef.current = false; } function getOutsidePressEvent(): PressType { const type = currentPointerTypeRef.current as 'pen' | 'mouse' | 'touch' | ''; const computedType = type === 'pen' || !type ? 'mouse' : type; const outsidePressEventValue = getOutsidePressEventProp(); const resolved = typeof outsidePressEventValue === 'function' ? outsidePressEventValue() : outsidePressEventValue; if (typeof resolved === 'string') { return resolved; } return resolved[computedType]; } function shouldIgnoreEvent(event: Event) { const computedOutsidePressEvent = getOutsidePressEvent(); return ( (computedOutsidePressEvent === 'intentional' && event.type !== 'click') || (computedOutsidePressEvent === 'sloppy' && event.type === 'click') ); } function isEventWithinFloatingTree(event: Event) { const nodeId = dataRef.current.floatingContext?.nodeId; const targetIsInsideChildren = tree && getNodeChildren(tree.nodesRef.current, nodeId).some((node) => isEventTargetWithin(event, node.context?.elements.floating), ); return isEventWithinOwnElements(event) || targetIsInsideChildren; } function closeOnPressOutside(event: MouseEvent | PointerEvent | TouchEvent) { if (shouldIgnoreEvent(event)) { // A new press began outside the floating element and its trigger. Clear any // leftover drag-out suppression so this press's eventual click can dismiss. if (event.type !== 'click' && !isEventWithinOwnElements(event)) { preventedPressSuppressionTimeout.clear(); suppressNextOutsideClickRef.current = false; } clearInsideReactTree(); return; } if (dataRef.current.insideReactTree) { clearInsideReactTree(); return; } const target = getTarget(event); const inertSelector = `[${createAttribute('inert')}]`; const targetRoot = isElement(target) ? target.getRootNode() : null; const markers = Array.from( (isShadowRoot(targetRoot) ? targetRoot : ownerDocument(store.select('floatingElement')) ).querySelectorAll(inertSelector), ); const triggers = store.context.triggerElements; // If another trigger is clicked, don't close the floating element. if ( target && (triggers.hasElement(target as Element) || triggers.hasMatchingElement((trigger) => contains(trigger, target as Element))) ) { return; } let targetRootAncestor = isElement(target) ? target : null; while (targetRootAncestor && !isLastTraversableNode(targetRootAncestor)) { const nextParent = getParentNode(targetRootAncestor); if (isLastTraversableNode(nextParent) || !isElement(nextParent)) { break; } targetRootAncestor = nextParent; } // Check if the click occurred on a third-party element injected after the // floating element rendered. if ( markers.length && isElement(target) && !isRootElement(target) && // Clicked on a direct ancestor (e.g. FloatingOverlay). !contains(target, store.select('floatingElement')) && // If the target root element contains none of the markers, then the // element was injected after the floating element rendered. markers.every((marker) => !contains(targetRootAncestor, marker)) ) { return; } // Check if the click occurred on the scrollbar // Skip for touch events: scrollbars don't receive touch events on most platforms if (isHTMLElement(target) && !('touches' in event)) { const lastTraversableNode = isLastTraversableNode(target); const style = getComputedStyle(target); const scrollRe = /auto|scroll/; const isScrollableX = lastTraversableNode || scrollRe.test(style.overflowX); const isScrollableY = lastTraversableNode || scrollRe.test(style.overflowY); const canScrollX = isScrollableX && target.clientWidth > 0 && target.scrollWidth > target.clientWidth; const canScrollY = isScrollableY && target.clientHeight > 0 && target.scrollHeight > target.clientHeight; const isRTL = style.direction === 'rtl'; // Check click position relative to scrollbar. // In some browsers it is possible to change the (or window) // scrollbar to the left side, but is very rare and is difficult to // check for. Plus, for modal dialogs with backdrops, it is more // important that the backdrop is checked but not so much the window. const pressedVerticalScrollbar = canScrollY && (isRTL ? event.offsetX <= target.offsetWidth - target.clientWidth : event.offsetX > target.clientWidth); const pressedHorizontalScrollbar = canScrollX && event.offsetY > target.clientHeight; if (pressedVerticalScrollbar || pressedHorizontalScrollbar) { return; } } if (isEventWithinFloatingTree(event)) { return; } // Only `click` events reach this point in intentional mode. if (getOutsidePressEvent() === 'intentional') { // Press-less clicks (keyboard, assistive technology, `element.click()`) report no // click count; `isVirtualClick` also catches the ones that do. if ( (event as MouseEvent).detail !== 0 && !isVirtualClick(event as MouseEvent) && !sawPressWhileOpenRef.current ) { return; } // A press that starts inside and ends outside gets one suppressed // outside click. Run this after inside-target checks so inside clicks // don't consume the one-shot suppression. if (suppressNextOutsideClickRef.current) { preventedPressSuppressionTimeout.clear(); suppressNextOutsideClickRef.current = false; return; } } if (typeof outsidePress === 'function' && !outsidePress(event)) { return; } if (hasBlockingChild('__outsidePressBubbles')) { return; } store.setOpen(false, createChangeEventDetails(REASONS.outsidePress, event)); clearInsideReactTree(); } function handlePointerDown(event: PointerEvent) { if ( getOutsidePressEvent() !== 'sloppy' || event.pointerType === 'touch' || !store.select('open') || !enabled || isEventWithinOwnElements(event) ) { return; } closeOnPressOutside(event); } function handleTouchStart(event: TouchEvent) { if ( getOutsidePressEvent() !== 'sloppy' || !store.select('open') || !enabled || isEventWithinOwnElements(event) ) { return; } const touch = event.touches[0]; if (touch) { touchStateRef.current = { startTime: Date.now(), startX: touch.clientX, startY: touch.clientY, dismissOnTouchEnd: false, dismissOnMouseDown: true, }; cancelDismissOnEndTimeout.start(1000, () => { if (touchStateRef.current) { touchStateRef.current.dismissOnTouchEnd = false; touchStateRef.current.dismissOnMouseDown = false; } }); } } function addTargetEventListenerOnce( event: EventType, listener: (event: EventType) => void, ) { const target = getTarget(event); if (!target) { return; } const unsubscribe = addEventListener(target, event.type, () => { listener(event); unsubscribe(); }); } function handleTouchStartCapture(event: TouchEvent) { currentPointerTypeRef.current = 'touch'; addTargetEventListenerOnce(event, handleTouchStart); } function closeOnPressOutsideCapture(event: PointerEvent | MouseEvent) { cancelDismissOnEndTimeout.clear(); // Only `pointerdown` marks a press; `mousedown` is its compatibility event, and // counting it would misattribute a gesture that started before open. if (event.type === 'pointerdown') { // Only a primary press can produce a `click`. if (event.button === 0) { sawPressWhileOpenRef.current = true; } currentPointerTypeRef.current = (event as PointerEvent).pointerType; } if ( event.type === 'mousedown' && touchStateRef.current && !touchStateRef.current.dismissOnMouseDown ) { return; } addTargetEventListenerOnce(event, (targetEvent) => { if (targetEvent.type === 'pointerdown') { handlePointerDown(targetEvent as PointerEvent); } else { closeOnPressOutside(targetEvent as MouseEvent); } }); } function handlePressEndCapture(event: PointerEvent | MouseEvent) { // A cancelled gesture produces no click. Not cleared on `pointerup`: the click // fires after it and must still find the press. if (event.type === 'pointercancel') { sawPressWhileOpenRef.current = false; } if (!pressStartedInsideRef.current) { return; } const pressStartedInsideDefaultPrevented = pressStartPreventedRef.current; resetPressStartState(); if (getOutsidePressEvent() !== 'intentional') { return; } if (event.type === 'pointercancel') { if (pressStartedInsideDefaultPrevented) { suppressImmediateOutsideClickAfterPreventedStart(); } return; } if (isEventWithinFloatingTree(event)) { return; } // If pointerdown was prevented, no click may be generated for that // interaction. However, Firefox may still emit an immediate click after // pointerup (e.g. NumberField scrub with pointer lock), so suppress for // one tick to absorb that synthetic click only. if (pressStartedInsideDefaultPrevented) { suppressImmediateOutsideClickAfterPreventedStart(); return; } // Avoid suppressing when outsidePress explicitly ignores this target. if (typeof outsidePress === 'function' && !outsidePress(event as MouseEvent)) { return; } preventedPressSuppressionTimeout.clear(); suppressNextOutsideClickRef.current = true; clearInsideReactTree(); } function handleTouchMove(event: TouchEvent) { if ( getOutsidePressEvent() !== 'sloppy' || !touchStateRef.current || isEventWithinOwnElements(event) ) { return; } const touch = event.touches[0]; if (!touch) { return; } const deltaX = Math.abs(touch.clientX - touchStateRef.current.startX); const deltaY = Math.abs(touch.clientY - touchStateRef.current.startY); const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); if (distance > 5) { touchStateRef.current.dismissOnTouchEnd = true; } if (distance > 10) { closeOnPressOutside(event); cancelDismissOnEndTimeout.clear(); touchStateRef.current = null; } } function handleTouchMoveCapture(event: TouchEvent) { addTargetEventListenerOnce(event, handleTouchMove); } function handleTouchEnd(event: TouchEvent) { if ( getOutsidePressEvent() !== 'sloppy' || !touchStateRef.current || isEventWithinOwnElements(event) ) { return; } if (touchStateRef.current.dismissOnTouchEnd) { closeOnPressOutside(event); } cancelDismissOnEndTimeout.clear(); touchStateRef.current = null; } function handleTouchEndCapture(event: TouchEvent) { addTargetEventListenerOnce(event, handleTouchEnd); } const unsubscribe = mergeCleanups( escapeKey && mergeCleanups( addEventListener(doc, 'keydown', closeOnEscapeKeyDown), addEventListener(doc, 'compositionstart', handleCompositionStart), addEventListener(doc, 'compositionend', handleCompositionEnd), ), outsidePressEnabled && mergeCleanups( addEventListener(doc, 'click', closeOnPressOutsideCapture, true), addEventListener(doc, 'pointerdown', closeOnPressOutsideCapture, true), addEventListener(doc, 'pointerup', handlePressEndCapture, true), addEventListener(doc, 'pointercancel', handlePressEndCapture, true), addEventListener(doc, 'mousedown', closeOnPressOutsideCapture, true), addEventListener(doc, 'mouseup', handlePressEndCapture, true), addEventListener(doc, 'touchstart', handleTouchStartCapture, { capture: true, passive: true, }), addEventListener(doc, 'touchmove', handleTouchMoveCapture, { capture: true, passive: true, }), addEventListener(doc, 'touchend', handleTouchEndCapture, { capture: true, passive: true, }), ), ); return () => { unsubscribe(); preventedPressSuppressionTimeout.clear(); resetPressStartState(); suppressNextOutsideClickRef.current = false; }; }, [ dataRef, floatingElement, escapeKey, outsidePressEnabled, outsidePress, open, enabled, escapeKeyBubbles, outsidePressBubbles, closeOnEscapeKeyDown, clearInsideReactTree, getOutsidePressEventProp, hasBlockingChild, isEventWithinOwnElements, tree, store, cancelDismissOnEndTimeout, compositionTimeout, ]); const reference: ElementProps['reference'] = React.useMemo( () => ({ onKeyDown: closeOnEscapeKeyDown, onPointerDown: closeOnReferencePress, onClick: closeOnReferencePress, }), [closeOnEscapeKeyDown, closeOnReferencePress], ); const floating: ElementProps['floating'] = React.useMemo( () => ({ onKeyDown: closeOnEscapeKeyDown, // `onMouseDown` may be blocked if `event.preventDefault()` is called in // `onPointerDown`, such as with . // See https://github.com/mui/base-ui/pull/3379 onPointerDown: markInsidePressStartPrevented, onMouseDown: markInsidePressStartPrevented, onClickCapture: markInsideReactTree, onMouseDownCapture(event) { markInsideReactTree(); markPressStartedInsideReactTree(event); }, onPointerDownCapture(event) { markInsideReactTree(); markPressStartedInsideReactTree(event); }, onMouseUpCapture: markInsideReactTree, onTouchEndCapture: markInsideReactTree, onTouchMoveCapture: markInsideReactTree, }), [ closeOnEscapeKeyDown, markInsideReactTree, markPressStartedInsideReactTree, markInsidePressStartPrevented, ], ); return React.useMemo( () => (enabled ? { reference, floating, trigger: reference } : {}), [enabled, reference, floating], ); }