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
157 changes: 157 additions & 0 deletions packages/mui-material/src/Modal/ModalManager.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect } from 'chai';
import { spy } from 'sinon';
import getScrollbarSize from '@mui/utils/getScrollbarSize';
import { ModalManager } from './ModalManager';

Expand Down Expand Up @@ -432,4 +433,160 @@ describe('ModalManager', () => {
expect(container2.children[2]).not.toBeInaccessible();
});
});

// Regression test for https://github.com/mui/material-ui/issues/46791
// iOS Safari ignores `overflow: hidden` on the document scroll container, so
// the page can still scroll behind a modal (e.g. when the on-screen keyboard
// opens for an Autocomplete inside a Drawer). On iOS we additionally pin the
// body with `position: fixed` and restore the scroll position on unlock.
describe('iOS scroll lock', () => {
let scrollYDescriptor: PropertyDescriptor | undefined;
let originalScrollTo: typeof window.scrollTo;
let scrollToSpy: ReturnType<typeof spy>;

// Shadow the read-only navigator getters with own properties; afterEach
// deletes them to fall back to the original prototype getters.
function stubNavigator(values: {
userAgent?: string;
platform?: string;
maxTouchPoints?: number;
}) {
if (values.userAgent !== undefined) {
Object.defineProperty(navigator, 'userAgent', {
value: values.userAgent,
configurable: true,
});
}
if (values.platform !== undefined) {
Object.defineProperty(navigator, 'platform', {
value: values.platform,
configurable: true,
});
}
if (values.maxTouchPoints !== undefined) {
Object.defineProperty(navigator, 'maxTouchPoints', {
value: values.maxTouchPoints,
configurable: true,
});
}
}

beforeEach(() => {
modalManager = new ModalManager();
scrollYDescriptor = Object.getOwnPropertyDescriptor(window, 'scrollY');
Object.defineProperty(window, 'scrollY', { value: 150, configurable: true });

originalScrollTo = window.scrollTo;
scrollToSpy = spy();
window.scrollTo = scrollToSpy as unknown as typeof window.scrollTo;
});

afterEach(() => {
delete (navigator as any).userAgent;
delete (navigator as any).platform;
delete (navigator as any).maxTouchPoints;
if (scrollYDescriptor) {
Object.defineProperty(window, 'scrollY', scrollYDescriptor);
} else {
delete (window as any).scrollY;
}
window.scrollTo = originalScrollTo;
document.body.style.position = '';
document.body.style.top = '';
document.body.style.left = '';
document.body.style.right = '';
document.body.style.width = '';
document.body.style.overflow = '';
});

it('pins the body with position: fixed and restores scroll on iOS', () => {
stubNavigator({ userAgent: 'iPhone OS 17_0 like Mac OS X', platform: 'iPhone' });

const modal = getDummyModal();
modalManager.add(modal, document.body);
modalManager.mount(modal, {});

expect(document.body.style.overflow).to.equal('hidden');
expect(document.body.style.position).to.equal('fixed');
expect(document.body.style.top).to.equal('-150px');
expect(document.body.style.width).to.equal('100%');

modalManager.remove(modal);

expect(document.body.style.position).to.equal('');
expect(document.body.style.top).to.equal('');
expect(document.body.style.overflow).to.equal('');
expect(scrollToSpy.calledOnceWith(0, 150)).to.equal(true);
});

it('detects iPadOS 13+ which reports as MacIntel with touch points', () => {
stubNavigator({
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/604.1',
platform: 'MacIntel',
maxTouchPoints: 5,
});

const modal = getDummyModal();
modalManager.add(modal, document.body);
modalManager.mount(modal, {});

expect(document.body.style.position).to.equal('fixed');

modalManager.remove(modal);
expect(scrollToSpy.calledOnceWith(0, 150)).to.equal(true);
});

it('does not pin the body on non-iOS platforms', () => {
stubNavigator({
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/604.1',
platform: 'MacIntel',
maxTouchPoints: 0,
});

const modal = getDummyModal();
modalManager.add(modal, document.body);
modalManager.mount(modal, {});

expect(document.body.style.overflow).to.equal('hidden');
expect(document.body.style.position).to.equal('');
expect(document.body.style.top).to.equal('');

modalManager.remove(modal);
expect(scrollToSpy.called).to.equal(false);
});

it('does nothing when disableScrollLock is set on iOS', () => {
stubNavigator({ userAgent: 'iPhone OS 17_0 like Mac OS X', platform: 'iPhone' });

const modal = getDummyModal();
modalManager.add(modal, document.body);
modalManager.mount(modal, { disableScrollLock: true });

expect(document.body.style.position).to.equal('');
expect(document.body.style.overflow).to.equal('');

modalManager.remove(modal);
expect(scrollToSpy.called).to.equal(false);
});

it('does not pin a non-document scroll container on iOS', () => {
stubNavigator({ userAgent: 'iPhone OS 17_0 like Mac OS X', platform: 'iPhone' });

const customContainer = document.createElement('div');
document.body.appendChild(customContainer);

const modal = getDummyModal();
modalManager.add(modal, customContainer);
modalManager.mount(modal, {});

expect(customContainer.style.overflow).to.equal('hidden');
expect(customContainer.style.position).to.equal('');

modalManager.remove(modal);
expect(scrollToSpy.called).to.equal(false);
document.body.removeChild(customContainer);
});
});
});
51 changes: 51 additions & 0 deletions packages/mui-material/src/Modal/ModalManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ export interface ManagedModalProps {
disableScrollLock?: boolean | undefined;
}

