From ab1e73105e67f5a286bc0e8557bae4f9c48cc1af Mon Sep 17 00:00:00 2001 From: diondiondion Date: Wed, 16 Sep 2026 20:42:15 +0000 Subject: [PATCH] Redesign: Prevent scrolling behind `BottomSheet` component (#40558) --- .../components/bottom_sheet/index.tsx | 3 + .../mastodon/containers/mastodon.jsx | 2 +- .../ui/components/body_scroll_lock.tsx | 38 ---------- .../mastodon/hooks/useBodyScrollLock.tsx | 74 +++++++++++++++++++ .../mastodon/reducers/slices/index.ts | 2 + .../reducers/slices/scrollLockStack.ts | 38 ++++++++++ 6 files changed, 118 insertions(+), 39 deletions(-) delete mode 100644 app/javascript/mastodon/features/ui/components/body_scroll_lock.tsx create mode 100644 app/javascript/mastodon/hooks/useBodyScrollLock.tsx create mode 100644 app/javascript/mastodon/reducers/slices/scrollLockStack.ts diff --git a/app/javascript/mastodon/components/bottom_sheet/index.tsx b/app/javascript/mastodon/components/bottom_sheet/index.tsx index 1ca0e1a517e..c65c9ca4b19 100644 --- a/app/javascript/mastodon/components/bottom_sheet/index.tsx +++ b/app/javascript/mastodon/components/bottom_sheet/index.tsx @@ -3,6 +3,7 @@ import { useLayoutEffect, useRef } from 'react'; import classNames from 'classnames'; +import { useBodyScrollLock } from '@/mastodon/hooks/useBodyScrollLock'; import { useMergedRefs } from '@/mastodon/hooks/useMergedRefs'; import { useOnClickOutside } from '@/mastodon/hooks/useOnClickOutside'; import { useScrollSensor } from '@/mastodon/hooks/useScrollSensor'; @@ -25,6 +26,8 @@ export const BottomSheet: React.FC = ({ const dialogRef = useRef(null); const contentRef = useRef(null); + useBodyScrollLock(); + useLayoutEffect(() => { const dialog = dialogRef.current; if (!dialog) return; diff --git a/app/javascript/mastodon/containers/mastodon.jsx b/app/javascript/mastodon/containers/mastodon.jsx index 4f87a1b823c..d3c75faa83f 100644 --- a/app/javascript/mastodon/containers/mastodon.jsx +++ b/app/javascript/mastodon/containers/mastodon.jsx @@ -7,6 +7,7 @@ import { Provider as ReduxProvider } from 'react-redux'; import { hydrateStore } from 'mastodon/actions/store'; import { connectUserStream } from 'mastodon/actions/streaming'; +import { BodyScrollLock } from '@/mastodon/hooks/useBodyScrollLock'; import ErrorBoundary from 'mastodon/components/error_boundary'; import { FocusTargetProvider } from '@/mastodon/components/navigation_focus_target'; import { Router } from 'mastodon/components/router'; @@ -16,7 +17,6 @@ import { initialState, title as siteTitle } from 'mastodon/initial_state'; import { IntlProvider } from 'mastodon/locales'; import { store } from 'mastodon/store'; import { isProduction } from 'mastodon/utils/environment'; -import { BodyScrollLock } from 'mastodon/features/ui/components/body_scroll_lock'; import { ScrollContext } from './scroll_container/scroll_context'; diff --git a/app/javascript/mastodon/features/ui/components/body_scroll_lock.tsx b/app/javascript/mastodon/features/ui/components/body_scroll_lock.tsx deleted file mode 100644 index 8e9d0d2dfb7..00000000000 --- a/app/javascript/mastodon/features/ui/components/body_scroll_lock.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useLayoutEffect } from 'react'; - -import { isRedesignEnabled } from '@/mastodon/utils/environment'; -import { createAppSelector, useAppSelector } from 'mastodon/store'; - -const getShouldLockBodyScroll = createAppSelector( - [ - (state) => state.navigation.open, - (state) => state.modal.get('stack').size > 0, - (state) => - isRedesignEnabled() && - state.composer.displayState === 'showing' && - state.meta.get('layout') === 'mobile', - ], - ( - isMobileMenuOpen: boolean, - isModalOpen: boolean, - isRedesignComposerOpen: boolean, - ) => isMobileMenuOpen || isModalOpen || isRedesignComposerOpen, -); - -/** - * This component locks scrolling on the body when - * `getShouldLockBodyScroll` returns true. - */ - -export const BodyScrollLock: React.FC = () => { - const shouldLockBodyScroll = useAppSelector(getShouldLockBodyScroll); - - useLayoutEffect(() => { - document.documentElement.classList.toggle( - 'has-modal', - shouldLockBodyScroll, - ); - }, [shouldLockBodyScroll]); - - return null; -}; diff --git a/app/javascript/mastodon/hooks/useBodyScrollLock.tsx b/app/javascript/mastodon/hooks/useBodyScrollLock.tsx new file mode 100644 index 00000000000..c03a9e8c726 --- /dev/null +++ b/app/javascript/mastodon/hooks/useBodyScrollLock.tsx @@ -0,0 +1,74 @@ +import { useId, useLayoutEffect } from 'react'; + +import { + addToScrollLockStack, + removeFromScrollLockStack, +} from '@/mastodon/reducers/slices/scrollLockStack'; +import { isRedesignEnabled } from '@/mastodon/utils/environment'; +import { + createAppSelector, + useAppDispatch, + useAppSelector, +} from 'mastodon/store'; + +const getShouldLockBodyScroll = createAppSelector( + [ + (state) => state.scrollLockStack.stack.length > 0, + (state) => state.navigation.open, + (state) => state.modal.get('stack').size > 0, + (state) => + isRedesignEnabled() && + state.composer.displayState === 'showing' && + state.meta.get('layout') === 'mobile', + ], + ( + hasScrollLockStackItems: boolean, + isMobileMenuOpen: boolean, + isModalOpen: boolean, + isRedesignComposerOpen: boolean, + ) => + hasScrollLockStackItems || + isMobileMenuOpen || + isModalOpen || + isRedesignComposerOpen, +); + +/** + * This component locks scrolling on the body when + * `getShouldLockBodyScroll` returns true. + * Should only be used once per app. + */ + +export const BodyScrollLock: React.FC = () => { + const shouldLockBodyScroll = useAppSelector(getShouldLockBodyScroll); + + useLayoutEffect(() => { + document.documentElement.classList.toggle( + 'has-modal', + shouldLockBodyScroll, + ); + }, [shouldLockBodyScroll]); + + return null; +}; + +/** + * Utility hook for engaging the body scroll lock for a component + * on mount & disabling it on unmount. + */ +export function useBodyScrollLock(active = true) { + const dispatch = useAppDispatch(); + const id = useId(); + + useLayoutEffect(() => { + if (active) { + dispatch(addToScrollLockStack(id)); + } else { + dispatch(removeFromScrollLockStack(id)); + } + + return () => { + dispatch(removeFromScrollLockStack(id)); + }; + }, [id, dispatch, active]); +} diff --git a/app/javascript/mastodon/reducers/slices/index.ts b/app/javascript/mastodon/reducers/slices/index.ts index a0f1b9cfb19..48956af5d2c 100644 --- a/app/javascript/mastodon/reducers/slices/index.ts +++ b/app/javascript/mastodon/reducers/slices/index.ts @@ -3,6 +3,7 @@ import { collections } from './collections'; import { composer } from './composer'; import { emojis } from './emojis'; import { profileEdit } from './profile_edit'; +import { scrollLockStack } from './scrollLockStack'; export const sliceReducers = { annualReport, @@ -10,4 +11,5 @@ export const sliceReducers = { composer, emojis, profileEdit, + scrollLockStack, }; diff --git a/app/javascript/mastodon/reducers/slices/scrollLockStack.ts b/app/javascript/mastodon/reducers/slices/scrollLockStack.ts new file mode 100644 index 00000000000..44becc695b0 --- /dev/null +++ b/app/javascript/mastodon/reducers/slices/scrollLockStack.ts @@ -0,0 +1,38 @@ +import type { PayloadAction } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; + +/** + * This state tracks the open/closed state of modal dialogs that were not opened + * via our existing `openModal` actions, for example the `BottomSheet` component + * that can be rendered standalone. + * + * Modals rendered like this should add an id to this state and remove it when + * they're closed. The presence of any id in this state is used by the + * `BodyScrollLock` component to prevent the page from scrolling behind modals. + */ +interface ScrollLockStackState { + stack: string[]; +} + +const initialState: ScrollLockStackState = { + stack: [], +}; + +const scrollLockStackSlice = createSlice({ + name: 'scrollLockStack', + initialState, + reducers: { + addToScrollLockStack(state, action: PayloadAction) { + if (!state.stack.includes(action.payload)) { + state.stack.push(action.payload); + } + }, + removeFromScrollLockStack(state, action: PayloadAction) { + state.stack = state.stack.filter((id) => id !== action.payload); + }, + }, +}); + +export const scrollLockStack = scrollLockStackSlice.reducer; +export const { addToScrollLockStack, removeFromScrollLockStack } = + scrollLockStackSlice.actions;