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/Modal/ReanimatedModal/Backdrop/index.tsx b/src/components/Modal/ReanimatedModal/Backdrop/index.tsx
index a5f78c517f88..f2d7331a3d05 100644
--- a/src/components/Modal/ReanimatedModal/Backdrop/index.tsx
+++ b/src/components/Modal/ReanimatedModal/Backdrop/index.tsx
@@ -23,8 +23,8 @@ function Backdrop({
const styles = useThemeStyles();
const {translate} = useLocalize();
- const Entering = new Keyframe(getModalInAnimation('fadeIn')).duration(animationInTiming);
- const Exiting = new Keyframe(getModalOutAnimation('fadeOut')).duration(animationOutTiming);
+ const Entering = new Keyframe(getModalInAnimation('fadeIn', backdropOpacity)).duration(animationInTiming);
+ const Exiting = new Keyframe(getModalOutAnimation('fadeOut', backdropOpacity)).duration(animationOutTiming);
const BackdropOverlay = (
{BackdropOverlay}
diff --git a/src/components/Modal/ReanimatedModal/Backdrop/index.web.tsx b/src/components/Modal/ReanimatedModal/Backdrop/index.web.tsx
index 6a3c86405d81..90b11dc9a6cf 100644
--- a/src/components/Modal/ReanimatedModal/Backdrop/index.web.tsx
+++ b/src/components/Modal/ReanimatedModal/Backdrop/index.web.tsx
@@ -52,7 +52,8 @@ function Backdrop({
return (
& ContainerProps) {
const styles = useThemeStyles();
+ const bottom = StyleSheet.flatten(style)?.bottom;
+ const bottomSlideOffset = (animationIn === 'slideInUp' || animationOut === 'slideOutDown') && typeof bottom === 'number' ? Math.max(0, bottom) : 0;
+ // Include the anchor's bottom gap in the animated height so a 100% slide reaches the screen edge.
+ const positionStyle = bottomSlideOffset > 0 ? {bottom: 0} : undefined;
+ const slidePaddingStyle = bottomSlideOffset > 0 ? {paddingBottom: bottomSlideOffset} : undefined;
+
const Entering = useMemo(() => {
const AnimationIn = new Keyframe(getModalInAnimation(animationIn));
@@ -51,7 +57,7 @@ function Container({
return (
diff --git a/src/components/Modal/ReanimatedModal/utils.ts b/src/components/Modal/ReanimatedModal/utils.ts
index c3758ed9a46b..02c26315b917 100644
--- a/src/components/Modal/ReanimatedModal/utils.ts
+++ b/src/components/Modal/ReanimatedModal/utils.ts
@@ -11,7 +11,7 @@ import type {AnimationIn, AnimationOut} from './types';
const easing = Easing.bezier(0.76, 0.0, 0.24, 1.0).factory();
-function getModalInAnimation(animationType: AnimationIn): ValidKeyframeProps {
+function getModalInAnimation(animationType: AnimationIn, fadeOpacity: number = variables.overlayOpacity): ValidKeyframeProps {
switch (animationType) {
case 'slideInRight':
return {
@@ -33,7 +33,7 @@ function getModalInAnimation(animationType: AnimationIn): ValidKeyframeProps {
return {
from: {opacity: 0},
to: {
- opacity: variables.overlayOpacity,
+ opacity: fadeOpacity,
easing,
},
};
@@ -69,7 +69,7 @@ function getModalInAnimationStyle(animationType: AnimationIn): (progress: number
}
}
-function getModalOutAnimation(animationType: AnimationOut): ValidKeyframeProps {
+function getModalOutAnimation(animationType: AnimationOut, fadeOpacity: number = variables.overlayOpacity): ValidKeyframeProps {
switch (animationType) {
case 'slideOutRight':
return {
@@ -89,7 +89,7 @@ function getModalOutAnimation(animationType: AnimationOut): ValidKeyframeProps {
};
case 'fadeOut':
return {
- from: {opacity: variables.overlayOpacity},
+ from: {opacity: fadeOpacity},
to: {
opacity: 0,
easing,
diff --git a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx b/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx
index a209e7fd0c03..38cb22e910b9 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,12 +235,13 @@ 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
// clipping viewport must be neutralized.
- style={{width: contentWidth, flexGrow: 0, flexShrink: 0, flexBasis: 'auto', overflow: 'visible'}}
+ // Clear FlashList's default flex: 1 too, or Android gives this container zero width and hides its children from accessibility.
+ style={{width: contentWidth, flex: 0, flexGrow: 0, flexShrink: 0, flexBasis: 'auto', overflow: 'visible'}}
scrollEnabled={false}
/>
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/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/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() {
diff --git a/src/components/PopoverMenu/index.tsx b/src/components/PopoverMenu/index.tsx
index 28aa4603bd04..89f14acefbf8 100644
--- a/src/components/PopoverMenu/index.tsx
+++ b/src/components/PopoverMenu/index.tsx
@@ -711,6 +711,10 @@ function BasePopoverMenu({
...restContainerStyles
} = StyleSheet.flatten(containerStyles) ?? {};
+ const menuWidth = StyleSheet.flatten([restMenuContainerStyle, restContainerStyles])?.width;
+ // Native popovers use bottom sheets. Keep their shell as wide as the menu instead of stretching across a tablet window.
+ const modalWidthStyle = !isWeb && !isSmallScreenWidth && typeof menuWidth === 'number' ? {...styles.alignSelfCenter, maxWidth: menuWidth} : undefined;
+
const scrollViewPaddingStyles = useMemo(
() => ({
paddingTop: paddingTop ?? containerPaddingTop ?? menuContainerPaddingTop,
@@ -756,7 +760,7 @@ function BasePopoverMenu({
shouldEnableNewFocusManagement={shouldUseNewFocusManagement}
shouldReturnFocus={shouldReturnFocus}
restoreFocusType={effectiveRestoreFocusType}
- innerContainerStyle={{...styles.pv0, ...innerContainerStyle}}
+ innerContainerStyle={{...styles.pv0, ...modalWidthStyle, ...innerContainerStyle}}
shouldUseModalPaddingStyle={shouldUseModalPaddingStyle}
shouldHandleNavigationBack={shouldHandleNavigationBack}
testID={testID}
diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/types.ts b/src/components/ReportActionItem/MoneyRequestReportPreview/types.ts
index 11730e1010c5..afb5c99bf4e8 100644
--- a/src/components/ReportActionItem/MoneyRequestReportPreview/types.ts
+++ b/src/components/ReportActionItem/MoneyRequestReportPreview/types.ts
@@ -13,7 +13,7 @@ type TransactionPreviewCarouselStyle = {
};
type TransactionPreviewStandaloneStyle = {
- [key in keyof TransactionPreviewStyleType]: string;
+ [key in keyof TransactionPreviewStyleType]: number | string;
};
type MoneyRequestReportPreviewStyleType = {
diff --git a/src/components/Search/SortableTableHeader.tsx b/src/components/Search/SortableTableHeader.tsx
index 4127da9436e9..56778dc89580 100644
--- a/src/components/Search/SortableTableHeader.tsx
+++ b/src/components/Search/SortableTableHeader.tsx
@@ -72,7 +72,7 @@ function SortableTableHeader({
return (
-
+
{columns.map(({columnName, translationKey, icon, isColumnSortable, sortColumnName, canEdit}) => {
if (!shouldShowColumn(columnName)) {
return null;
diff --git a/src/components/SidePanel/SidePanelContextProvider.tsx b/src/components/SidePanel/SidePanelContextProvider.tsx
index a332c8bd1b5a..668823b38bca 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.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/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/getResponsiveLayoutConstraints.ts b/src/hooks/useResponsiveLayout/getResponsiveLayoutConstraints.ts
new file mode 100644
index 000000000000..6afbe9021dd0
--- /dev/null
+++ b/src/hooks/useResponsiveLayout/getResponsiveLayoutConstraints.ts
@@ -0,0 +1,17 @@
+// Preserve the platform-specific height and landscape policies in the shared responsive hook.
+import variables from '@styles/variables';
+
+import {Dimensions, Platform} from 'react-native';
+
+function getResponsiveLayoutConstraints(windowHeight: number, isInLandscapeMode: boolean) {
+ const isWeb = Platform.OS === 'web';
+ // The soft keyboard changes the mWeb window height, so use the screen height there.
+ const height = isWeb ? Dimensions.get('screen').height : windowHeight;
+
+ return {
+ isExtraSmallScreenHeight: height <= variables.extraSmallMobileResponsiveHeightBreakpoint,
+ shouldUseNarrowLayoutForLandscape: isWeb && isInLandscapeMode,
+ };
+}
+
+export default getResponsiveLayoutConstraints;
diff --git a/src/hooks/useResponsiveLayout/index.native.ts b/src/hooks/useResponsiveLayout/index.native.ts
deleted file mode 100644
index c2740a6fc8af..000000000000
--- a/src/hooks/useResponsiveLayout/index.native.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import ModalContext from '@components/Modal/ModalContext';
-
-import useWindowDimensions from '@hooks/useWindowDimensions';
-
-import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
-
-import variables from '@styles/variables';
-
-import CONST from '@src/CONST';
-import NAVIGATORS from '@src/NAVIGATORS';
-
-import {NavigationContainerRefContext, NavigationContext} from '@react-navigation/native';
-import {useContext, useMemo} from 'react';
-
-import type ResponsiveLayoutResult from './types';
-
-/**
- * Hook to determine if we are on mobile devices or in the Modal Navigator. It also provides booleans for our breakpoints
- * Use "shouldUseNarrowLayout" for "on mobile or in RHP", "isSmallScreenWidth" for "on mobile", "isInNarrowPaneModal" for "in RHP".
- *
- * There are two kinds of modals in this app:
- * 1. Modal stack navigators from react-navigation
- * 2. Modal components that use react-native-reanimated
- *
- * This hook is designed to handle both. `shouldUseNarrowLayout` will return `true` if any of the following are true:
- * 1. The device screen width is narrow
- * 2. The consuming component is the child of a "right docked" react-native-reanimated component
- * 3. The consuming component is a screen in a modal stack navigator and not a child of a "non-right-docked" react-native-reanimated component.
- *
- * For more details on the various modal types we've defined for this app and implemented using react-native-reanimated, see `ModalType`.
- */
-export default function useResponsiveLayout(): ResponsiveLayoutResult {
- const {windowWidth, windowHeight} = useWindowDimensions();
- const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight);
-
- const isExtraSmallScreenHeight = windowHeight <= variables.extraSmallMobileResponsiveHeightBreakpoint;
- const isSmallScreenWidth = true;
- const isMediumScreenWidth = false;
- const isLargeScreenWidth = false;
- const isExtraLargeScreenWidth = false;
- const isExtraSmallScreenWidth = windowWidth <= variables.extraSmallMobileResponsiveWidthBreakpoint;
- const isSmallScreen = true;
-
- // we need to always take screen width into consideration, no matter the platform (with exception of landscape mode).
- const onboardingIsMediumOrLargerScreenWidth = !isInLandscapeMode && windowWidth > variables.mobileResponsiveWidthBreakpoint;
-
- // Note: activeModalType refers to our react-native-reanimated component wrapper, not react-navigation's modal stack navigators.
- // This means it will only be defined if the component calling this hook is a child of a modal component. See BaseModal for the provider.
- const {activeModalType} = useContext(ModalContext);
-
- // We are using these contexts directly instead of useNavigation/useNavigationState, because those will throw an error if used outside a navigator.
- // This hook can be used within or outside a navigator, so using useNavigationState does not work.
- // Furthermore, wrapping useNavigationState in a try/catch does not work either, because that breaks the rules of hooks.
- // Note that these three lines are copied closely from the internal implementation of useNavigation: https://github.com/react-navigation/react-navigation/blob/52a3234b7aaf4d4fcc9c0155f44f3ea2233f0f40/packages/core/src/useNavigation.tsx#L18-L28
- const navigationContainerRef = useContext(NavigationContainerRefContext);
- const navigator = useContext(NavigationContext);
- const currentNavigator = navigator ?? navigationContainerRef;
-
- const isDisplayedInNarrowModalNavigator = useMemo(() => !!currentNavigator?.getParent?.(NAVIGATORS.RIGHT_MODAL_NAVIGATOR as unknown as undefined), [currentNavigator]);
-
- // The component calling this hook is in a "narrow pane modal" if:
- const isInNarrowPaneModal =
- // it's a child of the right-docked modal
- activeModalType === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED ||
- // or there's a "right modal navigator" or "left modal navigator" on the top of the root navigation stack
- // and the component calling this hook is not the child of another modal type, such as a confirm modal
- (isDisplayedInNarrowModalNavigator && !activeModalType);
-
- const shouldUseNarrowLayout = isSmallScreenWidth || isInNarrowPaneModal;
-
- return {
- shouldUseNarrowLayout,
- isSmallScreenWidth,
- isInNarrowPaneModal,
- isExtraSmallScreenHeight,
- isExtraSmallScreenWidth,
- isMediumScreenWidth,
- onboardingIsMediumOrLargerScreenWidth,
- isLargeScreenWidth,
- isSmallScreen,
- isExtraLargeScreenWidth,
- isInLandscapeMode,
- };
-}
diff --git a/src/hooks/useResponsiveLayout/index.ts b/src/hooks/useResponsiveLayout/index.ts
index 0486cd295b94..ca8a8d441b4a 100644
--- a/src/hooks/useResponsiveLayout/index.ts
+++ b/src/hooks/useResponsiveLayout/index.ts
@@ -10,11 +10,12 @@ import CONST from '@src/CONST';
import NAVIGATORS from '@src/NAVIGATORS';
import {NavigationContainerRefContext, NavigationContext} from '@react-navigation/native';
-import {useContext, useMemo} from 'react';
-import {Dimensions} from 'react-native';
+import {useContext} from 'react';
import type ResponsiveLayoutResult from './types';
+import getResponsiveLayoutConstraints from './getResponsiveLayoutConstraints';
+
/**
* Hook to determine if we are on mobile devices or in the Modal Navigator. It also provides booleans for our breakpoints
* Use "shouldUseNarrowLayout" for "on mobile or in RHP", "isSmallScreenWidth" for "on mobile", "isInNarrowPaneModal" for "in RHP".
@@ -35,11 +36,9 @@ export default function useResponsiveLayout(): ResponsiveLayoutResult {
const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight);
- // When the soft keyboard opens on mWeb, the window height changes. Use static screen height instead to get real screenHeight.
- const screenHeight = Dimensions.get('screen').height;
- const isExtraSmallScreenHeight = screenHeight <= variables.extraSmallMobileResponsiveHeightBreakpoint;
- const isSmallScreenWidth = windowWidth <= variables.mobileResponsiveWidthBreakpoint || isInLandscapeMode;
- const isMediumScreenWidth = windowWidth > variables.mobileResponsiveWidthBreakpoint && windowWidth <= variables.tabletResponsiveWidthBreakpoint && !isInLandscapeMode;
+ const {isExtraSmallScreenHeight, shouldUseNarrowLayoutForLandscape} = getResponsiveLayoutConstraints(windowHeight, isInLandscapeMode);
+ const isSmallScreenWidth = windowWidth <= variables.mobileResponsiveWidthBreakpoint || shouldUseNarrowLayoutForLandscape;
+ const isMediumScreenWidth = windowWidth > variables.mobileResponsiveWidthBreakpoint && windowWidth <= variables.tabletResponsiveWidthBreakpoint && !shouldUseNarrowLayoutForLandscape;
const onboardingIsMediumOrLargerScreenWidth = !isInLandscapeMode && windowWidth > variables.mobileResponsiveWidthBreakpoint;
const isLargeScreenWidth = windowWidth > variables.tabletResponsiveWidthBreakpoint;
const isExtraLargeScreenWidth = windowWidth > variables.sidePanelResponsiveWidthBreakpoint;
@@ -60,7 +59,7 @@ export default function useResponsiveLayout(): ResponsiveLayoutResult {
const navigator = useContext(NavigationContext);
const currentNavigator = navigator ?? navigationContainerRef;
- const isDisplayedInNarrowModalNavigator = useMemo(() => !!currentNavigator?.getParent?.(NAVIGATORS.RIGHT_MODAL_NAVIGATOR as unknown as undefined), [currentNavigator]);
+ const isDisplayedInNarrowModalNavigator = !!currentNavigator?.getParent?.(NAVIGATORS.RIGHT_MODAL_NAVIGATOR as unknown as undefined);
// The component calling this hook is in a "narrow pane modal" if:
const isInNarrowPaneModal =
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..30e31c5887b1 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts
@@ -1,16 +1,22 @@
-import {animatedSuperWideRHPWidth, useWideRHPState} from '@components/WideRHPContextProvider';
+import {animatedSuperWideRHPWidth, animatedWideRHPWidth, useWideRHPState} from '@components/WideRHPContextProvider';
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 getRHPLayoutValue from '@libs/Navigation/helpers/getRHPLayoutValue';
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 +41,7 @@ function useWideModalStackScreenOptions() {
const {isSmallScreenWidth} = useResponsiveLayout();
const {wideRHPRouteKeys, superWideRHPRouteKeys} = useWideRHPState();
const {sidePanelOffset} = useSidePanelState();
+ const {windowWidth} = useWindowDimensions();
return useCallback<({route}: {route: PlatformStackRouteProp}) => PlatformStackNavigationOptions>(
({route}) => {
@@ -43,17 +50,22 @@ 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)),
+ 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) {
@@ -68,7 +80,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 +89,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/BaseOverlay.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx
index ca4ae4fe0870..996c8f7b59ce 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx
@@ -1,17 +1,19 @@
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
-import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import type {OverlayStylesParams} from '@styles/index';
-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, View} from 'react-native';
+import {Animated} from 'react-native';
+
+// Navigation supplies React Native Animated values, not Reanimated shared values.
+const AnimatedDismissal = Animated.createAnimatedComponent(PressableWithoutFeedback);
type BaseOverlayProps = {
/** Callback to close the modal */
@@ -25,44 +27,52 @@ type BaseOverlayProps = {
/** Overlay position from the right edge of the container */
positionRightValue?: number | Animated.Value | Animated.AnimatedAddition;
+
+ /** Pointer dismissal stops at this right inset, independently of the visual scrim. */
+ dismissalPositionRight?: number | Animated.Value | Animated.AnimatedAddition;
};
-// 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.
-function BaseOverlay({onPress, progress, positionLeftValue = -2 * variables.sideBarWidth, positionRightValue = 0}: BaseOverlayProps) {
+// Visual dimming and pointer dismissal are separate. Screen readers dismiss through the active panel's controls.
+function BaseOverlay({onPress, progress, positionLeftValue = 0, positionRightValue = 0, dismissalPositionRight}: BaseOverlayProps) {
const styles = useThemeStyles();
const {current} = useCardAnimation();
- const {translate} = useLocalize();
+ const isFocused = useIsFocused();
+ const left = typeof positionLeftValue === 'number' ? Math.max(0, positionLeftValue) : positionLeftValue;
return (
-
-
- {/* In the latest Electron version buttons can't be both clickable and draggable.
- That's why we added this workaround. Because of two Pressable components on the desktop app
- we have 30px draggable ba at the top and the rest of the dimmed area is clickable. On other devices,
- everything behaves normally like one big pressable */}
-
-
+
+ {!!onPress && isFocused && (
+
-
-
+ )}
+ >
);
}
diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx
deleted file mode 100644
index 8c17a8f47787..000000000000
--- a/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-function Overlay() {
- return null;
-}
-
-export default Overlay;
diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.tsx
index bdb22e6ffa40..979830a169f3 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.tsx
@@ -1,11 +1,3 @@
-import React from 'react';
-
-import type {BaseOverlayProps} from './BaseOverlay';
-
import BaseOverlay from './BaseOverlay';
-function Overlay({...rest}: Omit) {
- return ;
-}
-
-export default Overlay;
+export default BaseOverlay;
diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
index c10ea1ea7d30..55d71d71cce6 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
@@ -22,8 +22,10 @@ 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';
import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName';
import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
import Animations from '@libs/Navigation/PlatformStackNavigation/navigationOptions/animation';
@@ -80,12 +82,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 +98,7 @@ function SecondaryOverlay() {
return (
(sidePanelOffset.current, singleRHPWidth))}
onPress={Navigation.dismissToPreviousRHP}
/>
);
@@ -105,7 +108,7 @@ function SecondaryOverlay() {
return (
(sidePanelOffset.current, singleRHPWidth))}
onPress={Navigation.dismissToSuperWideRHP}
/>
);
@@ -143,6 +146,7 @@ type RightModalDialogFrameProps = {
function RightModalDialogFrame({hasDialogSemantics, style, onContainerRef, children}: RightModalDialogFrameProps) {
const {dialogAriaLabel} = useDialogLabelData();
const hasName = !!dialogAriaLabel;
+ const frameStyle = useRHPFrameStyle();
return (
{children}
@@ -195,8 +199,8 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) {
// When the wide rhp page is opened as first one, it will be animated with the entire RightModalNavigator.
const animationEnabledOnSearchReport = superWideRHPRouteKeys.length > 0 || wideRHPRouteKeys.length > 0 || isSmallScreenWidth;
- // When the Concierge/Help Side Panel is open on a wide (extra large) layout, it shifts the whole RHP
- // left by its width via paddingRight (see useModalCardStyleInterpolator + SidePanelContextProvider).
+ // When the Concierge/Help Side Panel is open on a wide (extra large) layout, the panel frame shifts
+ // left by its width (see useRHPFrameStyle + SidePanelContextProvider).
// The super wide RHP already spans almost the full window, so without shrinking it by the same amount
// its left edge would be pushed off-screen once the Side Panel opens. Subtract the Side Panel offset
// from the super wide width only (progress === 2) so the sheet's left edge stays put while the Side
@@ -211,13 +215,15 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) {
superWideRHPSidePanelOffset,
);
- const animatedWidthStyle = useMemo(() => {
- return {
- width: shouldUseNarrowLayout ? '100%' : animatedWidth,
- } as const;
- }, [animatedWidth, shouldUseNarrowLayout]);
+ 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]);
+ const dismissalPositionRight = getRHPLayoutValue(rhpWidth, Animated.add(animatedWidth, sidePanelOffset.current));
const screenListeners = useMemo(
() => ({
@@ -282,7 +288,7 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) {
{!shouldUseNarrowLayout && (
)}
@@ -552,7 +558,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/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..0fa3207241fd 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';
@@ -23,6 +24,23 @@ type ModalCardStyleInterpolatorProps = {
type ModalCardStyleInterpolator = (props: ModalCardStyleInterpolatorProps) => StackCardInterpolatedStyle;
+// Panel frames share card motion without inheriting the root card's full-window geometry.
+function getModalCardMotionStyle({current: {progress}, inverted}: Pick, distancePx: number, shouldFade: boolean) {
+ const translateX = Animated.multiply(
+ progress.interpolate({
+ inputRange: [0, 1],
+ outputRange: [distancePx, 0],
+ extrapolate: 'clamp',
+ }),
+ inverted,
+ );
+
+ return {
+ transform: [{translateX}],
+ ...(shouldFade ? {opacity: progress} : {}),
+ };
+}
+
const useModalCardStyleInterpolator = (): ModalCardStyleInterpolator => {
const {shouldUseNarrowLayout} = useResponsiveLayout();
const StyleUtils = useStyleUtils();
@@ -33,19 +51,15 @@ const useModalCardStyleInterpolator = (): ModalCardStyleInterpolator => {
// Hardening the animated card (opaque background + dedicated compositor layer) avoids that glitch while keeping the animation.
const shouldHardenAnimatedCardForMobileBrowser = (isMobileChrome() || isMobileSafari()) && shouldUseNarrowLayout;
- const modalCardStyleInterpolator: ModalCardStyleInterpolator = ({
- props: {
+ const modalCardStyleInterpolator: ModalCardStyleInterpolator = ({props, enter, applySidePanelOffset = false}) => {
+ const {
current: {progress},
- inverted,
layouts: {screen},
- },
- enter,
- applySidePanelOffset = false,
- }) => {
+ } = props;
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
@@ -63,24 +77,11 @@ const useModalCardStyleInterpolator = (): ModalCardStyleInterpolator => {
const widthFallback = shouldUseNarrowLayout ? screen.width : variables.sideBarWidth;
const distancePx = enter.kind === 'slide-and-fade' ? enter.distancePx : widthFallback;
- const translateX = Animated.multiply(
- progress.interpolate({
- inputRange: [0, 1],
- outputRange: [distancePx, 0],
- extrapolate: 'clamp',
- }),
- inverted,
- );
-
if (shouldHardenAnimatedCardForMobileBrowser) {
Object.assign(cardStyle, styles.appBG, styles.willChangeTransform);
}
- cardStyle.transform = [{translateX}];
-
- if (enter.kind === 'slide-and-fade') {
- cardStyle.opacity = progress;
- }
+ Object.assign(cardStyle, getModalCardMotionStyle(props, distancePx, enter.kind === 'slide-and-fade'));
return {
containerStyle: {overflow: 'hidden'},
@@ -92,4 +93,5 @@ const useModalCardStyleInterpolator = (): ModalCardStyleInterpolator => {
};
export type {EnterAnimation};
+export {getModalCardMotionStyle};
export default useModalCardStyleInterpolator;
diff --git a/src/libs/Navigation/AppNavigator/useRHPTransition/index.ts b/src/libs/Navigation/AppNavigator/useRHPTransition/index.ts
new file mode 100644
index 000000000000..92e7f6466bb0
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/useRHPTransition/index.ts
@@ -0,0 +1,42 @@
+// Wide RHP scrims stay stationary while the panel follows the root stack's transition progress.
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useSidePanelState from '@hooks/useSidePanelState';
+
+import useModalCardStyleInterpolator, {getModalCardMotionStyle} from '@libs/Navigation/AppNavigator/useModalCardStyleInterpolator';
+import getRHPLayoutValue from '@libs/Navigation/helpers/getRHPLayoutValue';
+
+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'},
+ // Wide hosts fill the window; their panel frame owns the Concierge offset instead.
+ applySidePanelOffset: shouldUseNarrowLayout,
+ });
+}
+
+function useRHPFrameStyle() {
+ const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const {sidePanelOffset} = useSidePanelState();
+ const props = useCardAnimation();
+
+ if (shouldUseNarrowLayout) {
+ return undefined;
+ }
+
+ return {
+ ...getModalCardMotionStyle(props, CONST.MODAL.RHP_ENTER_OFFSET_PX_WEB, true),
+ right: getRHPLayoutValue(0, sidePanelOffset.current),
+ };
+}
+
+export {useRootRHPCardStyleInterpolator, useRHPFrameStyle};
diff --git a/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts b/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts
index 1bf7bad237a5..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,
},
},
@@ -67,23 +61,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/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
new file mode 100644
index 000000000000..2c7dc0adafa9
--- /dev/null
+++ b/src/libs/Navigation/PlatformStackNavigation/createJSStackNavigatorComponent.tsx
@@ -0,0 +1,167 @@
+// 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';
+import StackScreenAccessibility from './StackScreenAccessibility';
+
+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 focusedKey = state.routes[state.index]?.key;
+ const accessibleDescriptors = Object.fromEntries(
+ Object.entries(wrappedDescriptors).map(([key, descriptor]) => [
+ key,
+ {
+ ...descriptor,
+ render: () => {descriptor.render()},
+ },
+ ]),
+ );
+
+ 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/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..2d6dfd1566f5 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,38 @@ 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 = (
-
+ {/* Keep the central stack under the same parents across breakpoints so its screens retain local state. */}
+ {sidebarScreen ? (
+
+ {!!split && {wrappedDescriptors[split.sidebarRoute.key]?.render()}}
+ {stack}
+
+ ) : (
+ stack
+ )}
{!!ExtraContent && }
);
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/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/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/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 a46a3ed6a6e6..b5b192194b19 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',
@@ -6752,7 +6759,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%',
@@ -6847,7 +6854,6 @@ const dynamicStyles = (theme: ThemeColors) =>
positionRightValue: number | Animated.Value | Animated.AnimatedAddition;
}) =>
({
- // We need to stretch the overlay to cover the sidebar and the translate animation distance.
left: positionLeftValue,
right: positionRightValue,
opacity: progress.interpolate({
@@ -6918,7 +6924,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/src/styles/utils/generators/ModalStyleUtils.ts b/src/styles/utils/generators/ModalStyleUtils.ts
index afd30f8545b2..03cfb458e2f0 100644
--- a/src/styles/utils/generators/ModalStyleUtils.ts
+++ b/src/styles/utils/generators/ModalStyleUtils.ts
@@ -10,6 +10,8 @@ import type ModalType from '@src/types/utils/ModalType';
import type {ViewStyle} from 'react-native';
+import {Platform} from 'react-native';
+
import type StyleUtilGenerator from './types';
function getCenteredModalStyles(styles: ThemeStyles, windowWidth: number, isSmallScreenWidth: boolean, isFullScreenWhenSmall = false): ViewStyle {
@@ -280,7 +282,8 @@ const createModalStyleUtils: StyleUtilGenerator = ({the
boxShadow: theme.shadow,
};
- hideBackdrop = true;
+ // Native anchored popovers still block the app through a full-screen modal.
+ hideBackdrop = Platform.OS === 'web';
swipeDirection = undefined;
animationIn = 'fadeIn';
animationOut = 'fadeOut';
diff --git a/src/styles/utils/getMoneyRequestReportPreviewStyle/index.ts b/src/styles/utils/getMoneyRequestReportPreviewStyle/index.ts
index 0b9786d41c21..6a1116cb7f2d 100644
--- a/src/styles/utils/getMoneyRequestReportPreviewStyle/index.ts
+++ b/src/styles/utils/getMoneyRequestReportPreviewStyle/index.ts
@@ -7,6 +7,8 @@ import spacing from '@styles/utils/spacing';
import CONST from '@src/CONST';
+import {Platform} from 'react-native';
+
const componentsSpacing = {
flatListStyle: [spacing.mhn4],
wrapperStyle: spacing.p4,
@@ -38,10 +40,15 @@ const desktopStyle = (currentWrapperWidth: number, transactionsCount: number) =>
const transactionPreviewWidth = currentWrapperWidth - CAROUSEL_ONE_SIDE_PADDING - getPeek(transactionsCount < 2);
const spaceForTransactions = Math.max(transactionsCount, 1);
const carouselExactMaxWidth = Math.min(minimalWrapperWidth + (TRANSACTION_WIDTH_WIDE + CAROUSEL_GAP) * (spaceForTransactions - 1), CAROUSEL_MAX_WIDTH_WIDE);
+ const carouselContentWidth = 2 * CAROUSEL_ONE_SIDE_PADDING + TRANSACTION_WIDTH_WIDE * spaceForTransactions + CAROUSEL_GAP * (spaceForTransactions - 1);
return {
transactionPreviewCarouselStyle: {width: currentWrapperWidth > minimalWrapperWidth || currentWrapperWidth === 0 ? TRANSACTION_WIDTH_WIDE : transactionPreviewWidth},
- transactionPreviewStandaloneStyle: {width: `min(100%, ${TRANSACTION_WIDTH_WIDE}px)`, maxWidth: `min(100%, ${TRANSACTION_WIDTH_WIDE}px)`},
- componentStyle: [{maxWidth: `min(${carouselExactMaxWidth}px, 100%)`}, {width: currentWrapperWidth > minimalWrapperWidth ? 'min-content' : '100%'}],
+ transactionPreviewStandaloneStyle: {width: TRANSACTION_WIDTH_WIDE, maxWidth: '100%'},
+ componentStyle: Platform.select({
+ web: [{maxWidth: `min(${carouselExactMaxWidth}px, 100%)`}, {width: currentWrapperWidth > minimalWrapperWidth ? 'min-content' : '100%'}],
+ // Yoga cannot resolve CSS min-content or min(). Size native previews to their cards, bounded by the available space.
+ default: [{width: Math.min(carouselContentWidth, CAROUSEL_MAX_WIDTH_WIDE), maxWidth: '100%'}],
+ }),
expenseCountVisible: transactionPreviewWidth >= TRANSACTION_WIDTH_WIDE,
};
};
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index 2bf01456f591..0609d9cfc56b 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 {
@@ -2459,8 +2459,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/navigation/NativeSplitNavigatorTests.tsx b/tests/navigation/NativeSplitNavigatorTests.tsx
new file mode 100644
index 000000000000..20fda9ee5b40
--- /dev/null
+++ b/tests/navigation/NativeSplitNavigatorTests.tsx
@@ -0,0 +1,125 @@
+import {act, fireEvent, 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 {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
+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, {useState} from 'react';
+import {TextInput, View} from 'react-native';
+
+const Split = createSplitNavigator();
+
+jest.mock('@hooks/useResponsiveLayout', () => jest.fn());
+jest.mock('@libs/getIsNarrowLayout', () => jest.fn());
+
+function SidebarScreen() {
+ return ;
+}
+
+function CentralScreen({route}: PlatformStackScreenProps) {
+ const [draft, setDraft] = useState('');
+
+ 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.each([true, false])('preserves central screen state across both breakpoint directions, starting narrow: %s', async (initiallyNarrow) => {
+ setNarrowLayout(initiallyNarrow);
+ const {rerender} = render();
+
+ act(() => navigationRef.dispatch(StackActions.push(SCREENS.REPORT, {reportID: '1'})));
+ fireEvent.changeText(await screen.findByLabelText('report-1-draft'), 'First report draft');
+
+ act(() => navigationRef.dispatch(StackActions.push(SCREENS.REPORT, {reportID: '2'})));
+ fireEvent.changeText(await screen.findByLabelText('report-2-draft'), 'Second report draft');
+ const routeKeys = navigationRef.getRootState().routes.map((route) => route.key);
+
+ setNarrowLayout(!initiallyNarrow);
+ rerender();
+ expect(navigationRef.getRootState().routes.map((route) => route.key)).toEqual(routeKeys);
+ expect(screen.getByLabelText('report-2-draft')).toHaveDisplayValue('Second report draft');
+
+ setNarrowLayout(initiallyNarrow);
+ rerender();
+ expect(navigationRef.getRootState().routes.map((route) => route.key)).toEqual(routeKeys);
+ expect(screen.getByLabelText('report-2-draft')).toHaveDisplayValue('Second report draft');
+
+ act(() => navigationRef.dispatch(StackActions.pop()));
+ await waitFor(() => expect(screen.getByLabelText('report-1-draft')).toHaveDisplayValue('First report draft'));
+ });
+
+ 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/ui/ModalBackdropAccessibilityTest.tsx b/tests/ui/ModalBackdropAccessibilityTest.tsx
new file mode 100644
index 000000000000..956a6309d25a
--- /dev/null
+++ b/tests/ui/ModalBackdropAccessibilityTest.tsx
@@ -0,0 +1,66 @@
+import {fireEvent, render, screen} from '@testing-library/react-native';
+
+import NativeBackdrop from '@components/Modal/ReanimatedModal/Backdrop';
+import WebBackdrop from '@components/Modal/ReanimatedModal/Backdrop/index.web';
+
+import React from 'react';
+import {Platform} from 'react-native';
+
+jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key}));
+
+describe.each([
+ {platform: 'ios', Backdrop: NativeBackdrop},
+ {platform: 'android', Backdrop: NativeBackdrop},
+ {platform: 'web', Backdrop: WebBackdrop},
+] as const)('Modal backdrop accessibility on $platform', ({platform, Backdrop}) => {
+ beforeEach(() => {
+ jest.replaceProperty(Platform, 'OS', platform);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ function renderBackdrop() {
+ const onBackdropPress = jest.fn();
+ render(
+ ,
+ );
+ return onBackdropPress;
+ }
+
+ it('exposes a Dismiss button that supports semantic activation', () => {
+ const onBackdropPress = renderBackdrop();
+ const dismiss = screen.getByRole('button', {name: 'common.dismiss'});
+
+ fireEvent.press(dismiss);
+
+ expect(onBackdropPress).toHaveBeenCalledTimes(1);
+ });
+
+ it('dismisses once on a completed touch rather than on both touch-down and activation', () => {
+ const onBackdropPress = renderBackdrop();
+ const dismiss = screen.getByRole('button', {name: 'common.dismiss'});
+
+ fireEvent(dismiss, 'pressIn');
+ expect(onBackdropPress).not.toHaveBeenCalled();
+ fireEvent(dismiss, 'pressOut');
+ fireEvent.press(dismiss);
+
+ expect(onBackdropPress).toHaveBeenCalledTimes(1);
+ });
+
+ if (platform !== 'web') {
+ it('dismisses through the native accessibility activation callback', () => {
+ const onBackdropPress = renderBackdrop();
+
+ fireEvent(screen.getByRole('button', {name: 'common.dismiss'}), 'accessibilityTap');
+
+ expect(onBackdropPress).toHaveBeenCalledTimes(1);
+ });
+ }
+});
diff --git a/tests/ui/NativeModalSlideTest.tsx b/tests/ui/NativeModalSlideTest.tsx
new file mode 100644
index 000000000000..f9e565704e72
--- /dev/null
+++ b/tests/ui/NativeModalSlideTest.tsx
@@ -0,0 +1,50 @@
+import {render, screen} from '@testing-library/react-native';
+
+import Container from '@components/Modal/ReanimatedModal/Container';
+
+import CONST from '@src/CONST';
+
+import React from 'react';
+import {View} from 'react-native';
+
+describe('Native modal slide bounds', () => {
+ it.each([64, 96])('includes a %i-point bottom anchor in the animated region', (bottom) => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('positioning-root')).toHaveStyle({bottom: 0, left: 16});
+ const animatedFrame = screen.UNSAFE_getAllByType(View).find((element) => element.props.entering !== undefined);
+ expect(animatedFrame?.props.style).toEqual(expect.arrayContaining([{paddingBottom: bottom}]));
+ });
+
+ it('does not move the anchor for fade-only menus', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('positioning-root')).toHaveStyle({bottom: 64});
+ const animatedFrame = screen.UNSAFE_getAllByType(View).find((element) => element.props.entering !== undefined);
+ expect(animatedFrame?.props.style).not.toEqual(expect.arrayContaining([{paddingBottom: 64}]));
+ });
+});
diff --git a/tests/ui/RHPOverlayTest.tsx b/tests/ui/RHPOverlayTest.tsx
new file mode 100644
index 000000000000..d867ecfd1886
--- /dev/null
+++ b/tests/ui/RHPOverlayTest.tsx
@@ -0,0 +1,109 @@
+import {fireEvent, render, screen} from '@testing-library/react-native';
+
+import Overlay from '@libs/Navigation/AppNavigator/Navigators/Overlay';
+
+import variables from '@styles/variables';
+
+import CONST from '@src/CONST';
+
+import type * as ReactNavigationNative from '@react-navigation/native';
+import type * as ReactNavigationStack 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, Platform} 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.each(['ios', 'android', 'web'] as const)('RHP scrims on %s', (platform) => {
+ beforeEach(() => {
+ jest.replaceProperty(Platform, 'OS', platform);
+ jest.mocked(useIsFocused).mockReturnValue(true);
+ jest.mocked(useCardAnimation).mockReturnValue(createMock({current: {progress: new Animated.Value(0)}}));
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ 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('uses one dismissal target with the ID recognized by form blur handling', () => {
+ const onPress = jest.fn();
+ render();
+ const dismiss = screen.getByTestId('rhp-overlay-dismiss', {includeHiddenElements: true});
+
+ expect(dismiss).toHaveProp('id', CONST.OVERLAY.BOTTOM_BUTTON_NATIVE_ID);
+ fireEvent.press(dismiss);
+ expect(onPress).toHaveBeenCalledTimes(1);
+ });
+
+ it('uses independent visual and dismissal bounds while the web Concierge offset changes', () => {
+ const offset = new Animated.Value(0);
+ const {rerender} = render(
+ (offset, 375)}
+ />,
+ );
+ expect(screen.getByTestId('rhp-overlay-dismiss', {includeHiddenElements: true})).toHaveStyle({right: 375});
+
+ offset.setValue(320);
+ rerender(
+ (offset, 375)}
+ />,
+ );
+ expect(screen.getByTestId('rhp-overlay-dismiss', {includeHiddenElements: true})).toHaveStyle({right: 695});
+ expect(screen.getByTestId('rhp-overlay', {includeHiddenElements: true})).toHaveStyle({right: 0});
+ });
+
+ 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/MoneyRequestReportPreviewStyleTest.ts b/tests/unit/MoneyRequestReportPreviewStyleTest.ts
new file mode 100644
index 000000000000..9ccc8d1f3c04
--- /dev/null
+++ b/tests/unit/MoneyRequestReportPreviewStyleTest.ts
@@ -0,0 +1,51 @@
+// Test the pure sizing utility without mounting theme providers.
+// eslint-disable-next-line no-restricted-imports
+import getMoneyRequestReportPreviewStyle from '@styles/utils/getMoneyRequestReportPreviewStyle';
+
+import {Platform, StyleSheet} from 'react-native';
+
+describe('Money request report preview sizing', () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ describe.each(['ios', 'android'] as const)('%s', (platform) => {
+ beforeEach(() => {
+ jest.replaceProperty(Platform, 'OS', platform);
+ });
+
+ it.each([
+ [0, 335],
+ [1, 335],
+ [2, 646],
+ [3, 680],
+ [20, 680],
+ ])('sizes a %i-transaction preview to its contents within the carousel limit', (count, expectedWidth) => {
+ const styles = getMoneyRequestReportPreviewStyle(false, count, 882, 882);
+
+ expect(StyleSheet.flatten(styles.componentStyle)).toEqual({width: expectedWidth, maxWidth: '100%'});
+ expect(styles.transactionPreviewCarouselStyle.width).toBe(303);
+ });
+
+ it('retains full-width narrow previews and the next-card peek', () => {
+ const styles = getMoneyRequestReportPreviewStyle(true, 2, 280, 280);
+
+ expect(StyleSheet.flatten(styles.componentStyle)).toEqual({width: '100%', maxWidth: '100%'});
+ expect(styles.transactionPreviewCarouselStyle.width).toBe(232);
+ });
+ });
+
+ it('retains intrinsic web sizing and its available-width fallback', () => {
+ jest.replaceProperty(Platform, 'OS', 'web');
+ jest.spyOn(Platform, 'select').mockImplementation((specifics) => specifics.web);
+
+ expect(StyleSheet.flatten(getMoneyRequestReportPreviewStyle(false, 2, 882, 882).componentStyle)).toEqual({
+ maxWidth: 'min(662px, 100%)',
+ width: 'min-content',
+ });
+ expect(StyleSheet.flatten(getMoneyRequestReportPreviewStyle(false, 2, 280, 280).componentStyle)).toEqual({
+ maxWidth: 'min(662px, 100%)',
+ width: '100%',
+ });
+ });
+});
diff --git a/tests/unit/NativeModalBackdropTest.ts b/tests/unit/NativeModalBackdropTest.ts
new file mode 100644
index 000000000000..9dd4a812eada
--- /dev/null
+++ b/tests/unit/NativeModalBackdropTest.ts
@@ -0,0 +1,54 @@
+import {getModalInAnimation, getModalOutAnimation} from '@components/Modal/ReanimatedModal/utils';
+
+import CONST from '@src/CONST';
+import createThemeStyles from '@src/styles';
+import {defaultTheme} from '@src/styles/theme';
+import createStyleUtils from '@src/styles/utils';
+import variables from '@src/styles/variables';
+
+import {Platform} from 'react-native';
+
+const {getModalStyles} = createStyleUtils(defaultTheme, createThemeStyles(defaultTheme));
+
+describe('Native modal backdrops', () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it.each(['android', 'ios'] as const)('keeps the backdrop visible for anchored popovers on %s', (platform) => {
+ jest.replaceProperty(Platform, 'OS', platform);
+
+ const {hideBackdrop} = getModalStyles({
+ type: CONST.MODAL.MODAL_TYPE.POPOVER,
+ windowDimensions: {windowWidth: 1200, windowHeight: 900, isSmallScreenWidth: false},
+ });
+
+ expect(hideBackdrop).toBe(false);
+ });
+
+ it('keeps anchored web popovers undimmed', () => {
+ jest.replaceProperty(Platform, 'OS', 'web');
+
+ const {hideBackdrop} = getModalStyles({
+ type: CONST.MODAL.MODAL_TYPE.POPOVER,
+ windowDimensions: {windowWidth: 1200, windowHeight: 900, isSmallScreenWidth: false},
+ });
+
+ expect(hideBackdrop).toBe(true);
+ });
+
+ it.each([0, 0.35, variables.overlayOpacity])('matches both fade endpoints to the resting opacity of %s', (opacity) => {
+ const entering = getModalInAnimation('fadeIn', opacity);
+ const exiting = getModalOutAnimation('fadeOut', opacity);
+
+ expect(entering.from).toEqual({opacity: 0});
+ expect(entering.to).toEqual(expect.objectContaining({opacity}));
+ expect(exiting.from).toEqual({opacity});
+ expect(exiting.to).toEqual(expect.objectContaining({opacity: 0}));
+ });
+
+ it('preserves the default fade opacity for callers without an override', () => {
+ expect(getModalInAnimation('fadeIn').to).toEqual(expect.objectContaining({opacity: variables.overlayOpacity}));
+ expect(getModalOutAnimation('fadeOut').from).toEqual({opacity: variables.overlayOpacity});
+ });
+});
diff --git a/tests/unit/NativeRHPLayoutTest.tsx b/tests/unit/NativeRHPLayoutTest.tsx
new file mode 100644
index 000000000000..b231d912418e
--- /dev/null
+++ b/tests/unit/NativeRHPLayoutTest.tsx
@@ -0,0 +1,117 @@
+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 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';
+
+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';
+
+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', () => {
+ 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'}});
+ const interpolationProps = createMock({
+ current: {progress: new Animated.Value(1)},
+ inverted: new Animated.Value(1),
+ layouts: {screen: {width: 1180, height: 820}},
+ });
+
+ 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 uses JS-stack horizontal transitions on native', () => {
+ const {result} = renderHook(() => ({root: useRootNavigatorScreenOptions(), inner: useRHPScreenOptions()}));
+ 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 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.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});
+ });
+});
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/RHPTransitionTest.tsx b/tests/unit/RHPTransitionTest.tsx
new file mode 100644
index 000000000000..312a85e05518
--- /dev/null
+++ b/tests/unit/RHPTransitionTest.tsx
@@ -0,0 +1,76 @@
+import {renderHook} from '@testing-library/react-native';
+
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useSidePanelState from '@hooks/useSidePanelState';
+import useStyleUtils from '@hooks/useStyleUtils';
+
+import {useRHPFrameStyle, useRootRHPCardStyleInterpolator} from '@libs/Navigation/AppNavigator/useRHPTransition';
+import getRHPLayoutValue from '@libs/Navigation/helpers/getRHPLayoutValue';
+
+import CONST from '@src/CONST';
+
+import type * as ReactNavigationStack from '@react-navigation/stack';
+
+import {useCardAnimation} from '@react-navigation/stack';
+// eslint-disable-next-line no-restricted-imports
+import {Animated, Platform} from 'react-native';
+
+import createMock from '../utils/createMock';
+
+jest.mock('@hooks/useResponsiveLayout', () => jest.fn());
+jest.mock('@hooks/useSidePanelState', () => jest.fn());
+jest.mock('@hooks/useStyleUtils', () => jest.fn());
+jest.mock('@libs/Navigation/helpers/getRHPLayoutValue', () => jest.fn());
+jest.mock('@react-navigation/stack', () => ({...jest.requireActual('@react-navigation/stack'), useCardAnimation: jest.fn()}));
+
+describe.each(['ios', 'android', 'web'] as const)('RHP transition ownership on %s', (platform) => {
+ let animation: ReactNavigationStack.StackCardInterpolationProps;
+ let sidePanelOffset: Animated.Value;
+
+ beforeEach(() => {
+ jest.replaceProperty(Platform, 'OS', platform);
+ jest.mocked(useResponsiveLayout).mockReturnValue({...CONST.NAVIGATION_TESTS.DEFAULT_USE_RESPONSIVE_LAYOUT_VALUE, isSmallScreenWidth: false, shouldUseNarrowLayout: false});
+ jest.mocked(getRHPLayoutValue).mockImplementation((value, animatedValue) => (platform === 'web' ? animatedValue : value));
+ jest.mocked(useStyleUtils).mockReturnValue(
+ createMock>({getCardStyles: (width) => (platform === 'web' ? {position: 'fixed', width, height: '100%'} : {})}),
+ );
+ sidePanelOffset = new Animated.Value(320);
+ jest.mocked(useSidePanelState).mockReturnValue(createMock>({sidePanelOffset: {current: sidePanelOffset}, isSidePanelTransitionEnded: true}));
+ animation = createMock({
+ current: {progress: new Animated.Value(0.25)},
+ inverted: new Animated.Value(1),
+ layouts: {screen: {width: 1440, height: 1000}},
+ });
+ jest.mocked(useCardAnimation).mockReturnValue(animation);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('keeps root geometry stationary and applies only motion and the Concierge offset to the frame', () => {
+ const {result} = renderHook(() => ({root: useRootRHPCardStyleInterpolator(), frame: useRHPFrameStyle()}));
+ const host = result.current.root(animation).cardStyle;
+
+ expect(host).not.toHaveProperty('transform');
+ expect(host).not.toHaveProperty('opacity');
+ expect(host).not.toHaveProperty('paddingRight');
+ expect(result.current.frame?.opacity).toBe(animation.current.progress);
+ expect(result.current.frame?.transform).toHaveLength(1);
+ expect(result.current.frame?.right).toBe(platform === 'web' ? sidePanelOffset : 0);
+ expect(result.current.frame).not.toHaveProperty('width');
+ expect(result.current.frame).not.toHaveProperty('height');
+ expect(result.current.frame).not.toHaveProperty('position');
+ });
+
+ it('keeps the narrow-screen slide and offset on the root without another frame transform', () => {
+ jest.mocked(useResponsiveLayout).mockReturnValue({...CONST.NAVIGATION_TESTS.DEFAULT_USE_RESPONSIVE_LAYOUT_VALUE, isSmallScreenWidth: true, shouldUseNarrowLayout: true});
+ const {result} = renderHook(() => ({root: useRootRHPCardStyleInterpolator(), frame: useRHPFrameStyle()}));
+ const host = result.current.root(animation).cardStyle;
+
+ expect(result.current.frame).toBeUndefined();
+ expect(host).toHaveProperty('transform');
+ expect(host).not.toHaveProperty('opacity');
+ expect(host).toHaveProperty('paddingRight', platform === 'web' ? sidePanelOffset : 0);
+ });
+});
diff --git a/tests/unit/ResponsiveLayoutTest.tsx b/tests/unit/ResponsiveLayoutTest.tsx
new file mode 100644
index 000000000000..508f6a429d82
--- /dev/null
+++ b/tests/unit/ResponsiveLayoutTest.tsx
@@ -0,0 +1,107 @@
+import {renderHook} from '@testing-library/react-native';
+
+import ModalContext from '@components/Modal/ModalContext';
+
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+
+import isInLandscapeMode from '@libs/isInLandscapeMode';
+
+import CONST from '@src/CONST';
+import NAVIGATORS from '@src/NAVIGATORS';
+
+import type {NavigationProp, ParamListBase} from '@react-navigation/native';
+import type {PropsWithChildren} from 'react';
+
+import {NavigationContext} from '@react-navigation/native';
+import React from 'react';
+import {Dimensions, Platform} from 'react-native';
+
+import createMock from '../utils/createMock';
+
+jest.mock('@hooks/useWindowDimensions', () => jest.fn());
+jest.mock('@libs/isInLandscapeMode', () => jest.fn());
+
+describe.each(['ios', 'android', 'web'] as const)('Responsive layout on %s', (platform) => {
+ beforeEach(() => {
+ jest.replaceProperty(Platform, 'OS', platform);
+ jest.mocked(useWindowDimensions).mockReturnValue({windowWidth: 1180, windowHeight: 820});
+ jest.mocked(isInLandscapeMode).mockReturnValue(false);
+ jest.spyOn(Dimensions, 'get').mockReturnValue({width: 1180, height: 820, scale: 1, fontScale: 1});
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it.each([
+ [800, true, false, false, false],
+ [801, false, true, false, false],
+ [1024, false, true, false, false],
+ [1025, false, false, true, false],
+ [1300, false, false, true, false],
+ [1301, false, false, true, true],
+ ])('updates breakpoints after resizing to %i', (width, isSmall, isMedium, isLarge, isExtraLarge) => {
+ const {result, rerender} = renderHook(() => useResponsiveLayout());
+ jest.mocked(useWindowDimensions).mockReturnValue({windowWidth: width, windowHeight: 820});
+ rerender({});
+
+ expect(result.current).toMatchObject({
+ shouldUseNarrowLayout: isSmall,
+ isSmallScreenWidth: isSmall,
+ isMediumScreenWidth: isMedium,
+ isLargeScreenWidth: isLarge,
+ isExtraLargeScreenWidth: isExtraLarge,
+ isInNarrowPaneModal: false,
+ });
+ });
+
+ it('preserves the phone-landscape exception only on web', () => {
+ jest.mocked(useWindowDimensions).mockReturnValue({windowWidth: 852, windowHeight: 393});
+ jest.mocked(isInLandscapeMode).mockReturnValue(true);
+ const {result} = renderHook(() => useResponsiveLayout());
+
+ expect(result.current).toMatchObject({
+ isInLandscapeMode: true,
+ isSmallScreen: true,
+ isSmallScreenWidth: platform === 'web',
+ shouldUseNarrowLayout: platform === 'web',
+ isMediumScreenWidth: platform !== 'web',
+ onboardingIsMediumOrLargerScreenWidth: false,
+ });
+ });
+
+ it('uses screen height on web and window height on native after the keyboard reduces the window', () => {
+ const {result, rerender} = renderHook(() => useResponsiveLayout());
+ expect(result.current.isExtraSmallScreenHeight).toBe(false);
+
+ jest.mocked(useWindowDimensions).mockReturnValue({windowWidth: 1180, windowHeight: 400});
+ rerender({});
+
+ expect(result.current.isExtraSmallScreenHeight).toBe(platform !== 'web');
+ });
+
+ it.each([
+ [undefined, false, false],
+ [undefined, true, true],
+ [CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED, false, true],
+ [CONST.MODAL.MODAL_TYPE.CENTERED, false, false],
+ [CONST.MODAL.MODAL_TYPE.CENTERED, true, false],
+ ] as const)('handles modal type %s inside RHP %s', (activeModalType, isInsideRHP, isNarrow) => {
+ const getParent = jest.fn().mockReturnValue(isInsideRHP ? {} : undefined);
+ const navigation = createMock>({getParent});
+ const modalContextValue = {activeModalType, default: false};
+ function Wrapper({children}: PropsWithChildren) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ const {result} = renderHook(() => useResponsiveLayout(), {wrapper: Wrapper});
+
+ expect(getParent).toHaveBeenCalledWith(NAVIGATORS.RIGHT_MODAL_NAVIGATOR);
+ expect(result.current).toMatchObject({isSmallScreenWidth: false, isInNarrowPaneModal: isNarrow, shouldUseNarrowLayout: isNarrow});
+ });
+});
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.