From 39e9ef3ada167bb8acef44064c2196ccf2d69138 Mon Sep 17 00:00:00 2001 From: atomiks Date: Thu, 12 Sep 2024 16:13:33 +1000 Subject: [PATCH 01/31] [useLockScroll] Avoid scrollbar issues --- packages/mui-base/src/utils/useScrollLock.ts | 64 +++++++------------- 1 file changed, 21 insertions(+), 43 deletions(-) diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index 674e27eba0..14f1129036 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -1,6 +1,5 @@ import { useEnhancedEffect } from './useEnhancedEffect'; import { useId } from './useId'; -import { isIOS } from './detectBrowser'; const activeLocks = new Set(); @@ -10,9 +9,8 @@ const activeLocks = new Set(); * @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; @@ -20,55 +18,35 @@ export function useScrollLock(enabled: boolean = true) { activeLocks.add(lockId!); - const rootStyle = document.documentElement.style; - // RTL 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 html = document.documentElement; + const rootStyle = html.style; 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', - }); - } + 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', + overflowY: html.scrollHeight > html.clientHeight ? 'scroll' : 'hidden', + overflowX: html.scrollWidth > html.clientWidth ? 'scroll' : 'hidden', + }); return () => { activeLocks.delete(lockId!); if (activeLocks.size === 0) { Object.assign(rootStyle, { - overflow: '', - [paddingProp]: '', + position: '', + top: '', + left: '', + right: '', + overflowX: '', + overflowY: '', }); - - if (isIOS()) { - Object.assign(rootStyle, { - position: '', - top: '', - left: '', - right: '', - }); - window.scrollTo(scrollX, scrollY); - } + window.scrollTo(scrollX, scrollY); } }; }, [lockId, enabled]); From 353ca90b03626ced6191eca986e5d61715360e94 Mon Sep 17 00:00:00 2001 From: atomiks Date: Thu, 12 Sep 2024 17:41:38 +1000 Subject: [PATCH 02/31] window.scrollTo?. for jsdom --- packages/mui-base/src/utils/useScrollLock.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index 14f1129036..4010db17c6 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -46,7 +46,7 @@ export function useScrollLock(enabled: boolean = true) { overflowX: '', overflowY: '', }); - window.scrollTo(scrollX, scrollY); + window.scrollTo?.(scrollX, scrollY); } }; }, [lockId, enabled]); From f55a1ea03b4c70ec6005c09485feb00370edfcde Mon Sep 17 00:00:00 2001 From: atomiks Date: Thu, 12 Sep 2024 17:53:41 +1000 Subject: [PATCH 03/31] Check for property --- packages/mui-base/src/utils/useScrollLock.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index 4010db17c6..ab23a19b6a 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -46,7 +46,10 @@ export function useScrollLock(enabled: boolean = true) { overflowX: '', overflowY: '', }); - window.scrollTo?.(scrollX, scrollY); + + if ('scrollTo' in window) { + window.scrollTo(scrollX, scrollY); + } } }; }, [lockId, enabled]); From f9c5894a6b4f4d398bda452bdc482703b13295b8 Mon Sep 17 00:00:00 2001 From: atomiks Date: Thu, 12 Sep 2024 18:07:33 +1000 Subject: [PATCH 04/31] Check for native scrollTo --- packages/mui-base/src/utils/useScrollLock.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index ab23a19b6a..c46692d3dc 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -47,7 +47,7 @@ export function useScrollLock(enabled: boolean = true) { overflowY: '', }); - if ('scrollTo' in window) { + if (window.scrollTo.toString().includes('[native code]')) { window.scrollTo(scrollX, scrollY); } } From de27905f87b8858b624433225ff1766f6da709b3 Mon Sep 17 00:00:00 2001 From: atomiks Date: Thu, 12 Sep 2024 19:47:29 +1000 Subject: [PATCH 05/31] Handle original styles --- packages/mui-base/src/utils/useScrollLock.ts | 24 +++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index c46692d3dc..3169ce298f 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -12,11 +12,11 @@ export function useScrollLock(enabled: boolean = true) { const lockId = useId(); useEnhancedEffect(() => { - if (!enabled) { + if (!enabled || !lockId || activeLocks.size > 0) { return undefined; } - activeLocks.add(lockId!); + activeLocks.add(lockId); const html = document.documentElement; const rootStyle = html.style; @@ -25,6 +25,15 @@ export function useScrollLock(enabled: boolean = true) { const offsetLeft = window.visualViewport?.offsetLeft || 0; const offsetTop = window.visualViewport?.offsetTop || 0; + const originalStyles = { + position: rootStyle.position, + top: rootStyle.top, + left: rootStyle.left, + right: rootStyle.right, + overflowX: rootStyle.overflowX, + overflowY: rootStyle.overflowY, + }; + Object.assign(rootStyle, { position: 'fixed', top: `${-(scrollY - Math.floor(offsetTop))}px`, @@ -35,17 +44,10 @@ export function useScrollLock(enabled: boolean = true) { }); return () => { - activeLocks.delete(lockId!); + activeLocks.delete(lockId); if (activeLocks.size === 0) { - Object.assign(rootStyle, { - position: '', - top: '', - left: '', - right: '', - overflowX: '', - overflowY: '', - }); + Object.assign(rootStyle, originalStyles); if (window.scrollTo.toString().includes('[native code]')) { window.scrollTo(scrollX, scrollY); From 51fd6823679735a6df62d45109a55e3ed38a07ce Mon Sep 17 00:00:00 2001 From: atomiks Date: Thu, 12 Sep 2024 19:48:57 +1000 Subject: [PATCH 06/31] Handle scrollbar-gutter: stable --- packages/mui-base/src/utils/useScrollLock.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index 3169ce298f..4710fe5fb1 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -35,7 +35,7 @@ export function useScrollLock(enabled: boolean = true) { }; 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', From 9e9c74af9bfd83d006f3b5427999b4cc6d2e4f75 Mon Sep 17 00:00:00 2001 From: atomiks Date: Thu, 12 Sep 2024 20:10:17 +1000 Subject: [PATCH 07/31] Improve nested locking --- packages/mui-base/src/utils/useScrollLock.ts | 44 +++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index 4710fe5fb1..d0a36e569c 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -2,6 +2,7 @@ import { useEnhancedEffect } from './useEnhancedEffect'; import { useId } from './useId'; const activeLocks = new Set(); +let originalStyles = {}; /** * Locks the scroll of the document when enabled. @@ -12,12 +13,10 @@ export function useScrollLock(enabled: boolean = true) { const lockId = useId(); useEnhancedEffect(() => { - if (!enabled || !lockId || activeLocks.size > 0) { + if (!enabled || !lockId) { return undefined; } - activeLocks.add(lockId); - const html = document.documentElement; const rootStyle = html.style; const scrollX = rootStyle.left ? parseFloat(rootStyle.left) : window.scrollX; @@ -25,7 +24,32 @@ export function useScrollLock(enabled: boolean = true) { const offsetLeft = window.visualViewport?.offsetLeft || 0; const offsetTop = window.visualViewport?.offsetTop || 0; - const originalStyles = { + function cleanup() { + if (!lockId) { + return; + } + + activeLocks.delete(lockId); + + if (activeLocks.size === 0) { + Object.assign(rootStyle, originalStyles); + + if (window.scrollTo.toString().includes('[native code]')) { + window.scrollTo(scrollX, 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, @@ -43,16 +67,6 @@ export function useScrollLock(enabled: boolean = true) { overflowX: html.scrollWidth > html.clientWidth ? 'scroll' : 'hidden', }); - return () => { - activeLocks.delete(lockId); - - if (activeLocks.size === 0) { - Object.assign(rootStyle, originalStyles); - - if (window.scrollTo.toString().includes('[native code]')) { - window.scrollTo(scrollX, scrollY); - } - } - }; + return cleanup; }, [lockId, enabled]); } From 8d23647a96bf7352c4025f6da990145efaec8585 Mon Sep 17 00:00:00 2001 From: atomiks Date: Thu, 12 Sep 2024 23:21:31 +1000 Subject: [PATCH 08/31] Apply to body When the body has an overflow-y: scroll style, layout shift would still occur --- packages/mui-base/src/utils/useScrollLock.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index d0a36e569c..4903f882f9 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -18,7 +18,9 @@ export function useScrollLock(enabled: boolean = true) { } const html = document.documentElement; - const rootStyle = html.style; + const body = document.body; + const rootStyle = body.style; + const scrollX = rootStyle.left ? parseFloat(rootStyle.left) : window.scrollX; const scrollY = rootStyle.top ? parseFloat(rootStyle.top) : window.scrollY; const offsetLeft = window.visualViewport?.offsetLeft || 0; From 25438b614ed058028c1d8afb8fb04869d21e6336 Mon Sep 17 00:00:00 2001 From: atomiks Date: Fri, 13 Sep 2024 15:22:12 +1000 Subject: [PATCH 09/31] Playground + resize + iOS fixes --- docs/app/experiments/scroll-lock.tsx | 57 ++++++++++++ packages/mui-base/src/utils/useScrollLock.ts | 92 +++++++++++++------- 2 files changed, 118 insertions(+), 31 deletions(-) create mode 100644 docs/app/experiments/scroll-lock.tsx diff --git a/docs/app/experiments/scroll-lock.tsx b/docs/app/experiments/scroll-lock.tsx new file mode 100644 index 0000000000..a4e7c774da --- /dev/null +++ b/docs/app/experiments/scroll-lock.tsx @@ -0,0 +1,57 @@ +'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 ? 'auto' : ''; + }, [bodyScrollY]); + + return ( +
+

Enable Show scroll bar: Always

+
+
+ +
+
+ +
+
+ +
+
+ {[...Array(longContent ? 100 : 10)].map(() => ( +

Scroll locking text content

+ ))} +
+ ); +} diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index 4903f882f9..df6a3a9f63 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -3,6 +3,7 @@ import { useId } from './useId'; const activeLocks = new Set(); let originalStyles = {}; +let originalBodyOverflow = ''; /** * Locks the scroll of the document when enabled. @@ -18,13 +19,56 @@ export function useScrollLock(enabled: boolean = true) { } const html = document.documentElement; - const body = document.body; - const rootStyle = body.style; + 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; - const offsetLeft = window.visualViewport?.offsetLeft || 0; - const offsetTop = window.visualViewport?.offsetTop || 0; + let resizeRaf: number; + let scrollX: number; + let scrollY: number; + + 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: 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', + }), + }); + + return undefined; + } function cleanup() { if (!lockId) { @@ -35,6 +79,7 @@ export function useScrollLock(enabled: boolean = true) { if (activeLocks.size === 0) { Object.assign(rootStyle, originalStyles); + bodyStyle.overflow = originalBodyOverflow; if (window.scrollTo.toString().includes('[native code]')) { window.scrollTo(scrollX, scrollY); @@ -42,33 +87,18 @@ export function useScrollLock(enabled: boolean = true) { } } - 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, - overflowX: rootStyle.overflowX, - overflowY: rootStyle.overflowY, + const handleResize = () => { + cleanup(); + cancelAnimationFrame(resizeRaf); + resizeRaf = requestAnimationFrame(lockScroll); }; - Object.assign(rootStyle, { - position: html.scrollHeight > html.clientHeight ? 'fixed' : '', - top: `${-(scrollY - Math.floor(offsetTop))}px`, - left: `${-(scrollX - Math.floor(offsetLeft))}px`, - right: '0', - overflowY: html.scrollHeight > html.clientHeight ? 'scroll' : 'hidden', - overflowX: html.scrollWidth > html.clientWidth ? 'scroll' : 'hidden', - }); + lockScroll(); + window.addEventListener('resize', handleResize); - return cleanup; + return () => { + cleanup(); + window.removeEventListener('resize', handleResize); + }; }, [lockId, enabled]); } From 9392376097a11ef2b8465406754ef9cd234a8396 Mon Sep 17 00:00:00 2001 From: atomiks Date: Fri, 13 Sep 2024 19:02:49 +1000 Subject: [PATCH 10/31] Move scrollbarWidth into function --- packages/mui-base/src/utils/useScrollLock.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index df6a3a9f63..1502901cea 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -21,13 +21,13 @@ export function useScrollLock(enabled: boolean = true) { const html = document.documentElement; const rootStyle = html.style; const bodyStyle = document.body.style; - const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; let resizeRaf: number; let scrollX: number; let scrollY: number; function lockScroll() { + const scrollbarWidth = window.innerWidth - html.clientWidth; const offsetLeft = window.visualViewport?.offsetLeft || 0; const offsetTop = window.visualViewport?.offsetTop || 0; scrollX = rootStyle.left ? parseFloat(rootStyle.left) : window.scrollX; From c74dc6b60cc3540d8ef27fcd2e8c31bbcbdf796f Mon Sep 17 00:00:00 2001 From: atomiks Date: Fri, 13 Sep 2024 19:09:49 +1000 Subject: [PATCH 11/31] Comments --- packages/mui-base/src/utils/useScrollLock.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index 1502901cea..412ebf874a 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -30,6 +30,7 @@ export function useScrollLock(enabled: boolean = true) { const scrollbarWidth = window.innerWidth - html.clientWidth; 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; @@ -57,10 +58,13 @@ export function useScrollLock(enabled: boolean = true) { bodyStyle.overflow = 'hidden'; Object.assign(rootStyle, { + // Handle `scrollbar-gutter` in Chrome when there is no scrollable content. position: html.scrollHeight > html.clientHeight ? 'fixed' : '', top: `${-(scrollY - Math.floor(offsetTop))}px`, left: `${-(scrollX - Math.floor(offsetLeft))}px`, right: '0', + // On iOS, the html can't be scrollable at it allows "pull-to-refresh" to still work. This + // is only necessary when scrollbars are present. ...(scrollbarWidth && { overflowY: html.scrollHeight > html.clientHeight ? 'scroll' : 'hidden', overflowX: html.scrollWidth > html.clientWidth ? 'scroll' : 'hidden', @@ -87,11 +91,11 @@ export function useScrollLock(enabled: boolean = true) { } } - const handleResize = () => { + function handleResize() { cleanup(); cancelAnimationFrame(resizeRaf); resizeRaf = requestAnimationFrame(lockScroll); - }; + } lockScroll(); window.addEventListener('resize', handleResize); From d27e79724deb7add84614e89a79f8f11fb66fdfb Mon Sep 17 00:00:00 2001 From: atomiks Date: Fri, 13 Sep 2024 19:10:27 +1000 Subject: [PATCH 12/31] typo --- packages/mui-base/src/utils/useScrollLock.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index 412ebf874a..d400e24e09 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -63,7 +63,7 @@ export function useScrollLock(enabled: boolean = true) { top: `${-(scrollY - Math.floor(offsetTop))}px`, left: `${-(scrollX - Math.floor(offsetLeft))}px`, right: '0', - // On iOS, the html can't be scrollable at it allows "pull-to-refresh" to still work. This + // On iOS, the html can't be scrollable as it allows "pull-to-refresh" to still work. This // is only necessary when scrollbars are present. ...(scrollbarWidth && { overflowY: html.scrollHeight > html.clientHeight ? 'scroll' : 'hidden', From acefccdcf584c69e3734712b4ec161b01a9ec1bb Mon Sep 17 00:00:00 2001 From: atomiks Date: Fri, 13 Sep 2024 19:13:01 +1000 Subject: [PATCH 13/31] Improve experiment --- docs/app/experiments/scroll-lock.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/app/experiments/scroll-lock.tsx b/docs/app/experiments/scroll-lock.tsx index a4e7c774da..1c0d62f3cf 100644 --- a/docs/app/experiments/scroll-lock.tsx +++ b/docs/app/experiments/scroll-lock.tsx @@ -16,8 +16,18 @@ export default function ScrollLock() { return (
-

Enable Show scroll bar: Always

-
+

useScrollLock

+

On macOS, enable `Show scroll bars: Always` in `Appearance` Settings.

+
- {[...Array(longContent ? 100 : 10)].map(() => ( -

Scroll locking text content

+ {[...Array(longContent ? 100 : 10)].map((_, i) => ( +

Scroll locking text content

))}
); diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index e45641a1da..f41b1bfaf5 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -1,8 +1,8 @@ import { isIOS } from './detectBrowser'; import { useEnhancedEffect } from './useEnhancedEffect'; -let originalStyles = {}; - +let originalRootStyles = {}; +let originalBodyStyles = {}; let preventScrollCount = 0; let restore: () => void = () => {}; @@ -13,7 +13,9 @@ function preventScrollIOS() { function preventScrollStandard() { const html = document.documentElement; + const body = document.body; const rootStyle = html.style; + const bodyStyle = body.style; let resizeRaf: number; let scrollX: number; @@ -28,7 +30,7 @@ function preventScrollStandard() { scrollX = rootStyle.left ? parseFloat(rootStyle.left) : window.scrollX; scrollY = rootStyle.top ? parseFloat(rootStyle.top) : window.scrollY; - originalStyles = { + originalRootStyles = { position: rootStyle.position, top: rootStyle.top, left: rootStyle.left, @@ -36,6 +38,9 @@ function preventScrollStandard() { overflowX: rootStyle.overflowX, overflowY: rootStyle.overflowY, }; + originalBodyStyles = { + overflow: bodyStyle.overflow, + }; Object.assign(rootStyle, { // Handle `scrollbar-gutter` in Chrome when there is no scrollable content. @@ -48,11 +53,16 @@ function preventScrollStandard() { overflowX: html.scrollWidth > html.clientWidth || hasConstantOverflowX ? 'scroll' : 'hidden', }); + // Ensure two scrollbars can't appear since `` now has a forced scrollbar, but the + // `` may have one too. + bodyStyle.overflow = 'hidden'; + return undefined; } function cleanup() { - Object.assign(rootStyle, originalStyles); + Object.assign(rootStyle, originalRootStyles); + Object.assign(bodyStyle, originalBodyStyles); if (window.scrollTo.toString().includes('[native code]')) { window.scrollTo(scrollX, scrollY); From 512c5d08112f519e2dfba5298c442376428c179e Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 16 Sep 2024 15:52:05 +1000 Subject: [PATCH 21/31] Update docs/app/experiments/scroll-lock.tsx Co-authored-by: Olivier Tassinari Signed-off-by: atomiks --- docs/app/experiments/scroll-lock.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/app/experiments/scroll-lock.tsx b/docs/app/experiments/scroll-lock.tsx index a608806ca7..d845e59e26 100644 --- a/docs/app/experiments/scroll-lock.tsx +++ b/docs/app/experiments/scroll-lock.tsx @@ -1,5 +1,4 @@ 'use client'; - import * as React from 'react'; import { useScrollLock } from '../../../packages/mui-base/src/utils/useScrollLock'; From 88e0e4feec6655af6d9d2643ea7299b444ede7a7 Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 16 Sep 2024 18:47:24 +1000 Subject: [PATCH 22/31] getComputedStyle --- packages/mui-base/src/utils/useScrollLock.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index f41b1bfaf5..18c690404c 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -24,8 +24,8 @@ function preventScrollStandard() { function lockScroll() { const offsetLeft = window.visualViewport?.offsetLeft || 0; const offsetTop = window.visualViewport?.offsetTop || 0; - const hasConstantOverflowY = rootStyle.overflowY === 'scroll'; - const hasConstantOverflowX = rootStyle.overflowX === 'scroll'; + const hasConstantOverflowY = getComputedStyle(html).overflowY === 'scroll'; + const hasConstantOverflowX = getComputedStyle(html).overflowX === 'scroll'; scrollX = rootStyle.left ? parseFloat(rootStyle.left) : window.scrollX; scrollY = rootStyle.top ? parseFloat(rootStyle.top) : window.scrollY; From a7fbacf3044b0509823c5c7b65df0a1ce9844135 Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 16 Sep 2024 18:49:08 +1000 Subject: [PATCH 23/31] perf --- packages/mui-base/src/utils/useScrollLock.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index 18c690404c..8b72100a30 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -24,8 +24,9 @@ function preventScrollStandard() { function lockScroll() { const offsetLeft = window.visualViewport?.offsetLeft || 0; const offsetTop = window.visualViewport?.offsetTop || 0; - const hasConstantOverflowY = getComputedStyle(html).overflowY === 'scroll'; - const hasConstantOverflowX = getComputedStyle(html).overflowX === 'scroll'; + const htmlComputedStyles = getComputedStyle(html); + const hasConstantOverflowY = htmlComputedStyles.overflowY === 'scroll'; + const hasConstantOverflowX = htmlComputedStyles.overflowX === 'scroll'; scrollX = rootStyle.left ? parseFloat(rootStyle.left) : window.scrollX; scrollY = rootStyle.top ? parseFloat(rootStyle.top) : window.scrollY; From 87444f87d0f130be12046ec17b44c1e49f90ae7c Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 16 Sep 2024 18:49:32 +1000 Subject: [PATCH 24/31] Remove return --- packages/mui-base/src/utils/useScrollLock.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index 8b72100a30..38c348a3d4 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -57,8 +57,6 @@ function preventScrollStandard() { // Ensure two scrollbars can't appear since `` now has a forced scrollbar, but the // `` may have one too. bodyStyle.overflow = 'hidden'; - - return undefined; } function cleanup() { From 70bbdfadd2141203ebc8e0d976704de9d8c1168e Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 16 Sep 2024 18:55:56 +1000 Subject: [PATCH 25/31] Conditional fixed styles --- packages/mui-base/src/utils/useScrollLock.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index 38c348a3d4..9874bfec4b 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -22,8 +22,6 @@ function preventScrollStandard() { let scrollY: number; function lockScroll() { - const offsetLeft = window.visualViewport?.offsetLeft || 0; - const offsetTop = window.visualViewport?.offsetTop || 0; const htmlComputedStyles = getComputedStyle(html); const hasConstantOverflowY = htmlComputedStyles.overflowY === 'scroll'; const hasConstantOverflowX = htmlComputedStyles.overflowX === 'scroll'; @@ -43,12 +41,19 @@ function preventScrollStandard() { overflow: bodyStyle.overflow, }; + // Handle `scrollbar-gutter` in Chrome when there is no scrollable content. + const isFixed = html.scrollHeight > html.clientHeight; + + if (isFixed) { + Object.assign(rootStyle, { + position: 'fixed', + top: `${-scrollY}px`, + left: `${-scrollX}px`, + right: '0', + }); + } + Object.assign(rootStyle, { - // Handle `scrollbar-gutter` in Chrome when there is no scrollable content. - position: html.scrollHeight > html.clientHeight ? 'fixed' : '', - top: `${-(scrollY - Math.floor(offsetTop))}px`, - left: `${-(scrollX - Math.floor(offsetLeft))}px`, - right: '0', overflowY: html.scrollHeight > html.clientHeight || hasConstantOverflowY ? 'scroll' : 'hidden', overflowX: html.scrollWidth > html.clientWidth || hasConstantOverflowX ? 'scroll' : 'hidden', From 8c8adc2679f97222140f30838538e27a1840d93d Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 16 Sep 2024 19:01:38 +1000 Subject: [PATCH 26/31] Assign vars earlier --- packages/mui-base/src/utils/useScrollLock.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index 9874bfec4b..02026f47c8 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -42,9 +42,10 @@ function preventScrollStandard() { }; // Handle `scrollbar-gutter` in Chrome when there is no scrollable content. - const isFixed = html.scrollHeight > html.clientHeight; + const isScrollableY = html.scrollHeight > html.clientHeight; + const isScrollableX = html.scrollWidth > html.clientWidth; - if (isFixed) { + if (isScrollableY) { Object.assign(rootStyle, { position: 'fixed', top: `${-scrollY}px`, @@ -54,9 +55,8 @@ function preventScrollStandard() { } Object.assign(rootStyle, { - overflowY: - html.scrollHeight > html.clientHeight || hasConstantOverflowY ? 'scroll' : 'hidden', - overflowX: html.scrollWidth > html.clientWidth || hasConstantOverflowX ? 'scroll' : 'hidden', + overflowY: isScrollableY || hasConstantOverflowY ? 'scroll' : 'hidden', + overflowX: isScrollableX || hasConstantOverflowX ? 'scroll' : 'hidden', }); // Ensure two scrollbars can't appear since `` now has a forced scrollbar, but the From 80ec0c1da668d444f1a33384932a43f4ebe8ee2d Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 16 Sep 2024 19:02:15 +1000 Subject: [PATCH 27/31] Add scrollable x check --- packages/mui-base/src/utils/useScrollLock.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index 02026f47c8..f82820629f 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -45,7 +45,7 @@ function preventScrollStandard() { const isScrollableY = html.scrollHeight > html.clientHeight; const isScrollableX = html.scrollWidth > html.clientWidth; - if (isScrollableY) { + if (isScrollableY || isScrollableX) { Object.assign(rootStyle, { position: 'fixed', top: `${-scrollY}px`, From 56ca8b9d937bdaaba187be51e87d9baa42c6ac55 Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 16 Sep 2024 19:05:52 +1000 Subject: [PATCH 28/31] Handle body overflow --- packages/mui-base/src/utils/useScrollLock.ts | 39 +++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index f82820629f..0a8a28056c 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -1,7 +1,7 @@ import { isIOS } from './detectBrowser'; import { useEnhancedEffect } from './useEnhancedEffect'; -let originalRootStyles = {}; +let originalHtmlStyles = {}; let originalBodyStyles = {}; let preventScrollCount = 0; let restore: () => void = () => {}; @@ -14,7 +14,7 @@ function preventScrollIOS() { function preventScrollStandard() { const html = document.documentElement; const body = document.body; - const rootStyle = html.style; + const htmlStyle = html.style; const bodyStyle = body.style; let resizeRaf: number; @@ -23,19 +23,22 @@ function preventScrollStandard() { function lockScroll() { const htmlComputedStyles = getComputedStyle(html); - const hasConstantOverflowY = htmlComputedStyles.overflowY === 'scroll'; - const hasConstantOverflowX = htmlComputedStyles.overflowX === 'scroll'; - - scrollX = rootStyle.left ? parseFloat(rootStyle.left) : window.scrollX; - scrollY = rootStyle.top ? parseFloat(rootStyle.top) : window.scrollY; - - originalRootStyles = { - position: rootStyle.position, - top: rootStyle.top, - left: rootStyle.left, - right: rootStyle.right, - overflowX: rootStyle.overflowX, - overflowY: rootStyle.overflowY, + 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 = { overflow: bodyStyle.overflow, @@ -46,7 +49,7 @@ function preventScrollStandard() { const isScrollableX = html.scrollWidth > html.clientWidth; if (isScrollableY || isScrollableX) { - Object.assign(rootStyle, { + Object.assign(htmlStyle, { position: 'fixed', top: `${-scrollY}px`, left: `${-scrollX}px`, @@ -54,7 +57,7 @@ function preventScrollStandard() { }); } - Object.assign(rootStyle, { + Object.assign(htmlStyle, { overflowY: isScrollableY || hasConstantOverflowY ? 'scroll' : 'hidden', overflowX: isScrollableX || hasConstantOverflowX ? 'scroll' : 'hidden', }); @@ -65,7 +68,7 @@ function preventScrollStandard() { } function cleanup() { - Object.assign(rootStyle, originalRootStyles); + Object.assign(htmlStyle, originalHtmlStyles); Object.assign(bodyStyle, originalBodyStyles); if (window.scrollTo.toString().includes('[native code]')) { From 2458dc0417858a947ca3d60e72fc171ee057af86 Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 16 Sep 2024 19:12:00 +1000 Subject: [PATCH 29/31] Handle body overflow --- packages/mui-base/src/utils/useScrollLock.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index 0a8a28056c..f40e2f2df2 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -41,7 +41,8 @@ function preventScrollStandard() { overflowY: htmlStyle.overflowY, }; originalBodyStyles = { - overflow: bodyStyle.overflow, + overflowX: bodyStyle.overflowX, + overflowY: bodyStyle.overflowY, }; // Handle `scrollbar-gutter` in Chrome when there is no scrollable content. @@ -64,7 +65,12 @@ function preventScrollStandard() { // Ensure two scrollbars can't appear since `` now has a forced scrollbar, but the // `` may have one too. - bodyStyle.overflow = 'hidden'; + if (isScrollableY || hasConstantOverflowY) { + bodyStyle.overflowY = 'hidden'; + } + if (isScrollableX || hasConstantOverflowX) { + bodyStyle.overflowX = 'hidden'; + } } function cleanup() { From d8715dd155c68fa9fa8cdbf4f76375cd92644de4 Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 16 Sep 2024 20:38:02 +1000 Subject: [PATCH 30/31] Update packages/mui-base/src/utils/useScrollLock.ts Co-authored-by: Vlad Moroz Signed-off-by: atomiks --- packages/mui-base/src/utils/useScrollLock.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index f40e2f2df2..a73d073f58 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -45,11 +45,13 @@ function preventScrollStandard() { 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) { + // 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`, @@ -59,8 +61,10 @@ function preventScrollStandard() { } Object.assign(htmlStyle, { - overflowY: isScrollableY || hasConstantOverflowY ? 'scroll' : 'hidden', - overflowX: isScrollableX || hasConstantOverflowX ? 'scroll' : 'hidden', + overflowY: + !hasScrollbarGutterStable && (isScrollableY || hasConstantOverflowY) ? 'scroll' : 'hidden', + overflowX: + !hasScrollbarGutterStable && (isScrollableX || hasConstantOverflowX) ? 'scroll' : 'hidden', }); // Ensure two scrollbars can't appear since `` now has a forced scrollbar, but the From a9d020e4b69ea0cbce58c4720cabf980da9e8515 Mon Sep 17 00:00:00 2001 From: atomiks Date: Tue, 17 Sep 2024 14:58:47 +1000 Subject: [PATCH 31/31] Add iOS implementation --- packages/mui-base/src/utils/useScrollLock.ts | 32 ++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/mui-base/src/utils/useScrollLock.ts b/packages/mui-base/src/utils/useScrollLock.ts index a73d073f58..270c0f46dc 100644 --- a/packages/mui-base/src/utils/useScrollLock.ts +++ b/packages/mui-base/src/utils/useScrollLock.ts @@ -7,8 +7,36 @@ let preventScrollCount = 0; let restore: () => void = () => {}; function preventScrollIOS() { - // To implement - return () => {}; + const body = document.body; + const bodyStyle = body.style; + + // iOS 12 does not support `visualViewport`. + const offsetLeft = window.visualViewport?.offsetLeft || 0; + const offsetTop = window.visualViewport?.offsetTop || 0; + const scrollX = bodyStyle.left ? parseFloat(bodyStyle.left) : window.scrollX; + const scrollY = bodyStyle.top ? parseFloat(bodyStyle.top) : window.scrollY; + + originalBodyStyles = { + position: bodyStyle.position, + top: bodyStyle.top, + left: bodyStyle.left, + right: bodyStyle.right, + overflowX: bodyStyle.overflowX, + overflowY: bodyStyle.overflowY, + }; + + Object.assign(bodyStyle, { + position: 'fixed', + top: `${-(scrollY - Math.floor(offsetTop))}px`, + left: `${-(scrollX - Math.floor(offsetLeft))}px`, + right: '0', + overflow: 'hidden', + }); + + return () => { + Object.assign(bodyStyle, originalBodyStyles); + window.scrollTo(scrollX, scrollY); + }; } function preventScrollStandard() {