diff --git a/change/@fluentui-react-headless-components-preview-4e33a94a-d467-47f7-87d9-2714995d6202.json b/change/@fluentui-react-headless-components-preview-4e33a94a-d467-47f7-87d9-2714995d6202.json new file mode 100644 index 0000000000000..156d995471f91 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-4e33a94a-d467-47f7-87d9-2714995d6202.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: reserve the scrollbar gutter while a modal dialog locks document scroll, so opening a dialog no longer shifts the page", + "packageName": "@fluentui/react-headless-components-preview", + "email": "array.knight@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.test.tsx index bd0fcf8fe3c25..ee6232e8a777a 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.test.tsx @@ -106,6 +106,92 @@ describe('Dialog', () => { expect(dialog).not.toHaveAttribute('aria-labelledby'); }); + describe('scroll lock', () => { + // jsdom reports clientWidth 0, which would read as a scrollbar on every page. + const setScrollbarWidth = (width: number) => + Object.defineProperty(document.documentElement, 'clientWidth', { + configurable: true, + value: window.innerWidth - width, + }); + + afterEach(() => { + delete (document.documentElement as Partial).clientWidth; + document.documentElement.style.removeProperty('scrollbar-gutter'); + document.body.style.removeProperty('overflow'); + }); + + const renderModal = () => + render( + + + + + + Dialog title + + + + + + + , + ); + + it('reserves the scrollbar gutter while a modal holds the lock', () => { + setScrollbarWidth(15); + const result = renderModal(); + + fireEvent.click(result.getByRole('button', { name: 'Open dialog' })); + + expect(document.body.style.overflow).toBe('visible clip'); + // On , not : scrollbar-gutter does not propagate to the viewport. + expect(document.documentElement.style.scrollbarGutter).toBe('stable'); + + fireEvent.click(result.getByRole('button', { name: 'Close dialog' })); + + expect(document.documentElement.style.scrollbarGutter).toBe(''); + }); + + it('reserves nothing when the scrollbar takes no layout width', () => { + setScrollbarWidth(0); + const result = renderModal(); + + fireEvent.click(result.getByRole('button', { name: 'Open dialog' })); + + expect(document.body.style.overflow).toBe('visible clip'); + expect(document.documentElement.style.scrollbarGutter).toBe(''); + }); + + it('restores a gutter the host application had already set', () => { + setScrollbarWidth(15); + document.documentElement.style.scrollbarGutter = 'both-edges'; + const result = renderModal(); + + fireEvent.click(result.getByRole('button', { name: 'Open dialog' })); + + expect(document.documentElement.style.scrollbarGutter).toBe('stable'); + + fireEvent.click(result.getByRole('button', { name: 'Close dialog' })); + + expect(document.documentElement.style.scrollbarGutter).toBe('both-edges'); + }); + + it('leaves a non-modal dialog out of the lock entirely', () => { + setScrollbarWidth(15); + const result = render( + + + Non-modal title + + , + ); + + expect(result.container.querySelector('dialog')).toHaveAttribute('data-open'); + expect(document.body.style.overflow).toBe(''); + expect(document.documentElement.style.scrollbarGutter).toBe(''); + }); + }); + it('keeps dialog mounted after close when unmountOnClose is false', () => { const result = render( diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/utils/scroll.ts b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/utils/scroll.ts index c2a583630357b..da909f82ed1a8 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/utils/scroll.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/utils/scroll.ts @@ -1,14 +1,20 @@ type ScrollLockState = { lockCount: number; previousBodyOverflow: string; + previousScrollbarGutter: string; }; const scrollLockStateByDocument = new WeakMap(); /** * Prevents background scrolling while a modal/alert dialog is open by applying - * `overflow: hidden` to ``. The `` element is intentionally left - * untouched so host-application styles on the document element are preserved. + * `overflow: hidden` to ``, and reserves the space the page scrollbar was + * occupying so nothing on the page moves sideways as it disappears. + * + * The gutter has to be reserved on ``: `scrollbar-gutter` does not propagate + * from `` to the viewport the way `overflow` does, so spelling it on `` + * reserves nothing. It is written only when the scrollbar actually takes layout + * width, because `stable` otherwise reserves a gutter the page never had. * * Nested modal dialogs share a single lock via a reference count. */ @@ -19,18 +25,27 @@ export function lockDocumentScroll(targetDocument: Document): void { return; } + const { body, documentElement } = targetDocument; + // Read the scrollbar's layout width before the lock takes it away. Overlay + // scrollbars and unscrollable pages both measure 0, and both want no gutter. + const scrollbarWidth = (targetDocument.defaultView?.innerWidth ?? 0) - documentElement.clientWidth; + scrollLockStateByDocument.set(targetDocument, { lockCount: 1, - previousBodyOverflow: targetDocument.body.style.overflow, + previousBodyOverflow: body.style.overflow, + previousScrollbarGutter: documentElement.style.scrollbarGutter, }); - targetDocument.body.style.overflow = 'visible clip'; + body.style.overflow = 'visible clip'; + if (scrollbarWidth > 0) { + documentElement.style.scrollbarGutter = 'stable'; + } } /** - * Restores the document's scroll behavior by reverting the `overflow` style - * on the `` element to its previous value. This function is typically - * called when a modal/alert dialog is closed. + * Restores the document's scroll behavior by reverting the `overflow` style on the + * `` element and the reserved scrollbar gutter on `` to their previous + * values. This function is typically called when a modal/alert dialog is closed. */ export function unlockDocumentScroll(targetDocument: Document): void { const state = scrollLockStateByDocument.get(targetDocument); @@ -44,5 +59,6 @@ export function unlockDocumentScroll(targetDocument: Document): void { } targetDocument.body.style.overflow = state.previousBodyOverflow; + targetDocument.documentElement.style.scrollbarGutter = state.previousScrollbarGutter; scrollLockStateByDocument.delete(targetDocument); }