From 517758ebc864d29dc53a10d051e4cc2374e00a0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Mon, 14 Sep 2026 15:50:10 +0200 Subject: [PATCH] Add web-only floating RHP style Inset the RHP from the viewport edges with rounded corners, a 1px border and a shadow, and lighten its scrim. Web wide layout only; narrow layout and native keep the current full-bleed frame. --- .../WideRHPOverlayWrapper/index.tsx | 16 ++++++++ .../Navigators/Overlay/BaseOverlay.tsx | 30 +++++++++++++- .../Navigators/RightModalNavigator.tsx | 41 ++++++++++++++++--- src/styles/index.ts | 22 +++++++++- src/styles/theme/themes/dark.ts | 1 + src/styles/theme/themes/light.ts | 1 + src/styles/theme/types.ts | 2 + src/styles/variables.ts | 4 ++ 8 files changed, 108 insertions(+), 9 deletions(-) diff --git a/src/components/WideRHPOverlayWrapper/index.tsx b/src/components/WideRHPOverlayWrapper/index.tsx index 1f8675555f93..2969b051f10b 100644 --- a/src/components/WideRHPOverlayWrapper/index.tsx +++ b/src/components/WideRHPOverlayWrapper/index.tsx @@ -9,15 +9,19 @@ import { } from '@components/WideRHPContextProvider'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; import Overlay from '@libs/Navigation/AppNavigator/Navigators/Overlay'; +import variables from '@styles/variables'; + import {useRoute} from '@react-navigation/native'; import React from 'react'; function SecondaryOverlay() { const {shouldRenderSecondaryOverlayForRHPOnSuperWideRHP, shouldRenderSecondaryOverlayForRHPOnWideRHP, shouldRenderSecondaryOverlayForWideRHP, superWideRHPRouteKeys, wideRHPRouteKeys} = useWideRHPState(); + const theme = useTheme(); const route = useRoute(); @@ -46,6 +50,10 @@ function SecondaryOverlay() { progress={secondOverlayRHPOnWideRHPProgress} // If RHP is displayed on Wide RHP which is displayed above the Super Wide RHP, the secondary overlay's position left should be calculated from the left edge of the super wide RHP. positionLeftValue={animatedReceiptPaneRHPWidth} + positionTopValue={variables.rhpFloatingCardMargin} + positionBottomValue={variables.rhpFloatingCardMargin} + overlayColor={theme.rhpOverlay} + maxOpacity={variables.rhpOverlayOpacity} /> ); } @@ -55,6 +63,10 @@ function SecondaryOverlay() { ); } @@ -64,6 +76,10 @@ function SecondaryOverlay() { ); } diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx index ca4ae4fe0870..398252e6ba77 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx @@ -4,6 +4,7 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import type {OverlayStylesParams} from '@styles/index'; +import type {Color} from '@styles/theme/types'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -25,10 +26,31 @@ type BaseOverlayProps = { /** Overlay position from the right edge of the container */ positionRightValue?: number | Animated.Value | Animated.AnimatedAddition; + + /** Scrim background color override. Defaults to `theme.overlay`; the floating RHP passes the lighter `theme.rhpOverlay`. */ + overlayColor?: Color; + + /** Max opacity the scrim ramps up to. Defaults to `variables.overlayOpacity`; the floating RHP passes the lighter `rhpOverlayOpacity`. */ + maxOpacity?: number; + + /** Overlay position from the top edge of the container. The floating RHP overlays pass the card's inset so the scrim doesn't bleed past it. */ + positionTopValue?: number; + + /** Overlay position from the bottom edge of the container. The floating RHP overlays pass the card's inset so the scrim doesn't bleed past it. */ + positionBottomValue?: number; }; // The default value of positionLeftValue is equal to -2 * variables.sideBarWidth, because we need to stretch the overlay to cover the sidebar and the translate animation distance. -function BaseOverlay({onPress, progress, positionLeftValue = -2 * variables.sideBarWidth, positionRightValue = 0}: BaseOverlayProps) { +function BaseOverlay({ + onPress, + progress, + positionLeftValue = -2 * variables.sideBarWidth, + positionRightValue = 0, + overlayColor, + maxOpacity, + positionTopValue, + positionBottomValue, +}: BaseOverlayProps) { const styles = useThemeStyles(); const {current} = useCardAnimation(); const {translate} = useLocalize(); @@ -37,7 +59,11 @@ function BaseOverlay({onPress, progress, positionLeftValue = -2 * variables.side {/* In the latest Electron version buttons can't be both clickable and draggable. diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index b0878cc2cea2..c665b9b6afff 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -13,6 +13,7 @@ import { import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSidePanelState from '@hooks/useSidePanelState'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -49,7 +50,7 @@ import type {View} from 'react-native'; import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; // eslint-disable-next-line no-restricted-imports -import {Animated, DeviceEventEmitter} from 'react-native'; +import {Animated, DeviceEventEmitter, Platform} from 'react-native'; import {NarrowPaneContextProvider} from './NarrowPaneContext'; import Overlay from './Overlay'; @@ -80,12 +81,17 @@ function SearchAdvancedFiltersWithContext(props: Record) { function SecondaryOverlay() { const {shouldRenderSecondaryOverlayForWideRHP, shouldRenderSecondaryOverlayForRHPOnWideRHP, shouldRenderSecondaryOverlayForRHPOnSuperWideRHP} = useWideRHPState(); const {sidePanelOffset} = useSidePanelState(); + const theme = useTheme(); if (shouldRenderSecondaryOverlayForWideRHP) { return ( Navigation.closeRHPFlow()} /> ); @@ -95,7 +101,11 @@ function SecondaryOverlay() { return ( ); @@ -105,7 +115,11 @@ function SecondaryOverlay() { return ( ); @@ -174,6 +188,7 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { const {windowWidth} = useWindowDimensions(); const modalStackScreenOptions = useModalStackScreenOptions(); const styles = useThemeStyles(); + const theme = useTheme(); const {sidePanelOffset} = useSidePanelState(); // When a fullscreen route is pre-inserted under the RHP, disable the slide-out animation @@ -220,6 +235,14 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { const overlayPositionLeft = useMemo(() => -1 * calculateSuperWideRHPWidth(windowWidth), [windowWidth]); + // Floating RHP (web wide layout only): inset the card from the viewport edges and give it rounded corners, a 1px + // border, and a shadow. This replaces the full-bleed `r0`/`h100` anchoring; width stays driven by the animated + // RHP width. Narrow layout and native keep the full-bleed frame. + const isFloatingRHP = Platform.OS === 'web' && !shouldUseNarrowLayout; + const frameStyle = isFloatingRHP + ? [styles.pAbsolute, styles.overflowHidden, styles.RHPFloatingCard, animatedWidthStyle] + : [styles.pAbsolute, styles.r0, styles.h100, styles.overflowHidden, animatedWidthStyle]; + const screenListeners = useMemo( () => ({ blur: () => { @@ -284,6 +307,8 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { {!shouldUseNarrowLayout && ( )} @@ -296,7 +321,7 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { )} diff --git a/src/styles/index.ts b/src/styles/index.ts index d8ba7a9de164..ef0847e9e8d7 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -3323,6 +3323,18 @@ const staticStyles = (theme: ThemeColors) => height: '100%', }, + // Floating RHP card (web wide layout only): inset from the viewport edges, rounded, 1px border, shadow. + // Anchoring replaces the card's `r0`/`h100`; width stays driven by the animated RHP width at the call site. + RHPFloatingCard: { + top: variables.rhpFloatingCardMargin, + right: variables.rhpFloatingCardMargin, + bottom: variables.rhpFloatingCardMargin, + borderRadius: variables.componentBorderRadiusLarge, + borderWidth: 1, + borderColor: theme.border, + boxShadow: theme.shadow, + }, + invisible: { position: 'absolute', opacity: 0, @@ -6845,18 +6857,26 @@ const dynamicStyles = (theme: ThemeColors) => progress, positionLeftValue, positionRightValue, + positionTopValue = 0, + positionBottomValue = 0, + maxOpacity = variables.overlayOpacity, }: { progress: OverlayStylesParams; positionLeftValue: number | Animated.Value | Animated.AnimatedAddition; positionRightValue: number | Animated.Value | Animated.AnimatedAddition; + positionTopValue?: number; + positionBottomValue?: number; + maxOpacity?: number; }) => ({ // We need to stretch the overlay to cover the sidebar and the translate animation distance. left: positionLeftValue, right: positionRightValue, + top: positionTopValue, + bottom: positionBottomValue, opacity: progress.interpolate({ inputRange: [0, 0.5], - outputRange: [0, variables.overlayOpacity], + outputRange: [0, maxOpacity], extrapolate: 'clamp', }), }) satisfies ViewStyle, diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index f04c08581918..5dc6f21c7a61 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -49,6 +49,7 @@ const darkTheme = { // Additional keys overlay: colors.productDark400, + rhpOverlay: colors.productDark300, inverse: colors.productDark900, shadow: '0px 4px 12px 0px rgba(2,18,4,0.24)', componentBG: colors.productDark100, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index f9685e8898b5..d90903cd168b 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -49,6 +49,7 @@ const lightTheme = { // Additional keys overlay: colors.productLight400, + rhpOverlay: colors.productLight300, inverse: colors.productLight900, shadow: '0px 4px 12px 0px rgba(2,18,4,0.06)', componentBG: colors.productLight100, diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts index d1a4f83d901a..27d74aa9282e 100644 --- a/src/styles/theme/types.ts +++ b/src/styles/theme/types.ts @@ -57,6 +57,8 @@ type ThemeColors = { // Additional keys overlay: Color; + // Lighter scrim behind the floating RHP card (web wide layout only). + rhpOverlay: Color; inverse: Color; shadow: Color; componentBG: Color; diff --git a/src/styles/variables.ts b/src/styles/variables.ts index ffec29f43dbe..9b1f0b59a371 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -106,6 +106,8 @@ export default { androidSafeAreaInsetsPercentage: 1, sideBarWidth: 375, sidePanelWidth: 375, + // Inset on the top/right/bottom edges of the floating RHP card (web wide layout only). + rhpFloatingCardMargin: 12, // Screen inset shared by the top- and bottom-anchored growl containers so both stay in sync. growlNotificationInset: 20, receiptPaneRHPMaxWidth: 465, @@ -160,6 +162,8 @@ export default { sectionMenuItemHeightCompact: 44, optionsListSectionHeaderHeight: getValueUsingPixelRatio(32, 38), overlayOpacity: 0.72, + // Lighter scrim opacity for the floating RHP overlay (web), scoped so other modal backdrops keep `overlayOpacity`. + rhpOverlayOpacity: 0.5, // fontSizeExtraSmall is fixed at 9, so the line height must never scale below the font's natural line height (~1.18em = 10.62), // otherwise Android clamps the descent and clips descenders and underlines at small device font scales. lineHeightXSmall: Math.max(getValueUsingPixelRatio(11, 17), 11),