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
40 changes: 32 additions & 8 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 Down
90 changes: 90 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,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(<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 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