diff --git a/packages/react-aria/src/overlays/useOverlay.ts b/packages/react-aria/src/overlays/useOverlay.ts index e660df06116..695865203f3 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'; @@ -57,6 +58,10 @@ export interface OverlayAria { const visibleOverlays: RefObject[] = []; +function supportsCloseWatcher(): boolean { + return typeof globalThis.CloseWatcher !== 'undefined'; +} + /** * Provides the behavior for overlays such as dialogs, popovers, and menus. * Hides the overlay when the user interacts outside it, when the Escape key is pressed, @@ -74,25 +79,44 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject>(undefined); + // Only hide the overlay when it is the topmost visible overlay in the stack + let onHide = () => { + if (visibleOverlays[visibleOverlays.length - 1] === ref && onClose) { + onClose(); + } + }; + + // Stable callback for CloseWatcher that always calls the latest onHide. + // useEffectEvent returns a stable reference, so the watcher doesn't need + // to be recreated when onClose changes. + 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. The browser + // internally stacks watchers so Escape dismisses the most recently created one first, + // which also handles the Android back button. The onKeyDown handler below is kept as + // a fallback and is a no-op if the CloseWatcher already dismissed the overlay. useEffect(() => { if (isOpen && !visibleOverlays.includes(ref)) { visibleOverlays.push(ref); + + let watcher: {onclose: (() => void) | null, destroy: () => void} | null = null; + if (!isKeyboardDismissDisabled && supportsCloseWatcher()) { + watcher = new (globalThis as any).CloseWatcher(); + watcher!.onclose = () => { + onHideEvent(); + }; + } + return () => { let index = visibleOverlays.indexOf(ref); if (index >= 0) { visibleOverlays.splice(index, 1); } + 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]; diff --git a/packages/react-aria/test/overlays/useOverlay.test.js b/packages/react-aria/test/overlays/useOverlay.test.js index 58367686829..51434f4cd2b 100644 --- a/packages/react-aria/test/overlays/useOverlay.test.js +++ b/packages/react-aria/test/overlays/useOverlay.test.js @@ -128,4 +128,94 @@ describe('useOverlay', function () { fireEvent.keyDown(el, {key: 'Escape'}); expect(onClose).toHaveBeenCalledTimes(1); }); + + 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); + } + } + }; + 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(); + + // Each overlay gets its own CloseWatcher + expect(closeWatcherInstances.length).toBe(2); + + // Browser fires close on the most recently created watcher (inner overlay) + closeWatcherInstances[1].onclose(); + expect(onCloseInner).toHaveBeenCalledTimes(1); + expect(onCloseOuter).not.toHaveBeenCalled(); + }); + + it('should dismiss inner then outer with per-overlay watchers', function () { + let onCloseOuter = jest.fn(); + let onCloseInner = jest.fn(); + render(); + let inner = render(); + + expect(closeWatcherInstances.length).toBe(2); + + // Dismiss inner overlay via its watcher + closeWatcherInstances[1].onclose(); + expect(onCloseInner).toHaveBeenCalledTimes(1); + + // Unmount inner - its watcher is destroyed + inner.unmount(); + expect(closeWatcherInstances.length).toBe(1); + + // Dismiss outer via its watcher + closeWatcherInstances[0].onclose(); + expect(onCloseOuter).toHaveBeenCalledTimes(1); + }); + }); });