Redesign: Prevent scrolling behind BottomSheet component (#40558)

This commit is contained in:
diondiondion
2026-09-16 20:42:15 +00:00
committed by GitHub
parent f466632ac0
commit ab1e73105e
6 changed files with 118 additions and 39 deletions

View File

@@ -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<BottomSheetProps> = ({
const dialogRef = useRef<HTMLDialogElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
useBodyScrollLock();
useLayoutEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;

View File

@@ -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';

View File

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

View File

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

View File

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

View File

@@ -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<string>) {
if (!state.stack.includes(action.payload)) {
state.stack.push(action.payload);
}
},
removeFromScrollLockStack(state, action: PayloadAction<string>) {
state.stack = state.stack.filter((id) => id !== action.payload);
},
},
});
export const scrollLockStack = scrollLockStackSlice.reducer;
export const { addToScrollLockStack, removeFromScrollLockStack } =
scrollLockStackSlice.actions;