// iOS Safari ignores `overflow: hidden` on the scroll container for touch
// scrolling, so the page can still scroll behind a modal. We detect iOS to
// apply a stronger `position: fixed` scroll lock there. Evaluated at call time
// (not module load) so it stays SSR-safe and testable.
function isIOS(): boolean {
if (typeof navigator === 'undefined') {
return false;
}
// iPadOS 13+ reports as "MacIntel"; distinguish a real Mac from an iPad
// via touch points.
return (
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
);
}

// Is a vertical scrollbar displayed?
function isOverflowing(container: Element): boolean {
const doc = ownerDocument(container);
Expand Down Expand Up @@ -94,6 +110,7 @@ function handleContainer(containerInfo: Container, props: ManagedModalProps) {
value: string;
}> = [];
const container = containerInfo.container;
let restoreScroll: (() => void) | undefined;

if (!props.disableScrollLock) {
if (isOverflowing(container)) {
Expand Down Expand Up @@ -157,6 +174,38 @@ function handleContainer(containerInfo: Container, props: ManagedModalProps) {
);

scrollContainer.style.overflow = 'hidden';

// iOS Safari does not reliably honor `overflow: hidden` for touch scrolling,
// so the document can still scroll behind the modal — e.g. once the on-screen
// keyboard opens for an Autocomplete inside a Drawer. Pin the body with
// `position: fixed` (the robust scroll-lock technique) and restore the scroll
// position on unlock to avoid a jump to the top. Only the document scroll
// container is affected; custom containers keep the existing behavior.
const doc = ownerDocument(container);
const isDocumentScrollContainer =
scrollContainer === doc.body || scrollContainer === doc.documentElement;

if (isIOS() && isDocumentScrollContainer) {
const scrollContainerWindow = ownerWindow(container);
const scrollY = scrollContainerWindow.scrollY;

(['position', 'top', 'left', 'right', 'width'] as const).forEach((property) => {
restoreStyle.push({
value: scrollContainer.style.getPropertyValue(property),
property,
el: scrollContainer,
});
});

scrollContainer.style.position = 'fixed';
scrollContainer.style.top = `${-scrollY}px`;
scrollContainer.style.left = '0';
scrollContainer.style.right = '0';
// Preserve the layout width so content doesn't reflow while pinned.
scrollContainer.style.width = '100%';

restoreScroll = () => scrollContainerWindow.scrollTo(0, scrollY);
}
}

const restore = () => {
Expand All @@ -167,6 +216,8 @@ function handleContainer(containerInfo: Container, props: ManagedModalProps) {
el.style.removeProperty(property);
}
});
// Restore scroll position after the `position: fixed` styles are reverted.
restoreScroll?.();
};

return restore;
Expand Down
Loading