Skip to content
Draft
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
88 changes: 87 additions & 1 deletion packages/react/src/dialog/root/DialogRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { expect, vi } from 'vitest';
import * as React from 'react';
import { act, fireEvent, screen, waitFor, flushMicrotasks } from '@mui/internal-test-utils';
import { Dialog } from '@base-ui/react/dialog';
import { createRenderer, isJSDOM, popupConformanceTests } from '#test-utils';
import { createRenderer, isJSDOM, popupConformanceTests, wait } from '#test-utils';
import { Menu } from '@base-ui/react/menu';
import { Select } from '@base-ui/react/select';
import { NumberField } from '@base-ui/react/number-field';
import { ScrollArea } from '@base-ui/react/scroll-area';
import { useRefWithInit } from '@base-ui/utils/useRefWithInit';
import { useTimeout } from '@base-ui/utils/useTimeout';
import { REASONS } from '../../internals/reasons';
import { useDialogRootContext } from './DialogRootContext';

Expand Down Expand Up @@ -1277,6 +1278,81 @@ describe('<Dialog.Root />', () => {
});
});

describe.for([
{ name: 'body', target: 'body' as const },
{ name: 'html', target: 'html' as const },
])('when a third-party $name lock hands off to the dialog', ({ target }) => {
it('keeps the page scroll-locked', async () => {
function App() {
const [dialogOpen, setDialogOpen] = React.useState(false);
const element = getScrollLockElement(document, target);
const timeout = useTimeout();

React.useEffect(() => {
return () => {
element.style.overflow = '';
};
}, [element]);

return (
<React.Fragment>
<button
onClick={() => {
element.style.overflow = 'hidden';
timeout.start(50, () => {
element.style.overflow = '';
});
setDialogOpen(true);
}}
>
Open dialog
</button>
<Dialog.Root open={dialogOpen} onOpenChange={setDialogOpen}>
<Dialog.Portal>
<Dialog.Popup>
<Dialog.Close>Close dialog</Dialog.Close>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
</React.Fragment>
);
}

await render(<App />);

const doc = document;

fireEvent.click(screen.getByRole('button', { name: 'Open dialog' }));
await act(async () => {
await wait(0);
});

await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBe(null);
});
expect(isScrollLocked(doc)).toBe(true);

await act(async () => {
await wait(75);
});
await waitFor(() => {
expect(isScrollLocked(doc)).toBe(true);
});

fireEvent.click(screen.getByRole('button', { name: 'Close dialog' }));
await act(async () => {
await wait(0);
});

await waitFor(() => {
expect(screen.queryByRole('dialog')).toBe(null);
});
await waitFor(() => {
expect(isScrollLocked(doc)).toBe(false);
});
});
});

it.skipIf(isJSDOM)(
'keeps focus trapped when dialog content contains a non-scrollable scroll area',
async () => {
Expand Down Expand Up @@ -1347,6 +1423,16 @@ function DialogOpenChangeSpy(props: {
return null;
}

function isScrollLocked(doc: Document) {
return /hidden|clip/.test(
getComputedStyle(doc.documentElement).overflowY + getComputedStyle(doc.body).overflowY,
);
}

function getScrollLockElement(doc: Document, target: 'body' | 'html') {
return target === 'body' ? doc.body : doc.documentElement;
}

type TestDialogProps = {
rootProps?: Omit<Dialog.Root.Props, 'children'>;
triggerProps?: Dialog.Trigger.Props;
Expand Down
50 changes: 46 additions & 4 deletions packages/utils/src/useScrollLock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ let originalHtmlStyles: Partial<CSSStyleDeclaration> = {};
let originalBodyStyles: Partial<CSSStyleDeclaration> = {};
let originalHtmlScrollBehavior = '';

function isPageScrollLocked(win: Window, html: Element, body: Element) {
return /hidden|clip/.test(
win.getComputedStyle(html).overflowY + win.getComputedStyle(body).overflowY,
);
}

function hasInsetScrollbars(referenceElement: Element | null) {
if (typeof document === 'undefined') {
return false;
Expand Down Expand Up @@ -242,18 +248,54 @@ class ScrollLocker {
}
};

private observePageScrollUnlock(referenceElement: Element | null) {
if (typeof MutationObserver !== 'function') {
return NOOP;
}

const doc = ownerDocument(referenceElement);
const win = ownerWindow(doc);
const html = doc.documentElement;
const body = doc.body;

let observer: MutationObserver;

function disconnect() {
observer.disconnect();
}

observer = new MutationObserver(() => {
if (
this.lockCount === 0 ||
this.restore !== disconnect ||
isPageScrollLocked(win, html, body)
) {
return;
}

disconnect();
this.restore = null;
this.lock(referenceElement);
});

observer.observe(html, { attributes: true });
observer.observe(body, { attributes: true });

return disconnect;
}

private lock(referenceElement: Element | null) {
if (this.lockCount === 0 || this.restore !== null) {
return;
}

const doc = ownerDocument(referenceElement);
const html = doc.documentElement;
const htmlOverflowY = ownerWindow(html).getComputedStyle(html).overflowY;
const body = doc.body;
const win = ownerWindow(html);

// If the site author already hid overflow on <html>, respect it and bail out.
if (htmlOverflowY === 'hidden' || htmlOverflowY === 'clip') {
this.restore = NOOP;
if (isPageScrollLocked(win, html, body)) {
this.restore = this.observePageScrollUnlock(referenceElement);
return;
}

Expand Down
Loading