From a3e1f8b016c1429c5120b9de2f12f67c752f34b7 Mon Sep 17 00:00:00 2001 From: Chuck Dries Date: Fri, 11 Sep 2026 15:19:32 -0700 Subject: [PATCH 1/4] PoC: split navigator, use all web breakpoints in native, fix expenses table width --- .../ExternalScrollFlashListTable.tsx | 10 ++- .../NavigationTabBar/InboxTabButton.tsx | 1 + .../NavigationTabBar/InsightsTabButton.tsx | 1 + .../NavigationTabBar/SearchTabButton.tsx | 1 + .../NavigationTabBar/WorkspacesTabButton.tsx | 1 + .../Navigation/NavigationTabBar/index.tsx | 7 +- .../SidePanel/SidePanelContextProvider.tsx | 4 +- .../WideRHPContextProvider/index.native.tsx | 59 ------------ .../useRHPWidth/index.native.ts | 6 -- .../useShouldRenderOverlay.native.ts | 6 ++ .../WideRHPOverlayWrapper/index.native.ts | 8 -- .../WideRHPOverlayWrapper/index.tsx | 15 +++- src/hooks/useResponsiveLayout/index.native.ts | 10 +-- .../index.native.ts | 14 --- .../useModalStackScreenOptions.ts | 13 ++- .../Navigators/Overlay/index.native.tsx | 37 +++++++- .../Navigators/RightModalNavigator.tsx | 20 +++-- .../Navigators/TabNavigator.native.tsx | 6 +- .../AppNavigator/useRHPScreenOptions.ts | 4 +- .../useRootNavigatorScreenOptions.ts | 2 + .../getNativeSplitRenderState.ts | 32 +++++++ .../index.native.tsx | 37 ++++++-- .../index.native.ts | 5 -- .../index.native.ts | 5 -- .../helpers/getRHPLayoutValue/index.native.ts | 4 + .../helpers/getRHPLayoutValue/index.ts | 6 ++ src/libs/getIsNarrowLayout/index.native.ts | 6 +- .../inbox/sidebar/NavigationTabBarAvatar.tsx | 2 +- src/styles/index.ts | 21 ++++- .../navigation/NativeSplitNavigatorTests.tsx | 89 +++++++++++++++++++ tests/ui/ExternalScrollFlashListTableTest.tsx | 47 ++++++++++ tests/unit/NativeRHPLayoutTest.tsx | 57 ++++++++++++ tests/unit/NativeResponsiveLayoutTest.tsx | 30 +++++++ tests/unit/calculateSuperWideRHPWidthTest.ts | 13 --- 34 files changed, 427 insertions(+), 152 deletions(-) delete mode 100644 src/components/WideRHPContextProvider/index.native.tsx delete mode 100644 src/components/WideRHPContextProvider/useRHPWidth/index.native.ts create mode 100644 src/components/WideRHPContextProvider/useShouldRenderOverlay.native.ts delete mode 100644 src/components/WideRHPOverlayWrapper/index.native.ts delete mode 100644 src/hooks/useResponsiveLayoutOnWideRHP/index.native.ts create mode 100644 src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/getNativeSplitRenderState.ts delete mode 100644 src/libs/Navigation/helpers/calculateReceiptPaneRHPWidth/index.native.ts delete mode 100644 src/libs/Navigation/helpers/calculateSuperWideRHPWidth/index.native.ts create mode 100644 src/libs/Navigation/helpers/getRHPLayoutValue/index.native.ts create mode 100644 src/libs/Navigation/helpers/getRHPLayoutValue/index.ts create mode 100644 tests/navigation/NativeSplitNavigatorTests.tsx create mode 100644 tests/ui/ExternalScrollFlashListTableTest.tsx create mode 100644 tests/unit/NativeRHPLayoutTest.tsx create mode 100644 tests/unit/NativeResponsiveLayoutTest.tsx diff --git a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx b/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx index a209e7fd0c03..7af37b3c1200 100644 --- a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx +++ b/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx @@ -66,6 +66,9 @@ type ExternalScrollDriverProps = Omit & { /** Where the table region starts within the parent page's scrollable content (px from the top). */ offsetTop?: number; + /** Native Views need an explicit cross-axis size inside the horizontal scroller. */ + contentWidth?: number; + /** Imperative handle FlashList drives (scrollTo/scrollToEnd/getScrollableNode…). */ ref?: React.Ref; }; @@ -78,7 +81,7 @@ type ExternalScrollDriverProps = Omit & { * corrections settle below the fold exactly like the parent-driven windowing. Must be a stable module-level component: * FlashList memoizes its scroll component on identity. */ -function ExternalScrollDriver({store, offsetTop = 0, onScroll, children, style, ref}: ExternalScrollDriverProps) { +function ExternalScrollDriver({store, offsetTop = 0, contentWidth, onScroll, children, style, ref}: ExternalScrollDriverProps) { const nodeRef = useRef(null); useImperativeHandle( @@ -123,7 +126,8 @@ function ExternalScrollDriver({store, offsetTop = 0, onScroll, children, style, return ( {children} @@ -231,7 +235,7 @@ function ExternalScrollFlashListTable({ drawDistance={estimatedRowHeight * 12} renderScrollComponent={ExternalScrollDriver} // Consumed by ExternalScrollDriver (FlashList spreads overrideProps onto the scroll component). - overrideProps={{store, offsetTop}} + overrideProps={{store, offsetTop, contentWidth}} // Treat the parent viewport as the list's window instead of measuring the (full-height) driver View. overrideWindowSize={{width: contentWidth, height: viewportHeight}} // Grow to content height and don't clip — the parent page owns vertical scroll, so the list's own diff --git a/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx b/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx index b35767100788..ef97e44f93c3 100644 --- a/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx @@ -178,6 +178,7 @@ function WideInboxTabButton({selectedTab, statusIndicatorColor, accessibilityLab role={CONST.ROLE.TAB} accessibilityLabel={accessibilityLabel} accessibilityState={{selected: selectedTab === NAVIGATION_TABS.INBOX}} + wrapperStyle={styles.leftNavigationTabBarItem} style={({hovered}) => [styles.leftNavigationTabBarItem, hovered && styles.navigationTabBarItemHovered]} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.INBOX} > diff --git a/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx b/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx index 19d2e6ecb5dd..a40243838e2f 100644 --- a/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx @@ -46,6 +46,7 @@ function InsightsTabButton({selectedTab, isWideLayout}: InsightsTabButtonProps) role={CONST.ROLE.TAB} accessibilityLabel={translate('common.insights')} accessibilityState={{selected: isSelected}} + wrapperStyle={styles.leftNavigationTabBarItem} style={({hovered}) => [styles.leftNavigationTabBarItem, hovered && styles.navigationTabBarItemHovered]} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.INSIGHTS} > diff --git a/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx b/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx index 72308f304f1f..abe6af3b53af 100644 --- a/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx @@ -62,6 +62,7 @@ function SearchTabButton({selectedTab, isWideLayout}: SearchTabButtonProps) { role={CONST.ROLE.TAB} accessibilityLabel={translate('common.spend')} accessibilityState={searchAccessibilityState} + wrapperStyle={styles.leftNavigationTabBarItem} style={({hovered}) => [styles.leftNavigationTabBarItem, hovered && styles.navigationTabBarItemHovered]} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.REPORTS} > diff --git a/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx b/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx index f996c54f0522..fdb9c4bc9915 100644 --- a/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx @@ -38,6 +38,7 @@ function WorkspacesTabButton({selectedTab, isWideLayout}: WorkspacesTabButtonPro role={CONST.ROLE.TAB} accessibilityLabel={`${translate('common.workspacesTabTitle')}${workspacesTabIndicatorStatus ? `. ${translate('common.yourReviewIsRequired')}` : ''}`} accessibilityState={workspacesAccessibilityState} + wrapperStyle={styles.leftNavigationTabBarItem} style={({hovered}) => [styles.leftNavigationTabBarItem, hovered && styles.navigationTabBarItemHovered]} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.WORKSPACES} > diff --git a/src/components/Navigation/NavigationTabBar/index.tsx b/src/components/Navigation/NavigationTabBar/index.tsx index bf51cac6e9c6..11def148b87b 100644 --- a/src/components/Navigation/NavigationTabBar/index.tsx +++ b/src/components/Navigation/NavigationTabBar/index.tsx @@ -10,6 +10,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -50,6 +51,7 @@ function NavigationTabBar({selectedTab, shouldShowFloatingButtons = true}: Navig const expensifyIcons = useMemoizedLazyExpensifyIcons(['ExpensifyAppIcon', 'Home']); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {paddingTop, paddingBottom} = useSafeAreaPaddings(true); const StyleUtils = useStyleUtils(); @@ -80,7 +82,7 @@ function NavigationTabBar({selectedTab, shouldShowFloatingButtons = true}: Navig {(isSidebarHovered) => ( @@ -103,6 +105,7 @@ function NavigationTabBar({selectedTab, shouldShowFloatingButtons = true}: Navig onPress={navigateToNewDotHome} role={CONST.ROLE.TAB} accessibilityLabel={translate('common.home')} + wrapperStyle={styles.leftNavigationTabBarItem} style={({hovered}) => [styles.leftNavigationTabBarItem, hovered && styles.navigationTabBarItemHovered]} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.HOME} > @@ -139,7 +142,7 @@ function NavigationTabBar({selectedTab, shouldShowFloatingButtons = true}: Navig onPress={navigateToSettings} /> - + diff --git a/src/components/SidePanel/SidePanelContextProvider.tsx b/src/components/SidePanel/SidePanelContextProvider.tsx index ce31953b80d2..863c03b36b4b 100644 --- a/src/components/SidePanel/SidePanelContextProvider.tsx +++ b/src/components/SidePanel/SidePanelContextProvider.tsx @@ -23,6 +23,8 @@ import React, {createContext, useEffect, useRef, useState} from 'react'; // eslint-disable-next-line no-restricted-imports import {Animated} from 'react-native'; +import isSidePanelReportSupported from './isSidePanelReportSupported'; + type SidePanelStateContextProps = { isSidePanelTransitionEnded: boolean; isSidePanelHiddenOrLargeScreen: boolean; @@ -72,7 +74,7 @@ function SidePanelContextProvider({children}: PropsWithChildren) { const {shouldHideSidePanel, shouldHideSidePanelBackdrop, shouldHideHelpButton, isSidePanelHiddenOrLargeScreen, sidePanelNVP} = useSidePanelDisplayStatus(); const shouldHideToolTip = isExtraLargeScreenWidth ? !isSidePanelTransitionEnded : !shouldHideSidePanel; - const shouldApplySidePanelOffset = isExtraLargeScreenWidth && !shouldHideSidePanel; + const shouldApplySidePanelOffset = isSidePanelReportSupported && isExtraLargeScreenWidth && !shouldHideSidePanel; const sidePanelOffset = useRef(new Animated.Value(shouldApplySidePanelOffset ? variables.sidePanelWidth : 0)); const sidePanelTranslateX = useRef(new Animated.Value(shouldHideSidePanel ? sidePanelWidth : 0)); const sidePanelWidthRef = useRef(sidePanelWidth); diff --git a/src/components/WideRHPContextProvider/index.native.tsx b/src/components/WideRHPContextProvider/index.native.tsx deleted file mode 100644 index 7166a58e81c1..000000000000 --- a/src/components/WideRHPContextProvider/index.native.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React, {createContext, useContext} from 'react'; -// We use Animated for all functionality related to wide RHP to make it easier -// to interact with react-navigation components (e.g., CardContainer, interpolator), which also use Animated. -// eslint-disable-next-line no-restricted-imports -import {Animated} from 'react-native'; - -import type {WideRHPActionsContextType, WideRHPStateContextType} from './types'; - -import {defaultWideRHPActionsContextValue, defaultWideRHPStateContextValue} from './default'; - -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); - -const WideRHPStateContext = createContext(defaultWideRHPStateContextValue); -const WideRHPActionsContext = createContext(defaultWideRHPActionsContextValue); - -function WideRHPContextProvider({children}: React.PropsWithChildren) { - return ( - - {children} - - ); -} - -function useWideRHPState() { - return useContext(WideRHPStateContext); -} - -function useWideRHPActions() { - return useContext(WideRHPActionsContext); -} - -export default WideRHPContextProvider; -export { - animatedReceiptPaneRHPWidth, - animatedSuperWideRHPWidth, - animatedWideRHPWidth, - expandedRHPProgress, - modalStackOverlaySuperWideRHPPositionLeft, - modalStackOverlayWideRHPPositionLeft, - secondOverlayRHPOnSuperWideRHPProgress, - secondOverlayRHPOnWideRHPProgress, - secondOverlayWideRHPProgress, - thirdOverlayProgress, - useWideRHPState, - useWideRHPActions, -}; -export type {WideRHPStateContextType, WideRHPActionsContextType}; diff --git a/src/components/WideRHPContextProvider/useRHPWidth/index.native.ts b/src/components/WideRHPContextProvider/useRHPWidth/index.native.ts deleted file mode 100644 index 1d6b056e0b4c..000000000000 --- a/src/components/WideRHPContextProvider/useRHPWidth/index.native.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type {RHPWidth} from '..'; - -// Wide/Super-Wide RHP is not displayed on native platforms. -const useRHPWidth: (width: RHPWidth) => void = () => {}; - -export default useRHPWidth; diff --git a/src/components/WideRHPContextProvider/useShouldRenderOverlay.native.ts b/src/components/WideRHPContextProvider/useShouldRenderOverlay.native.ts new file mode 100644 index 000000000000..30a9dc97db8f --- /dev/null +++ b/src/components/WideRHPContextProvider/useShouldRenderOverlay.native.ts @@ -0,0 +1,6 @@ +// Native overlays follow route visibility directly; native-stack owns their transitions. +import type {OverlayStylesParams} from '@styles/index'; + +const useShouldRenderOverlay: (shouldRender: boolean, progress: OverlayStylesParams) => boolean = (shouldRender) => shouldRender; + +export default useShouldRenderOverlay; diff --git a/src/components/WideRHPOverlayWrapper/index.native.ts b/src/components/WideRHPOverlayWrapper/index.native.ts deleted file mode 100644 index 43803a11607d..000000000000 --- a/src/components/WideRHPOverlayWrapper/index.native.ts +++ /dev/null @@ -1,8 +0,0 @@ -type WideRHPOverlayWrapperProps = { - children: React.ReactNode; -}; - -// Overlays aren't displayed on native platforms. -export default function WideRHPOverlayWrapper({children}: WideRHPOverlayWrapperProps) { - return children; -} diff --git a/src/components/WideRHPOverlayWrapper/index.tsx b/src/components/WideRHPOverlayWrapper/index.tsx index 1f8675555f93..f0523d6f8c3a 100644 --- a/src/components/WideRHPOverlayWrapper/index.tsx +++ b/src/components/WideRHPOverlayWrapper/index.tsx @@ -9,8 +9,14 @@ import { } from '@components/WideRHPContextProvider'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import Overlay from '@libs/Navigation/AppNavigator/Navigators/Overlay'; +import calculateReceiptPaneRHPWidth from '@libs/Navigation/helpers/calculateReceiptPaneRHPWidth'; +import calculateSuperWideRHPWidth from '@libs/Navigation/helpers/calculateSuperWideRHPWidth'; +import getRHPLayoutValue from '@libs/Navigation/helpers/getRHPLayoutValue'; + +import variables from '@styles/variables'; import {useRoute} from '@react-navigation/native'; import React from 'react'; @@ -20,6 +26,9 @@ function SecondaryOverlay() { useWideRHPState(); const route = useRoute(); + const {windowWidth} = useWindowDimensions(); + const receiptWidth = calculateReceiptPaneRHPWidth(windowWidth); + const superWideWidth = calculateSuperWideRHPWidth(windowWidth); const isWide = !!route?.key && wideRHPRouteKeys.includes(route.key); const isSuperWide = !!route?.key && superWideRHPRouteKeys.includes(route.key); @@ -45,7 +54,7 @@ function SecondaryOverlay() { ); } @@ -54,7 +63,7 @@ function SecondaryOverlay() { return ( ); } @@ -63,7 +72,7 @@ function SecondaryOverlay() { return ( ); } diff --git a/src/hooks/useResponsiveLayout/index.native.ts b/src/hooks/useResponsiveLayout/index.native.ts index c2740a6fc8af..72d6628902e4 100644 --- a/src/hooks/useResponsiveLayout/index.native.ts +++ b/src/hooks/useResponsiveLayout/index.native.ts @@ -34,12 +34,12 @@ export default function useResponsiveLayout(): ResponsiveLayoutResult { const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight); const isExtraSmallScreenHeight = windowHeight <= variables.extraSmallMobileResponsiveHeightBreakpoint; - const isSmallScreenWidth = true; - const isMediumScreenWidth = false; - const isLargeScreenWidth = false; - const isExtraLargeScreenWidth = false; + const isSmallScreenWidth = windowWidth <= variables.mobileResponsiveWidthBreakpoint; + const isMediumScreenWidth = windowWidth > variables.mobileResponsiveWidthBreakpoint && windowWidth <= variables.tabletResponsiveWidthBreakpoint; + const isLargeScreenWidth = windowWidth > variables.tabletResponsiveWidthBreakpoint; + const isExtraLargeScreenWidth = windowWidth > variables.sidePanelResponsiveWidthBreakpoint; const isExtraSmallScreenWidth = windowWidth <= variables.extraSmallMobileResponsiveWidthBreakpoint; - const isSmallScreen = true; + const isSmallScreen = Math.min(windowWidth, windowHeight) <= variables.mobileResponsiveWidthBreakpoint; // we need to always take screen width into consideration, no matter the platform (with exception of landscape mode). const onboardingIsMediumOrLargerScreenWidth = !isInLandscapeMode && windowWidth > variables.mobileResponsiveWidthBreakpoint; diff --git a/src/hooks/useResponsiveLayoutOnWideRHP/index.native.ts b/src/hooks/useResponsiveLayoutOnWideRHP/index.native.ts deleted file mode 100644 index 5253194caff9..000000000000 --- a/src/hooks/useResponsiveLayoutOnWideRHP/index.native.ts +++ /dev/null @@ -1,14 +0,0 @@ -import useResponsiveLayout from '@hooks/useResponsiveLayout'; - -import type ResponsiveLayoutOnWideRHPResult from './types'; - -// Super Wide and Wide RHPs are not displayed on native platforms. -export default function useResponsiveLayoutOnWideRHP(): ResponsiveLayoutOnWideRHPResult { - const responsiveLayoutValues = useResponsiveLayout(); - - return { - ...responsiveLayoutValues, - isWideRHPDisplayedOnWideLayout: false, - isSuperWideRHPDisplayedOnWideLayout: false, - }; -} diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts b/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts index 43b37502d3fc..a1455192b4dc 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts @@ -4,13 +4,18 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSidePanelState from '@hooks/useSidePanelState'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import enhanceCardStyleInterpolator from '@libs/Navigation/AppNavigator/enhanceCardStyleInterpolator'; import hideKeyboardOnSwipe from '@libs/Navigation/AppNavigator/hideKeyboardOnSwipe'; import RHP_WEB_TRANSITION_SPEC from '@libs/Navigation/AppNavigator/RHPTransitionSpec'; import useModalCardStyleInterpolator from '@libs/Navigation/AppNavigator/useModalCardStyleInterpolator'; +import calculateReceiptPaneRHPWidth from '@libs/Navigation/helpers/calculateReceiptPaneRHPWidth'; +import calculateSuperWideRHPWidth from '@libs/Navigation/helpers/calculateSuperWideRHPWidth'; import type {PlatformStackNavigationOptions, PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import variables from '@styles/variables'; + import CONST from '@src/CONST'; import type {ParamListBase} from '@react-navigation/native'; @@ -35,6 +40,7 @@ function useWideModalStackScreenOptions() { const {isSmallScreenWidth} = useResponsiveLayout(); const {wideRHPRouteKeys, superWideRHPRouteKeys} = useWideRHPState(); const {sidePanelOffset} = useSidePanelState(); + const {windowWidth} = useWindowDimensions(); return useCallback<({route}: {route: PlatformStackRouteProp}) => PlatformStackNavigationOptions>( ({route}) => { @@ -43,15 +49,18 @@ function useWideModalStackScreenOptions() { : (props) => modalCardStyleInterpolator({props, enter: {kind: 'slide-and-fade', distancePx: CONST.MODAL.RHP_ENTER_OFFSET_PX_WEB}}); let cardStyleInterpolator: StackCardStyleInterpolator = baseInterpolator; + let nativeWidth: number = variables.sideBarWidth; if (!isSmallScreenWidth) { if (superWideRHPRouteKeys.includes(route.key)) { + nativeWidth = calculateSuperWideRHPWidth(windowWidth); cardStyleInterpolator = enhanceCardStyleInterpolator(baseInterpolator, { // Shrink the super wide sheet by the Side Panel width while it is open so the sheet's // left edge stays put instead of being pushed off-screen. See https://github.com/Expensify/App/issues/99035 cardStyle: styles.getSuperWideRHPExtendedCardInterpolatorStyles(Animated.subtract(animatedSuperWideRHPWidth, sidePanelOffset.current)), }); } else if (wideRHPRouteKeys.includes(route.key)) { + nativeWidth = calculateReceiptPaneRHPWidth(windowWidth) + variables.sideBarWidth; cardStyleInterpolator = enhanceCardStyleInterpolator(baseInterpolator, { cardStyle: styles.wideRHPExtendedCardInterpolatorStyles, }); @@ -68,7 +77,7 @@ function useWideModalStackScreenOptions() { headerShown: false, animationTypeForReplace: 'pop', native: { - contentStyle: styles.navigationScreenCardStyle, + contentStyle: [styles.navigationScreenCardStyle, !isSmallScreenWidth && styles.nativeRHPContent(nativeWidth)], }, web: { cardStyle: isSmallScreenWidth ? StyleUtils.getStyleWithEnvSafeAreaPadding(styles.navigationScreenCardStyle) : styles.navigationScreenCardStyle, @@ -77,7 +86,7 @@ function useWideModalStackScreenOptions() { }, }; }, - [StyleUtils, isSmallScreenWidth, modalCardStyleInterpolator, sidePanelOffset, styles, superWideRHPRouteKeys, wideRHPRouteKeys], + [StyleUtils, isSmallScreenWidth, modalCardStyleInterpolator, sidePanelOffset, styles, superWideRHPRouteKeys, wideRHPRouteKeys, windowWidth], ); } diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx index 8c17a8f47787..d37809c1eb06 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx @@ -1,5 +1,38 @@ -function Overlay() { - return null; +// Native-stack owns transitions; this scrim only handles dimming and dismissal. +import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; + +import React from 'react'; +// eslint-disable-next-line no-restricted-imports +import {Animated} from 'react-native'; + +import type {BaseOverlayProps} from './BaseOverlay'; + +function Overlay({onPress, positionLeftValue = -2 * variables.sideBarWidth, positionRightValue = 0}: BaseOverlayProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + return ( + + + + ); } export default Overlay; diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index b0878cc2cea2..b3ae0fb602c3 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -24,6 +24,7 @@ import useModalStackScreenOptions from '@libs/Navigation/AppNavigator/ModalStack import useRHPScreenOptions from '@libs/Navigation/AppNavigator/useRHPScreenOptions'; import calculateReceiptPaneRHPWidth from '@libs/Navigation/helpers/calculateReceiptPaneRHPWidth'; import calculateSuperWideRHPWidth from '@libs/Navigation/helpers/calculateSuperWideRHPWidth'; +import getRHPLayoutValue from '@libs/Navigation/helpers/getRHPLayoutValue'; import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import Animations from '@libs/Navigation/PlatformStackNavigation/navigationOptions/animation'; @@ -80,12 +81,13 @@ function SearchAdvancedFiltersWithContext(props: Record) { function SecondaryOverlay() { const {shouldRenderSecondaryOverlayForWideRHP, shouldRenderSecondaryOverlayForRHPOnWideRHP, shouldRenderSecondaryOverlayForRHPOnSuperWideRHP} = useWideRHPState(); const {sidePanelOffset} = useSidePanelState(); + const {windowWidth} = useWindowDimensions(); if (shouldRenderSecondaryOverlayForWideRHP) { return ( (sidePanelOffset.current, animatedWideRHPWidth))} onPress={() => Navigation.closeRHPFlow()} /> ); @@ -95,7 +97,7 @@ function SecondaryOverlay() { return ( (sidePanelOffset.current, singleRHPWidth))} onPress={Navigation.dismissToPreviousRHP} /> ); @@ -105,7 +107,7 @@ function SecondaryOverlay() { return ( (sidePanelOffset.current, singleRHPWidth))} onPress={Navigation.dismissToSuperWideRHP} /> ); @@ -213,10 +215,16 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { ); const animatedWidthStyle = useMemo(() => { + let width: number = singleRHPWidth; + if (superWideRHPRouteKeys.length > 0) { + width = calculateSuperWideRHPWidth(windowWidth); + } else if (wideRHPRouteKeys.length > 0) { + width = getWideRHPWidth(windowWidth); + } return { - width: shouldUseNarrowLayout ? '100%' : animatedWidth, + width: shouldUseNarrowLayout ? '100%' : getRHPLayoutValue(width, animatedWidth), } as const; - }, [animatedWidth, shouldUseNarrowLayout]); + }, [animatedWidth, shouldUseNarrowLayout, superWideRHPRouteKeys.length, wideRHPRouteKeys.length, windowWidth]); const overlayPositionLeft = useMemo(() => -1 * calculateSuperWideRHPWidth(windowWidth), [windowWidth]); @@ -557,7 +565,7 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { {!shouldUseNarrowLayout && shouldRenderTertiaryOverlay && ( (sidePanelOffset.current, singleRHPWidth))} onPress={Navigation.dismissToPreviousRHP} /> )} diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx index 8b0b7c7e1856..226726323196 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx @@ -43,7 +43,6 @@ const TAB_SCREEN_OPTIONS_BASE = { lazy: true, animation: 'none' as const, freezeOnBlur: true, - tabBarPosition: 'bottom' as const, } as const; function TabNavigator() { @@ -59,11 +58,11 @@ function TabNavigator() { const tabState = useNavigationState((parentState) => parentState.routes.find((r) => r.key === route.key)?.state as NavigationState | undefined); useEffect(() => { - if (!shouldUseNarrowLayout || !parentNavigation) { + if (!parentNavigation) { return; } const isRootScreen = TAB_ROOT_SCREENS_WITHOUT_GESTURE.has(focusedRouteName ?? ''); - parentNavigation.setOptions({gestureEnabled: !isRootScreen}); + parentNavigation.setOptions({gestureEnabled: shouldUseNarrowLayout && !isRootScreen}); }, [focusedRouteName, shouldUseNarrowLayout, parentNavigation]); useEffect(() => { @@ -89,6 +88,7 @@ function TabNavigator() { const screenOptions = { ...TAB_SCREEN_OPTIONS_BASE, + tabBarPosition: shouldUseNarrowLayout ? ('bottom' as const) : ('left' as const), sceneStyle: {flex: 1, backgroundColor: theme.appBG}, }; diff --git a/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts b/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts index ec12662cacc6..47d0ec8e773d 100644 --- a/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts @@ -48,6 +48,8 @@ const useRHPScreenOptions = (): PlatformStackNavigationOptions => { headerShown: false, animation: Animations.SLIDE_FROM_RIGHT, gestureDirection: 'horizontal', + // Inner RHPs must also retain the wider report underneath them. + native: isSmallScreenWidth ? undefined : {presentation: 'containedTransparentModal', contentStyle: styles.bgTransparent}, web: { // The .forHorizontalIOS interpolator from `@react-navigation` is misbehaving on Safari, so we override it with Expensify custom interpolator cardStyleInterpolator: isSafari() @@ -60,7 +62,7 @@ const useRHPScreenOptions = (): PlatformStackNavigationOptions => { transitionSpec: isSmallScreenWidth ? undefined : RHP_WEB_TRANSITION_SPEC, }, }; - }, [customInterpolator, shouldAdjustInterpolatorProps, isSmallScreenWidth, styles.navigationScreenCardStyle]); + }, [customInterpolator, shouldAdjustInterpolatorProps, isSmallScreenWidth, styles.bgTransparent, styles.navigationScreenCardStyle]); }; export default useRHPScreenOptions; diff --git a/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts b/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts index 1bf7bad237a5..1102999fc414 100644 --- a/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts @@ -52,6 +52,8 @@ const useRootNavigatorScreenOptions = () => { animation: Animations.SLIDE_FROM_RIGHT, // We want pop in RHP since there are some flows that would work weird otherwise animationTypeForReplace: 'pop', + // Transparent presentation keeps the base split visible underneath the RHP. + native: shouldUseNarrowLayout ? undefined : {presentation: Presentation.TRANSPARENT_MODAL, contentStyle: themeStyles.bgTransparent}, web: { presentation: Presentation.TRANSPARENT_MODAL, cardStyleInterpolator: (props: StackCardInterpolationProps) => diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/getNativeSplitRenderState.ts b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/getNativeSplitRenderState.ts new file mode 100644 index 000000000000..108e303bb122 --- /dev/null +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/getNativeSplitRenderState.ts @@ -0,0 +1,32 @@ +// Project one split-router state into a sidebar and a native central stack without changing route keys. +import type {PlatformStackNavigationState} from '@libs/Navigation/PlatformStackNavigation/types'; + +import type {ParamListBase} from '@react-navigation/native'; + +function getNativeSplitRenderState(state: PlatformStackNavigationState, sidebarRouteName: string) { + const sidebarRoute = state.routes.find((route) => route.name === sidebarRouteName); + if (!sidebarRoute) { + return; + } + + const centralRoutes = state.routes.filter((route) => route.key !== sidebarRoute.key); + if (centralRoutes.length === 0) { + return; + } + + const focusedRoute = state.routes.at(state.index); + const focusedCentralIndex = centralRoutes.findIndex((route) => route.key === focusedRoute?.key); + + return { + sidebarRoute, + centralState: { + ...state, + routeNames: state.routeNames.filter((routeName) => routeName !== sidebarRouteName), + routes: centralRoutes, + index: focusedCentralIndex === -1 ? centralRoutes.length - 1 : focusedCentralIndex, + preloadedRoutes: state.preloadedRoutes.filter((route) => route.name !== sidebarRouteName), + }, + }; +} + +export default getNativeSplitRenderState; diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx index 5ef88b1e7cc3..3609ed0ec162 100644 --- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx @@ -1,4 +1,5 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; import convertToNativeNavigationOptions from '@libs/Navigation/PlatformStackNavigation/navigationOptions/convertToNativeNavigationOptions'; import screenLayout from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; @@ -17,7 +18,9 @@ import type {NativeStackNavigationEventMap, NativeStackNavigationOptions} from ' import {StackRouter, useNavigationBuilder} from '@react-navigation/native'; import {NativeStackView} from '@react-navigation/native-stack'; import React from 'react'; +import {View} from 'react-native'; +import getNativeSplitRenderState from './getNativeSplitRenderState'; import wrapDescriptorsWithNonTopScreensBehavior from './wrapDescriptorsWithNonTopScreensBehavior'; type PlatformNavigatorImplProps = PlatformStackNavigatorProps & { @@ -39,6 +42,7 @@ function PlatformNavigatorImpl) { const {shouldUseNarrowLayout} = useResponsiveLayout(); + const styles = useThemeStyles(); const { navigation, state: originalState, @@ -86,23 +91,37 @@ function PlatformNavigatorImpl> = { ...customCodeProps, state, }; - const wrappedDescriptors = wrapDescriptorsWithNonTopScreensBehavior(descriptors, state); + const wrappedDescriptors = wrapDescriptorsWithNonTopScreensBehavior(descriptors, state, isSplit ? persistentScreens : undefined); + const split = isSplit ? getNativeSplitRenderState(state, sidebarScreen) : undefined; + + const stack = ( + + ); const content = ( - + {split ? ( + + {wrappedDescriptors[split.sidebarRoute.key]?.render()} + {stack} + + ) : ( + stack + )} {!!ExtraContent && } ); diff --git a/src/libs/Navigation/helpers/calculateReceiptPaneRHPWidth/index.native.ts b/src/libs/Navigation/helpers/calculateReceiptPaneRHPWidth/index.native.ts deleted file mode 100644 index 776e79af4e65..000000000000 --- a/src/libs/Navigation/helpers/calculateReceiptPaneRHPWidth/index.native.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Wide RHP is not displayed on native platforms -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function calculateReceiptPaneRHPWidth(windowWidth: number) {} - -export default calculateReceiptPaneRHPWidth; diff --git a/src/libs/Navigation/helpers/calculateSuperWideRHPWidth/index.native.ts b/src/libs/Navigation/helpers/calculateSuperWideRHPWidth/index.native.ts deleted file mode 100644 index b0e4c44594da..000000000000 --- a/src/libs/Navigation/helpers/calculateSuperWideRHPWidth/index.native.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Super Wide RHP is not displayed on native platforms -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function calculateSuperWideRHPWidth(windowWidth: number) {} - -export default calculateSuperWideRHPWidth; diff --git a/src/libs/Navigation/helpers/getRHPLayoutValue/index.native.ts b/src/libs/Navigation/helpers/getRHPLayoutValue/index.native.ts new file mode 100644 index 000000000000..2763c07c8a61 --- /dev/null +++ b/src/libs/Navigation/helpers/getRHPLayoutValue/index.native.ts @@ -0,0 +1,4 @@ +// The native animation driver cannot animate width/left/right. Use current window geometry instead. +const getRHPLayoutValue: (value: number, animatedValue: T) => T | number = (value) => value; + +export default getRHPLayoutValue; diff --git a/src/libs/Navigation/helpers/getRHPLayoutValue/index.ts b/src/libs/Navigation/helpers/getRHPLayoutValue/index.ts new file mode 100644 index 000000000000..b81bfcb81ec3 --- /dev/null +++ b/src/libs/Navigation/helpers/getRHPLayoutValue/index.ts @@ -0,0 +1,6 @@ +// Web RHP geometry follows the JS stack's animated values. +function getRHPLayoutValue(_value: number, animatedValue: T): T | number { + return animatedValue; +} + +export default getRHPLayoutValue; diff --git a/src/libs/getIsNarrowLayout/index.native.ts b/src/libs/getIsNarrowLayout/index.native.ts index c43130a63b2a..233fc375f317 100644 --- a/src/libs/getIsNarrowLayout/index.native.ts +++ b/src/libs/getIsNarrowLayout/index.native.ts @@ -1,3 +1,7 @@ +import variables from '@styles/variables'; + +import {Dimensions} from 'react-native'; + export default function getIsNarrowLayout() { - return true; + return Dimensions.get('window').width <= variables.mobileResponsiveWidthBreakpoint; } diff --git a/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx b/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx index 9837c75340f1..501a1b45517a 100644 --- a/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx +++ b/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx @@ -80,7 +80,7 @@ function NavigationTabBarAvatar({onPress, isSelected = false, style}: Navigation onPress={onPress} accessibilityLabel={`${translate('initialSettingsPage.account')}, ${translate('sidebarScreen.buttonMySettings')}. ${status ? `${translate('common.yourReviewIsRequired')}.` : ''}`} role={CONST.ROLE.TAB} - wrapperStyle={styles.flex1} + wrapperStyle={shouldUseNarrowLayout ? styles.flex1 : styles.leftNavigationTabBarItem} accessibilityState={accountAccessibilityState} aria-selected={accountAccessibilityState.selected} style={({hovered}) => [style, !shouldUseNarrowLayout && hovered && styles.navigationTabBarItemHovered]} diff --git a/src/styles/index.ts b/src/styles/index.ts index 1413e4fbb265..67b0e5d6f086 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -719,11 +719,17 @@ const staticStyles = (theme: ThemeColors) => }, tabNavigatorBarContainer: { - width: variables.navigationTabBarSize + variables.sideBarWithLHBWidth, - marginRight: -variables.sideBarWithLHBWidth, + ...Platform.select({ + web: {width: variables.navigationTabBarSize + variables.sideBarWithLHBWidth, marginRight: -variables.sideBarWithLHBWidth}, + default: {width: variables.navigationTabBarSize}, + }), overflow: 'visible', }, + nativeSplitSidebar: { + width: variables.sideBarWithLHBWidth, + }, + navigationTabBarContainer: { flexDirection: 'row', height: variables.bottomTabHeight, @@ -766,6 +772,7 @@ const staticStyles = (theme: ThemeColors) => leftNavigationTabBarItem: { height: variables.navigationTabBarSize, + width: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center', @@ -6887,7 +6894,15 @@ const dynamicStyles = (theme: ThemeColors) => } satisfies ViewStyle; }, - rootNavigatorContainerStyles: (isSmallScreenWidth: boolean) => ({marginLeft: isSmallScreenWidth ? 0 : variables.sideBarWithLHBWidth, flex: 1}) satisfies ViewStyle, + // Web positions sidebar cards with a negative margin; native reserves the sidebar as a sibling. + rootNavigatorContainerStyles: (isSmallScreenWidth: boolean) => + ({marginLeft: Platform.OS === 'web' && !isSmallScreenWidth ? variables.sideBarWithLHBWidth : 0, flex: 1}) satisfies ViewStyle, + + navigationTabBarSafeAreaInsets: (paddingTop: number, paddingBottom: number) => ({paddingTop, paddingBottom}) satisfies ViewStyle, + + leftNavigationTabBarFABPosition: (bottom: number) => ({position: 'absolute', bottom, left: 0, width: variables.navigationTabBarSize}) satisfies ViewStyle, + + nativeRHPContent: (width: number) => ({width, maxWidth: '100%', alignSelf: 'flex-end'}) satisfies ViewStyle, RHPNavigatorContainerNavigatorContainerStyles: (isSmallScreenWidth: boolean) => ({marginLeft: isSmallScreenWidth ? 0 : variables.sideBarWidth, flex: 1}) satisfies ViewStyle, diff --git a/tests/navigation/NativeSplitNavigatorTests.tsx b/tests/navigation/NativeSplitNavigatorTests.tsx new file mode 100644 index 000000000000..0c0d669e6f18 --- /dev/null +++ b/tests/navigation/NativeSplitNavigatorTests.tsx @@ -0,0 +1,89 @@ +import {act, render, screen, waitFor} from '@testing-library/react-native'; + +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import createSplitNavigator from '@libs/Navigation/AppNavigator/createSplitNavigator'; +import navigationRef from '@libs/Navigation/navigationRef'; +import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; + +import CONST from '@src/CONST'; +import SCREENS from '@src/SCREENS'; + +import {CommonActions, NavigationContainer, StackActions} from '@react-navigation/native'; +import React from 'react'; +import {View} from 'react-native'; + +const Split = createSplitNavigator(); + +jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); +jest.mock('@libs/getIsNarrowLayout', () => jest.fn()); + +function SidebarScreen() { + return ; +} + +function CentralScreen() { + return ; +} + +function TestNavigator() { + return ( + + + + + + + ); +} + +function setNarrowLayout(isNarrow: boolean) { + jest.mocked(getIsNarrowLayout).mockReturnValue(isNarrow); + jest.mocked(useResponsiveLayout).mockReturnValue({...CONST.NAVIGATION_TESTS.DEFAULT_USE_RESPONSIVE_LAYOUT_VALUE, shouldUseNarrowLayout: isNarrow, isSmallScreenWidth: isNarrow}); +} + +describe('Native split navigation', () => { + it('renders both panes, keeps route keys on resize, and pops central history', async () => { + setNarrowLayout(false); + const {rerender} = render(); + + expect(await screen.findByTestId('split-central')).toBeOnTheScreen(); + expect(screen.getAllByTestId('split-sidebar')).toHaveLength(1); + + act(() => navigationRef.dispatch(StackActions.push(SCREENS.REPORT, {reportID: '2'}))); + const routeKeys = navigationRef.getRootState().routes.map((route) => route.key); + expect(routeKeys).toHaveLength(3); + + setNarrowLayout(true); + rerender(); + await waitFor(() => expect(navigationRef.getRootState().routes.map((route) => route.key)).toEqual(routeKeys)); + + setNarrowLayout(false); + rerender(); + expect(await screen.findByTestId('split-sidebar')).toBeOnTheScreen(); + expect(navigationRef.getRootState().routes.map((route) => route.key)).toEqual(routeKeys); + + act(() => navigationRef.dispatch(CommonActions.navigate(SCREENS.INBOX))); + expect(navigationRef.getRootState().index).toBe(2); + + act(() => navigationRef.dispatch(StackActions.pop())); + expect(navigationRef.getRootState().routes.map((route) => route.key)).toEqual(routeKeys.slice(0, 2)); + expect(screen.getByTestId('split-sidebar')).toBeOnTheScreen(); + expect(screen.getByTestId('split-central')).toBeOnTheScreen(); + }); +}); diff --git a/tests/ui/ExternalScrollFlashListTableTest.tsx b/tests/ui/ExternalScrollFlashListTableTest.tsx new file mode 100644 index 000000000000..182be654a4df --- /dev/null +++ b/tests/ui/ExternalScrollFlashListTableTest.tsx @@ -0,0 +1,47 @@ +import {render, screen} from '@testing-library/react-native'; + +import ExternalScrollFlashListTable, {createScrollOffsetStore} from '@components/MoneyRequestReportView/ExternalScrollFlashListTable'; + +import type {FlashListProps} from '@shopify/flash-list'; + +import React from 'react'; +import {View} from 'react-native'; + +jest.mock('@shopify/flash-list', () => ({ + FlashList: ({renderScrollComponent: ScrollComponent, overrideProps, ListHeaderComponent}: FlashListProps) => { + const {isValidElement} = jest.requireActual('react'); + if (!ScrollComponent || !isValidElement(ListHeaderComponent)) { + return null; + } + return {ListHeaderComponent}; + }, +})); + +const renderHeader = () => ; + +describe('ExternalScrollFlashListTable', () => { + it('gives the replacement scroll container the full table width, including after resize', () => { + const store = createScrollOffsetStore(); + const renderTable = (contentWidth: number) => ( + item} + getItemType={() => 'transaction'} + renderItem={() => null} + renderHeader={renderHeader} + estimatedRowHeight={75} + contentWidth={contentWidth} + store={store} + viewportHeight={600} + offsetTop={0} + /> + ); + const {rerender} = render(renderTable(1200)); + + // This View replaces FlashList's ScrollView. Its cross-axis measurement controls column widths. + expect(screen.getByTestId('external-scroll-driver')).toHaveStyle({width: 1200}); + + rerender(renderTable(1400)); + expect(screen.getByTestId('external-scroll-driver')).toHaveStyle({width: 1400}); + }); +}); diff --git a/tests/unit/NativeRHPLayoutTest.tsx b/tests/unit/NativeRHPLayoutTest.tsx new file mode 100644 index 000000000000..94a0b3607c8a --- /dev/null +++ b/tests/unit/NativeRHPLayoutTest.tsx @@ -0,0 +1,57 @@ +import {renderHook} from '@testing-library/react-native'; + +import {useWideRHPState} from '@components/WideRHPContextProvider'; +import type * as WideRHPContextProvider from '@components/WideRHPContextProvider'; +import {defaultWideRHPStateContextValue} from '@components/WideRHPContextProvider/default'; + +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useWindowDimensions from '@hooks/useWindowDimensions'; + +import useModalStackScreenOptions from '@libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions'; +import useRHPScreenOptions from '@libs/Navigation/AppNavigator/useRHPScreenOptions'; +import useRootNavigatorScreenOptions from '@libs/Navigation/AppNavigator/useRootNavigatorScreenOptions'; + +import CONST from '@src/CONST'; + +import {StyleSheet} from 'react-native'; + +jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); +jest.mock('@hooks/useWindowDimensions', () => jest.fn()); +jest.mock('@components/WideRHPContextProvider', () => ({ + ...jest.requireActual('@components/WideRHPContextProvider'), + useWideRHPState: jest.fn(), +})); + +describe('Native RHP layout', () => { + beforeEach(() => { + jest.mocked(useWindowDimensions).mockReturnValue({windowWidth: 1180, windowHeight: 820}); + jest.mocked(useResponsiveLayout).mockReturnValue({...CONST.NAVIGATION_TESTS.DEFAULT_USE_RESPONSIVE_LAYOUT_VALUE, isSmallScreenWidth: false, shouldUseNarrowLayout: false}); + jest.mocked(useWideRHPState).mockReturnValue({...defaultWideRHPStateContextValue, superWideRHPRouteKeys: ['expense'], wideRHPRouteKeys: ['transaction']}); + }); + + it('keeps the transaction narrower than its underlying expense report', () => { + const {result} = renderHook(() => useModalStackScreenOptions()); + const expense = result.current({route: {key: 'expense', name: 'expense'}}); + const transaction = result.current({route: {key: 'transaction', name: 'transaction'}}); + const detail = result.current({route: {key: 'detail', name: 'detail'}}); + + expect(StyleSheet.flatten(expense.native?.contentStyle)).toMatchObject({width: 1033, alignSelf: 'flex-end'}); + expect(StyleSheet.flatten(transaction.native?.contentStyle)).toMatchObject({width: 840, alignSelf: 'flex-end'}); + expect(StyleSheet.flatten(detail.native?.contentStyle)).toMatchObject({width: 375, alignSelf: 'flex-end'}); + }); + + it('retains the base scene and contains inner modals within the RHP host', () => { + const {result} = renderHook(() => ({root: useRootNavigatorScreenOptions(), inner: useRHPScreenOptions()})); + expect(result.current.root.rightModalNavigator.native?.presentation).toBe('transparentModal'); + expect(result.current.inner.native?.presentation).toBe('containedTransparentModal'); + }); + + it('lets narrow screens fill the native stack', () => { + jest.mocked(useResponsiveLayout).mockReturnValue({...CONST.NAVIGATION_TESTS.DEFAULT_USE_RESPONSIVE_LAYOUT_VALUE, isSmallScreenWidth: true, shouldUseNarrowLayout: true}); + const {result} = renderHook(() => ({options: useModalStackScreenOptions(), inner: useRHPScreenOptions()})); + const transaction = result.current.options({route: {key: 'transaction', name: 'transaction'}}); + + expect(StyleSheet.flatten(transaction.native?.contentStyle)?.width).toBeUndefined(); + expect(result.current.inner.native?.presentation).toBeUndefined(); + }); +}); diff --git a/tests/unit/NativeResponsiveLayoutTest.tsx b/tests/unit/NativeResponsiveLayoutTest.tsx new file mode 100644 index 000000000000..bfe75253e9f6 --- /dev/null +++ b/tests/unit/NativeResponsiveLayoutTest.tsx @@ -0,0 +1,30 @@ +import {renderHook} from '@testing-library/react-native'; + +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useWindowDimensions from '@hooks/useWindowDimensions'; + +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; + +import {Dimensions} from 'react-native'; + +jest.mock('@hooks/useWindowDimensions', () => jest.fn()); + +describe('Native responsive breakpoints', () => { + it.each([ + [800, 1200, true, false, false], + [801, 1200, false, true, false], + [1024, 768, false, true, false], + [1180, 820, false, false, true], + ])('keeps hook and router consistent at %i × %i', (width, height, isNarrow, isMedium, isLarge) => { + jest.mocked(useWindowDimensions).mockReturnValue({windowWidth: width, windowHeight: height}); + const spy = jest.spyOn(Dimensions, 'get').mockReturnValue({width, height, scale: 1, fontScale: 1}); + const {result} = renderHook(() => useResponsiveLayout()); + + expect(result.current.isSmallScreenWidth).toBe(isNarrow); + expect(result.current.shouldUseNarrowLayout).toBe(isNarrow); + expect(result.current.isMediumScreenWidth).toBe(isMedium); + expect(result.current.isLargeScreenWidth).toBe(isLarge); + expect(getIsNarrowLayout()).toBe(isNarrow); + spy.mockRestore(); + }); +}); diff --git a/tests/unit/calculateSuperWideRHPWidthTest.ts b/tests/unit/calculateSuperWideRHPWidthTest.ts index 12c8badcd521..e0145126a63a 100644 --- a/tests/unit/calculateSuperWideRHPWidthTest.ts +++ b/tests/unit/calculateSuperWideRHPWidthTest.ts @@ -1,18 +1,5 @@ import calculateSuperWideRHPWidth from '@libs/Navigation/helpers/calculateSuperWideRHPWidth'; -// jest-expo resolves `.native` files by default (defaultPlatform 'ios'), but the super wide RHP is a -// web/desktop-only layout whose native stubs are intentional no-ops. Force the web `index.ts` (and the -// receipt pane width it depends on) so these tests exercise the real width math (same pattern as -// resetOnboardingStackToRootTest). -jest.mock('@libs/Navigation/helpers/calculateSuperWideRHPWidth', () => - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - jest.requireActual('@libs/Navigation/helpers/calculateSuperWideRHPWidth/index.ts'), -); -jest.mock('@libs/Navigation/helpers/calculateReceiptPaneRHPWidth', () => - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - jest.requireActual('@libs/Navigation/helpers/calculateReceiptPaneRHPWidth/index.ts'), -); - // The expected widths below are pinned to concrete pixels rather than recomputed from variables, so any // change to superWideRHPLeftMargin (147), sideBarWidth (375), receiptPaneRHPMaxWidth (465) or // sidePanelWidth (375) forces a deliberate, visible update here instead of silently tracking the value. From 0f916d4ed49d32fd09d3f72149af7694c9b1cbbb Mon Sep 17 00:00:00 2001 From: Chuck Dries Date: Fri, 11 Sep 2026 15:45:05 -0700 Subject: [PATCH 2/4] PoC: native RHP stacks in JS --- src/components/Accordion/index.native.tsx | 8 +- src/components/Navigation/SearchSidebar.tsx | 8 +- .../useModalStackScreenOptions.ts | 9 +- .../createRightModalNavigator/index.tsx | 4 +- .../createRootStackNavigator/index.tsx | 4 +- .../useModalCardStyleInterpolator.ts | 3 +- .../AppNavigator/useRHPScreenOptions.ts | 4 +- .../useRootNavigatorScreenOptions.ts | 14 +- .../createJSStackNavigatorComponent.tsx | 156 ++++++++++++++++++ .../index.tsx | 156 +----------------- .../convertToJSStackNavigationOptions.ts | 42 +++++ .../convertToWebNavigationOptions.ts | 24 --- src/pages/Search/SearchTypeMenuAccordion.tsx | 3 + src/styles/index.ts | 2 +- src/styles/utils/index.ts | 5 +- tests/unit/NativeRHPLayoutTest.tsx | 44 +++-- 16 files changed, 266 insertions(+), 220 deletions(-) create mode 100644 src/libs/Navigation/PlatformStackNavigation/createJSStackNavigatorComponent.tsx create mode 100644 src/libs/Navigation/PlatformStackNavigation/navigationOptions/convertToJSStackNavigationOptions.ts delete mode 100644 src/libs/Navigation/PlatformStackNavigation/navigationOptions/convertToWebNavigationOptions.ts diff --git a/src/components/Accordion/index.native.tsx b/src/components/Accordion/index.native.tsx index 90405149db8d..29bc498d2322 100644 --- a/src/components/Accordion/index.native.tsx +++ b/src/components/Accordion/index.native.tsx @@ -1,5 +1,3 @@ -import useThemeStyles from '@hooks/useThemeStyles'; - import type {ReactNode} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import type {SharedValue} from 'react-native-reanimated'; @@ -26,7 +24,6 @@ type AccordionProps = { function Accordion({isExpanded, children, duration = 300, isToggleTriggered, style}: AccordionProps) { const height = useSharedValue(0); - const styles = useThemeStyles(); const derivedHeight = useDerivedValue(() => { if (!isToggleTriggered.get()) { @@ -69,9 +66,9 @@ function Accordion({isExpanded, children, duration = 300, isToggleTriggered, sty }; } return { - height: !isToggleTriggered.get() ? height.get() : derivedHeight.get(), + height: !isToggleTriggered.get() ? undefined : derivedHeight.get(), opacity: derivedOpacity.get(), - overflow: isExpanded.get() ? 'visible' : 'hidden', + overflow: isToggleTriggered.get() ? 'hidden' : 'visible', }; }); @@ -81,7 +78,6 @@ function Accordion({isExpanded, children, duration = 300, isToggleTriggered, sty onLayout={(e) => { height.set(e.nativeEvent.layout.height); }} - style={[styles.pAbsolute, styles.l0, styles.r0, styles.t0]} > {children} diff --git a/src/components/Navigation/SearchSidebar.tsx b/src/components/Navigation/SearchSidebar.tsx index c0d7d37e9242..c4e8d314b394 100644 --- a/src/components/Navigation/SearchSidebar.tsx +++ b/src/components/Navigation/SearchSidebar.tsx @@ -28,6 +28,7 @@ import type {ParamListBase} from '@react-navigation/native'; import React, {useEffect} from 'react'; import {View} from 'react-native'; import Animated from 'react-native-reanimated'; +import {SafeAreaView} from 'react-native-safe-area-context'; import { useSearchSidebarCollapse, @@ -109,7 +110,10 @@ function SearchSidebar({state}: SearchSidebarProps) { - + - + diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts b/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts index a1455192b4dc..30e31c5887b1 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts @@ -1,4 +1,4 @@ -import {animatedSuperWideRHPWidth, useWideRHPState} from '@components/WideRHPContextProvider'; +import {animatedSuperWideRHPWidth, animatedWideRHPWidth, useWideRHPState} from '@components/WideRHPContextProvider'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSidePanelState from '@hooks/useSidePanelState'; @@ -12,6 +12,7 @@ import RHP_WEB_TRANSITION_SPEC from '@libs/Navigation/AppNavigator/RHPTransition import useModalCardStyleInterpolator from '@libs/Navigation/AppNavigator/useModalCardStyleInterpolator'; import calculateReceiptPaneRHPWidth from '@libs/Navigation/helpers/calculateReceiptPaneRHPWidth'; import calculateSuperWideRHPWidth from '@libs/Navigation/helpers/calculateSuperWideRHPWidth'; +import getRHPLayoutValue from '@libs/Navigation/helpers/getRHPLayoutValue'; import type {PlatformStackNavigationOptions, PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import variables from '@styles/variables'; @@ -57,12 +58,14 @@ function useWideModalStackScreenOptions() { cardStyleInterpolator = enhanceCardStyleInterpolator(baseInterpolator, { // Shrink the super wide sheet by the Side Panel width while it is open so the sheet's // left edge stays put instead of being pushed off-screen. See https://github.com/Expensify/App/issues/99035 - cardStyle: styles.getSuperWideRHPExtendedCardInterpolatorStyles(Animated.subtract(animatedSuperWideRHPWidth, sidePanelOffset.current)), + cardStyle: styles.getSuperWideRHPExtendedCardInterpolatorStyles( + getRHPLayoutValue(nativeWidth, Animated.subtract(animatedSuperWideRHPWidth, sidePanelOffset.current)), + ), }); } else if (wideRHPRouteKeys.includes(route.key)) { nativeWidth = calculateReceiptPaneRHPWidth(windowWidth) + variables.sideBarWidth; cardStyleInterpolator = enhanceCardStyleInterpolator(baseInterpolator, { - cardStyle: styles.wideRHPExtendedCardInterpolatorStyles, + cardStyle: {...styles.wideRHPExtendedCardInterpolatorStyles, width: getRHPLayoutValue(nativeWidth, animatedWideRHPWidth)}, }); // single RHPs displayed above the wide RHP need to be positioned } else if (superWideRHPRouteKeys.length > 0 || wideRHPRouteKeys.length > 0) { diff --git a/src/libs/Navigation/AppNavigator/createRightModalNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createRightModalNavigator/index.tsx index 56e45cd6357f..c0f4a3ebafd3 100644 --- a/src/libs/Navigation/AppNavigator/createRightModalNavigator/index.tsx +++ b/src/libs/Navigation/AppNavigator/createRightModalNavigator/index.tsx @@ -1,5 +1,5 @@ import usePreserveNavigatorState from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; -import createPlatformStackNavigatorComponent from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent'; +import createJSStackNavigatorComponent from '@libs/Navigation/PlatformStackNavigation/createJSStackNavigatorComponent'; import defaultPlatformStackScreenOptions from '@libs/Navigation/PlatformStackNavigation/defaultPlatformStackScreenOptions'; import type {CustomEffectsHookProps, PlatformStackNavigationEventMap, PlatformStackNavigationOptions, PlatformStackNavigationState} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -18,7 +18,7 @@ function RightModalNavigatorEffects(props: CustomEffectsHookProps) { return <>; } -const RightModalNavigatorComponent = createPlatformStackNavigatorComponent(NAVIGATORS.RIGHT_MODAL_NAVIGATOR, { +const RightModalNavigatorComponent = createJSStackNavigatorComponent(NAVIGATORS.RIGHT_MODAL_NAVIGATOR, { createRouter: RightModalRouter, defaultScreenOptions: defaultPlatformStackScreenOptions, Effects: RightModalNavigatorEffects, diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createRootStackNavigator/index.tsx index 2f255b1fddee..e549d7f117bc 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/index.tsx +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/index.tsx @@ -2,7 +2,7 @@ import RootNavigatorExtraContent from '@components/Navigation/RootNavigatorExtra import addRootHistoryRouterExtension from '@libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtension'; import useNavigationResetOnLayoutChange from '@libs/Navigation/AppNavigator/useNavigationResetOnLayoutChange'; -import createPlatformStackNavigatorComponent from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent'; +import createJSStackNavigatorComponent from '@libs/Navigation/PlatformStackNavigation/createJSStackNavigatorComponent'; import defaultPlatformStackScreenOptions from '@libs/Navigation/PlatformStackNavigation/defaultPlatformStackScreenOptions'; import type { CustomEffectsHookProps, @@ -26,7 +26,7 @@ function RootStackNavigatorEffects(props: CustomEffectsHookProps) { return <>; } -const RootStackNavigatorComponent = createPlatformStackNavigatorComponent('RootStackNavigator', { +const RootStackNavigatorComponent = createJSStackNavigatorComponent('RootStackNavigator', { createRouter: addRootHistoryRouterExtension(RootStackRouter as PlatformStackRouterFactory), defaultScreenOptions: defaultPlatformStackScreenOptions, Effects: RootStackNavigatorEffects, diff --git a/src/libs/Navigation/AppNavigator/useModalCardStyleInterpolator.ts b/src/libs/Navigation/AppNavigator/useModalCardStyleInterpolator.ts index 901c8d43db24..d5ed266f4163 100644 --- a/src/libs/Navigation/AppNavigator/useModalCardStyleInterpolator.ts +++ b/src/libs/Navigation/AppNavigator/useModalCardStyleInterpolator.ts @@ -4,6 +4,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {isMobileChrome, isMobileSafari} from '@libs/Browser'; +import getRHPLayoutValue from '@libs/Navigation/helpers/getRHPLayoutValue'; import variables from '@styles/variables'; @@ -45,7 +46,7 @@ const useModalCardStyleInterpolator = (): ModalCardStyleInterpolator => { const cardStyle = StyleUtils.getCardStyles(screen.width); if (applySidePanelOffset) { - cardStyle.paddingRight = sidePanelOffset.current; + cardStyle.paddingRight = getRHPLayoutValue(0, sidePanelOffset.current); } // Suppress card entry animation while the side panel is mid-transition on narrow layout — keeps the diff --git a/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts b/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts index 47d0ec8e773d..ec12662cacc6 100644 --- a/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts @@ -48,8 +48,6 @@ const useRHPScreenOptions = (): PlatformStackNavigationOptions => { headerShown: false, animation: Animations.SLIDE_FROM_RIGHT, gestureDirection: 'horizontal', - // Inner RHPs must also retain the wider report underneath them. - native: isSmallScreenWidth ? undefined : {presentation: 'containedTransparentModal', contentStyle: styles.bgTransparent}, web: { // The .forHorizontalIOS interpolator from `@react-navigation` is misbehaving on Safari, so we override it with Expensify custom interpolator cardStyleInterpolator: isSafari() @@ -62,7 +60,7 @@ const useRHPScreenOptions = (): PlatformStackNavigationOptions => { transitionSpec: isSmallScreenWidth ? undefined : RHP_WEB_TRANSITION_SPEC, }, }; - }, [customInterpolator, shouldAdjustInterpolatorProps, isSmallScreenWidth, styles.bgTransparent, styles.navigationScreenCardStyle]); + }, [customInterpolator, shouldAdjustInterpolatorProps, isSmallScreenWidth, styles.navigationScreenCardStyle]); }; export default useRHPScreenOptions; diff --git a/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts b/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts index 1102999fc414..062da84fcc90 100644 --- a/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts @@ -52,8 +52,6 @@ const useRootNavigatorScreenOptions = () => { animation: Animations.SLIDE_FROM_RIGHT, // We want pop in RHP since there are some flows that would work weird otherwise animationTypeForReplace: 'pop', - // Transparent presentation keeps the base split visible underneath the RHP. - native: shouldUseNarrowLayout ? undefined : {presentation: Presentation.TRANSPARENT_MODAL, contentStyle: themeStyles.bgTransparent}, web: { presentation: Presentation.TRANSPARENT_MODAL, cardStyleInterpolator: (props: StackCardInterpolationProps) => @@ -69,23 +67,21 @@ const useRootNavigatorScreenOptions = () => { presentation: Presentation.TRANSPARENT_MODAL, web: { cardOverlayEnabled: false, - cardStyle: { + cardStyle: StyleUtils.getStyleWithEnvSafeAreaPadding({ ...StyleUtils.getNavigationModalCardStyle(), + ...themeStyles.modalStackNavigatorContainer, backgroundColor: 'transparent', width: '100%', top: 0, left: 0, - position: 'fixed', - paddingLeft: 'env(safe-area-inset-left)', - paddingRight: 'env(safe-area-inset-right)', - }, + }), cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator({props, enter: onboardingEnter}), }, }, centeredModalNavigator: { presentation: Presentation.TRANSPARENT_MODAL, - native: { - contentStyle: { + web: { + cardStyle: { ...StyleUtils.getBackgroundColorWithOpacityStyle(theme.overlay, variables.overlayOpacity), }, }, diff --git a/src/libs/Navigation/PlatformStackNavigation/createJSStackNavigatorComponent.tsx b/src/libs/Navigation/PlatformStackNavigation/createJSStackNavigatorComponent.tsx new file mode 100644 index 000000000000..1ebf320fc966 --- /dev/null +++ b/src/libs/Navigation/PlatformStackNavigation/createJSStackNavigatorComponent.tsx @@ -0,0 +1,156 @@ +// Shared renderer for web stacks and native root/RHP layers. Native split panes use NativeStackView separately. +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import type {ParamListBase, StackActionHelpers} from '@react-navigation/native'; +import type {StackNavigationEventMap, StackNavigationOptions} from '@react-navigation/stack'; + +import {StackRouter, useNavigationBuilder} from '@react-navigation/native'; +import {StackView} from '@react-navigation/stack'; +import React from 'react'; + +import type { + CreatePlatformStackNavigatorComponentOptions, + CustomCodeProps, + PlatformStackNavigationOptions, + PlatformStackNavigationState, + PlatformStackNavigatorProps, + PlatformStackRouterOptions, +} from './types'; + +import wrapDescriptorsWithNonTopScreensBehavior from './createPlatformStackNavigatorComponent/wrapDescriptorsWithNonTopScreensBehavior'; +import convertToJSStackNavigationOptions from './navigationOptions/convertToJSStackNavigationOptions'; +import screenLayout from './ScreenLayout'; + +type PlatformNavigatorImplProps = PlatformStackNavigatorProps & { + createRouter: NonNullable['createRouter']>; + getCustomState?: CreatePlatformStackNavigatorComponentOptions['getCustomState']; + defaultScreenOptions?: CreatePlatformStackNavigatorComponentOptions['defaultScreenOptions']; + ExtraContent?: CreatePlatformStackNavigatorComponentOptions['ExtraContent']; + NavigationContentWrapper?: CreatePlatformStackNavigatorComponentOptions['NavigationContentWrapper']; + Effects?: CreatePlatformStackNavigatorComponentOptions['Effects']; + displayName: string; +}; + +function PlatformNavigatorImpl({ + id, + initialRouteName, + screenOptions, + screenListeners, + children, + sidebarScreen, + defaultCentralScreen, + parentRoute, + persistentScreens, + createRouter, + getCustomState, + defaultScreenOptions, + ExtraContent, + NavigationContentWrapper, + Effects, + displayName, + ...props +}: PlatformNavigatorImplProps) { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const { + navigation, + state: originalState, + descriptors, + describe, + NavigationContent, + } = useNavigationBuilder< + PlatformStackNavigationState, + RouterOptions, + StackActionHelpers, + StackNavigationOptions, + StackNavigationEventMap, + PlatformStackNavigationOptions + >( + createRouter, + { + id, + children, + screenOptions: {...defaultScreenOptions, ...screenOptions}, + screenListeners, + initialRouteName, + defaultCentralScreen, + sidebarScreen, + parentRoute, + persistentScreens, + screenLayout, + }, + convertToJSStackNavigationOptions, + ); + + const customCodeProps: CustomCodeProps> = { + state: originalState, + navigation, + descriptors, + displayName, + parentRoute, + }; + + const state = getCustomState?.({...customCodeProps, shouldUseNarrowLayout}) ?? originalState; + const customCodePropsWithCustomState: CustomCodeProps> = { + ...customCodeProps, + state, + }; + + const mappedState = { + ...state, + routes: state.routes.map((route) => { + // eslint-disable-next-line rulesdir/no-negated-variables + const dontDetachScreen = persistentScreens?.includes(route.name) ? {dontDetachScreen: true} : {}; + return {...route, ...dontDetachScreen}; + }), + }; + + const wrappedDescriptors = wrapDescriptorsWithNonTopScreensBehavior(descriptors, state, persistentScreens); + + const content = ( + + + + {!!ExtraContent && } + + ); + + return ( + <> + {!!Effects && } + {NavigationContentWrapper === undefined ? content : {content}} + + ); +} + +function createJSStackNavigatorComponent( + displayName: string, + options?: CreatePlatformStackNavigatorComponentOptions, +) { + function PlatformNavigator(props: PlatformStackNavigatorProps) { + return ( + + ); + } + + PlatformNavigator.displayName = displayName; + + return PlatformNavigator; +} + +export default createJSStackNavigatorComponent; diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx index d18614af86c0..b6e24b8eb4b2 100644 --- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx @@ -1,155 +1,3 @@ -import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import createJSStackNavigatorComponent from '@libs/Navigation/PlatformStackNavigation/createJSStackNavigatorComponent'; -import convertToWebNavigationOptions from '@libs/Navigation/PlatformStackNavigation/navigationOptions/convertToWebNavigationOptions'; -import screenLayout from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; -import type { - CreatePlatformStackNavigatorComponentOptions, - CustomCodeProps, - PlatformStackNavigationOptions, - PlatformStackNavigationState, - PlatformStackNavigatorProps, - PlatformStackRouterOptions, -} from '@libs/Navigation/PlatformStackNavigation/types'; - -import type {ParamListBase, StackActionHelpers} from '@react-navigation/native'; -import type {StackNavigationEventMap, StackNavigationOptions} from '@react-navigation/stack'; - -import {StackRouter, useNavigationBuilder} from '@react-navigation/native'; -import {StackView} from '@react-navigation/stack'; -import React from 'react'; - -import wrapDescriptorsWithNonTopScreensBehavior from './wrapDescriptorsWithNonTopScreensBehavior'; - -type PlatformNavigatorImplProps = PlatformStackNavigatorProps & { - createRouter: NonNullable['createRouter']>; - getCustomState?: CreatePlatformStackNavigatorComponentOptions['getCustomState']; - defaultScreenOptions?: CreatePlatformStackNavigatorComponentOptions['defaultScreenOptions']; - ExtraContent?: CreatePlatformStackNavigatorComponentOptions['ExtraContent']; - NavigationContentWrapper?: CreatePlatformStackNavigatorComponentOptions['NavigationContentWrapper']; - Effects?: CreatePlatformStackNavigatorComponentOptions['Effects']; - displayName: string; -}; - -function PlatformNavigatorImpl({ - id, - initialRouteName, - screenOptions, - screenListeners, - children, - sidebarScreen, - defaultCentralScreen, - parentRoute, - persistentScreens, - createRouter, - getCustomState, - defaultScreenOptions, - ExtraContent, - NavigationContentWrapper, - Effects, - displayName, - ...props -}: PlatformNavigatorImplProps) { - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const { - navigation, - state: originalState, - descriptors, - describe, - NavigationContent, - } = useNavigationBuilder< - PlatformStackNavigationState, - RouterOptions, - StackActionHelpers, - StackNavigationOptions, - StackNavigationEventMap, - PlatformStackNavigationOptions - >( - createRouter, - { - id, - children, - screenOptions: {...defaultScreenOptions, ...screenOptions}, - screenListeners, - initialRouteName, - defaultCentralScreen, - sidebarScreen, - parentRoute, - persistentScreens, - screenLayout, - }, - convertToWebNavigationOptions, - ); - - const customCodeProps: CustomCodeProps> = { - state: originalState, - navigation, - descriptors, - displayName, - parentRoute, - }; - - const state = getCustomState?.({...customCodeProps, shouldUseNarrowLayout}) ?? originalState; - const customCodePropsWithCustomState: CustomCodeProps> = { - ...customCodeProps, - state, - }; - - const mappedState = { - ...state, - routes: state.routes.map((route) => { - // eslint-disable-next-line rulesdir/no-negated-variables - const dontDetachScreen = persistentScreens?.includes(route.name) ? {dontDetachScreen: true} : {}; - return {...route, ...dontDetachScreen}; - }), - }; - - const wrappedDescriptors = wrapDescriptorsWithNonTopScreensBehavior(descriptors, state, persistentScreens); - - const content = ( - - - - {!!ExtraContent && } - - ); - - return ( - <> - {!!Effects && } - {NavigationContentWrapper === undefined ? content : {content}} - - ); -} - -function createPlatformStackNavigatorComponent( - displayName: string, - options?: CreatePlatformStackNavigatorComponentOptions, -) { - function PlatformNavigator(props: PlatformStackNavigatorProps) { - return ( - - ); - } - - PlatformNavigator.displayName = displayName; - - return PlatformNavigator; -} - -export default createPlatformStackNavigatorComponent; +export default createJSStackNavigatorComponent; diff --git a/src/libs/Navigation/PlatformStackNavigation/navigationOptions/convertToJSStackNavigationOptions.ts b/src/libs/Navigation/PlatformStackNavigation/navigationOptions/convertToJSStackNavigationOptions.ts new file mode 100644 index 000000000000..e574304fdc38 --- /dev/null +++ b/src/libs/Navigation/PlatformStackNavigation/navigationOptions/convertToJSStackNavigationOptions.ts @@ -0,0 +1,42 @@ +import type {PlatformStackNavigationOptions, PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import {isRouteBasedScreenOptions} from '@libs/Navigation/PlatformStackNavigation/types'; + +import type {ParamListBase, ScreenOptionsOrCallback} from '@react-navigation/native'; +import type {StackNavigationOptions} from '@react-navigation/stack'; + +import Animations from './animation'; + +// Animation names here belong to the JS renderer. Native-stack mappings such as iOS's simple_push do not apply. +function getJSStackOptions({animation, web, native, ...common}: PlatformStackNavigationOptions): StackNavigationOptions { + let animationOptions: StackNavigationOptions = {}; + if (animation !== undefined) { + animationOptions = {animation}; + if (animation === Animations.NONE) { + animationOptions.gestureEnabled = false; + } else if (animation === Animations.SLIDE_FROM_BOTTOM) { + animationOptions.gestureDirection = 'vertical'; + } else if (animation === Animations.SLIDE_FROM_LEFT) { + animationOptions.gestureDirection = 'horizontal-inverted'; + } else if (animation === Animations.SLIDE_FROM_RIGHT) { + animationOptions.gestureDirection = 'horizontal'; + } + } + return {...animationOptions, ...common, ...web}; +} + +function convertToJSStackNavigationOptions(screenOptions: ScreenOptionsOrCallback | undefined): ScreenOptionsOrCallback | undefined { + if (!screenOptions) { + return undefined; + } + + if (isRouteBasedScreenOptions(screenOptions)) { + return (props: PlatformStackScreenProps) => { + const routeBasedScreenOptions = screenOptions(props); + return getJSStackOptions(routeBasedScreenOptions); + }; + } + + return getJSStackOptions(screenOptions); +} + +export default convertToJSStackNavigationOptions; diff --git a/src/libs/Navigation/PlatformStackNavigation/navigationOptions/convertToWebNavigationOptions.ts b/src/libs/Navigation/PlatformStackNavigation/navigationOptions/convertToWebNavigationOptions.ts deleted file mode 100644 index 5eeb9eab3e93..000000000000 --- a/src/libs/Navigation/PlatformStackNavigation/navigationOptions/convertToWebNavigationOptions.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type {PlatformStackNavigationOptions, PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import {isRouteBasedScreenOptions} from '@libs/Navigation/PlatformStackNavigation/types'; - -import type {ParamListBase, ScreenOptionsOrCallback} from '@react-navigation/native'; -import type {StackNavigationOptions} from '@react-navigation/stack'; - -import buildPlatformSpecificNavigationOptions from './buildPlatformSpecificNavigationOptions'; - -function convertToWebNavigationOptions(screenOptions: ScreenOptionsOrCallback | undefined): ScreenOptionsOrCallback | undefined { - if (!screenOptions) { - return undefined; - } - - if (isRouteBasedScreenOptions(screenOptions)) { - return (props: PlatformStackScreenProps) => { - const routeBasedScreenOptions = screenOptions(props); - return {...buildPlatformSpecificNavigationOptions(routeBasedScreenOptions), ...routeBasedScreenOptions.web}; - }; - } - - return {...buildPlatformSpecificNavigationOptions(screenOptions), ...screenOptions.web}; -} - -export default convertToWebNavigationOptions; diff --git a/src/pages/Search/SearchTypeMenuAccordion.tsx b/src/pages/Search/SearchTypeMenuAccordion.tsx index 62e790e7c3fb..22629883c019 100644 --- a/src/pages/Search/SearchTypeMenuAccordion.tsx +++ b/src/pages/Search/SearchTypeMenuAccordion.tsx @@ -34,14 +34,17 @@ type AnimatedBadgeProps = { }; function getBadgeOpacity(isExpanded: boolean) { + 'worklet'; return Number(!isExpanded); } function getBadgeOffsetY(isExpanded: boolean): `${number}%` | number { + 'worklet'; return isExpanded ? '50%' : 0; } function getArrowRotation(isExpanded: boolean) { + 'worklet'; return isExpanded ? 0 : 180; } diff --git a/src/styles/index.ts b/src/styles/index.ts index 67b0e5d6f086..5ad385ca23cd 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6728,7 +6728,7 @@ const dynamicStyles = (theme: ThemeColors) => // The width is shrunk by the Side Panel offset at the call site (passed in), so the super wide // sheet's left edge stays put instead of being pushed off-screen while the Side Panel is open. // See https://github.com/Expensify/App/issues/99035 - getSuperWideRHPExtendedCardInterpolatorStyles: (width: Animated.AnimatedSubtraction) => + getSuperWideRHPExtendedCardInterpolatorStyles: (width: number | Animated.AnimatedSubtraction) => ({ position: 'absolute', height: '100%', diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 81dafe19eaf8..9070a1669cec 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -20,7 +20,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import type {EdgeInsets} from 'react-native-safe-area-context'; import type {ValueOf} from 'type-fest'; -import {PixelRatio, Dimensions as RNDimensions, StyleSheet} from 'react-native'; +import {PixelRatio, Platform, Dimensions as RNDimensions, StyleSheet} from 'react-native'; import type {ThemeStyles} from '..'; import type { @@ -2454,8 +2454,7 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ getStyleWithEnvSafeAreaPadding: (style: ViewStyle): ViewStyle => ({ ...style, - paddingLeft: 'env(safe-area-inset-left)', - paddingRight: 'env(safe-area-inset-right)', + ...Platform.select({web: {paddingLeft: 'env(safe-area-inset-left)', paddingRight: 'env(safe-area-inset-right)'}}), }), }); diff --git a/tests/unit/NativeRHPLayoutTest.tsx b/tests/unit/NativeRHPLayoutTest.tsx index 94a0b3607c8a..5d3af970f93f 100644 --- a/tests/unit/NativeRHPLayoutTest.tsx +++ b/tests/unit/NativeRHPLayoutTest.tsx @@ -10,10 +10,16 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import useModalStackScreenOptions from '@libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions'; import useRHPScreenOptions from '@libs/Navigation/AppNavigator/useRHPScreenOptions'; import useRootNavigatorScreenOptions from '@libs/Navigation/AppNavigator/useRootNavigatorScreenOptions'; +import convertToJSStackNavigationOptions from '@libs/Navigation/PlatformStackNavigation/navigationOptions/convertToJSStackNavigationOptions'; import CONST from '@src/CONST'; -import {StyleSheet} from 'react-native'; +import type {StackCardInterpolationProps} from '@react-navigation/stack'; + +// eslint-disable-next-line no-restricted-imports +import {Animated, StyleSheet} from 'react-native'; + +import createMock from '../utils/createMock'; jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); jest.mock('@hooks/useWindowDimensions', () => jest.fn()); @@ -34,24 +40,42 @@ describe('Native RHP layout', () => { const expense = result.current({route: {key: 'expense', name: 'expense'}}); const transaction = result.current({route: {key: 'transaction', name: 'transaction'}}); const detail = result.current({route: {key: 'detail', name: 'detail'}}); + const interpolationProps = createMock({ + current: {progress: new Animated.Value(1)}, + inverted: new Animated.Value(1), + layouts: {screen: {width: 1180, height: 820}}, + }); - expect(StyleSheet.flatten(expense.native?.contentStyle)).toMatchObject({width: 1033, alignSelf: 'flex-end'}); - expect(StyleSheet.flatten(transaction.native?.contentStyle)).toMatchObject({width: 840, alignSelf: 'flex-end'}); - expect(StyleSheet.flatten(detail.native?.contentStyle)).toMatchObject({width: 375, alignSelf: 'flex-end'}); + expect(expense.web?.cardStyleInterpolator?.(interpolationProps).cardStyle).toMatchObject({width: 1033, right: 0}); + expect(transaction.web?.cardStyleInterpolator?.(interpolationProps).cardStyle).toMatchObject({width: 840, right: 0}); + expect(detail.web?.cardStyleInterpolator?.(interpolationProps).cardStyle).toMatchObject({width: 375, right: 0}); }); - it('retains the base scene and contains inner modals within the RHP host', () => { + it('retains the base scene and uses JS-stack horizontal transitions on native', () => { const {result} = renderHook(() => ({root: useRootNavigatorScreenOptions(), inner: useRHPScreenOptions()})); - expect(result.current.root.rightModalNavigator.native?.presentation).toBe('transparentModal'); - expect(result.current.inner.native?.presentation).toBe('containedTransparentModal'); + expect(convertToJSStackNavigationOptions(result.current.root.rightModalNavigator)).toMatchObject({presentation: 'transparentModal', animation: 'slide_from_right'}); + expect(convertToJSStackNavigationOptions(result.current.inner)).toMatchObject({presentation: 'transparentModal', animation: 'slide_from_right', gestureDirection: 'horizontal'}); }); - it('lets narrow screens fill the native stack', () => { + it('lets narrow screens fill the stack without CSS-only safe-area values', () => { jest.mocked(useResponsiveLayout).mockReturnValue({...CONST.NAVIGATION_TESTS.DEFAULT_USE_RESPONSIVE_LAYOUT_VALUE, isSmallScreenWidth: true, shouldUseNarrowLayout: true}); const {result} = renderHook(() => ({options: useModalStackScreenOptions(), inner: useRHPScreenOptions()})); const transaction = result.current.options({route: {key: 'transaction', name: 'transaction'}}); - expect(StyleSheet.flatten(transaction.native?.contentStyle)?.width).toBeUndefined(); - expect(result.current.inner.native?.presentation).toBeUndefined(); + expect(StyleSheet.flatten(transaction.web?.cardStyle)).toEqual({height: '100%'}); + expect(convertToJSStackNavigationOptions(result.current.inner)).toMatchObject({animation: 'slide_from_right', gestureDirection: 'horizontal'}); + }); + + it.each([ + ['slide_from_right', 'horizontal'], + ['slide_from_left', 'horizontal-inverted'], + ['slide_from_bottom', 'vertical'], + ] as const)('converts %s for the JS renderer, independently of native-stack mappings', (animation, gestureDirection) => { + expect(convertToJSStackNavigationOptions({animation, native: {animation: 'simple_push', presentation: 'containedTransparentModal'}})).toEqual({animation, gestureDirection}); + }); + + it('preserves no-animation and explicit JS-stack overrides', () => { + expect(convertToJSStackNavigationOptions({animation: 'none'})).toEqual({animation: 'none', gestureEnabled: false}); + expect(convertToJSStackNavigationOptions({animation: 'slide_from_right', web: {gestureEnabled: false}})).toMatchObject({animation: 'slide_from_right', gestureEnabled: false}); }); }); From 453b573b20aaac3604086033eeebbfcb548dc86f Mon Sep 17 00:00:00 2001 From: Chuck Dries Date: Fri, 11 Sep 2026 16:03:18 -0700 Subject: [PATCH 3/4] Fix useSearchSidebarLayoutWidthStyle on native split layout --- src/components/Navigation/SearchSidebarCollapseStore.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Navigation/SearchSidebarCollapseStore.ts b/src/components/Navigation/SearchSidebarCollapseStore.ts index 6a621c15e4c0..e06b87ebe5ef 100644 --- a/src/components/Navigation/SearchSidebarCollapseStore.ts +++ b/src/components/Navigation/SearchSidebarCollapseStore.ts @@ -17,6 +17,7 @@ const TOGGLE_BUTTON_COLLAPSED_TRANSLATE_X = -10; const layoutTransitionStyle: ViewStyle = Platform.OS === 'web' ? {transition: `width ${SEARCH_SIDEBAR_COLLAPSE_ANIMATION_DURATION_MS}ms ease, margin-left ${SEARCH_SIDEBAR_COLLAPSE_ANIMATION_DURATION_MS}ms ease`} : {}; +const layoutPositionStyle: ViewStyle = Platform.OS === 'web' ? {} : {position: 'absolute', top: 0, bottom: 0, left: 0}; const fadeTransitionStyle: ViewStyle = Platform.OS === 'web' ? {transition: `opacity ${SEARCH_SIDEBAR_COLLAPSE_ANIMATION_DURATION_MS}ms ease, transform ${SEARCH_SIDEBAR_COLLAPSE_ANIMATION_DURATION_MS}ms ease`} : {}; @@ -89,7 +90,7 @@ function useSearchSidebarCollapse() { function useSearchSidebarLayoutWidthStyle() { const {isCollapsed: collapsed} = useSearchSidebarCollapse(); - return useMemo(() => ({...layoutTransitionStyle, height: '100%', width: getSearchSidebarWidth(collapsed ? 1 : 0)}), [collapsed]); + return useMemo(() => ({...layoutTransitionStyle, ...layoutPositionStyle, height: '100%', width: getSearchSidebarWidth(collapsed ? 1 : 0)}), [collapsed]); } function useSearchSidebarVisualWidthStyle() { From a4f93061b3f97760a2911a644e3605c6f54cc8ba Mon Sep 17 00:00:00 2001 From: Chuck Dries Date: Fri, 11 Sep 2026 17:00:44 -0700 Subject: [PATCH 4/4] Fix scrim accessibility in JS RHPs, animations WIP --- .../useShouldRenderOverlay.native.ts | 6 - .../useShouldRenderOverlay.ts | 46 ++++---- .../Navigators/Overlay/BaseOverlay.tsx | 3 + .../Navigators/Overlay/index.native.tsx | 55 +++++---- .../Navigators/RightModalNavigator.tsx | 23 ++-- .../useRHPTransition/index.native.ts | 31 +++++ .../AppNavigator/useRHPTransition/index.ts | 28 +++++ .../useRootNavigatorScreenOptions.ts | 12 +- .../StackScreenAccessibility/index.native.tsx | 26 ++++ .../StackScreenAccessibility/index.tsx | 8 ++ .../StackScreenAccessibility/types.ts | 8 ++ .../createJSStackNavigatorComponent.tsx | 13 +- tests/ui/NativeRHPOverlayTest.tsx | 70 +++++++++++ tests/ui/StackedRHPOverlayAnimationTest.tsx | 111 ++++++++++++++++++ tests/unit/NativeRHPLayoutTest.tsx | 36 ++++++ 15 files changed, 408 insertions(+), 68 deletions(-) delete mode 100644 src/components/WideRHPContextProvider/useShouldRenderOverlay.native.ts create mode 100644 src/libs/Navigation/AppNavigator/useRHPTransition/index.native.ts create mode 100644 src/libs/Navigation/AppNavigator/useRHPTransition/index.ts create mode 100644 src/libs/Navigation/PlatformStackNavigation/StackScreenAccessibility/index.native.tsx create mode 100644 src/libs/Navigation/PlatformStackNavigation/StackScreenAccessibility/index.tsx create mode 100644 src/libs/Navigation/PlatformStackNavigation/StackScreenAccessibility/types.ts create mode 100644 tests/ui/NativeRHPOverlayTest.tsx create mode 100644 tests/ui/StackedRHPOverlayAnimationTest.tsx diff --git a/src/components/WideRHPContextProvider/useShouldRenderOverlay.native.ts b/src/components/WideRHPContextProvider/useShouldRenderOverlay.native.ts deleted file mode 100644 index 30a9dc97db8f..000000000000 --- a/src/components/WideRHPContextProvider/useShouldRenderOverlay.native.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Native overlays follow route visibility directly; native-stack owns their transitions. -import type {OverlayStylesParams} from '@styles/index'; - -const useShouldRenderOverlay: (shouldRender: boolean, progress: OverlayStylesParams) => boolean = (shouldRender) => shouldRender; - -export default useShouldRenderOverlay; diff --git a/src/components/WideRHPContextProvider/useShouldRenderOverlay.ts b/src/components/WideRHPContextProvider/useShouldRenderOverlay.ts index 9b8b0bb3082b..92292fe91775 100644 --- a/src/components/WideRHPContextProvider/useShouldRenderOverlay.ts +++ b/src/components/WideRHPContextProvider/useShouldRenderOverlay.ts @@ -2,9 +2,11 @@ import {useEffect, useRef, useState} from 'react'; // We use Animated for all functionality related to wide RHP to make it easier // to interact with react-navigation components (e.g., CardContainer, interpolator), which also use Animated. // eslint-disable-next-line no-restricted-imports -import {Animated} from 'react-native'; +import {Animated, Platform} from 'react-native'; const OVERLAY_TIMING_DURATION = 300; +// These values only drive opacity, so native can animate them while the incoming report renders on JS. +const USE_NATIVE_DRIVER = Platform.OS !== 'web'; function useShouldRenderOverlay(condition: boolean, overlayProgress: Animated.Value) { const [shouldRenderOverlay, setShouldRenderOverlay] = useState(false); @@ -15,26 +17,30 @@ function useShouldRenderOverlay(condition: boolean, overlayProgress: Animated.Va useEffect(() => { conditionRef.current = condition; - if (condition) { - setShouldRenderOverlay(true); - Animated.timing(overlayProgress, { - toValue: 1, - duration: OVERLAY_TIMING_DURATION, - useNativeDriver: false, - }).start(); - } else { - Animated.timing(overlayProgress, { - toValue: 0, - duration: OVERLAY_TIMING_DURATION, - useNativeDriver: false, - }).start(() => { - if (conditionRef.current) { - return; - } - setShouldRenderOverlay(false); - }); + // Commit the transparent overlay before starting its fade. Report rendering can otherwise + // consume the animation duration before the conditionally rendered overlay even mounts. + if (!shouldRenderOverlay) { + overlayProgress.setValue(0); + if (condition) { + setShouldRenderOverlay(true); + } + return; } - }, [condition, overlayProgress]); + + const animation = Animated.timing(overlayProgress, { + toValue: condition ? 1 : 0, + duration: OVERLAY_TIMING_DURATION, + useNativeDriver: USE_NATIVE_DRIVER, + }); + animation.start(({finished}) => { + if (!finished || conditionRef.current) { + return; + } + setShouldRenderOverlay(false); + }); + + return () => animation.stop(); + }, [condition, overlayProgress, shouldRenderOverlay]); return shouldRenderOverlay; } diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx index ca4ae4fe0870..cddfa9732629 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx @@ -25,6 +25,9 @@ type BaseOverlayProps = { /** Overlay position from the right edge of the container */ positionRightValue?: number | Animated.Value | Animated.AnimatedAddition; + + /** Native pointer dismissal stops at this panel width, independently of the visual scrim. */ + dismissalPositionRight?: number; }; // The default value of positionLeftValue is equal to -2 * variables.sideBarWidth, because we need to stretch the overlay to cover the sidebar and the translate animation distance. diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx index d37809c1eb06..175f6fb1ad4b 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx @@ -1,37 +1,52 @@ -// Native-stack owns transitions; this scrim only handles dimming and dismissal. +// Visual dimming and pointer dismissal are separate. Screen readers dismiss through the active panel's controls. import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; -import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import variables from '@styles/variables'; - -import CONST from '@src/CONST'; - +import {useIsFocused} from '@react-navigation/native'; +import {useCardAnimation} from '@react-navigation/stack'; import React from 'react'; // eslint-disable-next-line no-restricted-imports import {Animated} from 'react-native'; import type {BaseOverlayProps} from './BaseOverlay'; -function Overlay({onPress, positionLeftValue = -2 * variables.sideBarWidth, positionRightValue = 0}: BaseOverlayProps) { +function Overlay({onPress, progress, positionLeftValue = 0, positionRightValue = 0, dismissalPositionRight}: BaseOverlayProps) { const styles = useThemeStyles(); - const {translate} = useLocalize(); + const {current} = useCardAnimation(); + const isFocused = useIsFocused(); + // Native has no off-window card gutter to cover. Do not export off-window hit bounds. + const left = typeof positionLeftValue === 'number' ? Math.max(0, positionLeftValue) : positionLeftValue; return ( - - + - + {!!onPress && isFocused && ( + + )} + ); } diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index b3ae0fb602c3..58ee510a8060 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -22,6 +22,7 @@ import hideKeyboardOnSwipe from '@libs/Navigation/AppNavigator/hideKeyboardOnSwi import * as ModalStackNavigators from '@libs/Navigation/AppNavigator/ModalStackNavigators'; import useModalStackScreenOptions from '@libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions'; import useRHPScreenOptions from '@libs/Navigation/AppNavigator/useRHPScreenOptions'; +import {useRHPFrameStyle} from '@libs/Navigation/AppNavigator/useRHPTransition'; import calculateReceiptPaneRHPWidth from '@libs/Navigation/helpers/calculateReceiptPaneRHPWidth'; import calculateSuperWideRHPWidth from '@libs/Navigation/helpers/calculateSuperWideRHPWidth'; import getRHPLayoutValue from '@libs/Navigation/helpers/getRHPLayoutValue'; @@ -146,6 +147,7 @@ type RightModalDialogFrameProps = { function RightModalDialogFrame({hasDialogSemantics, style, onContainerRef, children}: RightModalDialogFrameProps) { const {dialogAriaLabel} = useDialogLabelData(); const hasName = !!dialogAriaLabel; + const frameStyle = useRHPFrameStyle(); return ( {children} @@ -214,17 +216,13 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { superWideRHPSidePanelOffset, ); - const animatedWidthStyle = useMemo(() => { - let width: number = singleRHPWidth; - if (superWideRHPRouteKeys.length > 0) { - width = calculateSuperWideRHPWidth(windowWidth); - } else if (wideRHPRouteKeys.length > 0) { - width = getWideRHPWidth(windowWidth); - } - return { - width: shouldUseNarrowLayout ? '100%' : getRHPLayoutValue(width, animatedWidth), - } as const; - }, [animatedWidth, shouldUseNarrowLayout, superWideRHPRouteKeys.length, wideRHPRouteKeys.length, windowWidth]); + let rhpWidth: number = singleRHPWidth; + if (superWideRHPRouteKeys.length > 0) { + rhpWidth = calculateSuperWideRHPWidth(windowWidth); + } else if (wideRHPRouteKeys.length > 0) { + rhpWidth = getWideRHPWidth(windowWidth); + } + const animatedWidthStyle = {width: shouldUseNarrowLayout ? '100%' : getRHPLayoutValue(rhpWidth, animatedWidth)} as const; const overlayPositionLeft = useMemo(() => -1 * calculateSuperWideRHPWidth(windowWidth), [windowWidth]); @@ -292,6 +290,7 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { {!shouldUseNarrowLayout && ( )} diff --git a/src/libs/Navigation/AppNavigator/useRHPTransition/index.native.ts b/src/libs/Navigation/AppNavigator/useRHPTransition/index.native.ts new file mode 100644 index 000000000000..3d4516c680ab --- /dev/null +++ b/src/libs/Navigation/AppNavigator/useRHPTransition/index.native.ts @@ -0,0 +1,31 @@ +// Keep native scrims stationary while the panel follows the root stack's transition progress. +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import useModalCardStyleInterpolator from '@libs/Navigation/AppNavigator/useModalCardStyleInterpolator'; + +import CONST from '@src/CONST'; + +import type {StackCardInterpolationProps} from '@react-navigation/stack'; + +import {useCardAnimation} from '@react-navigation/stack'; + +function useRootRHPCardStyleInterpolator() { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const interpolate = useModalCardStyleInterpolator(); + + return (props: StackCardInterpolationProps) => interpolate({props, enter: {kind: shouldUseNarrowLayout ? 'slide-from-width' : 'none'}, applySidePanelOffset: true}); +} + +function useRHPFrameStyle() { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const props = useCardAnimation(); + const interpolate = useModalCardStyleInterpolator(); + + if (shouldUseNarrowLayout) { + return undefined; + } + + return interpolate({props, enter: {kind: 'slide-and-fade', distancePx: CONST.MODAL.RHP_ENTER_OFFSET_PX_WEB}}).cardStyle; +} + +export {useRootRHPCardStyleInterpolator, useRHPFrameStyle}; diff --git a/src/libs/Navigation/AppNavigator/useRHPTransition/index.ts b/src/libs/Navigation/AppNavigator/useRHPTransition/index.ts new file mode 100644 index 000000000000..c11329a54727 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/useRHPTransition/index.ts @@ -0,0 +1,28 @@ +// Web keeps its existing card transition; native separates the host from the moving panel. +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import useModalCardStyleInterpolator from '@libs/Navigation/AppNavigator/useModalCardStyleInterpolator'; + +import CONST from '@src/CONST'; + +import type {StackCardInterpolationProps} from '@react-navigation/stack'; +// eslint-disable-next-line no-restricted-imports +import type {Animated, StyleProp, ViewStyle} from 'react-native'; + +function useRootRHPCardStyleInterpolator() { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const interpolate = useModalCardStyleInterpolator(); + + return (props: StackCardInterpolationProps) => + interpolate({ + props, + enter: shouldUseNarrowLayout ? {kind: 'slide-from-width'} : {kind: 'slide-and-fade', distancePx: CONST.MODAL.RHP_ENTER_OFFSET_PX_WEB}, + applySidePanelOffset: true, + }); +} + +function useRHPFrameStyle(): Animated.WithAnimatedValue> { + return undefined; +} + +export {useRootRHPCardStyleInterpolator, useRHPFrameStyle}; diff --git a/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts b/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts index 062da84fcc90..eda8ccdf0f42 100644 --- a/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts @@ -9,8 +9,6 @@ import type {PlatformStackNavigationOptions} from '@libs/Navigation/PlatformStac import variables from '@styles/variables'; -import CONST from '@src/CONST'; - import type {StackCardInterpolationProps} from '@react-navigation/stack'; import type {EnterAnimation} from './useModalCardStyleInterpolator'; @@ -18,6 +16,7 @@ import type {EnterAnimation} from './useModalCardStyleInterpolator'; import hideKeyboardOnSwipe from './hideKeyboardOnSwipe'; import RHP_WEB_TRANSITION_SPEC from './RHPTransitionSpec'; import useModalCardStyleInterpolator from './useModalCardStyleInterpolator'; +import {useRootRHPCardStyleInterpolator} from './useRHPTransition'; type RootNavigatorScreenOptions = { rightModalNavigator: PlatformStackNavigationOptions; @@ -38,12 +37,12 @@ const useRootNavigatorScreenOptions = () => { const theme = useTheme(); const StyleUtils = useStyleUtils(); const modalCardStyleInterpolator = useModalCardStyleInterpolator(); + const rhpCardStyleInterpolator = useRootRHPCardStyleInterpolator(); const {shouldUseNarrowLayout, onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); const themeStyles = useThemeStyles(); const fullScreenEnter: EnterAnimation = shouldUseNarrowLayout ? {kind: 'slide-from-width'} : {kind: 'none'}; const onboardingEnter: EnterAnimation = onboardingIsMediumOrLargerScreenWidth ? {kind: 'fade'} : {kind: 'slide-from-width'}; - const rhpEnter: EnterAnimation = shouldUseNarrowLayout ? {kind: 'slide-from-width'} : {kind: 'slide-and-fade', distancePx: CONST.MODAL.RHP_ENTER_OFFSET_PX_WEB}; return { rightModalNavigator: { @@ -54,12 +53,7 @@ const useRootNavigatorScreenOptions = () => { animationTypeForReplace: 'pop', web: { presentation: Presentation.TRANSPARENT_MODAL, - cardStyleInterpolator: (props: StackCardInterpolationProps) => - modalCardStyleInterpolator({ - props, - enter: rhpEnter, - applySidePanelOffset: true, - }), + cardStyleInterpolator: rhpCardStyleInterpolator, transitionSpec: shouldUseNarrowLayout ? undefined : RHP_WEB_TRANSITION_SPEC, }, }, diff --git a/src/libs/Navigation/PlatformStackNavigation/StackScreenAccessibility/index.native.tsx b/src/libs/Navigation/PlatformStackNavigation/StackScreenAccessibility/index.native.tsx new file mode 100644 index 000000000000..877e6b4741b8 --- /dev/null +++ b/src/libs/Navigation/PlatformStackNavigation/StackScreenAccessibility/index.native.tsx @@ -0,0 +1,26 @@ +// Covered JS-stack screens remain visible, but must not expose native controls behind a modal. +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; +import {View} from 'react-native'; + +import type StackScreenAccessibilityProps from './types'; + +function StackScreenAccessibility({isFocused, children}: StackScreenAccessibilityProps) { + const styles = useThemeStyles(); + + return ( + + {children} + + ); +} + +export default StackScreenAccessibility; diff --git a/src/libs/Navigation/PlatformStackNavigation/StackScreenAccessibility/index.tsx b/src/libs/Navigation/PlatformStackNavigation/StackScreenAccessibility/index.tsx new file mode 100644 index 000000000000..5a7b8d44faa0 --- /dev/null +++ b/src/libs/Navigation/PlatformStackNavigation/StackScreenAccessibility/index.tsx @@ -0,0 +1,8 @@ +// The web stack already manages accessibility and focus on its card wrapper. +import type StackScreenAccessibilityProps from './types'; + +function StackScreenAccessibility({children}: StackScreenAccessibilityProps) { + return children; +} + +export default StackScreenAccessibility; diff --git a/src/libs/Navigation/PlatformStackNavigation/StackScreenAccessibility/types.ts b/src/libs/Navigation/PlatformStackNavigation/StackScreenAccessibility/types.ts new file mode 100644 index 000000000000..64a07a46f8fb --- /dev/null +++ b/src/libs/Navigation/PlatformStackNavigation/StackScreenAccessibility/types.ts @@ -0,0 +1,8 @@ +import type {ReactElement} from 'react'; + +type StackScreenAccessibilityProps = { + isFocused: boolean; + children: ReactElement; +}; + +export default StackScreenAccessibilityProps; diff --git a/src/libs/Navigation/PlatformStackNavigation/createJSStackNavigatorComponent.tsx b/src/libs/Navigation/PlatformStackNavigation/createJSStackNavigatorComponent.tsx index 1ebf320fc966..2c7dc0adafa9 100644 --- a/src/libs/Navigation/PlatformStackNavigation/createJSStackNavigatorComponent.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/createJSStackNavigatorComponent.tsx @@ -20,6 +20,7 @@ import type { import wrapDescriptorsWithNonTopScreensBehavior from './createPlatformStackNavigatorComponent/wrapDescriptorsWithNonTopScreensBehavior'; import convertToJSStackNavigationOptions from './navigationOptions/convertToJSStackNavigationOptions'; import screenLayout from './ScreenLayout'; +import StackScreenAccessibility from './StackScreenAccessibility'; type PlatformNavigatorImplProps = PlatformStackNavigatorProps & { createRouter: NonNullable['createRouter']>; @@ -105,6 +106,16 @@ function PlatformNavigatorImpl [ + key, + { + ...descriptor, + render: () => {descriptor.render()}, + }, + ]), + ); const content = ( @@ -112,7 +123,7 @@ function PlatformNavigatorImpl diff --git a/tests/ui/NativeRHPOverlayTest.tsx b/tests/ui/NativeRHPOverlayTest.tsx new file mode 100644 index 000000000000..c23850490046 --- /dev/null +++ b/tests/ui/NativeRHPOverlayTest.tsx @@ -0,0 +1,70 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import Overlay from '@libs/Navigation/AppNavigator/Navigators/Overlay/index.native'; + +import variables from '@styles/variables'; + +import type {StackCardInterpolationProps} from '@react-navigation/stack'; + +import {useIsFocused} from '@react-navigation/native'; +import {useCardAnimation} from '@react-navigation/stack'; +import React from 'react'; +// eslint-disable-next-line no-restricted-imports +import {Animated} from 'react-native'; + +import createMock from '../utils/createMock'; + +jest.mock('@react-navigation/native', () => ({...jest.requireActual('@react-navigation/native'), useIsFocused: jest.fn()})); +jest.mock('@react-navigation/stack', () => ({...jest.requireActual('@react-navigation/stack'), useCardAnimation: jest.fn()})); + +describe('Native RHP scrims', () => { + beforeEach(() => { + jest.mocked(useIsFocused).mockReturnValue(true); + jest.mocked(useCardAnimation).mockReturnValue(createMock({current: {progress: new Animated.Value(0)}})); + }); + + it.each([0, 0.25, 1])('fades with navigation progress %s without a transform', (progress) => { + jest.mocked(useCardAnimation).mockReturnValue(createMock({current: {progress: new Animated.Value(progress)}})); + render(); + const scrim = screen.getByTestId('rhp-overlay', {includeHiddenElements: true}); + + expect(scrim).toHaveStyle({opacity: Math.min(progress * 2, 1) * variables.overlayOpacity, left: 0, right: 0}); + expect(scrim).toHaveProp('pointerEvents', 'none'); + expect(scrim).toHaveProp('importantForAccessibility', 'no-hide-descendants'); + expect(screen.queryByTestId('rhp-overlay-dismiss', {includeHiddenElements: true})).toBeNull(); + expect(screen.queryByLabelText('Close', {includeHiddenElements: true})).toBeNull(); + }); + + it('uses explicit progress for a secondary visual scrim', () => { + render(); + expect(screen.getByTestId('rhp-overlay', {includeHiddenElements: true})).toHaveStyle({opacity: variables.overlayOpacity}); + }); + + it.each([375, 840, 1033])('bounds pointer dismissal outside a %s-point panel without exporting a Close action', (width) => { + const onPress = jest.fn(); + render( + , + ); + const dismiss = screen.getByTestId('rhp-overlay-dismiss', {includeHiddenElements: true}); + + expect(dismiss).toHaveStyle({left: 0, right: width}); + expect(dismiss).toHaveProp('accessible', false); + expect(dismiss).toHaveProp('importantForAccessibility', 'no-hide-descendants'); + expect(screen.queryByRole('button', {includeHiddenElements: true})).toBeNull(); + expect(screen.queryByLabelText('Close', {includeHiddenElements: true})).toBeNull(); + fireEvent.press(dismiss); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('retains only visual dimming after its root route loses focus', () => { + jest.mocked(useIsFocused).mockReturnValue(false); + const onPress = jest.fn(); + render(); + expect(screen.queryByTestId('rhp-overlay-dismiss', {includeHiddenElements: true})).toBeNull(); + expect(onPress).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/ui/StackedRHPOverlayAnimationTest.tsx b/tests/ui/StackedRHPOverlayAnimationTest.tsx new file mode 100644 index 000000000000..16d27376cafc --- /dev/null +++ b/tests/ui/StackedRHPOverlayAnimationTest.tsx @@ -0,0 +1,111 @@ +import {act, render, screen} from '@testing-library/react-native'; + +import useShouldRenderOverlay from '@components/WideRHPContextProvider/useShouldRenderOverlay'; + +import React, {useLayoutEffect} from 'react'; +// eslint-disable-next-line no-restricted-imports +import {Animated, View} from 'react-native'; + +type AnimationCompletion = (result: {finished: boolean}) => void; + +const events: string[] = []; + +function Scrim() { + useLayoutEffect(() => { + events.push('mounted'); + }, []); + return ; +} + +function OverlayConsumer({condition, progress}: {condition: boolean; progress: Animated.Value}) { + const shouldRender = useShouldRenderOverlay(condition, progress); + return shouldRender ? : null; +} + +describe('Stacked RHP overlay animation', () => { + let completions: Array; + let timing: jest.SpyInstance; + let stop: jest.Mock; + + beforeEach(() => { + events.length = 0; + completions = []; + stop = jest.fn(); + timing = jest.spyOn(Animated, 'timing').mockImplementation(() => { + events.push('started'); + return {start: (callback) => completions.push(callback), stop, reset: jest.fn()}; + }); + }); + + afterEach(() => { + timing.mockRestore(); + }); + + it('mounts a transparent scrim before starting a native-driven fade', () => { + const progress = new Animated.Value(1); + const setValue = jest.spyOn(progress, 'setValue'); + render( + , + ); + + expect(setValue).toHaveBeenCalledWith(0); + expect(events).toEqual(['mounted', 'started']); + expect(timing).toHaveBeenCalledWith(progress, expect.objectContaining({toValue: 1, useNativeDriver: true})); + }); + + it('retains the scrim until its closing animation finishes', () => { + const progress = new Animated.Value(0); + const {rerender} = render( + , + ); + rerender( + , + ); + + expect(stop).toHaveBeenCalled(); + expect(screen.getByTestId('stacked-scrim')).toBeOnTheScreen(); + expect(timing).toHaveBeenLastCalledWith(progress, expect.objectContaining({toValue: 0, useNativeDriver: true})); + + act(() => completions.at(-1)?.({finished: true})); + expect(screen.queryByTestId('stacked-scrim')).toBeNull(); + }); + + it('does not unmount a reopened scrim when the old closing callback arrives', () => { + const progress = new Animated.Value(0); + const {rerender} = render( + , + ); + rerender( + , + ); + const close = completions.at(-1); + + act(() => close?.({finished: false})); + expect(screen.getByTestId('stacked-scrim')).toBeOnTheScreen(); + + rerender( + , + ); + act(() => close?.({finished: true})); + expect(screen.getByTestId('stacked-scrim')).toBeOnTheScreen(); + expect(events.filter((event) => event === 'mounted')).toHaveLength(1); + }); +}); diff --git a/tests/unit/NativeRHPLayoutTest.tsx b/tests/unit/NativeRHPLayoutTest.tsx index 5d3af970f93f..b231d912418e 100644 --- a/tests/unit/NativeRHPLayoutTest.tsx +++ b/tests/unit/NativeRHPLayoutTest.tsx @@ -8,7 +8,9 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useWindowDimensions from '@hooks/useWindowDimensions'; import useModalStackScreenOptions from '@libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions'; +import useModalCardStyleInterpolator from '@libs/Navigation/AppNavigator/useModalCardStyleInterpolator'; import useRHPScreenOptions from '@libs/Navigation/AppNavigator/useRHPScreenOptions'; +import {useRHPFrameStyle} from '@libs/Navigation/AppNavigator/useRHPTransition'; import useRootNavigatorScreenOptions from '@libs/Navigation/AppNavigator/useRootNavigatorScreenOptions'; import convertToJSStackNavigationOptions from '@libs/Navigation/PlatformStackNavigation/navigationOptions/convertToJSStackNavigationOptions'; @@ -16,6 +18,7 @@ import CONST from '@src/CONST'; import type {StackCardInterpolationProps} from '@react-navigation/stack'; +import {useCardAnimation} from '@react-navigation/stack'; // eslint-disable-next-line no-restricted-imports import {Animated, StyleSheet} from 'react-native'; @@ -23,16 +26,49 @@ import createMock from '../utils/createMock'; jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); jest.mock('@hooks/useWindowDimensions', () => jest.fn()); +jest.mock('@react-navigation/stack', () => ({ + ...jest.requireActual('@react-navigation/stack'), + useCardAnimation: jest.fn(), +})); jest.mock('@components/WideRHPContextProvider', () => ({ ...jest.requireActual('@components/WideRHPContextProvider'), useWideRHPState: jest.fn(), })); describe('Native RHP layout', () => { + let animation: StackCardInterpolationProps; + beforeEach(() => { jest.mocked(useWindowDimensions).mockReturnValue({windowWidth: 1180, windowHeight: 820}); jest.mocked(useResponsiveLayout).mockReturnValue({...CONST.NAVIGATION_TESTS.DEFAULT_USE_RESPONSIVE_LAYOUT_VALUE, isSmallScreenWidth: false, shouldUseNarrowLayout: false}); jest.mocked(useWideRHPState).mockReturnValue({...defaultWideRHPStateContextValue, superWideRHPRouteKeys: ['expense'], wideRHPRouteKeys: ['transaction']}); + animation = createMock({current: {progress: new Animated.Value(0.25)}, inverted: new Animated.Value(1), layouts: {screen: {width: 1180, height: 820}}}); + jest.mocked(useCardAnimation).mockReturnValue(animation); + }); + + it('keeps the wide host stationary and applies the same root progress only to the panel', () => { + const {result} = renderHook(() => ({root: useRootNavigatorScreenOptions(), frame: useRHPFrameStyle()})); + const host = result.current.root.rightModalNavigator.web?.cardStyleInterpolator?.(animation).cardStyle; + + expect(host).not.toHaveProperty('transform'); + expect(host).not.toHaveProperty('opacity'); + expect(result.current.frame).toMatchObject({opacity: animation.current.progress, transform: [{translateX: expect.anything()}]}); + }); + + it('keeps narrow motion on the root card without applying a second panel transform', () => { + jest.mocked(useResponsiveLayout).mockReturnValue({...CONST.NAVIGATION_TESTS.DEFAULT_USE_RESPONSIVE_LAYOUT_VALUE, isSmallScreenWidth: true, shouldUseNarrowLayout: true}); + const {result} = renderHook(() => ({root: useRootNavigatorScreenOptions(), frame: useRHPFrameStyle()})); + + expect(result.current.frame).toBeUndefined(); + expect(result.current.root.rightModalNavigator.web?.cardStyleInterpolator?.(animation).cardStyle).toHaveProperty('transform'); + }); + + it('preserves the shared interpolator used by the independent MFA navigator', () => { + const {result} = renderHook(() => useModalCardStyleInterpolator()); + const style = result.current({props: animation, enter: {kind: 'slide-from-width'}}).cardStyle; + + expect(style).toHaveProperty('transform'); + expect(style).not.toHaveProperty('opacity'); }); it('keeps the transaction narrower than its underlying expense report', () => {