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..e96dd81bda4ff 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,129 @@ 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(() => { + Reflect.deleteProperty(document.documentElement, 'clientWidth'); + document.documentElement.style.removeProperty('scrollbar-gutter'); + document.body.style.removeProperty('overflow'); + }); + + const renderModal = () => + render( + , + ); + + 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 an inline auto gutter after the lock replaces it', () => { + setScrollbarWidth(15); + document.documentElement.style.scrollbarGutter = 'auto'; + 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('auto'); + }); + + it.each(['stable', 'stable both-edges'])('preserves an inline %s gutter during and after the lock', gutter => { + setScrollbarWidth(15); + document.documentElement.style.scrollbarGutter = gutter; + const result = renderModal(); + + fireEvent.click(result.getByRole('button', { name: 'Open dialog' })); + + expect(document.documentElement.style.scrollbarGutter).toBe(gutter); + + fireEvent.click(result.getByRole('button', { name: 'Close dialog' })); + + expect(document.documentElement.style.scrollbarGutter).toBe(gutter); + }); + + it('preserves a stable both-edges gutter supplied by a stylesheet', () => { + setScrollbarWidth(15); + const stylesheet = document.createElement('style'); + stylesheet.textContent = 'html { scrollbar-gutter: stable both-edges; }'; + document.head.appendChild(stylesheet); + try { + const result = renderModal(); + expect(window.getComputedStyle(document.documentElement).scrollbarGutter).toBe('stable both-edges'); + + fireEvent.click(result.getByRole('button', { name: 'Open dialog' })); + + expect(document.documentElement.style.scrollbarGutter).toBe(''); + expect(window.getComputedStyle(document.documentElement).scrollbarGutter).toBe('stable both-edges'); + + fireEvent.click(result.getByRole('button', { name: 'Close dialog' })); + + expect(document.documentElement.style.scrollbarGutter).toBe(''); + expect(window.getComputedStyle(document.documentElement).scrollbarGutter).toBe('stable both-edges'); + } finally { + stylesheet.remove(); + } + }); + + it('leaves a non-modal dialog out of the lock entirely', () => { + setScrollbarWidth(15); + const result = render( + , + ); + + 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(