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
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>).clientWidth;
document.documentElement.style.removeProperty('scrollbar-gutter');
document.body.style.removeProperty('overflow');
});

const renderModal = () =>
render(
<Dialog unmountOnClose={false}>
<DialogTrigger>
<button>Open dialog</button>
</DialogTrigger>
<DialogSurface>
<DialogTitle>Dialog title</DialogTitle>
<DialogActions>
<DialogTrigger>
<button>Close dialog</button>
</DialogTrigger>
</DialogActions>
</DialogSurface>
</Dialog>,
);

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 <html>, not <body>: 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(
<Dialog defaultOpen modalType="non-modal">
<DialogSurface>
<DialogTitle>Non-modal title</DialogTitle>
</DialogSurface>
</Dialog>,
);

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(
<Dialog unmountOnClose={false}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
type ScrollLockState = {
lockCount: number;
previousBodyOverflow: string;
previousScrollbarGutter: string;
};

const scrollLockStateByDocument = new WeakMap<Document, ScrollLockState>();

/**
* Prevents background scrolling while a modal/alert dialog is open by applying
* `overflow: hidden` to `<body>`. The `<html>` element is intentionally left
* untouched so host-application styles on the document element are preserved.
* `overflow: hidden` to `<body>`, 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 `<html>`: `scrollbar-gutter` does not propagate
* from `<body>` to the viewport the way `overflow` does, so spelling it on `<body>`
* 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.
*/
Expand All @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we use useApplyScrollbarWidth or useScrollbarWidth hooks from @fluentui/react-utilities?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked at both hooks before settling on this shape — they solve a different half of the problem, so I kept the native gutter, but happy to switch if you prefer the tradeoff:

  • This fix reserves the gutter natively (scrollbar-gutter: stable on <html>) instead of compensating with a measured pixel width. Its only measurement, innerWidth - documentElement.clientWidth, asks whether this page, right now has a layout-consuming scrollbar.
  • useScrollbarWidth/useApplyScrollbarWidth measure a probe element (measureScrollbarWidth), which reports the UA's classic scrollbar width even when the page itself doesn't scroll — so on an unscrollable page a width/padding compensation would introduce the very shift this PR removes, and we'd still need the current-viewport check on top.
  • useApplyScrollbarWidth is a mount-only ref callback (it writes ${width}px on attach and early-returns on detach), so it can't express the lock's restore semantics — previous inline values plus the refcount for nested modals — and there's no natural ref to hand it for document.documentElement.

Adopting them would replace only the measurement while keeping all the lock/restore code, and would trade native reservation for pixel compensation. If you'd rather standardize on the shared utility regardless, I'm glad to rework it that way.


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 `<body>` 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
* `<body>` element and the reserved scrollbar gutter on `<html>` 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);
Expand All @@ -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);
}