diff --git a/packages/react-aria-components/test/CloseWatcher.browser.test.tsx b/packages/react-aria-components/test/CloseWatcher.browser.test.tsx new file mode 100644 index 00000000000..a0805ad49d6 --- /dev/null +++ b/packages/react-aria-components/test/CloseWatcher.browser.test.tsx @@ -0,0 +1,390 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Autocomplete} from '../src/Autocomplete'; +import {Button} from '../src/Button'; +import {Checkbox} from '../src/Checkbox'; +import {describe, expect, it} from 'vitest'; +import {Dialog, DialogTrigger} from '../src/Dialog'; +import {GridList, GridListItem} from '../src/GridList'; +import {Heading} from '../src/Heading'; +import {Input} from '../src/Input'; +import {Menu, MenuItem, MenuTrigger, SubmenuTrigger} from '../src/Menu'; +import {Modal, ModalOverlay} from '../src/Modal'; +import {Popover} from '../src/Popover'; +import React from 'react'; +import {render} from 'vitest-browser-react'; +import {SearchField} from '../src/SearchField'; +import {useFilter} from 'react-aria/useFilter'; +import {User} from '@react-aria/test-utils'; +import {userEvent} from 'vitest/browser'; + +async function press(key: string) { + await userEvent.keyboard(`{${key}}`); +} + +describe('CloseWatcher dismissal', () => { + it('closes a single modal on Escape', async () => { + let screen = await render( + + + + + Hello + + + + + ); + await expect.element(screen.getByRole('dialog')).toBeInTheDocument(); + await press('Escape'); + await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument(); + }); + + it('does not close when isKeyboardDismissDisabled', async () => { + let screen = await render( + + + + + + Locked + + + + + + ); + await expect.element(screen.getByRole('dialog')).toBeInTheDocument(); + await press('Escape'); + await expect.element(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('closes the top-most overlay first for nested modals', async () => { + // Outer opens on mount (the single "free" watcher). The inner modal is + // opened by a click so it gets its own transient-user-activation watcher + // and is NOT grouped with the outer (grouping would close both at once). + let screen = await render( + + + + + Outer + + + + + + Inner + + + + + + + + + ); + await expect.element(screen.getByRole('button', {name: 'Open inner'})).toBeInTheDocument(); + let outerDialog = screen.getByRole('dialog'); + let outerButton = screen.getByRole('button', {name: 'Open inner'}); + await userEvent.click(outerButton); + await expect.element(screen.getByRole('button', {name: 'Inner focus'})).toBeInTheDocument(); + let innerButton = screen.getByRole('button', {name: 'Inner focus'}); + await press('Escape'); + // Inner closed, outer remains. + await expect.element(innerButton).not.toBeInTheDocument(); + await expect.element(screen.getByRole('dialog')).toBeInTheDocument(); + await expect.poll(() => screen.getByRole('dialog').elements().length).toBe(1); + // Focus restoration to the trigger is async (focus briefly falls to + // before FocusScope restores it). Wait for focus to return to the outer trigger + // otherwise Escape will fire on and never reach the overlay. + await expect.poll(() => document.activeElement).toBe(outerButton.element()); + await press('Escape'); + // Outer closes on the replacement/next watcher. + await expect.element(outerDialog).not.toBeInTheDocument(); + }); + + it('closes an inner popover before the modal', async () => { + // Modal opens on mount (free watcher); popover opened via click so its + // watcher is independent (not grouped) and receives the first close request. + let screen = await render( + + + + + Modal + + + + + + + + + + + + ); + let popoverTrigger = screen.getByRole('button', {name: 'Open popover'}); + await userEvent.click(popoverTrigger); + await expect.element(screen.getByText('Popover content')).toBeInTheDocument(); + await press('Escape'); + // Popover (most-recent watcher) closes; modal stays. + await expect.element(screen.getByText('Popover content')).not.toBeInTheDocument(); + await expect.element(screen.getByRole('dialog')).toBeInTheDocument(); + + await expect.poll(() => document.activeElement).toBe(popoverTrigger.element()); + await press('Escape'); + // Modal closes on the next close request. + await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument(); + }); + + it('recreates a watcher when a controlled parent ignores onOpenChange', async () => { + let closeCount = 0; + function Controlled() { + let [isOpen, setIsOpen] = React.useState(true); + // Always open; ignore onOpenChange(false) so the modal stays open. + return ( + { + if (!isOpen && closeCount < 1) { + closeCount++; + return; + } + setIsOpen(isOpen); + }}> + + + + Sticky + + + + + ); + } + let screen = await render(); + await expect.element(screen.getByRole('dialog')).toBeInTheDocument(); + + await press('Escape'); + await expect.poll(() => closeCount).toBe(1); + await expect.element(screen.getByRole('dialog')).toBeInTheDocument(); + + // Second Escape only fires if a replacement watcher was created. + await press('Escape'); + await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument(); + }); + + it('a non-dismissable modal on top blocks Escape from closing the modal beneath it', async () => { + // Outer (dismissable) opens on mount; inner (isKeyboardDismissDisabled) is + // opened on top. Escape must NOT close the outer modal past the inner one. + let screen = await render( + + + + + Outer + + + + + + Inner + + + + + + + + + ); + await userEvent.click(screen.getByRole('button', {name: 'Open inner'})); + await expect.poll(() => screen.getByRole('dialog').elements().length).toBe(2); + let innerContent = screen.getByRole('button', {name: 'Inner focus'}); + await expect.poll(() => document.activeElement).toBe(innerContent.element()); + + // Escape does nothing: the non-dismissable inner modal absorbs/blocks the + // close request, so neither modal closes. + await press('Escape'); + await press('Escape'); + await expect.poll(() => screen.getByRole('dialog').elements().length).toBe(2); + }); + + it('Escape clears the autocomplete input, then closes the popover, then the dialog', async () => { + function Example() { + let {contains} = useFilter({sensitivity: 'base'}); + return ( + + + + + Dialog + + + + contains(t, i)}> + + + + + Foo + Bar + Baz + + + + + + + + ); + } + let screen = await render(); + let popoverTrigger = screen.getByRole('button', {name: 'Open popover'}); + await userEvent.click(popoverTrigger); + let searchbox = screen.getByRole('searchbox'); + await expect.element(searchbox).toBeInTheDocument(); + await userEvent.type(searchbox, 'Fo'); + await expect.element(searchbox).toHaveValue('Fo'); + + // Esc #1: the autocomplete/search field consumes Escape to clear its input. + // The popover (and dialog) stay open, and the close request is suppressed. + await press('Escape'); + await expect.element(searchbox).toHaveValue(''); + await expect.element(searchbox).toBeInTheDocument(); + + // Esc #2: with an empty input, Escape closes the popover. The modal remains. + await press('Escape'); + await expect.element(screen.getByRole('searchbox')).not.toBeInTheDocument(); + await expect.element(screen.getByRole('dialog')).toBeInTheDocument(); + + // Wait for focus to return to the popover trigger (async) so the fallback + // focus-bound Escape reaches the modal, then Esc #3 closes the dialog. + await expect.poll(() => document.activeElement).toBe(popoverTrigger.element()); + await press('Escape'); + await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument(); + }); + + it('Escape clears the GridList selection, then closes the dialog', async () => { + let selection = new Set(); + function Example() { + return ( + + + + + List + { + selection = keys as Set; + }}> + + + One + + + + Two + + + + Three + + + + + + ); + } + let testUtilUser = new User(); + let screen = await render(); + await expect.element(screen.getByRole('grid')).toBeInTheDocument(); + let grid = screen.getByRole('grid').element() as HTMLElement; + let tester = testUtilUser.createTester('GridList', { + root: grid, + layout: 'grid', + interactionType: 'keyboard' + }); + let rows = tester.getRows(); + await tester.toggleRowSelection({row: rows[0]}); + await tester.toggleRowSelection({row: rows[1]}); + await expect.poll(() => selection.size).toBe(2); + + // Esc #1: the GridList consumes Escape to clear its selection. Dialog stays. + await press('Escape'); + await expect.poll(() => selection.size).toBe(0); + await expect.element(screen.getByRole('dialog')).toBeInTheDocument(); + + // Esc #2: with no selection, Escape closes the dialog. + await press('Escape'); + await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument(); + }); + + it('Escape closes one submenu level at a time (3 deep)', async () => { + function Example() { + return ( + + + + + Foo + + Bar + + + Sub Foo + + Sub Baz + + + Deep Foo + Deep Bar + + + + + + + + + + ); + } + let menuCount = () => document.querySelectorAll('[role="menu"]').length; + let testUtilUser = new User(); + let screen = await render(); + let menuTester = testUtilUser.createTester('Menu', { + root: screen.getByRole('button', {name: 'Menu'}).element() as HTMLElement, + interactionType: 'keyboard' + }); + await menuTester.open(); + let l2 = await menuTester.openSubmenu({submenuTrigger: menuTester.getSubmenuTriggers()[0]}); + await l2.openSubmenu({submenuTrigger: l2.getSubmenuTriggers()[0]}); + await expect.poll(menuCount).toBe(3); + + // Each Escape closes only the deepest submenu, restoring focus to its trigger. + await press('Escape'); + await expect.poll(menuCount).toBe(2); + await press('Escape'); + await expect.poll(menuCount).toBe(1); + await press('Escape'); + await expect.poll(menuCount).toBe(0); + await expect + .poll(() => document.activeElement) + .toBe(screen.getByRole('button', {name: 'Menu'}).element()); + }); +}); diff --git a/packages/react-aria/src/overlays/closeWatchers.ts b/packages/react-aria/src/overlays/closeWatchers.ts new file mode 100644 index 00000000000..812607ed64e --- /dev/null +++ b/packages/react-aria/src/overlays/closeWatchers.ts @@ -0,0 +1,139 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import {RefObject} from '@react-types/shared'; +import {visibleOverlays} from './useOverlay'; + +// CloseWatcher is not yet in the TypeScript DOM lib (TS 5.8.2) +interface CloseWatcher { + destroy(): void; + onclose: (() => void) | null; +} +interface CloseWatcherConstructor { + new (): CloseWatcher; +} +declare global { + interface Window { + CloseWatcher?: CloseWatcherConstructor; + } +} + +// CloseWatchers are an interesting API for web development. You may not be familiar with them +// https://developer.mozilla.org/en-US/docs/Web/API/CloseWatcher +// https://html.spec.whatwg.org/multipage/interaction.html#close-requests-and-close-watchers +// The quick synopsis is that they are EventTargets which emit cancel->close events and also +// call an `onclose` property. CloseWatchers will disconnect and be destroyed after they close. +// They are in a browser managed stack in terms of which one gets called for a given event. +// This stack isn't straightforward though, it contains "groupings", and one gesture/event may +// trigger multiple close watchers to fire synchronously. + +// We create a singleton which manages subscriptions from overlays that want to use CloseWatchers. +// It creates/destroys CloseWatchers as needed to match the number of subscribers. +// When a CloseWatcher fires, we determine which subscriber/overlay is the top-most and call its onClose. +// There is a weird edge case that we handle for controlled overlay triggers. If an overlay is +// controlled open and tries to close, but the parent ignores the onOpenChange, then the +// CloseWatcher will have been destroyed, but the total number of subscribers has not changed. +// In this case, we need to reconcile that difference and create a new CloseWatcher. + +export interface CloseWatcherSubscriber { + /** The overlay container ref, used to determine react aria's stacking order via visibleOverlays. */ + ref: RefObject; + /** Called when this overlay should close because it is the top-most. */ + onClose: () => void; +} + +const subscribers = new Set(); +const watchers: CloseWatcher[] = []; + +// Refs asked to close during the current (synchronous) close request. Excluded +// from the top-most calculation so grouped synchronous fires close distinct +// overlays. Cleared in the next microtask. +const pendingClose = new Set>(); +let pendingClear = false; + +function hasCloseWatcher(): boolean { + return typeof window !== 'undefined' && typeof window.CloseWatcher === 'function'; +} + +function createWatcher(): void { + let watcher = new window.CloseWatcher!(); + watcher.onclose = () => { + let i = watchers.indexOf(watcher); + if (i >= 0) { + watchers.splice(i, 1); + } + try { + notify(); + } finally { + reconcile(); + } + }; + watchers.push(watcher); +} + +// Invariant: watchers.length === subscribers.size. +// There may be a troublesome case here, creating them this way means they won't be +// "trusted" and may be "grouped" with other watchers and cause too many +// overlays to close at once. I'm unsure how to test this... but the spec seems to +// suggest this could happen. +function reconcile(): void { + if (!hasCloseWatcher()) { + return; + } + while (watchers.length < subscribers.size) { + createWatcher(); + } + while (watchers.length > subscribers.size) { + watchers.pop()!.destroy(); + } +} + +// Question about this, should all overlays close synchronously? Or should it be one at a time? +// When CloseWatchers are "grouped", they will all fire synchronously, but our current hooks +// may not like that. Either overlays have closed one at a time, or the parent has closed and unmounted +// all of them. I'm not sure if there is a race to worry about if they all try to close individually. +function notify(): void { + // Snapshot at the start so mutations (closing removes from visibleOverlays) + // and synchronous unsubscribes do not shift the top-most check mid-pass. + let overlays = visibleOverlays.filter(o => !pendingClose.has(o)); + let topMost = overlays[overlays.length - 1]; + if (!topMost) { + return; + } + + pendingClose.add(topMost); + if (!pendingClear) { + pendingClear = true; + queueMicrotask(() => { + pendingClose.clear(); + pendingClear = false; + }); + } + + // Only the top-most one closes. + for (let subscriber of [...subscribers]) { + if (subscriber.ref === topMost) { + subscriber.onClose(); + } + } +} + +export function subscribeCloseWatcher(subscriber: CloseWatcherSubscriber): () => void { + if (!hasCloseWatcher()) { + return () => {}; + } + subscribers.add(subscriber); + reconcile(); + return () => { + subscribers.delete(subscriber); + reconcile(); + }; +} diff --git a/packages/react-aria/src/overlays/useOverlay.ts b/packages/react-aria/src/overlays/useOverlay.ts index 1f2f476f4f8..e1c1af54fdb 100644 --- a/packages/react-aria/src/overlays/useOverlay.ts +++ b/packages/react-aria/src/overlays/useOverlay.ts @@ -13,7 +13,9 @@ import {DOMAttributes, RefObject} from '@react-types/shared'; import {getEventTarget} from '../utils/shadowdom/DOMFunctions'; import {isElementInChildOfActiveScope} from '../focus/FocusScope'; +import {subscribeCloseWatcher} from './closeWatchers'; 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 +60,7 @@ export interface OverlayAria { underlayProps: DOMAttributes; } -const visibleOverlays: RefObject[] = []; +export const visibleOverlays: RefObject[] = []; /** * Provides the behavior for overlays such as dialogs, popovers, and menus. @@ -97,6 +99,17 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject onClose?.()); + useEffect(() => { + if (!isOpen || isKeyboardDismissDisabled || !hasCloseWatcher) { + return; + } + // Should *every* overlay be closed by close watcher? should some just be Esc? "Tooltip" for example? + return subscribeCloseWatcher({ref, onClose: onCloseRequest}); + }, [isOpen, isKeyboardDismissDisabled, hasCloseWatcher, ref]); + let onInteractOutsideStart = (e: PointerEvent) => { const topMostOverlay = visibleOverlays[visibleOverlays.length - 1]; lastVisibleOverlay.current = topMostOverlay; @@ -125,18 +138,25 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { - if (!isKeyboardDismissDisabled) { - onHide(); - return; + // Fallback Escape handling for browsers without CloseWatcher support. + // When CloseWatcher is available, Escape is handled via the subscription + // above and we must NOT preventDefault here or the native close request + // would be suppressed. + let {keyboardProps} = useKeyboard( + hasCloseWatcher + ? {} + : { + shortcuts: { + Escape: () => { + if (!isKeyboardDismissDisabled) { + onHide(); + return; + } + return false; + } + } } - return false; - } - } - }); + ); // Handle clicking outside the overlay to close it useInteractOutside({