diff --git a/packages/react-aria-components/test/Modal.browser.test.tsx b/packages/react-aria-components/test/Modal.browser.test.tsx new file mode 100644 index 00000000000..bb721dd5b93 --- /dev/null +++ b/packages/react-aria-components/test/Modal.browser.test.tsx @@ -0,0 +1,110 @@ +/* + * 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 {Button} from '../src/Button'; +import {commands, page, userEvent} from 'vitest/browser'; +import {Dialog, DialogTrigger} from '../src/Dialog'; +import {expect, it} from 'vitest'; +import {Heading} from '../src/Heading'; +import {Modal, ModalOverlay} from '../src/Modal'; +import React from 'react'; +import {render} from 'vitest-browser-react'; + +declare module 'vitest/browser' { + interface BrowserCommands { + mouseDownOnElement: (selector: string, offsetX?: number, offsetY?: number) => Promise; + mouseUp: () => Promise; + } +} + +const OFFSET_VH = 5; + +function ScrollJumpExample() { + return ( + + + + + + Modal in a scrollable overlay + {Array.from({length: 10}, (_, i) => ( +
+ {i + 1} +
+ ))} +
+
+
+
+ ); +} + +// mousedown on the backdrop moves focus to in Chrome/Safari; Firefox does not. +// FocusScope containment must restore focus to the Dialog without scrolling, +// otherwise the modal visibly jumps to the top of the screen. +// Uses a trusted press so the native focus move actually happens. This cannot be +// tested in a unit test nor in Chromatic play. +it('does not scroll the modal into view when the backdrop is pressed', async () => { + await render(); + + await userEvent.click(page.getByRole('button', {name: 'Open modal'})); + await expect.element(page.getByRole('dialog')).toBeInTheDocument(); + + let overlay = page.getByTestId('scroll-jump-backdrop').element() as HTMLElement; + let modal = page.getByTestId('scroll-jump-modal').element() as HTMLElement; + + overlay.scrollTop = 0; + let modalTopBefore = Math.round(modal.getBoundingClientRect().top); + expect(overlay.scrollTop).toBe(0); + expect(modalTopBefore).toBeGreaterThan(0); + + // Do not release so we can observe the state + await commands.mouseDownOnElement(page.getByTestId('scroll-jump-backdrop').selector, 5); + + // Wait a couple frames for FocusScope's requestAnimationFrame focus restore to run. + await new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(() => resolve(null))) + ); + + // the modal stays at its offset + expect(overlay.scrollTop).toBe(0); + expect(Math.round(modal.getBoundingClientRect().top)).toBe(modalTopBefore); + + await commands.mouseUp(); +}); diff --git a/packages/react-aria/src/focus/FocusScope.tsx b/packages/react-aria/src/focus/FocusScope.tsx index 6f423f12b7f..ad4f0a427a3 100644 --- a/packages/react-aria/src/focus/FocusScope.tsx +++ b/packages/react-aria/src/focus/FocusScope.tsx @@ -410,7 +410,7 @@ function useFocusContainment(scopeRef: RefObject, contain?: bo // If a focus event occurs outside the active scope (e.g. user tabs from browser location bar), // restore focus to the previously focused node or the first tabbable element in the active scope. if (focusedNode.current) { - focusedNode.current.focus(); + focusElement(focusedNode.current); } else if (activeScope && activeScope.current) { focusFirstInScope(activeScope.current); } @@ -444,7 +444,7 @@ function useFocusContainment(scopeRef: RefObject, contain?: bo let target = getEventTarget(e) as FocusableElement; if (target && target.isConnected) { focusedNode.current = target; - focusedNode.current?.focus(); + focusElement(focusedNode.current); } else if (activeScope.current) { focusFirstInScope(activeScope.current); } diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index d250a6b8b1f..1b0e7732da9 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -191,6 +191,10 @@ declare module 'vitest/browser' { ) => Promise; // Commit text that doesn't come from a key press (finalizes an active composition). commitComposition: (text: string) => Promise; + // Placeholder until newer version of library + mouseDownOnElement: (selector: string, offsetX?: number, offsetY?: number) => Promise; + // Same as above + mouseUp: () => Promise; } } @@ -305,6 +309,25 @@ export default defineConfig({ commitComposition: async ({page, context}: any, text) => { const cdp = await getCDP(page, context); await cdp.send('Input.insertText', {text}); + }, + // Once we upgrade to a newer version, we can use the below and delete mouseDownOnElement + // await userEvent.hover(button) + // await userEvent.pointer({ keys: '[MouseLeft>]', target: button }) + // await userEvent.pointer('[/MouseLeft]') + mouseDownOnElement: async ( + {page, iframe}: any, + selector: string, + offsetX: number = 5, + offsetY?: number + ) => { + const box = await iframe.locator(selector).boundingBox(); + const x = box.x + offsetX; + const y = offsetY == null ? box.y + box.height / 2 : box.y + offsetY; + await page.mouse.move(x, y); + await page.mouse.down(); + }, + mouseUp: async ({page}: any) => { + await page.mouse.up(); } } },