Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ const CONST = {
ANIMATED_PROGRESS_BAR_OPACITY_DURATION: 300,
ANIMATED_PROGRESS_BAR_DURATION: 750,
ANIMATION_IN_TIMING: 100,
COMPOSER_FOCUS_DELAY: 150,
ANIMATION_DIRECTION: {
IN: 'in',
OUT: 'out',
Expand Down
10 changes: 7 additions & 3 deletions src/components/Modal/BaseModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import useKeyboardState from '@hooks/useKeyboardState';
import usePrevious from '@hooks/usePrevious';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSafeAreaInsets from '@hooks/useSafeAreaInsets';
import useSidePane from '@hooks/useSidePane';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
Expand Down Expand Up @@ -78,6 +79,7 @@ function BaseModal(
swipeDirection,
shouldPreventScrollOnFocus = false,
enableEdgeToEdgeBottomSafeAreaPadding = false,
shouldApplySidePaneOffset = type === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED,
}: BaseModalProps,
ref: React.ForwardedRef<View>,
) {
Expand All @@ -88,6 +90,8 @@ function BaseModal(
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply correct modal width
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
const {sidePaneOffset} = useSidePane();
const sidePaneStyle = shouldApplySidePaneOffset && !isSmallScreenWidth ? {paddingRight: sidePaneOffset.current} : undefined;
const keyboardStateContextValue = useKeyboardState();

const safeAreaInsets = useSafeAreaInsets();
Expand Down Expand Up @@ -154,10 +158,10 @@ function BaseModal(

const handleShowModal = useCallback(() => {
if (shouldSetModalVisibility) {
setModalVisibility(true);
setModalVisibility(true, type);
}
onModalShow();
}, [onModalShow, shouldSetModalVisibility]);
}, [onModalShow, shouldSetModalVisibility, type]);

const handleBackdropPress = (e?: KeyboardEvent) => {
if (e?.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) {
Expand Down Expand Up @@ -269,7 +273,7 @@ function BaseModal(
backdropTransitionOutTiming={0}
hasBackdrop={fullscreen}
coverScreen={fullscreen}
style={modalStyle}
style={[modalStyle, sidePaneStyle]}
deviceHeight={windowHeight}
deviceWidth={windowWidth}
animationIn={animationIn ?? modalStyleAnimationIn}
Expand Down
2 changes: 1 addition & 1 deletion src/components/Modal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = (
type === CONST.MODAL.MODAL_TYPE.CENTERED ||
type === CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE ||
type === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED ||
CONST.MODAL.MODAL_TYPE.CENTERED_SWIPABLE_TO_RIGHT;
type === CONST.MODAL.MODAL_TYPE.CENTERED_SWIPABLE_TO_RIGHT;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This was a mistake that was in the code for couple months 😅


if (statusBarColor) {
setPreviousStatusBarColor(statusBarColor);
Expand Down
6 changes: 6 additions & 0 deletions src/components/Modal/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ type BaseModalProps = Partial<ReactNativeModalProps> &
* This flag can be removed, once all components/screens have switched to edge-to-edge safe area handling.
*/
enableEdgeToEdgeBottomSafeAreaPadding?: boolean;

/**
* Whether the modal should apply the side pane offset.
* This is used to adjust the modal position when the side pane is open.
*/
shouldApplySidePaneOffset?: boolean;
};

export default BaseModalProps;
Expand Down
1 change: 1 addition & 0 deletions src/components/Search/SearchRouter/SearchRouterModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ function SearchRouterModal() {
onClose={closeSearchRouter}
onModalHide={() => setShouldHideInputCaret(isMobileWebSafari)}
onModalShow={() => setShouldHideInputCaret(false)}
shouldApplySidePaneOffset={!shouldUseNarrowLayout}
>
<KeyboardAvoidingView
behavior="padding"
Expand Down
8 changes: 5 additions & 3 deletions src/components/SidePane/Help/HelpContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@ import useEnvironment from '@hooks/useEnvironment';
import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useRootNavigationState from '@hooks/useRootNavigationState';
import useSidePane from '@hooks/useSidePane';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import {substituteRouteParameters} from '@libs/SidePaneUtils';

function HelpContent() {
type HelpContentProps = {
closeSidePane: (shouldUpdateNarrow?: boolean) => void;
};

function HelpContent({closeSidePane}: HelpContentProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {isProduction} = useEnvironment();
const {isExtraLargeScreenWidth} = useResponsiveLayout();
const {closeSidePane} = useSidePane();
const route = useRootNavigationState((state) => {
const params = (findFocusedRoute(state)?.params as Record<string, string>) ?? {};
const activeRoute = Navigation.getActiveRouteWithoutParams();
Expand Down
25 changes: 12 additions & 13 deletions src/components/SidePane/Help/index.android.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
import {useFocusEffect} from '@react-navigation/native';
import React, {useCallback} from 'react';
// eslint-disable-next-line no-restricted-imports
import {Animated, BackHandler} from 'react-native';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings';
import useThemeStyles from '@hooks/useThemeStyles';
import {BackHandler} from 'react-native';
import Modal from '@components/Modal';
import CONST from '@src/CONST';
import HelpContent from './HelpContent';
import type HelpProps from './types';

function Help({sidePaneTranslateX, closeSidePane}: HelpProps) {
const styles = useThemeStyles();
const {isExtraLargeScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();
const {paddingTop, paddingBottom} = useSafeAreaPaddings();

function Help({isPaneHidden, closeSidePane}: HelpProps) {
// SidePane isn't a native screen, this handles the back button press on Android
useFocusEffect(
useCallback(() => {
Expand All @@ -27,9 +21,14 @@ function Help({sidePaneTranslateX, closeSidePane}: HelpProps) {
);

return (
<Animated.View style={[styles.sidePaneContainer(shouldUseNarrowLayout, isExtraLargeScreenWidth), {transform: [{translateX: sidePaneTranslateX.current}], paddingTop, paddingBottom}]}>
<HelpContent />
</Animated.View>
<Modal
onClose={() => closeSidePane()}
isVisible={!isPaneHidden}
type={CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED}
shouldHandleNavigationBack
>
<HelpContent closeSidePane={closeSidePane} />
</Modal>
);
}

Expand Down
59 changes: 12 additions & 47 deletions src/components/SidePane/Help/index.ios.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,21 @@
import React from 'react';
// eslint-disable-next-line no-restricted-imports
import {Animated, Dimensions} from 'react-native';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings';
import useThemeStyles from '@hooks/useThemeStyles';
import Modal from '@components/Modal';
import CONST from '@src/CONST';
import HelpContent from './HelpContent';
import type HelpProps from './types';

const SCREEN_WIDTH = Dimensions.get('window').width;

function Help({sidePaneTranslateX, closeSidePane}: HelpProps) {
const styles = useThemeStyles();
const {isExtraLargeScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();
const {paddingTop, paddingBottom} = useSafeAreaPaddings();

// SidePane isn't a native screen, this simulates the 'close swipe gesture' on iOS
const panGesture = Gesture.Pan()
.runOnJS(true)
.hitSlop({left: 0, width: 20})
.onUpdate((event) => {
if (event.translationX <= 0) {
return;
}
sidePaneTranslateX.current.setValue(event.translationX);
})
.onEnd((event) => {
if (event.translationX > 100) {
// If swiped far enough, animate out and close
Animated.timing(sidePaneTranslateX.current, {
toValue: SCREEN_WIDTH,
duration: CONST.ANIMATED_TRANSITION,
useNativeDriver: false,
}).start(() => closeSidePane());
} else {
// Otherwise, animate back to original position
Animated.spring(sidePaneTranslateX.current, {
toValue: 0,
useNativeDriver: false,
}).start();
}
});

function Help({isPaneHidden, closeSidePane}: HelpProps) {
return (
<GestureDetector gesture={panGesture}>
<Animated.View
style={[styles.sidePaneContainer(shouldUseNarrowLayout, isExtraLargeScreenWidth), {transform: [{translateX: sidePaneTranslateX.current}], paddingTop, paddingBottom}]}
>
<HelpContent />
</Animated.View>
</GestureDetector>
<Modal
onClose={() => closeSidePane()}
isVisible={!isPaneHidden}
type={CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED}
shouldHandleNavigationBack
propagateSwipe
swipeDirection={CONST.SWIPE_DIRECTION.RIGHT}
>
<HelpContent closeSidePane={closeSidePane} />
</Modal>
);
}

Expand Down
68 changes: 61 additions & 7 deletions src/components/SidePane/Help/index.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,79 @@
import React from 'react';
import React, {useEffect} from 'react';
// eslint-disable-next-line no-restricted-imports
import {Animated} from 'react-native';
import {Animated, View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
// @ts-expect-error This is a workaround to display HelpPane on top of everything,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I’ve really tried every possible approach 😮‍💨

  1. react-native Modal : The modal must be in fullscreen mode; otherwise, the rest of the app becomes unresponsive (users can't click anything outside the side pane)
  2. react-native-modal (src/components/Modal/index.tsx): Similar issue as above, but additionally, the side pane was sometimes rendered under other modals in a non-deterministic way
  3. Portals: The help pane still appeared under React Native modals
  4. react-navigation primitives: Doesn’t work on web
  5. Custom overlay with a very high z-index: React Native modals are still rendered on top, causing the side pane to be hidden

// Modal from react-native can't be used here, as it would block interactions with the rest of the app
import ModalPortal from 'react-native-web/dist/exports/Modal/ModalPortal';
import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal';
import SidePaneOverlay from '@components/SidePane/SidePaneOverlay';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import HelpContent from './HelpContent';
import type HelpProps from './types';

function Help({sidePaneTranslateX, closeSidePane}: HelpProps) {
function Help({sidePaneTranslateX, closeSidePane, shouldHideSidePaneBackdrop}: HelpProps) {
const styles = useThemeStyles();
const {isExtraLargeScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();
const {paddingTop, paddingBottom} = useSafeAreaPaddings();
const [isRHPVisible = false] = useOnyx(ONYXKEYS.MODAL, {selector: (modal) => modal?.type === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This logic is necessary to prevent showing overlay twice:

Screen.Recording.2025-03-18.at.14.29.44.mov


useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => closeSidePane(), {isActive: !isExtraLargeScreenWidth});
const onCloseSidePaneOnSmallScreens = () => {
if (isExtraLargeScreenWidth) {
return;
}

closeSidePane();
};

// Close side pane on escape key press
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => closeSidePane(), {isActive: !isExtraLargeScreenWidth, shouldBubble: false});
Comment on lines +33 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think I found an odd behavior while testing this. After closing the pane on a narrow screen (with the escape key or the < arrow) and then expanding to a wide screen view, the modal auto-reopens for some reason. It's also happening on main so NAB.

Screen.Recording.2025-03-19.at.4.58.44.PM.mov

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@francoisl @brunovjk This is the intended way it should work. When transitioning from a wide to a narrow screen, the pane is closed to prevent it from covering the rest of the app. When switching back to a wide screen, the previous state is preserved. If the pane was open before on a large screen, it will be shown again to the user.

That is why there are two booleans in the NVP:

type SidePane = {
    /** Whether the side pane is open on large screens */
    open: boolean;

    /** Whether the side pane is open on small screens */
    openNarrowScreen: boolean;
};


// Close side pane on small screens when navigation keyboard shortcuts are used
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.SEARCH, onCloseSidePaneOnSmallScreens, {shouldBubble: true});
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.NEW_CHAT, onCloseSidePaneOnSmallScreens, {shouldBubble: true});
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.SHORTCUTS, onCloseSidePaneOnSmallScreens, {shouldBubble: true});
Comment on lines +36 to +39

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Logic for hiding the side pane when user triggers navigation shortcuts:

Screen.Recording.2025-03-18.at.13.48.09.mov


// Web back button: push history state and close side pane on popstate
useEffect(() => {
window.history.pushState({isSidePaneOpen: true}, '', null);
const handlePopState = () => {
if (isExtraLargeScreenWidth) {
return;
}

closeSidePane();
};

window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, []);
Comment on lines +42 to +55

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This part handles the back button behavior on the web. We use a similar approach for Modals/Popovers. It's not perfect, but it works in most cases:

Screen.Recording.2025-03-18.at.14.35.24.mov

One known bug that I won't be able to fix is that when opening both a Modal (src/components/Modal/index.tsx) and the side pane, pressing the back button dismisses both at once instead of handling them separately:

Screen.Recording.2025-03-18.at.14.38.34.mov


return (
<Animated.View style={[styles.sidePaneContainer(shouldUseNarrowLayout, isExtraLargeScreenWidth), {transform: [{translateX: sidePaneTranslateX.current}], paddingTop, paddingBottom}]}>
<HelpContent />
</Animated.View>
<ModalPortal>
<FocusTrapForModal active>
<View style={styles.sidePaneContainer}>
<View>
{!shouldHideSidePaneBackdrop && (
<SidePaneOverlay
onBackdropPress={closeSidePane}
isRHPVisible={isRHPVisible}
/>
)}
</View>
<Animated.View
style={[styles.sidePaneContent(shouldUseNarrowLayout, isExtraLargeScreenWidth), {transform: [{translateX: sidePaneTranslateX.current}], paddingTop, paddingBottom}]}
>
<HelpContent closeSidePane={closeSidePane} />
</Animated.View>
</View>
</FocusTrapForModal>
</ModalPortal>
);
}

Expand Down
2 changes: 2 additions & 0 deletions src/components/SidePane/Help/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import type {MutableRefObject} from 'react';
import type {Animated} from 'react-native';

type HelpProps = {
isPaneHidden: boolean;
sidePaneTranslateX: MutableRefObject<Animated.Value>;
shouldHideSidePaneBackdrop: boolean;
closeSidePane: (shouldUpdateNarrow?: boolean) => void;
};

Expand Down
12 changes: 6 additions & 6 deletions src/components/SidePane/SidePaneOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';

type SidePaneOverlayProps = {
/** Whether the side pane is displayed inside of RHP */
isInNarrowPaneModal: boolean;
/** Whether the side pane is displayed over RHP */
isRHPVisible: boolean;

/** Callback fired when pressing the backdrop */
onBackdropPress: () => void;
};

const easing = Easing.bezier(0.76, 0.0, 0.24, 1.0).factory();

function SidePaneOverlay({isInNarrowPaneModal, onBackdropPress}: SidePaneOverlayProps) {
function SidePaneOverlay({isRHPVisible, onBackdropPress}: SidePaneOverlayProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();

Expand All @@ -37,9 +37,9 @@ function SidePaneOverlay({isInNarrowPaneModal, onBackdropPress}: SidePaneOverlay

return (
<Animated.View
style={styles.sidePaneOverlay(isInNarrowPaneModal)}
entering={isInNarrowPaneModal ? undefined : CustomFadeIn}
exiting={isInNarrowPaneModal ? undefined : CustomFadeOut}
style={styles.sidePaneOverlay(isRHPVisible)}
entering={isRHPVisible ? undefined : CustomFadeIn}
exiting={isRHPVisible ? undefined : CustomFadeOut}
>
<PressableWithoutFeedback
accessible
Expand Down
27 changes: 7 additions & 20 deletions src/components/SidePane/index.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,21 @@
import React from 'react';
import {View} from 'react-native';
import useRootNavigationState from '@hooks/useRootNavigationState';
import useSidePane from '@hooks/useSidePane';
import NAVIGATORS from '@src/NAVIGATORS';
import Help from './Help';
import SidePaneOverlay from './SidePaneOverlay';

function SidePane() {
const {shouldHideSidePane, sidePaneTranslateX, shouldHideSidePaneBackdrop, closeSidePane} = useSidePane();
const isInNarrowPaneModal = useRootNavigationState((state) => state?.routes.at(-1)?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR);
const {shouldHideSidePane, isPaneHidden, sidePaneTranslateX, shouldHideSidePaneBackdrop, closeSidePane} = useSidePane();

if (shouldHideSidePane) {
return null;
}

return (
<>
<View>
{!shouldHideSidePaneBackdrop && (
<SidePaneOverlay
onBackdropPress={closeSidePane}
isInNarrowPaneModal={isInNarrowPaneModal}
/>
)}
</View>
<Help
sidePaneTranslateX={sidePaneTranslateX}
closeSidePane={closeSidePane}
/>
</>
<Help
isPaneHidden={isPaneHidden}
sidePaneTranslateX={sidePaneTranslateX}
closeSidePane={closeSidePane}
shouldHideSidePaneBackdrop={shouldHideSidePaneBackdrop}
/>
);
}

Expand Down
Loading