packages/react/src/hooks/useDismiss.ts
import {
contains,
getDocument,
getTarget,
isEventTargetWithin,
isReactEvent,
isRootElement,
} from '@floating-ui/react/utils';
import {getOverflowAncestors} from '@floating-ui/react-dom';
import {
getComputedStyle,
getParentNode,
isElement,
isHTMLElement,
isLastTraversableNode,
isWebKit,
} from '@floating-ui/utils/dom';
import * as React from 'react';
import {useFloatingTree} from '../components/FloatingTree';
import type {ElementProps, FloatingRootContext} from '../types';
import {createAttribute} from '../utils/createAttribute';
import {getChildren} from '../utils/getChildren';
import {useEffectEvent} from './utils/useEffectEvent';
const bubbleHandlerKeys = {
pointerdown: 'onPointerDown',
mousedown: 'onMouseDown',
click: 'onClick',
};
const captureHandlerKeys = {
pointerdown: 'onPointerDownCapture',
mousedown: 'onMouseDownCapture',
click: 'onClickCapture',
};
export const normalizeProp = (
normalizable?: boolean | {escapeKey?: boolean; outsidePress?: boolean},
) => {
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;
/**
* Whether to dismiss the floating element upon pressing the `esc` key.
* @default true
*/
escapeKey?: boolean;
/**
* 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.
* @default false
*/
referencePress?: boolean;
/**
* The type of event to use to determine a “press”.
* - `pointerdown` is eager on both mouse + touch input.
* - `mousedown` is eager on mouse input, but lazy on touch input.
* - `click` is lazy on both mouse + touch input.
* @default 'pointerdown'
*/
referencePressEvent?: 'pointerdown' | 'mousedown' | 'click';
/**
* 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) => boolean);
/**
* The type of event to use to determine an outside “press”.
* - `pointerdown` is eager on both mouse + touch input.
* - `mousedown` is eager on mouse input, but lazy on touch input.
* - `click` is lazy on both mouse + touch input.
* @default 'pointerdown'
*/
outsidePressEvent?: 'pointerdown' | 'mousedown' | 'click';
/**
* Whether to dismiss the floating element upon scrolling an overflow
* ancestor.
* @default false
*/
ancestorScroll?: boolean;
/**
* Determines whether event listeners bubble upwards through a tree of
* floating elements.
*/
bubbles?: boolean | {escapeKey?: boolean; outsidePress?: boolean};
/**
* Determines whether to use capture phase event listeners.
*/
capture?: boolean | {escapeKey?: boolean; outsidePress?: boolean};
}
/**
* 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(
context: FloatingRootContext,
props: UseDismissProps = {},
): ElementProps {
const {open, onOpenChange, elements, dataRef} = context;
const {
enabled = true,
escapeKey = true,
outsidePress: unstable_outsidePress = true,
outsidePressEvent = 'pointerdown',
referencePress = false,
referencePressEvent = 'pointerdown',
ancestorScroll = false,
bubbles,
capture,
} = props;
const tree = useFloatingTree();
const outsidePressFn = useEffectEvent(
typeof unstable_outsidePress === 'function'
? unstable_outsidePress
: () => false,
);
const outsidePress =
typeof unstable_outsidePress === 'function'
? outsidePressFn
: unstable_outsidePress;
const insideReactTreeRef = React.useRef(false);
const endedOrStartedInsideRef = React.useRef(false);
const {escapeKey: escapeKeyBubbles, outsidePress: outsidePressBubbles} =
normalizeProp(bubbles);
const {escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture} =
normalizeProp(capture);
const isComposingRef = React.useRef(false);
const closeOnEscapeKeyDown = useEffectEvent(
(event: React.KeyboardEvent<Element> | KeyboardEvent) => {
if (!open || !enabled || !escapeKey || event.key !== 'Escape') {
return;
}
// Wait until IME is settled. Pressing `Escape` while composing should
// close the compose menu, but not the floating element.
if (isComposingRef.current) {
return;
}
const nodeId = dataRef.current.floatingContext?.nodeId;
const children = tree ? getChildren(tree.nodesRef.current, nodeId) : [];
if (!escapeKeyBubbles) {
event.stopPropagation();
if (children.length > 0) {
let shouldDismiss = true;
children.forEach((child) => {
if (
child.context?.open &&
!child.context.dataRef.current.__escapeKeyBubbles
) {
shouldDismiss = false;
return;
}
});
if (!shouldDismiss) {
return;
}
}
}
onOpenChange(
false,
isReactEvent(event) ? event.nativeEvent : event,
'escape-key',
);
},
);
const closeOnEscapeKeyDownCapture = useEffectEvent((event: KeyboardEvent) => {
const callback = () => {
closeOnEscapeKeyDown(event);
getTarget(event)?.removeEventListener('keydown', callback);
};
getTarget(event)?.addEventListener('keydown', callback);
});
const closeOnPressOutside = useEffectEvent((event: MouseEvent) => {
// Given developers can stop the propagation of the synthetic event,
// we can only be confident with a positive value.
const insideReactTree = insideReactTreeRef.current;
insideReactTreeRef.current = false;
// When click outside is lazy (`click` event), handle dragging.
// Don't close if:
// - The click started inside the floating element.
// - The click ended inside the floating element.
const endedOrStartedInside = endedOrStartedInsideRef.current;
endedOrStartedInsideRef.current = false;
if (outsidePressEvent === 'click' && endedOrStartedInside) {
return;
}
if (insideReactTree) {
return;
}
if (typeof outsidePress === 'function' && !outsidePress(event)) {
return;
}
const target = getTarget(event);
const inertSelector = `[${createAttribute('inert')}]`;
const markers = getDocument(elements.floating).querySelectorAll(
inertSelector,
);
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, elements.floating) &&
// If the target root element contains none of the markers, then the
// element was injected after the floating element rendered.
Array.from(markers).every(
(marker) => !contains(targetRootAncestor, marker),
)
) {
return;
}
// Check if the click occurred on the scrollbar
if (isHTMLElement(target) && floating) {
// In Firefox, `target.scrollWidth > target.clientWidth` for inline
// elements.
const canScrollX =
target.clientWidth > 0 && target.scrollWidth > target.clientWidth;
const canScrollY =
target.clientHeight > 0 && target.scrollHeight > target.clientHeight;
let xCond = canScrollY && event.offsetX > target.clientWidth;
// In some browsers it is possible to change the <body> (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.
if (canScrollY) {
const isRTL = getComputedStyle(target).direction === 'rtl';
if (isRTL) {
xCond = event.offsetX <= target.offsetWidth - target.clientWidth;
}
}
if (xCond || (canScrollX && event.offsetY > target.clientHeight)) {
return;
}
}
const nodeId = dataRef.current.floatingContext?.nodeId;
const targetIsInsideChildren =
tree &&
getChildren(tree.nodesRef.current, nodeId).some((node) =>
isEventTargetWithin(event, node.context?.elements.floating),
);
if (
isEventTargetWithin(event, elements.floating) ||
isEventTargetWithin(event, elements.domReference) ||
targetIsInsideChildren
) {
return;
}
const children = tree ? getChildren(tree.nodesRef.current, nodeId) : [];
if (children.length > 0) {
let shouldDismiss = true;
children.forEach((child) => {
if (
child.context?.open &&
!child.context.dataRef.current.__outsidePressBubbles
) {
shouldDismiss = false;
return;
}
});
if (!shouldDismiss) {
return;
}
}
onOpenChange(false, event, 'outside-press');
});
const closeOnPressOutsideCapture = useEffectEvent((event: MouseEvent) => {
const callback = () => {
closeOnPressOutside(event);
getTarget(event)?.removeEventListener(outsidePressEvent, callback);
};
getTarget(event)?.addEventListener(outsidePressEvent, callback);
});
React.useEffect(() => {
if (!open || !enabled) {
return;
}
dataRef.current.__escapeKeyBubbles = escapeKeyBubbles;
dataRef.current.__outsidePressBubbles = outsidePressBubbles;
let compositionTimeout = -1;
function onScroll(event: Event) {
onOpenChange(false, event, 'ancestor-scroll');
}
function handleCompositionStart() {
window.clearTimeout(compositionTimeout);
isComposingRef.current = true;
}
function handleCompositionEnd() {
// Safari fires `compositionend` before `keydown`, so we need to wait
// until the next tick to set `isComposing` to `false`.
// https://bugs.webkit.org/show_bug.cgi?id=165004
compositionTimeout = window.setTimeout(
() => {
isComposingRef.current = false;
},
// 0ms or 1ms don't work in Safari. 5ms appears to consistently work.
// Only apply to WebKit for the test to remain 0ms.
isWebKit() ? 5 : 0,
);
}
const doc = getDocument(elements.floating);
if (escapeKey) {
doc.addEventListener(
'keydown',
escapeKeyCapture ? closeOnEscapeKeyDownCapture : closeOnEscapeKeyDown,
escapeKeyCapture,
);
doc.addEventListener('compositionstart', handleCompositionStart);
doc.addEventListener('compositionend', handleCompositionEnd);
}
outsidePress &&
doc.addEventListener(
outsidePressEvent,
outsidePressCapture ? closeOnPressOutsideCapture : closeOnPressOutside,
outsidePressCapture,
);
let ancestors: (Element | Window | VisualViewport)[] = [];
if (ancestorScroll) {
if (isElement(elements.domReference)) {
ancestors = getOverflowAncestors(elements.domReference);
}
if (isElement(elements.floating)) {
ancestors = ancestors.concat(getOverflowAncestors(elements.floating));
}
if (
!isElement(elements.reference) &&
elements.reference &&
elements.reference.contextElement
) {
ancestors = ancestors.concat(
getOverflowAncestors(elements.reference.contextElement),
);
}
}
// Ignore the visual viewport for scrolling dismissal (allow pinch-zoom)
ancestors = ancestors.filter(
(ancestor) => ancestor !== doc.defaultView?.visualViewport,
);
ancestors.forEach((ancestor) => {
ancestor.addEventListener('scroll', onScroll, {passive: true});
});
return () => {
if (escapeKey) {
doc.removeEventListener(
'keydown',
escapeKeyCapture ? closeOnEscapeKeyDownCapture : closeOnEscapeKeyDown,
escapeKeyCapture,
);
doc.removeEventListener('compositionstart', handleCompositionStart);
doc.removeEventListener('compositionend', handleCompositionEnd);
}
outsidePress &&
doc.removeEventListener(
outsidePressEvent,
outsidePressCapture
? closeOnPressOutsideCapture
: closeOnPressOutside,
outsidePressCapture,
);
ancestors.forEach((ancestor) => {
ancestor.removeEventListener('scroll', onScroll);
});
window.clearTimeout(compositionTimeout);
};
}, [
dataRef,
elements,
escapeKey,
outsidePress,
outsidePressEvent,
open,
onOpenChange,
ancestorScroll,
enabled,
escapeKeyBubbles,
outsidePressBubbles,
closeOnEscapeKeyDown,
escapeKeyCapture,
closeOnEscapeKeyDownCapture,
closeOnPressOutside,
outsidePressCapture,
closeOnPressOutsideCapture,
]);
React.useEffect(() => {
insideReactTreeRef.current = false;
}, [outsidePress, outsidePressEvent]);
const reference: ElementProps['reference'] = React.useMemo(
() => ({
onKeyDown: closeOnEscapeKeyDown,
[bubbleHandlerKeys[referencePressEvent]]: (
event: React.SyntheticEvent,
) => {
if (referencePress) {
onOpenChange(false, event.nativeEvent, 'reference-press');
}
},
}),
[closeOnEscapeKeyDown, onOpenChange, referencePress, referencePressEvent],
);
const floating: ElementProps['floating'] = React.useMemo(
() => ({
onKeyDown: closeOnEscapeKeyDown,
onMouseDown() {
endedOrStartedInsideRef.current = true;
},
onMouseUp() {
endedOrStartedInsideRef.current = true;
},
[captureHandlerKeys[outsidePressEvent]]: () => {
insideReactTreeRef.current = true;
},
}),
[closeOnEscapeKeyDown, outsidePressEvent],
);
return React.useMemo(
() => (enabled ? {reference, floating} : {}),
[enabled, reference, floating],
);
}