diff --git a/packages/react-aria/src/overlays/useOverlay.ts b/packages/react-aria/src/overlays/useOverlay.ts index 1f2f476f4f8..8673d54b963 100644 --- a/packages/react-aria/src/overlays/useOverlay.ts +++ b/packages/react-aria/src/overlays/useOverlay.ts @@ -14,6 +14,7 @@ import {DOMAttributes, RefObject} from '@react-types/shared'; import {getEventTarget} from '../utils/shadowdom/DOMFunctions'; import {isElementInChildOfActiveScope} from '../focus/FocusScope'; import {useEffect, useRef} from 'react'; +import {useEffectEvent} from '../utils/useEffectEvent'; import {useFocusWithin} from '../interactions/useFocusWithin'; import {useInteractOutside} from '../interactions/useInteractOutside'; import {useKeyboard} from '../interactions/useKeyboard'; @@ -58,7 +59,65 @@ export interface OverlayAria { underlayProps: DOMAttributes; } -const visibleOverlays: RefObject[] = []; +interface VisibleOverlayData { + onClose: () => void; + isKeyboardDismissDisabled: boolean; +} + +interface VisibleOverlay { + ref: RefObject; + data: VisibleOverlayData; +} + +const visibleOverlays: VisibleOverlay[] = []; + +interface CloseWatcher { + onclose: (() => void) | null; + destroy: () => void; +} + +function supportsCloseWatcher(): boolean { + return typeof globalThis.CloseWatcher !== 'undefined'; +} + +function getTopMostOverlay(): VisibleOverlay | undefined { + let topMostOverlay: VisibleOverlay | undefined; + for (let overlay of visibleOverlays) { + let element = overlay.ref.current; + if (!element) { + continue; + } + + if (!topMostOverlay?.ref.current) { + topMostOverlay = overlay; + continue; + } + + let topMostElement = topMostOverlay.ref.current; + if (topMostElement === element) { + topMostOverlay = overlay; + continue; + } + + let ownerNode = element.ownerDocument.defaultView?.Node; + let position = topMostElement.compareDocumentPosition(element); + if ( + ownerNode && + !(position & ownerNode.DOCUMENT_POSITION_DISCONNECTED) && + position & ownerNode.DOCUMENT_POSITION_FOLLOWING + ) { + topMostOverlay = overlay; + } + } + + return topMostOverlay ?? visibleOverlays[visibleOverlays.length - 1]; +} + +function isOnlyVisibleOverlayForRef(overlay: VisibleOverlay): boolean { + return !visibleOverlays.some( + visibleOverlay => visibleOverlay !== overlay && visibleOverlay.ref === overlay.ref + ); +} /** * Provides the behavior for overlays such as dialogs, popovers, and menus. @@ -75,36 +134,62 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject>(undefined); + let lastVisibleOverlay = useRef(undefined); + let visibleOverlay = useRef(null); + + let onHide = () => { + onClose?.(); + }; + + let onHideEvent = useEffectEvent(onHide); // Add the overlay ref to the stack of visible overlays on mount, and remove on unmount. + // When CloseWatcher is supported, each overlay gets its own instance so the browser's + // native close watcher stack handles nested overlay ordering for Escape and Android back. useEffect(() => { - if (isOpen && !visibleOverlays.includes(ref)) { - visibleOverlays.push(ref); + if (isOpen && !visibleOverlay.current) { + let overlay: VisibleOverlay = { + ref, + data: { + onClose: () => onHideEvent(), + isKeyboardDismissDisabled + } + }; + visibleOverlay.current = overlay; + visibleOverlays.push(overlay); + + let watcher: CloseWatcher | null = null; + if (!isKeyboardDismissDisabled && supportsCloseWatcher()) { + let closeWatcher: CloseWatcher = new (globalThis as any).CloseWatcher(); + closeWatcher.onclose = () => { + onHideEvent(); + }; + watcher = closeWatcher; + } + return () => { - let index = visibleOverlays.indexOf(ref); + let index = visibleOverlays.indexOf(overlay); if (index >= 0) { visibleOverlays.splice(index, 1); } + visibleOverlay.current = null; + watcher?.destroy(); }; } - }, [isOpen, ref]); - - // Only hide the overlay when it is the topmost visible overlay in the stack - let onHide = () => { - if (visibleOverlays[visibleOverlays.length - 1] === ref && onClose) { - onClose(); - } - }; + }, [isOpen, isKeyboardDismissDisabled, ref]); let onInteractOutsideStart = (e: PointerEvent) => { - const topMostOverlay = visibleOverlays[visibleOverlays.length - 1]; + const topMostOverlay = getTopMostOverlay(); lastVisibleOverlay.current = topMostOverlay; if ( !shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(getEventTarget(e) as Element) ) { - if (topMostOverlay === ref) { + if ( + topMostOverlay && + topMostOverlay === visibleOverlay.current && + isOnlyVisibleOverlayForRef(topMostOverlay) + ) { e.stopPropagation(); } } @@ -115,27 +200,55 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { - if (!isKeyboardDismissDisabled) { - onHide(); - return; + shortcuts: supportsCloseWatcher() + ? undefined + : { + Escape: e => { + if (e.nativeEvent.cancelBubble) { + return false; + } + + let topMostOverlay = getTopMostOverlay(); + if (!topMostOverlay) { + return false; + } + + let overlay = topMostOverlay.ref === ref ? visibleOverlay.current : topMostOverlay; + if (!overlay) { + return false; + } + + if (overlay.data.isKeyboardDismissDisabled) { + if (overlay !== visibleOverlay.current) { + return; + } + return false; + } + + overlay.data.onClose(); + return; + } } - return false; - } - } }); // Handle clicking outside the overlay to close it diff --git a/packages/react-aria/test/overlays/useOverlay.test.js b/packages/react-aria/test/overlays/useOverlay.test.js index 0ba68c5d2ee..69ad8f42556 100644 --- a/packages/react-aria/test/overlays/useOverlay.test.js +++ b/packages/react-aria/test/overlays/useOverlay.test.js @@ -11,13 +11,14 @@ */ import { + act, fireEvent, installMouseEvent, installPointerEvent, render } from '@react-spectrum/test-utils-internal'; import {mergeProps} from '../../src/utils/mergeProps'; -import React, {useRef} from 'react'; +import React, {useRef, useState} from 'react'; import {useOverlay} from '../../src/overlays/useOverlay'; function Example(props) { @@ -25,13 +26,48 @@ function Example(props) { let {overlayProps, underlayProps} = useOverlay(props, ref); return (
-
+
{props.children}
); } +function StatefulExample({defaultIsOpen, onClose, ...props}) { + let [isOpen, setIsOpen] = useState(defaultIsOpen); + // Unmount on close the way a real consumer does. Example renders its node + // unconditionally, so leaving it mounted would keep both the element and the + // overlay's CloseWatcher alive and make the nested-dismiss assertions + // unobservable. + if (!isOpen) { + return null; + } + return ( + { + setIsOpen(false); + onClose?.(); + }} + /> + ); +} + +function SharedRefExample(props) { + let ref = useRef(); + let first = useOverlay({isOpen: true, onClose: props.onCloseFirst}, ref); + let second = useOverlay({isOpen: true, onClose: props.onCloseSecond}, ref); + return ( +
+
+
+ ); +} + describe('useOverlay', function () { describe.each` type | prepare | actions @@ -138,4 +174,237 @@ describe('useOverlay', function () { fireEvent.keyDown(el, {key: 'Escape'}); expect(onClose).toHaveBeenCalledTimes(1); }); + + it('should not hide the overlay when an earlier Escape handler stops propagation', function () { + let onClose = jest.fn(); + let res = render( + e.stopPropagation() + }} + /> + ); + let el = res.getByTestId('test'); + fireEvent.keyDown(el, {key: 'Escape'}); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('should hide the innermost overlay when nested overlays mount together', function () { + let onCloseOuter = jest.fn(); + let onCloseInner = jest.fn(); + let res = render( + + + + ); + + fireEvent.keyDown(res.getByTestId('inner'), {key: 'Escape'}); + expect(onCloseInner).toHaveBeenCalledTimes(1); + expect(onCloseOuter).not.toHaveBeenCalled(); + }); + + it('should keep separate close handlers for overlays using the same ref', function () { + let onCloseFirst = jest.fn(); + let onCloseSecond = jest.fn(); + let res = render( + + ); + + fireEvent.keyDown(res.getByTestId('test'), {key: 'Escape'}); + expect(onCloseSecond).toHaveBeenCalledTimes(1); + expect(onCloseFirst).not.toHaveBeenCalled(); + }); + + describe('CloseWatcher', function () { + let closeWatcherInstances; + let MockCloseWatcher; + + beforeEach(function () { + closeWatcherInstances = []; + MockCloseWatcher = class { + constructor() { + this.onclose = null; + closeWatcherInstances.push(this); + } + destroy() { + let index = closeWatcherInstances.indexOf(this); + if (index >= 0) { + closeWatcherInstances.splice(index, 1); + } + } + static closeTopMost() { + closeWatcherInstances[closeWatcherInstances.length - 1]?.onclose(); + } + }; + globalThis.CloseWatcher = MockCloseWatcher; + }); + + afterEach(function () { + delete globalThis.CloseWatcher; + }); + + it('should use CloseWatcher to dismiss overlay when available', function () { + let onClose = jest.fn(); + render(); + expect(closeWatcherInstances.length).toBe(1); + closeWatcherInstances[0].onclose(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should not create CloseWatcher when isKeyboardDismissDisabled is true', function () { + let onClose = jest.fn(); + render(); + expect(closeWatcherInstances.length).toBe(0); + }); + + it('should not create CloseWatcher when overlay is not open', function () { + let onClose = jest.fn(); + render(); + expect(closeWatcherInstances.length).toBe(0); + }); + + it('should destroy CloseWatcher when overlay unmounts', function () { + let onClose = jest.fn(); + let res = render(); + expect(closeWatcherInstances.length).toBe(1); + res.unmount(); + expect(closeWatcherInstances.length).toBe(0); + }); + + it('should dismiss only the top-most overlay with nested overlays', function () { + let onCloseOuter = jest.fn(); + let onCloseInner = jest.fn(); + render(); + render(); + + expect(closeWatcherInstances.length).toBe(2); + + // The browser's native CloseWatcher stack closes the most recently created watcher first. + closeWatcherInstances[1].onclose(); + expect(onCloseInner).toHaveBeenCalledTimes(1); + expect(onCloseOuter).not.toHaveBeenCalled(); + }); + + it('should dismiss the most recently opened overlay even when focus is in an older overlay', function () { + let onCloseFirst = jest.fn(); + let onCloseSecond = jest.fn(); + let first = render( + + + + ); + render( + + + + ); + + expect(closeWatcherInstances.length).toBe(2); + let firstInput = first.getByTestId('first-input'); + act(() => { + firstInput.focus(); + }); + expect(document.activeElement).toBe(firstInput); + + closeWatcherInstances[1].onclose(); + expect(onCloseSecond).toHaveBeenCalledTimes(1); + expect(onCloseFirst).not.toHaveBeenCalled(); + }); + + it('should let CloseWatcher handle Escape when supported', function () { + let onClose = jest.fn(); + let onKeyDown = jest.fn(); + let res = render( +
+ +
+ ); + let el = res.getByTestId('test'); + + fireEvent.keyDown(el, {key: 'Escape'}); + expect(onKeyDown).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + + closeWatcherInstances[0].onclose(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should let native CloseWatcher dismiss the most recently opened overlay', function () { + let onCloseFirst = jest.fn(); + let onCloseSecond = jest.fn(); + let first = render( + + + + ); + render( + + + + ); + + expect(closeWatcherInstances.length).toBe(2); + let firstInput = first.getByTestId('first-input'); + act(() => { + firstInput.focus(); + }); + expect(document.activeElement).toBe(firstInput); + + fireEvent.keyDown(firstInput, {key: 'Escape'}); + expect(onCloseSecond).not.toHaveBeenCalled(); + expect(onCloseFirst).not.toHaveBeenCalled(); + + closeWatcherInstances[1].onclose(); + expect(onCloseSecond).toHaveBeenCalledTimes(1); + expect(onCloseFirst).not.toHaveBeenCalled(); + }); + + it('should only dismiss the top-most nested overlay on Escape when CloseWatcher is active', function () { + let onCloseOuter = jest.fn(); + let onCloseInner = jest.fn(); + let res = render( + <> + + + + ); + + let innerEl = res.getByTestId('inner'); + + fireEvent.keyDown(innerEl, {key: 'Escape'}); + expect(onCloseInner).not.toHaveBeenCalled(); + expect(onCloseOuter).not.toHaveBeenCalled(); + + act(() => MockCloseWatcher.closeTopMost()); + expect(onCloseInner).toHaveBeenCalledTimes(1); + expect(onCloseOuter).not.toHaveBeenCalled(); + expect(res.queryByTestId('inner')).toBeNull(); + expect(closeWatcherInstances).toHaveLength(1); + }); + + it('should dismiss inner then outer with native watcher stack', function () { + let onCloseOuter = jest.fn(); + let onCloseInner = jest.fn(); + let res = render( + <> + + + + ); + + expect(closeWatcherInstances.length).toBe(2); + + act(() => MockCloseWatcher.closeTopMost()); + expect(onCloseInner).toHaveBeenCalledTimes(1); + expect(res.queryByTestId('inner')).toBeNull(); + expect(closeWatcherInstances.length).toBe(1); + + act(() => MockCloseWatcher.closeTopMost()); + expect(onCloseOuter).toHaveBeenCalledTimes(1); + expect(res.queryByTestId('outer')).toBeNull(); + expect(closeWatcherInstances.length).toBe(0); + }); + }); });