Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 37 additions & 10 deletions packages/react-aria/src/overlays/useOverlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -57,6 +58,10 @@ export interface OverlayAria {

const visibleOverlays: RefObject<Element | null>[] = [];

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,
Expand All @@ -74,25 +79,44 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject<Element | nul

let lastVisibleOverlay = useRef<RefObject<Element | null>>(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];
Expand All @@ -116,8 +140,11 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject<Element | nul
lastVisibleOverlay.current = undefined;
};

// Handle the escape key
let onKeyDown = (e) => {
// Handle the escape key — only used as a fallback when CloseWatcher is not supported.
// When CloseWatcher handles dismiss, the keydown handler is skipped entirely to avoid
// double-dismiss in nested overlays (e.g. submenus where Escape would close both the
// submenu via CloseWatcher and the parent menu via the bubbling keydown event).
let onKeyDown = supportsCloseWatcher() ? undefined : (e) => {
if (e.key === 'Escape' && !isKeyboardDismissDisabled && !e.nativeEvent.isComposing) {
e.stopPropagation();
e.preventDefault();
Expand Down
124 changes: 124 additions & 0 deletions packages/react-aria/test/overlays/useOverlay.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,128 @@ 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(<Example isOpen onClose={onClose} />);
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(<Example isOpen onClose={onClose} isKeyboardDismissDisabled />);
expect(closeWatcherInstances.length).toBe(0);
});

it('should not create CloseWatcher when overlay is not open', function () {
let onClose = jest.fn();
render(<Example isOpen={false} onClose={onClose} />);
expect(closeWatcherInstances.length).toBe(0);
});

it('should destroy CloseWatcher when overlay unmounts', function () {
let onClose = jest.fn();
let res = render(<Example isOpen onClose={onClose} />);
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(<Example isOpen onClose={onCloseOuter} data-testid="outer" />);
render(<Example isOpen onClose={onCloseInner} data-testid="inner" />);

// 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 not attach onKeyDown when CloseWatcher is supported', function () {
let onClose = jest.fn();
let res = render(<Example isOpen onClose={onClose} />);
let el = res.getByTestId('test');

// With CloseWatcher active, Escape keydown should not trigger onClose
// (the browser's CloseWatcher handles it instead)
fireEvent.keyDown(el, {key: 'Escape'});
expect(onClose).not.toHaveBeenCalled();

// But CloseWatcher still works
closeWatcherInstances[0].onclose();
expect(onClose).toHaveBeenCalledTimes(1);
});

it('should not double-dismiss nested overlays on Escape when CloseWatcher is active', function () {
let onCloseOuter = jest.fn();
let onCloseInner = jest.fn();
let outer = render(<Example isOpen onClose={onCloseOuter} data-testid="outer" />);
render(<Example isOpen onClose={onCloseInner} data-testid="inner" />);

let outerEl = outer.getByTestId('outer');

// Simulate browser behavior: CloseWatcher fires for inner overlay
closeWatcherInstances[1].onclose();
expect(onCloseInner).toHaveBeenCalledTimes(1);

// The Escape keydown event that triggered CloseWatcher also bubbles to the
// outer overlay's DOM. With the fix, onKeyDown is undefined so the outer
// overlay is NOT dismissed.
fireEvent.keyDown(outerEl, {key: 'Escape'});
expect(onCloseOuter).not.toHaveBeenCalled();
});

it('should dismiss inner then outer with per-overlay watchers', function () {
let onCloseOuter = jest.fn();
let onCloseInner = jest.fn();
render(<Example isOpen onClose={onCloseOuter} data-testid="outer" />);
let inner = render(<Example isOpen onClose={onCloseInner} data-testid="inner" />);

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);
});
});
});
Loading