diff --git a/src/components/TimeModalPicker.tsx b/src/components/TimeModalPicker.tsx index 0cb18a9543ad..c0d9f2da15db 100644 --- a/src/components/TimeModalPicker.tsx +++ b/src/components/TimeModalPicker.tsx @@ -3,6 +3,7 @@ import type {ForwardedRef} from 'react'; import {View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; +import useIsCenteredRHPModal from '@libs/Navigation/AppNavigator/useIsCenteredRHPModal'; import CONST from '@src/CONST'; import HeaderWithBackButton from './HeaderWithBackButton'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; @@ -10,6 +11,18 @@ import Modal from './Modal'; import ScreenWrapper from './ScreenWrapper'; import TimePicker from './TimePicker/TimePicker'; +/** Configuration handed to the parent step so it can render the picker inline within the same modal. */ +type InlineTimePickerConfig = { + /** The currently selected value (ISO datetime string). */ + value?: string; + + /** Header label for the picker. */ + label: string; + + /** Called with the selected 12-hour time string when the user saves. */ + onSubmit: (time: string) => void; +}; + type TimeModalPickerProps = { /** Current value of the selected item */ value?: string; @@ -23,12 +36,16 @@ type TimeModalPickerProps = { /** Label for the picker */ label: string; + /** When provided (centered RHP modal), tapping the row asks the parent step to render the picker inline instead of opening a separate modal. */ + onRequestOpenInline?: (config: InlineTimePickerConfig) => void; + /** Reference to the outer element */ ref?: ForwardedRef; }; -function TimeModalPicker({value, errorText, label, onInputChange = () => {}, ref}: TimeModalPickerProps) { +function TimeModalPicker({value, errorText, label, onInputChange = () => {}, onRequestOpenInline, ref}: TimeModalPickerProps) { const styles = useThemeStyles(); + const isCenteredModal = useIsCenteredRHPModal(); const [isPickerVisible, setIsPickerVisible] = useState(false); const currentTime = value ? DateUtils.extractTime12Hour(value) : undefined; @@ -42,45 +59,51 @@ function TimeModalPicker({value, errorText, label, onInputChange = () => {}, ref hidePickerModal(); }; + // Inside a centered RHP modal, render the picker inline via the parent step instead of opening a second modal. + const shouldRenderInline = isCenteredModal && !!onRequestOpenInline; + return ( <> setIsPickerVisible(true)} + onPress={() => (shouldRenderInline ? onRequestOpenInline?.({value, label, onSubmit: updateInput}) : setIsPickerVisible(true))} brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={errorText} ref={ref} /> - - - - - + - - - + + + + + + )} ); } export default TimeModalPicker; +export type {InlineTimePickerConfig}; diff --git a/src/components/ValuePicker/index.tsx b/src/components/ValuePicker/index.tsx index dadc697b0974..f4b44f37d287 100644 --- a/src/components/ValuePicker/index.tsx +++ b/src/components/ValuePicker/index.tsx @@ -1,6 +1,7 @@ import React, {useState} from 'react'; import {View} from 'react-native'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import useIsCenteredRHPModal from '@libs/Navigation/AppNavigator/useIsCenteredRHPModal'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import type {ValuePickerItem, ValuePickerProps} from './types'; @@ -22,7 +23,9 @@ function ValuePicker({ addBottomSafeAreaPadding = true, disableKeyboardShortcuts = false, alternateNumberOfSupportedLines, + onRequestOpenInline, }: ValuePickerProps) { + const isCenteredModal = useIsCenteredRHPModal(); const [isPickerVisible, setIsPickerVisible] = useState(false); const showPickerModal = () => { @@ -42,6 +45,9 @@ function ValuePicker({ const selectedItem = items?.find((item) => item.value === value); + // Inside a centered RHP modal, render the selection list inline via the parent step instead of opening a second modal. + const shouldRenderInline = isCenteredModal && !!onRequestOpenInline; + return ( {shouldShowModal ? ( @@ -52,25 +58,27 @@ function ValuePicker({ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing title={selectedItem?.label || placeholder || ''} description={label} - onPress={showPickerModal} + onPress={() => (shouldRenderInline ? onRequestOpenInline?.({label, items, selectedItem, onItemSelected: updateInput, shouldShowTooltips}) : showPickerModal())} furtherDetails={furtherDetails} brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={errorText} forwardedFSClass={forwardedFSClass} /> - + {!shouldRenderInline && ( + + )} ) : ( ; + type ValuePickerProps = ForwardedFSClassProps & { /** Item to display */ value?: string; @@ -98,6 +101,9 @@ type ValuePickerProps = ForwardedFSClassProps & { /** Number of lines to show for alternate text */ alternateNumberOfSupportedLines?: number; + + /** When provided (centered RHP modal), tapping the row asks the parent step to render the selection list inline instead of opening a separate modal. */ + onRequestOpenInline?: (config: InlineValuePickerConfig) => void; }; -export type {ValuePickerItem, ValueSelectorModalProps, ValuePickerProps, ValueSelectionListProps}; +export type {ValuePickerItem, ValueSelectorModalProps, ValuePickerProps, ValueSelectionListProps, InlineValuePickerConfig}; diff --git a/src/components/WideRHPContextProvider/index.native.tsx b/src/components/WideRHPContextProvider/index.native.tsx index a1fd7a0ff897..a0ecdf17ab2f 100644 --- a/src/components/WideRHPContextProvider/index.native.tsx +++ b/src/components/WideRHPContextProvider/index.native.tsx @@ -7,15 +7,11 @@ import {defaultWideRHPActionsContextValue, defaultWideRHPStateContextValue} from import type {WideRHPActionsContextType, WideRHPStateContextType} from './types'; const secondOverlayWideRHPProgress = new Animated.Value(0); -const secondOverlayRHPOnWideRHPProgress = new Animated.Value(0); -const secondOverlayRHPOnSuperWideRHPProgress = new Animated.Value(0); -const thirdOverlayProgress = new Animated.Value(0); const animatedReceiptPaneRHPWidth = new Animated.Value(0); const animatedWideRHPWidth = new Animated.Value(0); const animatedSuperWideRHPWidth = new Animated.Value(0); -const modalStackOverlaySuperWideRHPPositionLeft = new Animated.Value(0); const modalStackOverlayWideRHPPositionLeft = new Animated.Value(0); const expandedRHPProgress = new Animated.Value(0); @@ -45,12 +41,8 @@ export { animatedSuperWideRHPWidth, animatedWideRHPWidth, expandedRHPProgress, - modalStackOverlaySuperWideRHPPositionLeft, modalStackOverlayWideRHPPositionLeft, - secondOverlayRHPOnSuperWideRHPProgress, - secondOverlayRHPOnWideRHPProgress, secondOverlayWideRHPProgress, - thirdOverlayProgress, useWideRHPState, useWideRHPActions, }; diff --git a/src/components/WideRHPContextProvider/index.tsx b/src/components/WideRHPContextProvider/index.tsx index 687a7a7a1aaa..3c638a315e4b 100644 --- a/src/components/WideRHPContextProvider/index.tsx +++ b/src/components/WideRHPContextProvider/index.tsx @@ -357,12 +357,8 @@ export { animatedSuperWideRHPWidth, animatedWideRHPWidth, expandedRHPProgress, - modalStackOverlaySuperWideRHPPositionLeft, modalStackOverlayWideRHPPositionLeft, secondOverlayWideRHPProgress, - secondOverlayRHPOnWideRHPProgress, - secondOverlayRHPOnSuperWideRHPProgress, - thirdOverlayProgress, useWideRHPState, useWideRHPActions, }; diff --git a/src/components/WideRHPOverlayWrapper/index.tsx b/src/components/WideRHPOverlayWrapper/index.tsx index da63e02b7a35..572b09a6ba7f 100644 --- a/src/components/WideRHPOverlayWrapper/index.tsx +++ b/src/components/WideRHPOverlayWrapper/index.tsx @@ -1,52 +1,29 @@ import {useRoute} from '@react-navigation/native'; import React from 'react'; -import { - animatedReceiptPaneRHPWidth, - modalStackOverlaySuperWideRHPPositionLeft, - modalStackOverlayWideRHPPositionLeft, - secondOverlayRHPOnSuperWideRHPProgress, - secondOverlayRHPOnWideRHPProgress, - secondOverlayWideRHPProgress, - useWideRHPState, -} from '@components/WideRHPContextProvider'; +import {modalStackOverlayWideRHPPositionLeft, secondOverlayWideRHPProgress, useWideRHPState} from '@components/WideRHPContextProvider'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import Overlay from '@libs/Navigation/AppNavigator/Navigators/Overlay'; function SecondaryOverlay() { - const {shouldRenderSecondaryOverlayForRHPOnSuperWideRHP, shouldRenderSecondaryOverlayForRHPOnWideRHP, shouldRenderSecondaryOverlayForWideRHP, superWideRHPRouteKeys, wideRHPRouteKeys} = - useWideRHPState(); + const {shouldRenderSecondaryOverlayForWideRHP, superWideRHPRouteKeys} = useWideRHPState(); const route = useRoute(); - const isWide = !!route?.key && wideRHPRouteKeys.includes(route.key); const isSuperWide = !!route?.key && superWideRHPRouteKeys.includes(route.key); - const isRHPDisplayedOnWideRHP = shouldRenderSecondaryOverlayForRHPOnWideRHP && isWide; - const isRHPDisplayedOnSuperWideRHP = shouldRenderSecondaryOverlayForRHPOnSuperWideRHP && isSuperWide; const isWideRHPDisplayedOnSuperWideRHP = shouldRenderSecondaryOverlayForWideRHP && isSuperWide; /** - * These overlays are used to cover the space under the narrower RHP screen when more than one RHP width is displayed on the screen - * Their position is calculated as follows: + * This overlay is used to cover the space under the narrower RHP screen when more than one RHP width is displayed on the screen. + * Its position is calculated as follows: * The width of the window for which we calculate the overlay positions is the width of the RHP window, for example for Super Wide RHP it will be 1260 px on a wide layout. * We need to move the overlay left from the left edge of the RHP below to the left edge of the RHP above. * To calculate this, subtract the width of the widest RHP from the width of the RHP above. - * Please note that in these cases, the overlay is rendered from the RHP screen displayed below. For example, if we display RHP on Wide RHP, the secondary overlay is rendered from Wide RHP, etc. - * Three cases were described for the secondary overlay: - * 1. Single RHP is displayed on Wide RHP - * 2. Single RHP is displayed on Super Wide RHP - * 3. Wide RHP is displayed on Super Wide RHP route. + * Please note that in this case, the overlay is rendered from the RHP screen displayed below (the Wide RHP). + * + * The "single RHP displayed on (super) wide RHP" cases are not handled here: those small RHPs are centered modals with their + * own dim (see ModalStackNavigators index). Only "Wide RHP displayed on Super Wide RHP" remains. * */ - if (isRHPDisplayedOnWideRHP) { - return ( - - ); - } - if (isWideRHPDisplayedOnSuperWideRHP) { return ( - ); - } - return null; } diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index a79e1158e3aa..3785a94b97f4 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -53,6 +53,7 @@ import createRootStackNavigator from './createRootStackNavigator'; import {screensWithEnteringAnimation} from './createRootStackNavigator/GetStateForActionHandlers'; import defaultScreenOptions from './defaultScreenOptions'; import DelegatorConnectGuard from './DelegatorConnectGate'; +import doesRHPStackHaveWidePane from './doesRHPStackHaveWidePane'; import hideKeyboardOnSwipe from './hideKeyboardOnSwipe'; import KeyboardShortcutsHandler from './KeyboardShortcutsHandler'; import {ShareModalStackNavigator} from './ModalStackNavigators'; @@ -65,6 +66,7 @@ import TestDriveModalNavigator from './Navigators/TestDriveModalNavigator'; import TestToolsModalNavigator from './Navigators/TestToolsModalNavigator'; import TestDriveDemoNavigator from './TestDriveDemoNavigator'; import ThreeDSAuthHandler from './ThreeDSAuthHandler'; +import useIsCenteredRHPModal from './useIsCenteredRHPModal'; import useModalCardStyleInterpolator from './useModalCardStyleInterpolator'; import useRootNavigatorScreenOptions from './useRootNavigatorScreenOptions'; import UserStatusHandler from './UserStatusHandler'; @@ -125,6 +127,7 @@ function AuthScreens() { const {shouldUseNarrowLayout} = useResponsiveLayout(); const rootNavigatorScreenOptions = useRootNavigatorScreenOptions(); const modalCardStyleInterpolator = useModalCardStyleInterpolator(); + const isCenteredRHPModal = useIsCenteredRHPModal(); const {isOnboardingCompleted} = useOnboardingFlowRouter(); useEffect(() => { @@ -135,6 +138,23 @@ function AuthScreens() { }; }, [theme]); + // Dynamic options for RIGHT_MODAL_NAVIGATOR: on wide layout a standalone small RHP fades the whole navigator in place + // (matching centered/alert modals, so there's no lateral movement). Wide expense/report panes and narrow layout keep the slide entrance. + const getRightModalNavigatorOptions = ({route}: {route: RouteProp}) => { + const baseOptions = rootNavigatorScreenOptions.rightModalNavigator; + if (!isCenteredRHPModal || doesRHPStackHaveWidePane(route)) { + return baseOptions; + } + return { + ...baseOptions, + web: { + ...baseOptions.web, + // A centered modal is positioned independently (fixed, centered box), so the right-docked side-panel offset doesn't apply. + cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator({props, enter: {kind: 'fade'}}), + }, + }; + }; + // Dynamic options for TAB_NAVIGATOR: supports entering animation for pushed instances const getTabNavigatorOptions = ({route}: {route: RouteProp}) => { if (!shouldUseNarrowLayout) { @@ -292,7 +312,7 @@ function AuthScreens() { /> diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 6a86b048a5dc..2b7cdd7e9b03 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -1,9 +1,14 @@ import type {ParamListBase} from '@react-navigation/routers'; -import React, {useCallback} from 'react'; +import React, {useCallback, useMemo} from 'react'; +import type {ViewStyle} from 'react-native'; import {View} from 'react-native'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import Overlay from '@libs/Navigation/AppNavigator/Navigators/Overlay'; +import useCenteredRHPModalState from '@libs/Navigation/AppNavigator/useCenteredRHPModalState'; +import useCenteredRHPModalStyle from '@libs/Navigation/AppNavigator/useCenteredRHPModalStyle'; import withAgentAccessDenied from '@libs/Navigation/AppNavigator/withAgentAccessDenied'; +import Navigation from '@libs/Navigation/Navigation'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import Animations from '@libs/Navigation/PlatformStackNavigation/navigationOptions/animation'; import type {PlatformStackNavigationOptions} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -125,11 +130,19 @@ function createModalStackNavigator(screens: Scr function ModalStack() { const styles = useThemeStyles(); const screenOptions = useModalStackScreenOptions(); + const {isCenteredModal, hasWidePane} = useCenteredRHPModalState(); // We have to use the isSmallScreenWidth instead of shouldUseNarrow layout, because we want to have information about screen width without the context of side modal. // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); + // A centered modal above a wide pane gets a full-screen dim backdrop (dismisses on outside press) plus the centered box. + const isCenteredModalOverWidePane = isCenteredModal && hasWidePane; + + // Geometry of the centered box; we constrain the inner navigator to it so taps outside fall through to the backdrop. + const centeredModalGeometry = useCenteredRHPModalStyle(); + const centeredBoxStyle = useMemo(() => ({position: 'absolute', ...centeredModalGeometry, overflow: 'hidden'}), [centeredModalGeometry]); + const getScreenOptions = useCallback( ({route: optionRoute}) => { // Extend common options if they are defined for the screen. @@ -141,25 +154,41 @@ function createModalStackNavigator(screens: Scr [screenOptions], ); + const navigatorElement = ( + + {Object.keys(screens as Required).map((name) => ( + )[name as Screen]} + // For some reason, screenOptions is not working with function as options so we have to pass it to every screen. + options={getScreenOptions} + /> + ))} + + ); + return ( // This container is necessary to hide card translation during transition. Without it the user would see un-clipped cards. + // On wide layout a centered modal fills the (centered & clipped) content card instead of docking right; on narrow layout it keeps full-width behavior. - - {Object.keys(screens as Required).map((name) => ( - )[name as Screen]} - // For some reason, screenOptions is not working with function as options so we have to pass it to every screen. - options={getScreenOptions} - /> - ))} - + {isCenteredModalOverWidePane ? ( + <> + {/* Full-screen dim on top of the wide pane; dismisses the modal on outside press. The primary RHP overlay stays mounted underneath so closing only fades this dim away. */} + Navigation.dismissToPreviousRHP()} /> + {/* Constrain the inner navigator to the centered box so taps outside fall through to the overlay. */} + {navigatorElement} + + ) : ( + navigatorElement + )} ); } diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts b/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts index 713f15df5e63..56ac553e47b3 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts @@ -1,6 +1,7 @@ import type {ParamListBase} from '@react-navigation/native'; import type {StackCardStyleInterpolator} from '@react-navigation/stack'; -import {useCallback} from 'react'; +import {useCallback, useMemo} from 'react'; +import type {ViewStyle} from 'react-native'; import {useWideRHPState} from '@components/WideRHPContextProvider'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -8,6 +9,7 @@ import enhanceCardStyleInterpolator from '@libs/Navigation/AppNavigator/enhanceC import hideKeyboardOnSwipe from '@libs/Navigation/AppNavigator/hideKeyboardOnSwipe'; import useModalCardStyleInterpolator from '@libs/Navigation/AppNavigator/useModalCardStyleInterpolator'; import type {PlatformStackNavigationOptions, PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; function useWideModalStackScreenOptions() { @@ -21,11 +23,27 @@ function useWideModalStackScreenOptions() { const {isSmallScreenWidth} = useResponsiveLayout(); const {wideRHPRouteKeys, superWideRHPRouteKeys} = useWideRHPState(); + // A centered card needs rounded corners on the card itself, because the fixed-positioned card escapes the container's borderRadius clip. + const roundedCardStyle = useMemo( + () => ({...styles.navigationScreenCardStyle, borderRadius: variables.componentBorderRadiusLarge, overflow: 'hidden' as const}), + [styles.navigationScreenCardStyle], + ); + + // Override the base `position: 'fixed'` card style so a centered card fills the inner navigator's wrapper box instead of the full viewport. + const filledCenteredCardStyle = useMemo(() => ({...styles.fullScreen, overflow: 'hidden'}), [styles.fullScreen]); + return useCallback<({route}: {route: PlatformStackRouteProp}) => PlatformStackNavigationOptions>( ({route}) => { const baseInterpolator: StackCardStyleInterpolator = (props) => modalCardStyleInterpolator({props, enter: {kind: 'slide-and-fade', distancePx: CONST.MODAL.RHP_ENTER_OFFSET_PX_WEB}}); + const isWideRoute = superWideRHPRouteKeys.includes(route.key) || wideRHPRouteKeys.includes(route.key); + const hasWidePane = superWideRHPRouteKeys.length > 0 || wideRHPRouteKeys.length > 0; + + // A small RHP on wide layout is always a centered modal (alone, or floating above a wide pane). Wide panes keep their corners. + const isCenteredCard = !isSmallScreenWidth && !isWideRoute; + const navigationScreenCardStyle = isCenteredCard ? roundedCardStyle : styles.navigationScreenCardStyle; + let cardStyleInterpolator: StackCardStyleInterpolator = baseInterpolator; if (!isSmallScreenWidth) { @@ -37,10 +55,10 @@ function useWideModalStackScreenOptions() { cardStyleInterpolator = enhanceCardStyleInterpolator(baseInterpolator, { cardStyle: styles.wideRHPExtendedCardInterpolatorStyles, }); - // single RHPs displayed above the wide RHP need to be positioned - } else if (superWideRHPRouteKeys.length > 0 || wideRHPRouteKeys.length > 0) { + // A centered modal on top of a wide pane fills the inner navigator's wrapper box. + } else if (hasWidePane) { cardStyleInterpolator = enhanceCardStyleInterpolator(baseInterpolator, { - cardStyle: styles.singleRHPExtendedCardInterpolatorStyles, + cardStyle: filledCenteredCardStyle, }); } } @@ -50,11 +68,13 @@ function useWideModalStackScreenOptions() { headerShown: false, animationTypeForReplace: 'pop', native: { - contentStyle: styles.navigationScreenCardStyle, + contentStyle: navigationScreenCardStyle, }, web: { - cardStyle: styles.navigationScreenCardStyle, + cardStyle: navigationScreenCardStyle, cardStyleInterpolator, + // Expensify dims via its own Overlay components, so disable React Navigation's default card overlay (else an extra dim band appears). + cardOverlayEnabled: false, transitionSpec: { open: {animation: 'timing', config: {duration: CONST.MODAL.ANIMATION_TIMING.RHP_DURATION_IN_WEB}}, close: {animation: 'timing', config: {duration: CONST.MODAL.ANIMATION_TIMING.RHP_DURATION_OUT_WEB}}, @@ -62,7 +82,7 @@ function useWideModalStackScreenOptions() { }, }; }, - [isSmallScreenWidth, modalCardStyleInterpolator, styles, superWideRHPRouteKeys, wideRHPRouteKeys], + [isSmallScreenWidth, modalCardStyleInterpolator, roundedCardStyle, filledCenteredCardStyle, styles, superWideRHPRouteKeys, wideRHPRouteKeys], ); } diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index c64869214283..d50b53ed2e73 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -3,18 +3,10 @@ import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useEffect, useMemo, useRef} from 'react'; // eslint-disable-next-line no-restricted-imports import {Animated, DeviceEventEmitter} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; import {DialogLabelProvider} from '@components/DialogLabelContext'; import NoDropZone from '@components/DragAndDrop/NoDropZone'; -import { - animatedWideRHPWidth, - expandedRHPProgress, - secondOverlayRHPOnSuperWideRHPProgress, - secondOverlayRHPOnWideRHPProgress, - secondOverlayWideRHPProgress, - thirdOverlayProgress, - useWideRHPActions, - useWideRHPState, -} from '@components/WideRHPContextProvider'; +import {animatedWideRHPWidth, expandedRHPProgress, secondOverlayWideRHPProgress, useWideRHPActions, useWideRHPState} from '@components/WideRHPContextProvider'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSidePanelState from '@hooks/useSidePanelState'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -24,6 +16,8 @@ import {clearTwoFactorAuthData} from '@libs/actions/TwoFactorAuthActions'; import hideKeyboardOnSwipe from '@libs/Navigation/AppNavigator/hideKeyboardOnSwipe'; import * as ModalStackNavigators from '@libs/Navigation/AppNavigator/ModalStackNavigators'; import useModalStackScreenOptions from '@libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions'; +import useCenteredRHPModalState from '@libs/Navigation/AppNavigator/useCenteredRHPModalState'; +import useCenteredRHPModalStyle from '@libs/Navigation/AppNavigator/useCenteredRHPModalStyle'; import useRHPScreenOptions from '@libs/Navigation/AppNavigator/useRHPScreenOptions'; import calculateReceiptPaneRHPWidth from '@libs/Navigation/helpers/calculateReceiptPaneRHPWidth'; import calculateSuperWideRHPWidth from '@libs/Navigation/helpers/calculateSuperWideRHPWidth'; @@ -68,7 +62,7 @@ function SearchAdvancedFiltersWithContext(props: Record) { } function SecondaryOverlay() { - const {shouldRenderSecondaryOverlayForWideRHP, shouldRenderSecondaryOverlayForRHPOnWideRHP, shouldRenderSecondaryOverlayForRHPOnSuperWideRHP} = useWideRHPState(); + const {shouldRenderSecondaryOverlayForWideRHP} = useWideRHPState(); const {sidePanelOffset} = useSidePanelState(); if (shouldRenderSecondaryOverlayForWideRHP) { @@ -81,26 +75,7 @@ function SecondaryOverlay() { ); } - if (shouldRenderSecondaryOverlayForRHPOnWideRHP) { - return ( - - ); - } - - if (shouldRenderSecondaryOverlayForRHPOnSuperWideRHP) { - return ( - - ); - } - + // Small RHPs over a wide/super-wide pane are centered modals with their own dim (see ModalStackNavigators), so no docked secondary overlay here. return null; } @@ -114,12 +89,11 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { const containerRef = useRef(null); const isExecutingRef = useRef(false); const screenOptions = useRHPScreenOptions(); - const {superWideRHPRouteKeys, wideRHPRouteKeys, shouldRenderTertiaryOverlay} = useWideRHPState(); + const {superWideRHPRouteKeys, wideRHPRouteKeys} = useWideRHPState(); const {clearWideRHPKeys, syncRHPKeys} = useWideRHPActions(); const {windowWidth} = useWindowDimensions(); const modalStackScreenOptions = useModalStackScreenOptions(); const styles = useThemeStyles(); - const {sidePanelOffset} = useSidePanelState(); // When a fullscreen route is pre-inserted under the RHP, disable the slide-out animation // so the dismiss reveals the destination instantly. If the pre-insert is later cleaned up @@ -152,6 +126,19 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { } as const; }, [animatedWidth, shouldUseNarrowLayout]); + // Render "small" RHPs (everything except the wide/super-wide expense & report panes) as a centered modal on wide layout. + const {isCenteredModal, hasWidePane, isFocusedOverWidePane} = useCenteredRHPModalState(); + const centeredModalStyle = useCenteredRHPModalStyle(); + + let containerLayoutStyle: StyleProp = [styles.r0, styles.h100, animatedWidthStyle]; + if (isFocusedOverWidePane) { + // Small RHP floating above a wide/super-wide pane: full-viewport container so the centered card isn't clipped. + containerLayoutStyle = [styles.t0, styles.l0, styles.r0, styles.b0]; + } else if (isCenteredModal && !hasWidePane) { + // Standalone small RHP (no wide pane in the stack): the container itself is the centered box. + containerLayoutStyle = centeredModalStyle; + } + const overlayPositionLeft = useMemo(() => -1 * calculateSuperWideRHPWidth(windowWidth), [windowWidth]); const screenListeners = useMemo( @@ -215,6 +202,7 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { return ( + {/* The original RHP dim, kept mounted even while a centered modal (with its own dim on top) is open, so closing the modal never blinks this one. */} {!shouldUseNarrowLayout && ( } - {!shouldUseNarrowLayout && shouldRenderTertiaryOverlay && ( - - )} ); diff --git a/src/libs/Navigation/AppNavigator/doesRHPStackHaveWidePane.ts b/src/libs/Navigation/AppNavigator/doesRHPStackHaveWidePane.ts new file mode 100644 index 000000000000..4ff328a6870c --- /dev/null +++ b/src/libs/Navigation/AppNavigator/doesRHPStackHaveWidePane.ts @@ -0,0 +1,24 @@ +import type {NavigationState, PartialState, RouteProp} from '@react-navigation/native'; +import {ALL_WIDE_RIGHT_MODALS} from '@components/WideRHPContextProvider/WIDE_RIGHT_MODALS'; +import type {AuthScreensParamList} from '@libs/Navigation/types'; +import type NAVIGATORS from '@src/NAVIGATORS'; + +type RightModalNavigatorRoute = RouteProp & { + state?: NavigationState | PartialState; +}; + +/** + * Whether the RHP stack currently contains a wide/super-wide expense or report pane. + * Reads the route directly (no WideRHPContextProvider), so it can be used above that provider — e.g. when + * choosing the RightModalNavigator's root entrance animation. Wide panes keep sliding; everything else can fade. + */ +function doesRHPStackHaveWidePane(route: RightModalNavigatorRoute): boolean { + if (route.state?.routes?.length) { + return route.state.routes.some((nestedRoute) => ALL_WIDE_RIGHT_MODALS.has(nestedRoute.name)); + } + // Before the nested state hydrates (initial mount), the focused screen comes through params. + const screen = route.params?.screen; + return !!screen && ALL_WIDE_RIGHT_MODALS.has(screen); +} + +export default doesRHPStackHaveWidePane; diff --git a/src/libs/Navigation/AppNavigator/useCenteredRHPModalState.ts b/src/libs/Navigation/AppNavigator/useCenteredRHPModalState.ts new file mode 100644 index 000000000000..38864881439e --- /dev/null +++ b/src/libs/Navigation/AppNavigator/useCenteredRHPModalState.ts @@ -0,0 +1,20 @@ +import {useWideRHPState} from '@components/WideRHPContextProvider'; +import useIsCenteredRHPModal from './useIsCenteredRHPModal'; + +/** + * Shared centered-modal conditions for the navigator side (avoids recomputing them per file). + * - `isCenteredModal`: wide layout, so small RHPs should render as centered modals. + * - `hasWidePane`: a wide/super-wide pane exists in the stack (focused or below). + * - `isFocusedOverWidePane`: a centered modal is the focused card above a wide/super-wide pane. + */ +function useCenteredRHPModalState() { + const isCenteredModal = useIsCenteredRHPModal(); + const {wideRHPRouteKeys, superWideRHPRouteKeys, shouldRenderSecondaryOverlayForRHPOnWideRHP, shouldRenderSecondaryOverlayForRHPOnSuperWideRHP} = useWideRHPState(); + + const hasWidePane = wideRHPRouteKeys.length > 0 || superWideRHPRouteKeys.length > 0; + const isFocusedOverWidePane = isCenteredModal && (shouldRenderSecondaryOverlayForRHPOnWideRHP || shouldRenderSecondaryOverlayForRHPOnSuperWideRHP); + + return {isCenteredModal, hasWidePane, isFocusedOverWidePane}; +} + +export default useCenteredRHPModalState; diff --git a/src/libs/Navigation/AppNavigator/useCenteredRHPModalStyle.ts b/src/libs/Navigation/AppNavigator/useCenteredRHPModalStyle.ts new file mode 100644 index 000000000000..6da05f1b00ee --- /dev/null +++ b/src/libs/Navigation/AppNavigator/useCenteredRHPModalStyle.ts @@ -0,0 +1,32 @@ +import {useMemo} from 'react'; +import type {ViewStyle} from 'react-native'; +import useTheme from '@hooks/useTheme'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import variables from '@styles/variables'; + +const CENTERED_MODAL_VERTICAL_MARGIN = 32; + +/** Geometry of the centered RHP modal box (width/height/position/radius). Shared so the container and its inner content stay aligned. */ +function useCenteredRHPModalStyle(): ViewStyle { + const {windowWidth, windowHeight} = useWindowDimensions(); + const theme = useTheme(); + + return useMemo(() => { + // Height tracks the viewport (minus the vertical margin on each side) but is capped so the box keeps modal-like proportions on large screens. + const modalHeight = Math.min(windowHeight - 2 * CENTERED_MODAL_VERTICAL_MARGIN, variables.rhpCenteredModalMaxHeight); + return { + width: variables.rhpCenteredModalWidth, + height: modalHeight, + // Pin to a fixed top offset rather than centering: equal margins read as centered until the cap is reached, after which the box stays anchored near the top. + top: CENTERED_MODAL_VERTICAL_MARGIN, + left: (windowWidth - variables.rhpCenteredModalWidth) / 2, + borderRadius: variables.componentBorderRadiusLarge, + // Same drop shadow we use for centered/alert and big attachment modals. + boxShadow: theme.shadow, + // Without it, Safari drops the containment once the fade entry settles and the modal stretches to the bottom of the screen. + transform: [{translateX: 0}], + }; + }, [windowHeight, windowWidth, theme.shadow]); +} + +export default useCenteredRHPModalStyle; diff --git a/src/libs/Navigation/AppNavigator/useIsCenteredRHPModal.ts b/src/libs/Navigation/AppNavigator/useIsCenteredRHPModal.ts new file mode 100644 index 000000000000..a4faf0dafa90 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/useIsCenteredRHPModal.ts @@ -0,0 +1,12 @@ +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +/** On wide layout every "small" RHP renders as a centered modal instead of a right-docked panel; on narrow layout it stays full-screen. */ +function useIsCenteredRHPModal(): boolean { + // Raw screen width (ignoring the side-modal context) so the value is identical across the RHP container and the modal stack screens it wraps. + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + + return !isSmallScreenWidth; +} + +export default useIsCenteredRHPModal; diff --git a/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts b/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts index 39f3ecdca8f3..9dbf8685d075 100644 --- a/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts @@ -1,5 +1,5 @@ import {CardStyleInterpolators} from '@react-navigation/stack'; -import type {StackCardInterpolationProps} from '@react-navigation/stack'; +import type {StackCardInterpolationProps, StackCardStyleInterpolator} from '@react-navigation/stack'; import {useMemo} from 'react'; import {useWideRHPState} from '@components/WideRHPContextProvider'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -8,6 +8,7 @@ import {isSafari} from '@libs/Browser'; import Animations from '@libs/Navigation/PlatformStackNavigation/navigationOptions/animation'; import Presentation from '@libs/Navigation/PlatformStackNavigation/navigationOptions/presentation'; import type {PlatformStackNavigationOptions} from '@libs/Navigation/PlatformStackNavigation/types/NavigationOptions'; +import useCenteredRHPModalState from './useCenteredRHPModalState'; import useModalCardStyleInterpolator from './useModalCardStyleInterpolator'; // This function is necessary for proper animation if a wide format RHP screen is visible. @@ -29,6 +30,7 @@ const useRHPScreenOptions = (): PlatformStackNavigationOptions => { const styles = useThemeStyles(); const customInterpolator = useModalCardStyleInterpolator(); const {wideRHPRouteKeys} = useWideRHPState(); + const {isCenteredModal, hasWidePane, isFocusedOverWidePane} = useCenteredRHPModalState(); // We have to use the isSmallScreenWidth instead of shouldUseNarrow layout, because we want to have information about screen width without the context of side modal. // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -37,23 +39,36 @@ const useRHPScreenOptions = (): PlatformStackNavigationOptions => { // Adjust props on wide layout and when the wide RHP is visible const shouldAdjustInterpolatorProps = !isSmallScreenWidth && wideRHPRouteKeys.length; + // A small centered modal fades in place (matching centered/alert modals) so there's no lateral movement: + // either it's focused over a wide pane (a slide would drag its dim across the screen), or it's a standalone + // centered modal with no wide pane in the stack. Wide expense/report panes (hasWidePane && !focused over) keep sliding. + const shouldFadeCenteredModal = isFocusedOverWidePane || (isCenteredModal && !hasWidePane); + + const cardStyleInterpolator = useMemo(() => { + if (shouldFadeCenteredModal) { + return (props) => customInterpolator({props, enter: {kind: 'fade'}}); + } + // The .forHorizontalIOS interpolator from `@react-navigation` is misbehaving on Safari, so we override it with Expensify custom interpolator + if (isSafari()) { + return (props) => customInterpolator({props, enter: {kind: 'slide-from-width'}}); + } + return (props) => CardStyleInterpolators.forHorizontalIOS(shouldAdjustInterpolatorProps ? getModifiedCardStyleInterpolatorProps(props) : props); + }, [customInterpolator, shouldFadeCenteredModal, shouldAdjustInterpolatorProps]); + return useMemo(() => { return { headerShown: false, animation: Animations.SLIDE_FROM_RIGHT, gestureDirection: 'horizontal', web: { - // The .forHorizontalIOS interpolator from `@react-navigation` is misbehaving on Safari, so we override it with Expensify custom interpolator - cardStyleInterpolator: isSafari() - ? (props) => customInterpolator({props, enter: {kind: 'slide-from-width'}}) - : (props) => CardStyleInterpolators.forHorizontalIOS(shouldAdjustInterpolatorProps ? getModifiedCardStyleInterpolatorProps(props) : props), + cardStyleInterpolator, presentation: Presentation.TRANSPARENT_MODAL, cardOverlayEnabled: false, cardStyle: styles.navigationScreenCardStyle, gestureDirection: 'horizontal', }, }; - }, [customInterpolator, shouldAdjustInterpolatorProps, styles.navigationScreenCardStyle]); + }, [cardStyleInterpolator, styles.navigationScreenCardStyle]); }; export default useRHPScreenOptions; diff --git a/src/pages/iou/request/step/IOURequestStepSubrate.tsx b/src/pages/iou/request/step/IOURequestStepSubrate.tsx index a079ed4475c3..09e094302f24 100644 --- a/src/pages/iou/request/step/IOURequestStepSubrate.tsx +++ b/src/pages/iou/request/step/IOURequestStepSubrate.tsx @@ -14,6 +14,8 @@ import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; import ValuePicker from '@components/ValuePicker'; +import type {InlineValuePickerConfig} from '@components/ValuePicker/types'; +import ValueSelectionList from '@components/ValuePicker/ValueSelectionList'; import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -93,6 +95,8 @@ function IOURequestStepSubrate({ const currentSubrate: CommentSubrate | undefined = allSubrates.at(parsedIndex) ?? undefined; const totalSubrateCount = allPossibleSubrates.length; const filledSubrateCount = allSubrates.length; + // When set (centered RHP modal), the subrate selection list is rendered inline here over the still-mounted form instead of in a second modal. + const [activeValuePicker, setActiveValuePicker] = useState(null); const [subrateValue, setSubrateValue] = useState(currentSubrate?.id); const [quantityValue, setQuantityValue] = useState(() => (currentSubrate?.quantity ? String(currentSubrate.quantity) : undefined)); @@ -194,6 +198,8 @@ function IOURequestStepSubrate({ [CONST.IOU.TYPE.INVOICE]: translate('workspace.invoices.sendInvoice'), [CONST.IOU.TYPE.CREATE]: translate('iou.createExpense'), }; + const titleDefault = backTo ? translate('common.subrate') : tabTitles[iouType]; + const title = shouldDisableEditor || !activeValuePicker ? titleDefault : (activeValuePicker.label ?? ''); return ( setActiveValuePicker(null) : goBack} + shouldShowThreeDotsButton={!activeValuePicker && shouldShowThreeDotsButton} shouldSetModalVisibility={false} threeDotsMenuItems={[ { @@ -219,45 +225,62 @@ function IOURequestStepSubrate({ }, ]} /> - - {translate('iou.subrateSelection')} - + + + {translate('iou.subrateSelection')} + + { + setSubrateValue(value as string); + InteractionManager.runAfterInteractions(() => { + textInputRef.current?.focus(); + }); + }} + /> + { - setSubrateValue(value as string); - InteractionManager.runAfterInteractions(() => { - textInputRef.current?.focus(); - }); - }} + InputComponent={TextInput} + inputID={`quantity${pageIndex}`} + ref={textInputRef} + containerStyles={[styles.mt4]} + label={translate('iou.quantity')} + value={quantityValue} + inputMode={CONST.INPUT_MODE.NUMERIC} + maxLength={CONST.IOU.QUANTITY_MAX_LENGTH} + onChangeText={onChangeQuantity} /> - - - + + {!!activeValuePicker && ( + // Overlay the selection list on the still-mounted form so its onItemSelected stays valid. + + { + activeValuePicker.onItemSelected?.(item); + setActiveValuePicker(null); + }} + /> + + )} + ); diff --git a/src/pages/iou/request/step/IOURequestStepTime.tsx b/src/pages/iou/request/step/IOURequestStepTime.tsx index 7403172d99f0..028fd2e70c63 100644 --- a/src/pages/iou/request/step/IOURequestStepTime.tsx +++ b/src/pages/iou/request/step/IOURequestStepTime.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react'; +import React, {useMemo, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import DatePicker from '@components/DatePicker'; @@ -7,6 +7,8 @@ import InputWrapper from '@components/Form/InputWrapper'; import type {FormOnyxValues} from '@components/Form/types'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import TimeModalPicker from '@components/TimeModalPicker'; +import type {InlineTimePickerConfig} from '@components/TimeModalPicker'; +import TimePicker from '@components/TimePicker/TimePicker'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -63,6 +65,8 @@ function IOURequestStepTime({ }); const {translate} = useLocalize(); + // When set (centered RHP modal), the time picker is rendered inline here over the still-mounted form instead of in a second modal. + const [activeTimePicker, setActiveTimePicker] = useState(null); const currentDateAttributes = transaction?.comment?.customUnit?.attributes?.dates; const currentStartDate = currentDateAttributes?.start ? DateUtils.extractDate(currentDateAttributes.start) : undefined; const currentEndDate = currentDateAttributes?.end ? DateUtils.extractDate(currentDateAttributes.end) : undefined; @@ -151,54 +155,74 @@ function IOURequestStepTime({ ); } + const headerTitle = activeTimePicker?.label ?? (backTo ? translate('iou.time') : tabTitles[iouType]); + return ( + // While editing a time field inside a centered RHP modal, the picker renders over the (still-mounted) form so onInputChange stays valid. setActiveTimePicker(null) : navigateBack} shouldShowNotFoundPage={shouldShowNotFound} shouldShowWrapper testID="IOURequestStepTime" includeSafeAreaPaddingBottom > - - - + + - - - + + + - - + + + + + {!!activeTimePicker && ( + // Overlay the picker on the still-mounted form so its onInputChange stays valid. + + { + activeTimePicker.onSubmit(time); + setActiveTimePicker(null); + }} + shouldValidateFutureTime={false} + /> + + )} + ); } diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 0e9eaee8ec44..4ec086491c5c 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -108,6 +108,8 @@ export default { androidSafeAreaInsetsPercentage: 1, sideBarWidth: 375, sidePanelWidth: 375, + rhpCenteredModalWidth: 480, + rhpCenteredModalMaxHeight: 960, receiptPaneRHPMaxWidth: 465, receiptPreviewMaxWidth: 440, receiptPreviewMaxHeight: 440,