diff --git a/src/CONST.ts b/src/CONST.ts index e8f2d0ebd5b7..deb153eafa44 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -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', diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 3605a368d467..b91eb15094e8 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -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'; @@ -78,6 +79,7 @@ function BaseModal( swipeDirection, shouldPreventScrollOnFocus = false, enableEdgeToEdgeBottomSafeAreaPadding = false, + shouldApplySidePaneOffset = type === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED, }: BaseModalProps, ref: React.ForwardedRef, ) { @@ -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(); @@ -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) { @@ -269,7 +273,7 @@ function BaseModal( backdropTransitionOutTiming={0} hasBackdrop={fullscreen} coverScreen={fullscreen} - style={modalStyle} + style={[modalStyle, sidePaneStyle]} deviceHeight={windowHeight} deviceWidth={windowWidth} animationIn={animationIn ?? modalStyleAnimationIn} diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx index fcec907d8a36..daa64070f14a 100644 --- a/src/components/Modal/index.tsx +++ b/src/components/Modal/index.tsx @@ -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; if (statusBarColor) { setPreviousStatusBarColor(statusBarColor); diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts index ddc1e2bbd0e3..38e41f2326bc 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -117,6 +117,12 @@ type BaseModalProps = Partial & * 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; diff --git a/src/components/Search/SearchRouter/SearchRouterModal.tsx b/src/components/Search/SearchRouter/SearchRouterModal.tsx index 9d4841e9aa3b..5920eb615f8a 100644 --- a/src/components/Search/SearchRouter/SearchRouterModal.tsx +++ b/src/components/Search/SearchRouter/SearchRouterModal.tsx @@ -40,6 +40,7 @@ function SearchRouterModal() { onClose={closeSearchRouter} onModalHide={() => setShouldHideInputCaret(isMobileWebSafari)} onModalShow={() => setShouldHideInputCaret(false)} + shouldApplySidePaneOffset={!shouldUseNarrowLayout} > 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) ?? {}; const activeRoute = Navigation.getActiveRouteWithoutParams(); diff --git a/src/components/SidePane/Help/index.android.tsx b/src/components/SidePane/Help/index.android.tsx index 8be21c35a535..5518bf1875ee 100644 --- a/src/components/SidePane/Help/index.android.tsx +++ b/src/components/SidePane/Help/index.android.tsx @@ -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(() => { @@ -27,9 +21,14 @@ function Help({sidePaneTranslateX, closeSidePane}: HelpProps) { ); return ( - - - + closeSidePane()} + isVisible={!isPaneHidden} + type={CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED} + shouldHandleNavigationBack + > + + ); } diff --git a/src/components/SidePane/Help/index.ios.tsx b/src/components/SidePane/Help/index.ios.tsx index 171b98e2c14d..613040f1251c 100644 --- a/src/components/SidePane/Help/index.ios.tsx +++ b/src/components/SidePane/Help/index.ios.tsx @@ -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 ( - - - - - + closeSidePane()} + isVisible={!isPaneHidden} + type={CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED} + shouldHandleNavigationBack + propagateSwipe + swipeDirection={CONST.SWIPE_DIRECTION.RIGHT} + > + + ); } diff --git a/src/components/SidePane/Help/index.tsx b/src/components/SidePane/Help/index.tsx index 597bff5391ad..659ad52ba425 100644 --- a/src/components/SidePane/Help/index.tsx +++ b/src/components/SidePane/Help/index.tsx @@ -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, +// 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}); - 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}); + + // 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}); + + // 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 + }, []); return ( - - - + + + + + {!shouldHideSidePaneBackdrop && ( + + )} + + + + + + + ); } diff --git a/src/components/SidePane/Help/types.ts b/src/components/SidePane/Help/types.ts index 8054cc6cd43d..f54cff0d3d5c 100644 --- a/src/components/SidePane/Help/types.ts +++ b/src/components/SidePane/Help/types.ts @@ -3,7 +3,9 @@ import type {MutableRefObject} from 'react'; import type {Animated} from 'react-native'; type HelpProps = { + isPaneHidden: boolean; sidePaneTranslateX: MutableRefObject; + shouldHideSidePaneBackdrop: boolean; closeSidePane: (shouldUpdateNarrow?: boolean) => void; }; diff --git a/src/components/SidePane/SidePaneOverlay.tsx b/src/components/SidePane/SidePaneOverlay.tsx index 95f486b07262..a16cfb1b9084 100644 --- a/src/components/SidePane/SidePaneOverlay.tsx +++ b/src/components/SidePane/SidePaneOverlay.tsx @@ -6,8 +6,8 @@ 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; @@ -15,7 +15,7 @@ type SidePaneOverlayProps = { 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(); @@ -37,9 +37,9 @@ function SidePaneOverlay({isInNarrowPaneModal, onBackdropPress}: SidePaneOverlay return ( state?.routes.at(-1)?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR); + const {shouldHideSidePane, isPaneHidden, sidePaneTranslateX, shouldHideSidePaneBackdrop, closeSidePane} = useSidePane(); if (shouldHideSidePane) { return null; } return ( - <> - - {!shouldHideSidePaneBackdrop && ( - - )} - - - + ); } diff --git a/src/hooks/useSidePane.ts b/src/hooks/useSidePane.ts index a879696359fc..74150028c04c 100644 --- a/src/hooks/useSidePane.ts +++ b/src/hooks/useSidePane.ts @@ -5,6 +5,8 @@ import {Animated} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import {triggerSidePane} from '@libs/actions/SidePane'; +import focusComposerWithDelay from '@libs/focusComposerWithDelay'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -29,8 +31,9 @@ function useSidePane() { const [sidePaneNVP] = useOnyx(ONYXKEYS.NVP_SIDE_PANE); const [language] = useOnyx(ONYXKEYS.NVP_PREFERRED_LOCALE); + const [isAttachmentModalVisible = false] = useOnyx(ONYXKEYS.MODAL, {selector: (modal) => modal?.type === CONST.MODAL.MODAL_TYPE.CENTERED}); const isLanguageUnsupported = language !== CONST.LOCALES.EN; - const isPaneHidden = isSidePaneHidden(sidePaneNVP, isExtraLargeScreenWidth) || isLanguageUnsupported; + const isPaneHidden = isSidePaneHidden(sidePaneNVP, isExtraLargeScreenWidth) || isLanguageUnsupported || isAttachmentModalVisible; const sidePaneWidth = shouldUseNarrowLayout ? windowWidth : variables.sideBarWidth; const shouldApplySidePaneOffset = isExtraLargeScreenWidth && !isPaneHidden; @@ -62,12 +65,12 @@ function useSidePane() { Animated.timing(sidePaneOffset.current, { toValue: shouldApplySidePaneOffset ? variables.sideBarWidth : 0, duration: CONST.ANIMATED_TRANSITION, - useNativeDriver: false, + useNativeDriver: true, }), Animated.timing(sidePaneTranslateX.current, { toValue: isPaneHidden ? sidePaneWidth : 0, duration: CONST.ANIMATED_TRANSITION, - useNativeDriver: false, + useNativeDriver: true, }), ]).start(() => { setShouldHideSidePane(isPaneHidden); @@ -86,12 +89,16 @@ function useSidePane() { isOpen: shouldOnlyUpdateNarrowLayout ? undefined : false, isOpenNarrowScreen: shouldOnlyUpdateNarrowLayout ? false : undefined, }); + + // Focus the composer after closing the side pane + focusComposerWithDelay(ReportActionComposeFocusManager.composerRef.current, CONST.ANIMATED_TRANSITION + CONST.COMPOSER_FOCUS_DELAY)(true); }, [isExtraLargeScreenWidth, sidePaneNVP], ); return { sidePane: sidePaneNVP, + isPaneHidden, shouldHideSidePane, shouldHideSidePaneBackdrop, shouldHideHelpButton, diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 892ec2d6ce65..3f2779411dc6 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -182,7 +182,7 @@ const RootStack = createRootStackNavigator(); const modalScreenListeners = { focus: () => { - Modal.setModalVisibility(true); + Modal.setModalVisibility(true, CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED); }, blur: () => { Modal.setModalVisibility(false); diff --git a/src/libs/actions/Modal.ts b/src/libs/actions/Modal.ts index 9f912fd888bd..34233f16b6e7 100644 --- a/src/libs/actions/Modal.ts +++ b/src/libs/actions/Modal.ts @@ -1,5 +1,6 @@ import Onyx from 'react-native-onyx'; import ONYXKEYS from '@src/ONYXKEYS'; +import type ModalType from '@src/types/utils/ModalType'; const closeModals: Array<(isNavigating?: boolean) => void> = []; @@ -69,8 +70,8 @@ function onModalDidClose() { /** * Allows other parts of the app to know when a modal has been opened or closed */ -function setModalVisibility(isVisible: boolean) { - Onyx.merge(ONYXKEYS.MODAL, {isVisible}); +function setModalVisibility(isVisible: boolean, type: ModalType | null = null) { + Onyx.merge(ONYXKEYS.MODAL, {isVisible, type}); } /** diff --git a/src/libs/focusComposerWithDelay/index.ts b/src/libs/focusComposerWithDelay/index.ts index ab251bf8403a..1f18a4b9d4bc 100644 --- a/src/libs/focusComposerWithDelay/index.ts +++ b/src/libs/focusComposerWithDelay/index.ts @@ -1,13 +1,14 @@ import ComposerFocusManager from '@libs/ComposerFocusManager'; import isWindowReadyToFocus from '@libs/isWindowReadyToFocus'; import * as EmojiPickerAction from '@userActions/EmojiPickerAction'; +import CONST from '@src/CONST'; import setTextInputSelection from './setTextInputSelection'; import type {FocusComposerWithDelay, InputType} from './types'; /** * Create a function that focuses the composer. */ -function focusComposerWithDelay(textInput: InputType | null): FocusComposerWithDelay { +function focusComposerWithDelay(textInput: InputType | null, delay: number = CONST.COMPOSER_FOCUS_DELAY): FocusComposerWithDelay { /** * Focus the text input * @param [shouldDelay] Impose delay before focusing the text input @@ -33,7 +34,7 @@ function focusComposerWithDelay(textInput: InputType | null): FocusComposerWithD } // When the closing modal has a focused text input focus() needs a delay to properly work. // Setting 150ms here is a temporary workaround for the Android HybridApp. It should be reverted once we identify the real root cause of this issue: https://github.com/Expensify/App/issues/56311. - setTimeout(() => textInput.focus(), 150); + setTimeout(() => textInput.focus(), delay); if (forcedSelectionRange) { setTextInputSelection(textInput, forcedSelectionRange); } diff --git a/src/styles/index.ts b/src/styles/index.ts index 750bc6f256e9..1d34008e1cd2 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5499,6 +5499,8 @@ const styles = (theme: ThemeColors) => marginHorizontal: 8, alignSelf: 'center', }, + // We have to use 10000 here as sidePane has to be displayed on top of modals which have z-index of 9999 + sidePaneContainer: {zIndex: 10000}, sidePaneOverlay: (isOverlayVisible: boolean) => ({ ...positioning.pFixed, top: 0, @@ -5508,8 +5510,10 @@ const styles = (theme: ThemeColors) => backgroundColor: theme.overlay, opacity: isOverlayVisible ? 0 : variables.overlayOpacity, }), - sidePaneContainer: (shouldUseNarrowLayout: boolean, isExtraLargeScreenWidth: boolean): ViewStyle => ({ + sidePaneContent: (shouldUseNarrowLayout: boolean, isExtraLargeScreenWidth: boolean): ViewStyle => ({ position: Platform.OS === 'web' ? 'fixed' : 'absolute', + top: 0, + bottom: 0, right: 0, width: shouldUseNarrowLayout ? '100%' : variables.sideBarWidth, height: '100%', diff --git a/src/types/onyx/Modal.ts b/src/types/onyx/Modal.ts index b4b761cc8677..f5e169ae44f0 100644 --- a/src/types/onyx/Modal.ts +++ b/src/types/onyx/Modal.ts @@ -1,3 +1,5 @@ +import type ModalType from '@src/types/utils/ModalType'; + /** Modal state */ type Modal = { /** Indicates when an Alert modal is about to be visible */ @@ -9,6 +11,9 @@ type Modal = { /** Indicates if there is a modal currently visible or not */ isVisible?: boolean; + /** The type of the modal if it's visible */ + type?: ModalType; + /** Indicates if the modal is a popover */ isPopover?: boolean; };