-
Notifications
You must be signed in to change notification settings - Fork 2.9k
fix(react-headless-components-preview): reserve the scrollbar gutter while a dialog locks scroll #36666
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
fix(react-headless-components-preview): reserve the scrollbar gutter while a dialog locks scroll #36666
Changes from all commits
26361fb
7d77229
24d31e5
bffa278
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
|---|---|---|
| @@ -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: visible clip` 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. | ||
| */ | ||
|
|
@@ -19,18 +25,29 @@ 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; | ||
| const scrollbarGutter = targetDocument.defaultView?.getComputedStyle(documentElement).scrollbarGutter; | ||
| const hasStableGutter = scrollbarGutter?.split(/\s+/).includes('stable'); | ||
|
|
||
| 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 && !hasStableGutter) { | ||
| documentElement.style.scrollbarGutter = 'stable'; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed: the lock now reads the root's computed The tests use valid CSS values and assert both phases. Temporarily restoring the unconditional fallback fails the inline |
||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 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); | ||
|
|
@@ -44,5 +61,6 @@ export function unlockDocumentScroll(targetDocument: Document): void { | |
| } | ||
|
|
||
| targetDocument.body.style.overflow = state.previousBodyOverflow; | ||
| targetDocument.documentElement.style.scrollbarGutter = state.previousScrollbarGutter; | ||
| scrollLockStateByDocument.delete(targetDocument); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
could we use
useApplyScrollbarWidthoruseScrollbarWidthhooks from@fluentui/react-utilities?There was a problem hiding this comment.
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:
scrollbar-gutter: stableon<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/useApplyScrollbarWidthmeasure 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.useApplyScrollbarWidthis a mount-only ref callback (it writes${width}pxon 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 fordocument.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.