-
-
Notifications
You must be signed in to change notification settings - Fork 46
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
[useScrollLock] Avoid scrollbar layout shift issues #604
Merged
Merged
Changes from 29 commits
Commits
Show all changes
31 commits
Select commit
Hold shift + click to select a range
39e9ef3
[useLockScroll] Avoid scrollbar issues
atomiks 353ca90
window.scrollTo?. for jsdom
atomiks f55a1ea
Check for property
atomiks f9c5894
Check for native scrollTo
atomiks de27905
Handle original styles
atomiks 51fd682
Handle scrollbar-gutter: stable
atomiks 9e9c74a
Improve nested locking
atomiks 8d23647
Apply to body
atomiks 25438b6
Playground + resize + iOS fixes
atomiks 9392376
Move scrollbarWidth into function
atomiks c74dc6b
Comments
atomiks d27e797
typo
atomiks acefccd
Improve experiment
atomiks 4ccd89b
Use react-aria for iOS
atomiks 2a21a48
Simplify lockIds
atomiks e3f9bf7
Rewrite implementation
atomiks 66abfac
Remove body style
atomiks 01fec79
Remove id
atomiks bc8ecc9
Handle constant overflow
atomiks bbf5374
Handle dual scrollbars
atomiks 512c5d0
Update docs/app/experiments/scroll-lock.tsx
atomiks 88e0e4f
getComputedStyle
atomiks a7fbacf
perf
atomiks 87444f8
Remove return
atomiks 70bbdfa
Conditional fixed styles
atomiks 8c8adc2
Assign vars earlier
atomiks 80ec0c1
Add scrollable x check
atomiks 56ca8b9
Handle body overflow
atomiks 2458dc0
Handle body overflow
atomiks d8715dd
Update packages/mui-base/src/utils/useScrollLock.ts
atomiks a9d020e
Add iOS implementation
atomiks File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
'use client'; | ||
import * as React from 'react'; | ||
import { useScrollLock } from '../../../packages/mui-base/src/utils/useScrollLock'; | ||
|
||
export default function ScrollLock() { | ||
const [enabled, setEnabled] = React.useState(false); | ||
const [bodyScrollY, setBodyScrollY] = React.useState(false); | ||
const [longContent, setLongContent] = React.useState(true); | ||
|
||
useScrollLock(enabled); | ||
|
||
React.useEffect(() => { | ||
document.body.style.overflowY = bodyScrollY ? 'scroll' : ''; | ||
}, [bodyScrollY]); | ||
|
||
return ( | ||
<div> | ||
<h1>useScrollLock</h1> | ||
<p>On macOS, enable `Show scroll bars: Always` in `Appearance` Settings.</p> | ||
<div | ||
style={{ | ||
position: 'fixed', | ||
top: 15, | ||
display: 'flex', | ||
gap: 10, | ||
background: 'white', | ||
padding: 20, | ||
}} | ||
> | ||
<div> | ||
<label> | ||
<input | ||
type="checkbox" | ||
checked={enabled} | ||
onChange={(e) => setEnabled(e.target.checked)} | ||
/> | ||
Scroll lock | ||
</label> | ||
</div> | ||
<div> | ||
<label> | ||
<input | ||
type="checkbox" | ||
checked={bodyScrollY} | ||
onChange={(e) => setBodyScrollY(e.target.checked)} | ||
/> | ||
body `overflow` | ||
</label> | ||
</div> | ||
<div> | ||
<label> | ||
<input | ||
type="checkbox" | ||
checked={longContent} | ||
onChange={(e) => setLongContent(e.target.checked)} | ||
/> | ||
Long content | ||
</label> | ||
</div> | ||
</div> | ||
{[...Array(longContent ? 100 : 10)].map((_, i) => ( | ||
<p key={i}>Scroll locking text content</p> | ||
))} | ||
</div> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,75 +1,123 @@ | ||
import { useEnhancedEffect } from './useEnhancedEffect'; | ||
import { useId } from './useId'; | ||
import { isIOS } from './detectBrowser'; | ||
import { useEnhancedEffect } from './useEnhancedEffect'; | ||
|
||
let originalHtmlStyles = {}; | ||
let originalBodyStyles = {}; | ||
let preventScrollCount = 0; | ||
let restore: () => void = () => {}; | ||
|
||
function preventScrollIOS() { | ||
// To implement | ||
return () => {}; | ||
} | ||
|
||
function preventScrollStandard() { | ||
const html = document.documentElement; | ||
const body = document.body; | ||
const htmlStyle = html.style; | ||
const bodyStyle = body.style; | ||
|
||
let resizeRaf: number; | ||
let scrollX: number; | ||
let scrollY: number; | ||
|
||
function lockScroll() { | ||
const htmlComputedStyles = getComputedStyle(html); | ||
const bodyComputedStyles = getComputedStyle(body); | ||
const hasConstantOverflowY = | ||
htmlComputedStyles.overflowY === 'scroll' || bodyComputedStyles.overflowY === 'scroll'; | ||
const hasConstantOverflowX = | ||
htmlComputedStyles.overflowX === 'scroll' || bodyComputedStyles.overflowX === 'scroll'; | ||
|
||
scrollX = htmlStyle.left ? parseFloat(htmlStyle.left) : window.scrollX; | ||
scrollY = htmlStyle.top ? parseFloat(htmlStyle.top) : window.scrollY; | ||
|
||
originalHtmlStyles = { | ||
position: htmlStyle.position, | ||
top: htmlStyle.top, | ||
left: htmlStyle.left, | ||
right: htmlStyle.right, | ||
overflowX: htmlStyle.overflowX, | ||
overflowY: htmlStyle.overflowY, | ||
}; | ||
originalBodyStyles = { | ||
overflowX: bodyStyle.overflowX, | ||
overflowY: bodyStyle.overflowY, | ||
}; | ||
|
||
// Handle `scrollbar-gutter` in Chrome when there is no scrollable content. | ||
const isScrollableY = html.scrollHeight > html.clientHeight; | ||
const isScrollableX = html.scrollWidth > html.clientWidth; | ||
|
||
if (isScrollableY || isScrollableX) { | ||
Object.assign(htmlStyle, { | ||
position: 'fixed', | ||
top: `${-scrollY}px`, | ||
left: `${-scrollX}px`, | ||
right: '0', | ||
}); | ||
} | ||
|
||
Object.assign(htmlStyle, { | ||
overflowY: isScrollableY || hasConstantOverflowY ? 'scroll' : 'hidden', | ||
overflowX: isScrollableX || hasConstantOverflowX ? 'scroll' : 'hidden', | ||
}); | ||
atomiks marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
const activeLocks = new Set<string>(); | ||
// Ensure two scrollbars can't appear since `<html>` now has a forced scrollbar, but the | ||
// `<body>` may have one too. | ||
if (isScrollableY || hasConstantOverflowY) { | ||
bodyStyle.overflowY = 'hidden'; | ||
} | ||
if (isScrollableX || hasConstantOverflowX) { | ||
bodyStyle.overflowX = 'hidden'; | ||
} | ||
} | ||
|
||
function cleanup() { | ||
Object.assign(htmlStyle, originalHtmlStyles); | ||
Object.assign(bodyStyle, originalBodyStyles); | ||
|
||
if (window.scrollTo.toString().includes('[native code]')) { | ||
window.scrollTo(scrollX, scrollY); | ||
} | ||
} | ||
|
||
function handleResize() { | ||
cleanup(); | ||
cancelAnimationFrame(resizeRaf); | ||
resizeRaf = requestAnimationFrame(lockScroll); | ||
} | ||
|
||
lockScroll(); | ||
window.addEventListener('resize', handleResize); | ||
|
||
return () => { | ||
cleanup(); | ||
window.removeEventListener('resize', handleResize); | ||
}; | ||
} | ||
|
||
/** | ||
* Locks the scroll of the document when enabled. | ||
* | ||
* @param enabled - Whether to enable the scroll lock. | ||
*/ | ||
export function useScrollLock(enabled: boolean = true) { | ||
// Based on Floating UI's FloatingOverlay | ||
|
||
const lockId = useId(); | ||
useEnhancedEffect(() => { | ||
if (!enabled) { | ||
return undefined; | ||
} | ||
|
||
activeLocks.add(lockId!); | ||
|
||
const rootStyle = document.documentElement.style; | ||
// RTL <body> scrollbar | ||
const scrollbarX = | ||
Math.round(document.documentElement.getBoundingClientRect().left) + | ||
document.documentElement.scrollLeft; | ||
const paddingProp = scrollbarX ? 'paddingLeft' : 'paddingRight'; | ||
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; | ||
const scrollX = rootStyle.left ? parseFloat(rootStyle.left) : window.scrollX; | ||
const scrollY = rootStyle.top ? parseFloat(rootStyle.top) : window.scrollY; | ||
|
||
rootStyle.overflow = 'hidden'; | ||
|
||
if (scrollbarWidth) { | ||
rootStyle[paddingProp] = `${scrollbarWidth}px`; | ||
} | ||
|
||
// Only iOS doesn't respect `overflow: hidden` on document.body, and this | ||
// technique has fewer side effects. | ||
if (isIOS()) { | ||
// iOS 12 does not support `visualViewport`. | ||
const offsetLeft = window.visualViewport?.offsetLeft || 0; | ||
const offsetTop = window.visualViewport?.offsetTop || 0; | ||
|
||
Object.assign(rootStyle, { | ||
position: 'fixed', | ||
top: `${-(scrollY - Math.floor(offsetTop))}px`, | ||
left: `${-(scrollX - Math.floor(offsetLeft))}px`, | ||
right: '0', | ||
}); | ||
preventScrollCount += 1; | ||
if (preventScrollCount === 1) { | ||
restore = isIOS() ? preventScrollIOS() : preventScrollStandard(); | ||
} | ||
|
||
return () => { | ||
activeLocks.delete(lockId!); | ||
|
||
if (activeLocks.size === 0) { | ||
Object.assign(rootStyle, { | ||
overflow: '', | ||
[paddingProp]: '', | ||
}); | ||
|
||
if (isIOS()) { | ||
Object.assign(rootStyle, { | ||
position: '', | ||
top: '', | ||
left: '', | ||
right: '', | ||
}); | ||
window.scrollTo(scrollX, scrollY); | ||
} | ||
preventScrollCount -= 1; | ||
if (preventScrollCount === 0) { | ||
restore(); | ||
} | ||
}; | ||
}, [lockId, enabled]); | ||
}, [enabled]); | ||
} |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
As far as I know, this should only be a fallback. It breaks supports for iframes and popups, we fixed a bunch of those through the years, e.g. mui/material-ui#18701, mui/material-ui#10328. Example of use case: https://www.openfin.co/.
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.
That doesn't apply here since it's locking document level of the current script. There's no element to grab an owner of.
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.
This is the point I get to, I don't see how this API can work:
base-ui/packages/mui-base/src/Dialog/Popup/useDialogPopup.tsx
Line 49 in 1ab27b0
I think we should provide to useScrollLock.ts enough context to know which document it needs to scroll lock. Issue created #629.