diff --git a/packages/react/src/floating-ui-react/components/FloatingFocusManager.tsx b/packages/react/src/floating-ui-react/components/FloatingFocusManager.tsx index dedfacbbfc..c77699c3d8 100644 --- a/packages/react/src/floating-ui-react/components/FloatingFocusManager.tsx +++ b/packages/react/src/floating-ui-react/components/FloatingFocusManager.tsx @@ -437,6 +437,10 @@ export function FloatingFocusManager(props: FloatingFocusManagerProps): React.JS const target = getTarget(event) as HTMLElement | null; queueMicrotask(() => { + if (!store.select('open')) { + return; + } + const nodeId = getNodeId(); const triggers = store.context.triggerElements; const movedToUnrelatedNode = !( diff --git a/packages/react/src/floating-ui-react/hooks/useDismiss.ts b/packages/react/src/floating-ui-react/hooks/useDismiss.ts index 85c1b1b302..091edbf51f 100644 --- a/packages/react/src/floating-ui-react/hooks/useDismiss.ts +++ b/packages/react/src/floating-ui-react/hooks/useDismiss.ts @@ -8,8 +8,10 @@ import { isLastTraversableNode, isWebKit, } from '@floating-ui/utils/dom'; -import { Timeout, useTimeout } from '@base-ui-components/utils/useTimeout'; -import { useStableCallback } from '@base-ui-components/utils/useStableCallback'; +import { Timeout } from '@base-ui-components/utils/useTimeout'; +import { useRefWithInit } from '@base-ui-components/utils/useRefWithInit'; +import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect'; +import { EMPTY_OBJECT } from '@base-ui-components/utils/empty'; import { contains, getDocument, @@ -28,6 +30,7 @@ import type { ElementProps, FloatingContext, FloatingRootContext } from '../type import { createChangeEventDetails } from '../../utils/createBaseUIEventDetails'; import { REASONS } from '../../utils/reasons'; import { createAttribute } from '../utils/createAttribute'; +import { HTMLProps } from '../../utils/types'; type PressType = 'intentional' | 'sloppy'; @@ -121,296 +124,332 @@ export interface UseDismissProps { externalTree?: FloatingTreeStore; } -/** - * 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 | FloatingContext, - props: UseDismissProps = {}, -): ElementProps { - const store = 'rootStore' in context ? context.rootStore : context; - const open = store.useState('open'); - const floatingElement = store.useState('floatingElement'); - const referenceElement = store.useState('referenceElement'); - const domReferenceElement = store.useState('domReferenceElement'); - - const { onOpenChange, dataRef } = store.context; - - const { - enabled = true, - escapeKey = true, - outsidePress: outsidePressProp = true, - outsidePressEvent = 'sloppy', - referencePress = false, - referencePressEvent = 'sloppy', - ancestorScroll = false, - bubbles, - externalTree, - } = props; - - const tree = useFloatingTree(externalTree); - const outsidePressFn = useStableCallback( - typeof outsidePressProp === 'function' ? outsidePressProp : () => false, - ); - const outsidePress = typeof outsidePressProp === 'function' ? outsidePressFn : outsidePressProp; +type DismissInteractionControllerParameters = Omit & { + tree: FloatingTreeStore | null; +}; + +type DismissInteractionControllerSettings = Required< + Omit +> & { + escapeKeyBubbles: boolean; + outsidePressBubbles: boolean; +}; + +export class DismissInteractionController { + private settings: DismissInteractionControllerSettings; + + public onPropsChange?: () => void; + + constructor( + private rootStore: FloatingRootContext, + settings: DismissInteractionControllerParameters, + ) { + this.settings = this.generateSettings(settings); + } + + public updateSettings(settings: DismissInteractionControllerParameters) { + this.settings = this.generateSettings(settings); + } + + private generateSettings( + settings: DismissInteractionControllerParameters, + ): DismissInteractionControllerSettings { + const { escapeKey: escapeKeyBubbles, outsidePress: outsidePressBubbles } = normalizeProp( + settings.bubbles, + ); - const endedOrStartedInsideRef = React.useRef(false); - const { escapeKey: escapeKeyBubbles, outsidePress: outsidePressBubbles } = normalizeProp(bubbles); + const outsidePressFn = + typeof settings.outsidePress === 'function' ? settings.outsidePress : () => false; + + this.outsidePress = + typeof settings.outsidePress === 'function' + ? outsidePressFn + : (settings.outsidePress ?? true); + + return { + enabled: settings.enabled ?? true, + escapeKey: settings.escapeKey ?? true, + referencePress: settings.referencePress ?? false, + referencePressEvent: settings.referencePressEvent ?? 'sloppy', + outsidePress: settings.outsidePress ?? true, + outsidePressEvent: settings.outsidePressEvent ?? 'sloppy', + ancestorScroll: settings.ancestorScroll ?? false, + tree: settings.tree, + escapeKeyBubbles, + outsidePressBubbles, + }; + } + + private outsidePress: boolean | ((event: MouseEvent | TouchEvent) => boolean) = () => false; + + private endedOrStartedInsideRef = false; - const touchStateRef = React.useRef<{ + private touchStateRef: { startTime: number; startX: number; startY: number; dismissOnTouchEnd: boolean; dismissOnMouseDown: boolean; - } | null>(null); + } | null = null; - const cancelDismissOnEndTimeout = useTimeout(); - const clearInsideReactTreeTimeout = useTimeout(); + private cancelDismissOnEndTimeout = new Timeout(); - const clearInsideReactTree = useStableCallback(() => { - clearInsideReactTreeTimeout.clear(); - dataRef.current.insideReactTree = false; - }); + private clearInsideReactTreeTimeout = new Timeout(); - const isComposingRef = React.useRef(false); - const currentPointerTypeRef = React.useRef(''); + private isComposingRef = false; - const trackPointerType = useStableCallback((event: PointerEvent) => { - currentPointerTypeRef.current = event.pointerType; - }); + private currentPointerTypeRef: PointerEvent['pointerType'] = ''; - const getOutsidePressEvent = useStableCallback(() => { - const type = currentPointerTypeRef.current as 'pen' | 'mouse' | 'touch' | ''; + private trackPointerType(event: PointerEvent) { + this.currentPointerTypeRef = event.pointerType; + } + + private getOutsidePressEvent() { + const type = this.currentPointerTypeRef as 'pen' | 'mouse' | 'touch' | ''; const computedType = type === 'pen' || !type ? 'mouse' : type; const resolved = - typeof outsidePressEvent === 'function' ? outsidePressEvent() : outsidePressEvent; + typeof this.settings.outsidePressEvent === 'function' + ? this.settings.outsidePressEvent() + : this.settings.outsidePressEvent; if (typeof resolved === 'string') { return resolved; } return resolved[computedType]; - }); - - const closeOnEscapeKeyDown = useStableCallback( - (event: React.KeyboardEvent | 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; - } + private closeOnEscapeKeyDown(event: React.KeyboardEvent | KeyboardEvent) { + if ( + !this.rootStore.select('open') || + !this.settings.enabled || + !this.settings.escapeKey || + event.key !== 'Escape' + ) { + return; + } - const nodeId = dataRef.current.floatingContext?.nodeId; + // Wait until IME is settled. Pressing `Escape` while composing should + // close the compose menu, but not the floating element. + if (this.isComposingRef) { + return; + } - const children = tree ? getNodeChildren(tree.nodesRef.current, nodeId) : []; + const nodeId = this.rootStore.context.dataRef.current.floatingContext?.nodeId; - if (!escapeKeyBubbles) { - if (children.length > 0) { - let shouldDismiss = true; + const children = this.settings.tree + ? getNodeChildren(this.settings.tree.nodesRef.current, nodeId) + : []; - children.forEach((child) => { - if (child.context?.open && !child.context.dataRef.current.__escapeKeyBubbles) { - shouldDismiss = false; - } - }); + if (!this.settings.escapeKeyBubbles) { + if (children.length > 0) { + let shouldDismiss = true; - if (!shouldDismiss) { - return; + children.forEach((child) => { + if (child.context?.open && !child.context.dataRef.current.__escapeKeyBubbles) { + shouldDismiss = false; } + }); + + if (!shouldDismiss) { + return; } } + } - const native = isReactEvent(event) ? event.nativeEvent : event; - const eventDetails = createChangeEventDetails(REASONS.escapeKey, native); + const native = isReactEvent(event) ? event.nativeEvent : event; + const eventDetails = createChangeEventDetails(REASONS.escapeKey, native); - store.setOpen(false, eventDetails); + this.rootStore.setOpen(false, eventDetails); - if (!escapeKeyBubbles && !eventDetails.isPropagationAllowed) { - event.stopPropagation(); - } - }, - ); + if (!this.settings.escapeKeyBubbles && !eventDetails.isPropagationAllowed) { + event.stopPropagation(); + } + } - const shouldIgnoreEvent = useStableCallback((event: Event) => { - const computedOutsidePressEvent = getOutsidePressEvent(); + private shouldIgnoreEvent(event: Event) { + const computedOutsidePressEvent = this.getOutsidePressEvent(); return ( (computedOutsidePressEvent === 'intentional' && event.type !== 'click') || (computedOutsidePressEvent === 'sloppy' && event.type === 'click') ); - }); - - const markInsideReactTree = useStableCallback(() => { - dataRef.current.insideReactTree = true; - clearInsideReactTreeTimeout.start(0, clearInsideReactTree); - }); + } + + private markInsideReactTree() { + this.rootStore.context.dataRef.current.insideReactTree = true; + this.clearInsideReactTreeTimeout.start(0, this.clearInsideReactTree); + } + + private clearInsideReactTree() { + this.clearInsideReactTreeTimeout.clear(); + this.rootStore.context.dataRef.current.insideReactTree = false; + } + + private closeOnPressOutside( + event: MouseEvent | PointerEvent | TouchEvent, + endedOrStartedInside = false, + ) { + if (this.shouldIgnoreEvent(event)) { + this.clearInsideReactTree(); + return; + } - const closeOnPressOutside = useStableCallback( - (event: MouseEvent | PointerEvent | TouchEvent, endedOrStartedInside = false) => { - if (shouldIgnoreEvent(event)) { - clearInsideReactTree(); - return; - } + if (this.rootStore.context.dataRef.current.insideReactTree) { + this.clearInsideReactTree(); + return; + } - if (dataRef.current.insideReactTree) { - clearInsideReactTree(); - return; - } + if (this.getOutsidePressEvent() === 'intentional' && endedOrStartedInside) { + return; + } - if (getOutsidePressEvent() === 'intentional' && endedOrStartedInside) { - return; - } + if (typeof this.outsidePress === 'function' && !this.outsidePress(event)) { + return; + } - if (typeof outsidePress === 'function' && !outsidePress(event)) { - return; - } + const target = getTarget(event); + const inertSelector = `[${createAttribute('inert')}]`; + const markers = getDocument(this.rootStore.select('floatingElement')).querySelectorAll( + inertSelector, + ); - const target = getTarget(event); - const inertSelector = `[${createAttribute('inert')}]`; - const markers = getDocument(store.select('floatingElement')).querySelectorAll(inertSelector); + const triggers = this.rootStore.context.triggerElements; - 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; + } - // 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; } - let targetRootAncestor = isElement(target) ? target : null; - while (targetRootAncestor && !isLastTraversableNode(targetRootAncestor)) { - const nextParent = getParentNode(targetRootAncestor); - if (isLastTraversableNode(nextParent) || !isElement(nextParent)) { - break; - } + targetRootAncestor = nextParent; + } - 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, this.rootStore.select('floatingElement')) && + // 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 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. - Array.from(markers).every((marker) => !contains(targetRootAncestor, marker)) - ) { + // 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; } + } - // 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; - } - } - - const nodeId = dataRef.current.floatingContext?.nodeId; - - const targetIsInsideChildren = - tree && - getNodeChildren(tree.nodesRef.current, nodeId).some((node) => - isEventTargetWithin(event, node.context?.elements.floating), - ); + const nodeId = this.rootStore.context.dataRef.current.floatingContext?.nodeId; - if ( - isEventTargetWithin(event, store.select('floatingElement')) || - isEventTargetWithin(event, store.select('domReferenceElement')) || - targetIsInsideChildren - ) { - return; - } + const targetIsInsideChildren = + this.settings.tree && + getNodeChildren(this.settings.tree.nodesRef.current, nodeId).some((node) => + isEventTargetWithin(event, node.context?.elements.floating), + ); - const children = tree ? getNodeChildren(tree.nodesRef.current, nodeId) : []; - if (children.length > 0) { - let shouldDismiss = true; + if ( + isEventTargetWithin(event, this.rootStore.select('floatingElement')) || + isEventTargetWithin(event, this.rootStore.select('domReferenceElement')) || + targetIsInsideChildren + ) { + return; + } - children.forEach((child) => { - if (child.context?.open && !child.context.dataRef.current.__outsidePressBubbles) { - shouldDismiss = false; - } - }); + const children = this.settings.tree + ? getNodeChildren(this.settings.tree.nodesRef.current, nodeId) + : []; + if (children.length > 0) { + let shouldDismiss = true; - if (!shouldDismiss) { - return; + children.forEach((child) => { + if (child.context?.open && !child.context.dataRef.current.__outsidePressBubbles) { + shouldDismiss = false; } + }); + + if (!shouldDismiss) { + return; } + } - store.setOpen(false, createChangeEventDetails(REASONS.outsidePress, event)); - clearInsideReactTree(); - }, - ); + this.rootStore.setOpen(false, createChangeEventDetails(REASONS.outsidePress, event)); + this.clearInsideReactTree(); + } - const handlePointerDown = useStableCallback((event: PointerEvent) => { + private handlePointerDown(event: PointerEvent) { if ( - getOutsidePressEvent() !== 'sloppy' || + this.getOutsidePressEvent() !== 'sloppy' || event.pointerType === 'touch' || - !store.select('open') || - !enabled || - isEventTargetWithin(event, store.select('floatingElement')) || - isEventTargetWithin(event, store.select('domReferenceElement')) + !this.rootStore.select('open') || + !this.settings.enabled || + isEventTargetWithin(event, this.rootStore.select('floatingElement')) || + isEventTargetWithin(event, this.rootStore.select('domReferenceElement')) ) { return; } - closeOnPressOutside(event); - }); + this.closeOnPressOutside(event); + } - const handleTouchStart = useStableCallback((event: TouchEvent) => { + private handleTouchStart(event: TouchEvent) { if ( - getOutsidePressEvent() !== 'sloppy' || - !store.select('open') || - !enabled || - isEventTargetWithin(event, store.select('floatingElement')) || - isEventTargetWithin(event, store.select('domReferenceElement')) + this.getOutsidePressEvent() !== 'sloppy' || + !this.rootStore.select('open') || + !this.settings.enabled || + isEventTargetWithin(event, this.rootStore.select('floatingElement')) || + isEventTargetWithin(event, this.rootStore.select('domReferenceElement')) ) { return; } const touch = event.touches[0]; if (touch) { - touchStateRef.current = { + this.touchStateRef = { startTime: Date.now(), startX: touch.clientX, startY: touch.clientY, @@ -418,61 +457,62 @@ export function useDismiss( dismissOnMouseDown: true, }; - cancelDismissOnEndTimeout.start(1000, () => { - if (touchStateRef.current) { - touchStateRef.current.dismissOnTouchEnd = false; - touchStateRef.current.dismissOnMouseDown = false; + this.cancelDismissOnEndTimeout.start(1000, () => { + if (this.touchStateRef) { + this.touchStateRef.dismissOnTouchEnd = false; + this.touchStateRef.dismissOnMouseDown = false; } }); } - }); + } - const handleTouchStartCapture = useStableCallback((event: TouchEvent) => { + private handleTouchStartCapture(event: TouchEvent) { const target = getTarget(event); - function callback() { - handleTouchStart(event); + const callback = () => { + this.handleTouchStart(event); target?.removeEventListener(event.type, callback); - } + }; + target?.addEventListener(event.type, callback); - }); + } - const closeOnPressOutsideCapture = useStableCallback((event: PointerEvent | MouseEvent) => { + private closeOnPressOutsideCapture(event: PointerEvent | MouseEvent) { // When click outside is lazy (`up` 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; + const endedOrStartedInside = this.endedOrStartedInsideRef; + this.endedOrStartedInsideRef = false; - cancelDismissOnEndTimeout.clear(); + this.cancelDismissOnEndTimeout.clear(); if ( event.type === 'mousedown' && - touchStateRef.current && - !touchStateRef.current.dismissOnMouseDown + this.touchStateRef && + !this.touchStateRef.dismissOnMouseDown ) { return; } const target = getTarget(event); - function callback() { + const callback = () => { if (event.type === 'pointerdown') { - handlePointerDown(event as PointerEvent); + this.handlePointerDown(event as PointerEvent); } else { - closeOnPressOutside(event as MouseEvent, endedOrStartedInside); + this.closeOnPressOutside(event as MouseEvent, endedOrStartedInside); } target?.removeEventListener(event.type, callback); - } + }; target?.addEventListener(event.type, callback); - }); + } - const handleTouchMove = useStableCallback((event: TouchEvent) => { + private handleTouchMove(event: TouchEvent) { if ( - getOutsidePressEvent() !== 'sloppy' || - !touchStateRef.current || - isEventTargetWithin(event, store.select('floatingElement')) || - isEventTargetWithin(event, store.select('domReferenceElement')) + this.getOutsidePressEvent() !== 'sloppy' || + !this.touchStateRef || + isEventTargetWithin(event, this.rootStore.select('floatingElement')) || + isEventTargetWithin(event, this.rootStore.select('domReferenceElement')) ) { return; } @@ -482,227 +522,286 @@ export function useDismiss( return; } - const deltaX = Math.abs(touch.clientX - touchStateRef.current.startX); - const deltaY = Math.abs(touch.clientY - touchStateRef.current.startY); + const deltaX = Math.abs(touch.clientX - this.touchStateRef.startX); + const deltaY = Math.abs(touch.clientY - this.touchStateRef.startY); const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); if (distance > 5) { - touchStateRef.current.dismissOnTouchEnd = true; + this.touchStateRef.dismissOnTouchEnd = true; } if (distance > 10) { - closeOnPressOutside(event); - cancelDismissOnEndTimeout.clear(); - touchStateRef.current = null; + this.closeOnPressOutside(event); + this.cancelDismissOnEndTimeout.clear(); + this.touchStateRef = null; } - }); + } - const handleTouchMoveCapture = useStableCallback((event: TouchEvent) => { + private handleTouchMoveCapture(event: TouchEvent) { const target = getTarget(event); - function callback() { - handleTouchMove(event); + const callback = () => { + this.handleTouchMove(event); target?.removeEventListener(event.type, callback); - } + }; target?.addEventListener(event.type, callback); - }); + } - const handleTouchEnd = useStableCallback((event: TouchEvent) => { + private handleTouchEnd(event: TouchEvent) { if ( - getOutsidePressEvent() !== 'sloppy' || - !touchStateRef.current || - isEventTargetWithin(event, store.select('floatingElement')) || - isEventTargetWithin(event, store.select('domReferenceElement')) + this.getOutsidePressEvent() !== 'sloppy' || + !this.touchStateRef || + isEventTargetWithin(event, this.rootStore.select('floatingElement')) || + isEventTargetWithin(event, this.rootStore.select('domReferenceElement')) ) { return; } - if (touchStateRef.current.dismissOnTouchEnd) { - closeOnPressOutside(event); + if (this.touchStateRef.dismissOnTouchEnd) { + this.closeOnPressOutside(event); } - cancelDismissOnEndTimeout.clear(); - touchStateRef.current = null; - }); + this.cancelDismissOnEndTimeout.clear(); + this.touchStateRef = null; + } - const handleTouchEndCapture = useStableCallback((event: TouchEvent) => { + private handleTouchEndCapture(event: TouchEvent) { const target = getTarget(event); - function callback() { - handleTouchEnd(event); + const callback = () => { + this.handleTouchEnd(event); target?.removeEventListener(event.type, callback); - } + }; target?.addEventListener(event.type, callback); - }); - - React.useEffect(() => { - if (!open || !enabled) { - return undefined; - } - - dataRef.current.__escapeKeyBubbles = escapeKeyBubbles; - dataRef.current.__outsidePressBubbles = outsidePressBubbles; + } - const compositionTimeout = new Timeout(); + private handleAncestorScroll(event: Event) { + this.rootStore.setOpen(false, createChangeEventDetails(REASONS.none, event)); + } - function onScroll(event: Event) { - store.setOpen(false, createChangeEventDetails(REASONS.none, event)); - } - - 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`. - // https://bugs.webkit.org/show_bug.cgi?id=165004 - 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. - isWebKit() ? 5 : 0, - () => { - isComposingRef.current = false; - }, - ); + private handlePressedInside(event: React.MouseEvent) { + const target = getTarget(event.nativeEvent) as Element | null; + if (!contains(this.rootStore.select('floatingElement'), target) || event.button !== 0) { + return; } + this.endedOrStartedInsideRef = true; + } + + public useSetup() { + const open = this.rootStore.useState('open'); + const floatingElement = this.rootStore.useState('floatingElement'); + const referenceElement = this.rootStore.useState('referenceElement'); + const domReferenceElement = this.rootStore.useState('domReferenceElement'); + + // eslint-disable-next-line react-hooks/rules-of-hooks + React.useEffect(() => { + const escapeKey = this.settings.escapeKey; + const outsidePress = this.settings.outsidePress; + + if (!open || !this.settings.enabled) { + return undefined; + } - const doc = getDocument(floatingElement); + this.rootStore.context.dataRef.current.__escapeKeyBubbles = this.settings.escapeKeyBubbles; + this.rootStore.context.dataRef.current.__outsidePressBubbles = + this.settings.outsidePressBubbles; - doc.addEventListener('pointerdown', trackPointerType, true); + const compositionTimeout = new Timeout(); - if (escapeKey) { - doc.addEventListener('keydown', closeOnEscapeKeyDown); - doc.addEventListener('compositionstart', handleCompositionStart); - doc.addEventListener('compositionend', handleCompositionEnd); - } + const handleCompositionStart = () => { + compositionTimeout.clear(); + this.isComposingRef = true; + }; - if (outsidePress) { - doc.addEventListener('click', closeOnPressOutsideCapture, true); - doc.addEventListener('pointerdown', closeOnPressOutsideCapture, true); - doc.addEventListener('touchstart', handleTouchStartCapture, true); - doc.addEventListener('touchmove', handleTouchMoveCapture, true); - doc.addEventListener('touchend', handleTouchEndCapture, true); - doc.addEventListener('mousedown', closeOnPressOutsideCapture, true); - } + const 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.start( + // 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, + () => { + this.isComposingRef = false; + }, + ); + }; - let ancestors: (Element | Window | VisualViewport)[] = []; + const doc = getDocument(floatingElement); - if (ancestorScroll) { - if (isElement(domReferenceElement)) { - ancestors = getOverflowAncestors(domReferenceElement); - } + doc.addEventListener('pointerdown', this.trackPointerType, true); - if (isElement(floatingElement)) { - ancestors = ancestors.concat(getOverflowAncestors(floatingElement)); + if (escapeKey) { + doc.addEventListener('keydown', this.closeOnEscapeKeyDown); + doc.addEventListener('compositionstart', handleCompositionStart); + doc.addEventListener('compositionend', handleCompositionEnd); } - if (!isElement(referenceElement) && referenceElement && referenceElement.contextElement) { - ancestors = ancestors.concat(getOverflowAncestors(referenceElement.contextElement)); + if (outsidePress) { + doc.addEventListener('click', this.closeOnPressOutsideCapture, true); + doc.addEventListener('pointerdown', this.closeOnPressOutsideCapture, true); + doc.addEventListener('touchstart', this.handleTouchStartCapture, true); + doc.addEventListener('touchmove', this.handleTouchMoveCapture, true); + doc.addEventListener('touchend', this.handleTouchEndCapture, true); + doc.addEventListener('mousedown', this.closeOnPressOutsideCapture, true); } - } - // Ignore the visual viewport for scrolling dismissal (allow pinch-zoom) - ancestors = ancestors.filter((ancestor) => ancestor !== doc.defaultView?.visualViewport); + let ancestors: (Element | Window | VisualViewport)[] = []; - ancestors.forEach((ancestor) => { - ancestor.addEventListener('scroll', onScroll, { passive: true }); - }); + if (this.settings.ancestorScroll) { + if (isElement(domReferenceElement)) { + ancestors = getOverflowAncestors(domReferenceElement); + } - return () => { - doc.removeEventListener('pointerdown', trackPointerType, true); + if (isElement(floatingElement)) { + ancestors = ancestors.concat(getOverflowAncestors(floatingElement)); + } - if (escapeKey) { - doc.removeEventListener('keydown', closeOnEscapeKeyDown); - doc.removeEventListener('compositionstart', handleCompositionStart); - doc.removeEventListener('compositionend', handleCompositionEnd); + if (!isElement(referenceElement) && referenceElement && referenceElement.contextElement) { + ancestors = ancestors.concat(getOverflowAncestors(referenceElement.contextElement)); + } } - if (outsidePress) { - doc.removeEventListener('click', closeOnPressOutsideCapture, true); - doc.removeEventListener('pointerdown', closeOnPressOutsideCapture, true); - doc.removeEventListener('touchstart', handleTouchStartCapture, true); - doc.removeEventListener('touchmove', handleTouchMoveCapture, true); - doc.removeEventListener('touchend', handleTouchEndCapture, true); - doc.removeEventListener('mousedown', closeOnPressOutsideCapture, true); - } + // Ignore the visual viewport for scrolling dismissal (allow pinch-zoom) + ancestors = ancestors.filter((ancestor) => ancestor !== doc.defaultView?.visualViewport); ancestors.forEach((ancestor) => { - ancestor.removeEventListener('scroll', onScroll); + ancestor.addEventListener('scroll', this.handleAncestorScroll, { passive: true }); }); - compositionTimeout.clear(); - }; - }, [ - dataRef, - floatingElement, - referenceElement, - domReferenceElement, - escapeKey, - outsidePress, - open, - onOpenChange, - ancestorScroll, - enabled, - escapeKeyBubbles, - outsidePressBubbles, - closeOnEscapeKeyDown, - closeOnPressOutside, - closeOnPressOutsideCapture, - handlePointerDown, - handleTouchStartCapture, - handleTouchMoveCapture, - handleTouchEndCapture, - trackPointerType, - store, - ]); - - React.useEffect(clearInsideReactTree, [outsidePress, clearInsideReactTree]); - - const reference: ElementProps['reference'] = React.useMemo( - () => ({ - onKeyDown: closeOnEscapeKeyDown, - ...(referencePress && { - [bubbleHandlerKeys[referencePressEvent]]: (event: React.SyntheticEvent) => { - store.setOpen( + return () => { + doc.removeEventListener('pointerdown', this.trackPointerType, true); + + if (escapeKey) { + doc.removeEventListener('keydown', this.closeOnEscapeKeyDown); + doc.removeEventListener('compositionstart', handleCompositionStart); + doc.removeEventListener('compositionend', handleCompositionEnd); + } + + if (outsidePress) { + doc.removeEventListener('click', this.closeOnPressOutsideCapture, true); + doc.removeEventListener('pointerdown', this.closeOnPressOutsideCapture, true); + doc.removeEventListener('touchstart', this.handleTouchStartCapture, true); + doc.removeEventListener('touchmove', this.handleTouchMoveCapture, true); + doc.removeEventListener('touchend', this.handleTouchEndCapture, true); + doc.removeEventListener('mousedown', this.closeOnPressOutsideCapture, true); + } + + ancestors.forEach((ancestor) => { + ancestor.removeEventListener('scroll', this.handleAncestorScroll); + }); + + compositionTimeout.clear(); + }; + }, [floatingElement, open, referenceElement, domReferenceElement]); + + // eslint-disable-next-line react-hooks/exhaustive-deps, react-hooks/rules-of-hooks + React.useEffect(this.clearInsideReactTree, [ + this.settings.outsidePress, + this.clearInsideReactTree, + ]); + } + + public getReferenceProps(): HTMLProps { + if (!this.settings.enabled) { + return EMPTY_OBJECT; + } + + return { + onKeyDown: this.closeOnEscapeKeyDown, + ...(this.settings.referencePress && { + [bubbleHandlerKeys[this.settings.referencePressEvent]]: (event: React.SyntheticEvent) => { + this.rootStore.setOpen( false, createChangeEventDetails(REASONS.triggerPress, event.nativeEvent as any), ); }, - ...(referencePressEvent !== 'intentional' && { - onClick(event) { - store.setOpen(false, createChangeEventDetails(REASONS.triggerPress, event.nativeEvent)); + ...(this.settings.referencePressEvent !== 'intentional' && { + onClick: (event) => { + this.rootStore.setOpen( + false, + createChangeEventDetails(REASONS.triggerPress, event.nativeEvent), + ); }, }), }), - }), - [closeOnEscapeKeyDown, store, referencePress, referencePressEvent], - ); + }; + } - const handlePressedInside = useStableCallback((event: React.MouseEvent) => { - const target = getTarget(event.nativeEvent) as Element | null; - if (!contains(store.select('floatingElement'), target) || event.button !== 0) { - return; + public getFloatingProps(): HTMLProps { + if (!this.settings.enabled) { + return EMPTY_OBJECT; } - endedOrStartedInsideRef.current = true; - }); - const floating: ElementProps['floating'] = React.useMemo( - () => ({ - onKeyDown: closeOnEscapeKeyDown, - onMouseDown: handlePressedInside, - onMouseUp: handlePressedInside, - onPointerDownCapture: markInsideReactTree, - onMouseDownCapture: markInsideReactTree, - onClickCapture: markInsideReactTree, - onMouseUpCapture: markInsideReactTree, - onTouchEndCapture: markInsideReactTree, - onTouchMoveCapture: markInsideReactTree, - }), - [closeOnEscapeKeyDown, handlePressedInside, markInsideReactTree], + return { + onKeyDown: this.closeOnEscapeKeyDown, + onMouseDown: this.handlePressedInside, + onMouseUp: this.handlePressedInside, + onPointerDownCapture: this.markInsideReactTree, + onMouseDownCapture: this.markInsideReactTree, + onClickCapture: this.markInsideReactTree, + onMouseUpCapture: this.markInsideReactTree, + onTouchEndCapture: this.markInsideReactTree, + onTouchMoveCapture: this.markInsideReactTree, + }; + } + + public dispose() { + this.cancelDismissOnEndTimeout.clear(); + this.clearInsideReactTreeTimeout.clear(); + } +} + +/** + * 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 | FloatingContext, + props: UseDismissProps = {}, +): ElementProps { + const store = 'rootStore' in context ? context.rootStore : context; + const tree = useFloatingTree(props.externalTree); + + const controllerParameters = { + ...props, + tree, + }; + + const controller = useRefWithInit( + () => new DismissInteractionController(store, controllerParameters), + ).current; + + const [referenceProps, setReferenceProps] = React.useState( + controller.getReferenceProps(), ); + const [floatingProps, setFloatingProps] = React.useState( + controller.getFloatingProps(), + ); + + // Update controller settings on each render + useIsoLayoutEffect(() => { + controller.updateSettings(controllerParameters); + }, Object.values(controllerParameters)); + + controller.onPropsChange = () => { + setReferenceProps(controller.getReferenceProps()); + setFloatingProps(controller.getFloatingProps()); + }; + + controller.useSetup(); + + React.useEffect(() => { + return () => { + controller.dispose(); + }; + }, [controller]); return React.useMemo( - () => (enabled ? { reference, floating, trigger: reference } : {}), - [enabled, reference, floating], + () => ({ + reference: referenceProps, + trigger: referenceProps, + floating: floatingProps, + }), + [referenceProps, floatingProps], ); } diff --git a/packages/react/src/floating-ui-react/hooks/useFocus.ts b/packages/react/src/floating-ui-react/hooks/useFocus.ts index 1698b38dcb..b41161048d 100644 --- a/packages/react/src/floating-ui-react/hooks/useFocus.ts +++ b/packages/react/src/floating-ui-react/hooks/useFocus.ts @@ -53,11 +53,11 @@ export function useFocus( const keyboardModalityRef = React.useRef(true); React.useEffect(() => { - const domReference = store.select('domReferenceElement'); if (!enabled) { return undefined; } + const domReference = store.select('domReferenceElement'); const win = getWindow(domReference); // If the reference was focused and the user left the tab/window, and the