diff --git a/packages-internal/core-docs/src/AppLayout/components/AppSearch.tsx b/packages-internal/core-docs/src/AppLayout/components/AppSearch.tsx index 2efa9851bc33f9..d1429868ec011a 100644 --- a/packages-internal/core-docs/src/AppLayout/components/AppSearch.tsx +++ b/packages-internal/core-docs/src/AppLayout/components/AppSearch.tsx @@ -453,6 +453,13 @@ export function AppSearch(props: AppSearchProps) { }, }, body: { + // DocSearch unconditionally sets `margin-right: ` on the body to + // compensate for the scrollbar it expects to hide. It only hides the one on , + // while the docs keep the scrollbar on (overflow-y: scroll), so nothing + // disappears and the compensation shifts the whole page to the left instead. + '&.DocSearch--active': { + marginRight: '0 !important', + }, '.DocSearch-Container': { transition: `opacity ${FADE_DURATION}ms`, opacity: 0, diff --git a/packages-internal/core-docs/src/branding/brandingTheme.ts b/packages-internal/core-docs/src/branding/brandingTheme.ts index 2015ff9a874536..0f5c924ea3b5a1 100644 --- a/packages-internal/core-docs/src/branding/brandingTheme.ts +++ b/packages-internal/core-docs/src/branding/brandingTheme.ts @@ -1555,9 +1555,7 @@ export function getThemedComponents(): ThemeOptions { styleOverrides: { html: { overflowY: 'scroll', - // TODO add support for it, - // https://github.com/mui/material-ui/issues/40748 - // scrollbarGutter: 'stable', + scrollbarGutter: 'stable', }, }, }, diff --git a/packages/mui-material/src/Modal/ModalManager.test.ts b/packages/mui-material/src/Modal/ModalManager.test.ts index a98f2c38cfeedd..f13d2352b75dd3 100644 --- a/packages/mui-material/src/Modal/ModalManager.test.ts +++ b/packages/mui-material/src/Modal/ModalManager.test.ts @@ -1,5 +1,6 @@ import { describe, beforeAll, afterAll, it, expect, beforeEach, afterEach } from 'vitest'; import getScrollbarSize from '@mui/utils/getScrollbarSize'; +import { isJsdom } from '@mui/internal-test-utils'; import { ModalManager } from './ModalManager'; interface Modal { @@ -125,6 +126,7 @@ describe('ModalManager', () => { afterEach(() => { document.body.removeChild(fixedNode); + container1.style.removeProperty('scrollbar-gutter'); window.innerWidth -= 1; }); @@ -143,6 +145,30 @@ describe('ModalManager', () => { expect(fixedNode.style.paddingRight).to.equal('14.4px'); }); + // A stable scrollbar gutter keeps the scrollbar space reserved while the scroll is locked, + // so compensating for it would shift the content instead of keeping it in place. + it.skipIf(isJsdom())( + 'should not compensate the scrollbar when the scroll container has a stable gutter', + () => { + // This test is useless without support. + expect(CSS.supports('scrollbar-gutter', 'stable')).to.equal(true); + + fixedNode.style.paddingRight = '14.4px'; + container1.style.setProperty('scrollbar-gutter', 'stable'); + + const modal = getDummyModal(); + modalManager.add(modal, container1); + modalManager.mount(modal, {}); + expect(container1.style.overflow).to.equal('hidden'); + expect(container1.style.paddingRight).to.equal('20px'); + expect(fixedNode.style.paddingRight).to.equal('14.4px'); + modalManager.remove(modal); + expect(container1.style.overflow).to.equal(''); + expect(container1.style.paddingRight).to.equal('20px'); + expect(fixedNode.style.paddingRight).to.equal('14.4px'); + }, + ); + it('should disable the scroll even when not overflowing', () => { // simulate non-overflowing container const container2 = document.createElement('div'); diff --git a/packages/mui-material/src/Modal/ModalManager.ts b/packages/mui-material/src/Modal/ModalManager.ts index 54a976e7bd4cf8..b86c0606b00da8 100644 --- a/packages/mui-material/src/Modal/ModalManager.ts +++ b/packages/mui-material/src/Modal/ModalManager.ts @@ -72,6 +72,25 @@ function ariaHiddenSiblings( }); } +// A stable scrollbar gutter keeps the scrollbar space reserved while the scroll is locked, +// so there is no layout shift to compensate for. +// Reading the computed value doubles as a feature detection: browsers that don't support +// scrollbar-gutter resolve it to an empty string. +function hasStableScrollbarGutter(scrollContainer: HTMLElement): boolean { + const win = ownerWindow(scrollContainer); + const doc = ownerDocument(scrollContainer); + // scrollbar-gutter isn't inherited, but the one set on the root element propagates to the + // viewport, so check as well when locking the document scroller. + const elements = + scrollContainer === doc.body || scrollContainer === doc.documentElement + ? [scrollContainer, doc.documentElement] + : [scrollContainer]; + + return elements.some((element) => + win.getComputedStyle(element).getPropertyValue('scrollbar-gutter').includes('stable'), + ); +} + function handleContainer(containerInfo: Container, props: ManagedModalProps) { const restoreStyle: Array<{ /** @@ -100,7 +119,7 @@ function handleContainer(containerInfo: Container, props: ManagedModalProps) { : container; } - if (isOverflowing(scrollContainer)) { + if (isOverflowing(scrollContainer) && !hasStableScrollbarGutter(scrollContainer)) { // Compute the size before applying overflow hidden to avoid any scroll jumps. const scrollbarSize = getScrollbarSize(ownerWindow(scrollContainer));