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 9 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
57 changes: 57 additions & 0 deletions docs/app/experiments/scroll-lock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
'use client';

atomiks marked this conversation as resolved.
Show resolved Hide resolved
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 ? 'auto' : '';
}, [bodyScrollY]);

return (
<div>
<h2>Enable Show scroll bar: Always</h2>
<div style={{ display: 'flex', gap: 10 }}>
<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(() => (
<p>Scroll locking text content</p>
))}
</div>
);
}
105 changes: 67 additions & 38 deletions packages/mui-base/src/utils/useScrollLock.ts
Original file line number Diff line number Diff line change
@@ -1,75 +1,104 @@
import { useEnhancedEffect } from './useEnhancedEffect';
import { useId } from './useId';
import { isIOS } from './detectBrowser';

const activeLocks = new Set<string>();
let originalStyles = {};
let originalBodyOverflow = '';

/**
* 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) {
if (!enabled || !lockId) {
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 html = document.documentElement;
const rootStyle = html.style;
const bodyStyle = document.body.style;
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';
let resizeRaf: number;
let scrollX: number;
let scrollY: number;

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`.
function lockScroll() {
const offsetLeft = window.visualViewport?.offsetLeft || 0;
const offsetTop = window.visualViewport?.offsetTop || 0;
scrollX = rootStyle.left ? parseFloat(rootStyle.left) : window.scrollX;
scrollY = rootStyle.top ? parseFloat(rootStyle.top) : window.scrollY;

activeLocks.add(lockId!);

// We don't need to lock the scroll if there's already an active lock. However, it's possible
// that the one that originally locked it doesn't get cleaned up last. In that case, one of the
// newer locks needs to perform the style and scroll restoration.
if (activeLocks.size > 1) {
return cleanup;
}

originalStyles = {
position: rootStyle.position,
top: rootStyle.top,
left: rootStyle.left,
right: rootStyle.right,
...(scrollbarWidth && {
overflowX: rootStyle.overflowX,
overflowY: rootStyle.overflowY,
}),
};
originalBodyOverflow = bodyStyle.overflow;

bodyStyle.overflow = 'hidden';

Object.assign(rootStyle, {
position: 'fixed',
position: html.scrollHeight > html.clientHeight ? 'fixed' : '',
top: `${-(scrollY - Math.floor(offsetTop))}px`,
left: `${-(scrollX - Math.floor(offsetLeft))}px`,
right: '0',
...(scrollbarWidth && {
overflowY: html.scrollHeight > html.clientHeight ? 'scroll' : 'hidden',
overflowX: html.scrollWidth > html.clientWidth ? 'scroll' : 'hidden',
}),
vladmoroz marked this conversation as resolved.
Show resolved Hide resolved
});

return undefined;
}

return () => {
activeLocks.delete(lockId!);
function cleanup() {
if (!lockId) {
return;
}

activeLocks.delete(lockId);

if (activeLocks.size === 0) {
Object.assign(rootStyle, {
overflow: '',
[paddingProp]: '',
});

if (isIOS()) {
Object.assign(rootStyle, {
position: '',
top: '',
left: '',
right: '',
});
Object.assign(rootStyle, originalStyles);
bodyStyle.overflow = originalBodyOverflow;

if (window.scrollTo.toString().includes('[native code]')) {
window.scrollTo(scrollX, scrollY);
}
}
}

const handleResize = () => {
cleanup();
cancelAnimationFrame(resizeRaf);
resizeRaf = requestAnimationFrame(lockScroll);
};

lockScroll();
window.addEventListener('resize', handleResize);

return () => {
cleanup();
window.removeEventListener('resize', handleResize);
};
}, [lockId, enabled]);
}