Skip to content
Merged
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
110 changes: 110 additions & 0 deletions packages/react-aria-components/test/Modal.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
mouseUp: () => Promise<void>;
}
}

const OFFSET_VH = 5;

function ScrollJumpExample() {
return (
<DialogTrigger>
<Button>Open modal</Button>
<ModalOverlay
isDismissable
data-testid="scroll-jump-backdrop"
style={{
position: 'fixed',
inset: 0,
zIndex: 100,
display: 'flex',
justifyContent: 'center',
overflowY: 'auto',
background: 'rgba(0, 0, 0, 0.7)'
}}>
<Modal
data-testid="scroll-jump-modal"
style={{
marginTop: `${OFFSET_VH}dvh`,
minHeight: `${100 - OFFSET_VH}dvh`,
width: '100%',
maxWidth: '42rem',
background: 'white'
}}>
<Dialog
data-testid="scroll-jump-dialog"
style={{
display: 'flex',
minHeight: '100%',
flexDirection: 'column',
padding: '2rem',
outline: 'none'
}}>
<Heading slot="title">Modal in a scrollable overlay</Heading>
{Array.from({length: 10}, (_, i) => (
<div key={i} style={{height: '10rem', flexShrink: 0}}>
{i + 1}
</div>
))}
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
);
}

// mousedown on the backdrop moves focus to <body> 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(<ScrollJumpExample />);

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();
});
4 changes: 2 additions & 2 deletions packages/react-aria/src/focus/FocusScope.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ function useFocusContainment(scopeRef: RefObject<Element[] | null>, 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);
}
Expand Down Expand Up @@ -444,7 +444,7 @@ function useFocusContainment(scopeRef: RefObject<Element[] | null>, 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);
}
Expand Down
23 changes: 23 additions & 0 deletions vitest.browser.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ declare module 'vitest/browser' {
) => Promise<void>;
// Commit text that doesn't come from a key press (finalizes an active composition).
commitComposition: (text: string) => Promise<void>;
// Placeholder until newer version of library
mouseDownOnElement: (selector: string, offsetX?: number, offsetY?: number) => Promise<void>;
// Same as above
mouseUp: () => Promise<void>;
}
}

Expand Down Expand Up @@ -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();
}
}
},
Expand Down