Skip to content
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 31 commits into from
Sep 17, 2024
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
39e9ef3
[useLockScroll] Avoid scrollbar issues
atomiks Sep 12, 2024
353ca90
window.scrollTo?. for jsdom
atomiks Sep 12, 2024
f55a1ea
Check for property
atomiks Sep 12, 2024
f9c5894
Check for native scrollTo
atomiks Sep 12, 2024
de27905
Handle original styles
atomiks Sep 12, 2024
51fd682
Handle scrollbar-gutter: stable
atomiks Sep 12, 2024
9e9c74a
Improve nested locking
atomiks Sep 12, 2024
8d23647
Apply to body
atomiks Sep 12, 2024
25438b6
Playground + resize + iOS fixes
atomiks Sep 13, 2024
9392376
Move scrollbarWidth into function
atomiks Sep 13, 2024
c74dc6b
Comments
atomiks Sep 13, 2024
d27e797
typo
atomiks Sep 13, 2024
acefccd
Improve experiment
atomiks Sep 13, 2024
4ccd89b
Use react-aria for iOS
atomiks Sep 13, 2024
2a21a48
Simplify lockIds
atomiks Sep 13, 2024
e3f9bf7
Rewrite implementation
atomiks Sep 16, 2024
66abfac
Remove body style
atomiks Sep 16, 2024
01fec79
Remove id
atomiks Sep 16, 2024
bc8ecc9
Handle constant overflow
atomiks Sep 16, 2024
bbf5374
Handle dual scrollbars
atomiks Sep 16, 2024
512c5d0
Update docs/app/experiments/scroll-lock.tsx
atomiks Sep 16, 2024
88e0e4f
getComputedStyle
atomiks Sep 16, 2024
a7fbacf
perf
atomiks Sep 16, 2024
87444f8
Remove return
atomiks Sep 16, 2024
70bbdfa
Conditional fixed styles
atomiks Sep 16, 2024
8c8adc2
Assign vars earlier
atomiks Sep 16, 2024
80ec0c1
Add scrollable x check
atomiks Sep 16, 2024
56ca8b9
Handle body overflow
atomiks Sep 16, 2024
2458dc0
Handle body overflow
atomiks Sep 16, 2024
d8715dd
Update packages/mui-base/src/utils/useScrollLock.ts
atomiks Sep 16, 2024
a9d020e
Add iOS implementation
atomiks Sep 17, 2024
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
66 changes: 66 additions & 0 deletions docs/app/experiments/scroll-lock.tsx
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>
);
}
162 changes: 107 additions & 55 deletions packages/mui-base/src/utils/useScrollLock.ts
Original file line number Diff line number Diff line change
@@ -1,75 +1,127 @@
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;
Comment on lines +43 to +44
Copy link
Member

@oliviertassinari oliviertassinari Sep 16, 2024

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/.

Copy link
Contributor Author

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.

Copy link
Member

@oliviertassinari oliviertassinari Sep 17, 2024

Choose a reason for hiding this comment

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

There's no element to grab an owner of.

This is the point I get to, I don't see how this API can work:

useScrollLock(modal && mounted);

I think we should provide to useScrollLock.ts enough context to know which document it needs to scroll lock. Issue created #629.

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,
};

const isScrollableY = html.scrollHeight > html.clientHeight;
const isScrollableX = html.scrollWidth > html.clientWidth;

// Handle `scrollbar-gutter` in Chrome when there is no scrollable content.
const hasScrollbarGutterStable = htmlComputedStyles.scrollbarGutter?.includes('stable');

if (!hasScrollbarGutterStable) {
Object.assign(htmlStyle, {
position: 'fixed',
top: `${-scrollY}px`,
left: `${-scrollX}px`,
right: '0',
});
}

const activeLocks = new Set<string>();
Object.assign(htmlStyle, {
overflowY:
!hasScrollbarGutterStable && (isScrollableY || hasConstantOverflowY) ? 'scroll' : 'hidden',
overflowX:
!hasScrollbarGutterStable && (isScrollableX || hasConstantOverflowX) ? 'scroll' : 'hidden',
});

// 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]);
}