From 96b1b878ec611c954785d9be22cf6b9517b6871b Mon Sep 17 00:00:00 2001
From: Adam Grzybowski <adam.grzybowski@swmansion.com>
Date: Wed, 1 Jul 2026 16:12:09 +0200
Subject: [PATCH 1/6] Anchor receipt hover preview to row and add
 WidgetHeaderMenu

Position the receipt preview beside the hovered thumbnail instead of a
fixed corner, standardize card header menus with WidgetHeaderMenu, and
extract shared style values into variables.
---
 .../DataCells/ReceiptCell.tsx                 |  9 +-
 .../getAnchoredPreviewPosition.ts             | 51 +++++++++++
 .../ReceiptPreview/index.tsx                  | 16 +++-
 src/pages/home/RecentlyAddedSection/index.tsx | 73 ++++-----------
 .../SpendOverTimeSectionContent.tsx           | 22 ++---
 .../home/common/WidgetHeaderMenu/index.tsx    | 90 +++++++++++++++++++
 src/styles/index.ts                           | 24 ++++-
 src/styles/variables.ts                       |  2 +
 .../components/ReceiptPreviewPositionTest.ts  | 83 +++++++++++++++++
 9 files changed, 296 insertions(+), 74 deletions(-)
 create mode 100644 src/components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition.ts
 create mode 100644 src/pages/home/common/WidgetHeaderMenu/index.tsx
 create mode 100644 tests/unit/components/ReceiptPreviewPositionTest.ts

diff --git a/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx b/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx
index 899ddc5501ee..30dfd4516665 100644
--- a/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx
+++ b/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx
@@ -1,5 +1,5 @@
 import {Str} from 'expensify-common';
-import React, {useState} from 'react';
+import React, {useRef, useState} from 'react';
 import {View} from 'react-native';
 import type {ViewStyle} from 'react-native';
 import ReceiptImage from '@components/ReceiptImage';
@@ -40,11 +40,16 @@ function ReceiptCell({
     // ReceiptPreview handles its own visibility via debounced state, so keeping it
     // mounted avoids re-creating the portal and reloading images on subsequent hovers.
     const [shouldMountPreview, setShouldMountPreview] = useState(false);
+    const cellRef = useRef<View>(null);
+    // The preview is a document.body portal, so it needs the hovered cell's window position to
+    // anchor itself beside the row instead of sitting fixed in the upper-left corner.
+    const [previewAnchor, setPreviewAnchor] = useState<{top: number; left: number; width: number; height: number}>();
 
     const handleMouseEnter = () => {
         if (!shouldMountPreview) {
             setShouldMountPreview(true);
         }
+        cellRef.current?.measureInWindow((left, top, width, height) => setPreviewAnchor({left, top, width, height}));
         bind.onMouseEnter();
     };
 
@@ -63,6 +68,7 @@ function ReceiptCell({
 
     return (
         <View
+            ref={cellRef}
             style={[
                 StyleUtils.getWidthAndHeightStyle(isLargeScreenWidth ? variables.w28 : variables.h36, isLargeScreenWidth ? variables.h32 : variables.w40),
                 StyleUtils.getBorderRadiusStyle(variables.componentBorderRadiusSmall),
@@ -97,6 +103,7 @@ function ReceiptCell({
                     hovered={hovered && shouldShowPreview}
                     isEReceipt={!!isEReceipt}
                     transactionItem={transactionItem}
+                    anchorPosition={previewAnchor}
                 />
             )}
         </View>
diff --git a/src/components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition.ts b/src/components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition.ts
new file mode 100644
index 000000000000..c8f1480a20b1
--- /dev/null
+++ b/src/components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition.ts
@@ -0,0 +1,51 @@
+import variables from '@styles/variables';
+
+/** Width of the preview, shared with `styles.receiptPreview.width` via `variables.receiptPreviewWidth`. */
+const RECEIPT_PREVIEW_WIDTH = variables.receiptPreviewWidth;
+
+/** Horizontal gap between the hovered thumbnail and the preview. */
+const RECEIPT_PREVIEW_GAP = 16;
+
+/** Gap kept between the preview and the viewport edges. */
+const RECEIPT_PREVIEW_EDGE_MARGIN = 24;
+
+/** Minimum slice of the preview kept on-screen so a row near the bottom doesn't push it fully out of view. */
+const RECEIPT_PREVIEW_MIN_VISIBLE_HEIGHT = 160;
+
+type AnchorPosition = {top: number; left: number; width: number; height: number};
+
+/**
+ * Anchors the preview's bottom-left corner to the right of the hovered thumbnail, so the preview grows upward
+ * from the row. If there isn't room on the right, it flips to the left of the thumbnail. The top is clamped to a
+ * viewport margin so a tall receipt near the top of the screen never runs off the top edge. Returns undefined
+ * when there is no anchor, so the caller keeps the static style.
+ *
+ * @param previewHeight Measured height of the preview. 0 means "not measured yet" — we can't bottom-align without
+ * it, so we fall back to aligning the top with the row (keeping a minimum slice on-screen) for the first frame.
+ */
+function getAnchoredPreviewPosition(anchorPosition: AnchorPosition | undefined, windowWidth: number, windowHeight: number, previewHeight = 0) {
+    if (!anchorPosition) {
+        return undefined;
+    }
+
+    const rightOfThumbnail = anchorPosition.left + anchorPosition.width + RECEIPT_PREVIEW_GAP;
+    const overflowsRight = windowWidth > 0 && rightOfThumbnail + RECEIPT_PREVIEW_WIDTH + RECEIPT_PREVIEW_EDGE_MARGIN > windowWidth;
+    const left = overflowsRight ? Math.max(RECEIPT_PREVIEW_EDGE_MARGIN, anchorPosition.left - RECEIPT_PREVIEW_WIDTH - RECEIPT_PREVIEW_GAP) : rightOfThumbnail;
+
+    // Before it's measured we can't bottom-align, so keep a minimum slice on-screen relative to the row top.
+    if (previewHeight <= 0) {
+        const lowestTop = windowHeight - RECEIPT_PREVIEW_MIN_VISIBLE_HEIGHT;
+        return {left, top: Math.min(Math.max(anchorPosition.top, RECEIPT_PREVIEW_EDGE_MARGIN), Math.max(RECEIPT_PREVIEW_EDGE_MARGIN, lowestTop))};
+    }
+
+    // Align the preview's bottom edge with the thumbnail's bottom (bottom-left corner sits at the thumbnail's
+    // right side), then clamp to the top margin so a preview taller than the space above the row stays on-screen.
+    const thumbnailBottom = anchorPosition.top + anchorPosition.height;
+    const top = Math.max(RECEIPT_PREVIEW_EDGE_MARGIN, thumbnailBottom - previewHeight);
+
+    return {left, top};
+}
+
+export default getAnchoredPreviewPosition;
+export {RECEIPT_PREVIEW_WIDTH, RECEIPT_PREVIEW_GAP, RECEIPT_PREVIEW_EDGE_MARGIN, RECEIPT_PREVIEW_MIN_VISIBLE_HEIGHT};
+export type {AnchorPosition};
diff --git a/src/components/TransactionItemRow/ReceiptPreview/index.tsx b/src/components/TransactionItemRow/ReceiptPreview/index.tsx
index 81fdbeb81bf2..8a570f11a035 100644
--- a/src/components/TransactionItemRow/ReceiptPreview/index.tsx
+++ b/src/components/TransactionItemRow/ReceiptPreview/index.tsx
@@ -18,6 +18,8 @@ import Image from '@src/components/Image';
 import CONST from '@src/CONST';
 import type {Transaction} from '@src/types/onyx';
 import type {ReceiptSource} from '@src/types/onyx/Transaction';
+import getAnchoredPreviewPosition from './getAnchoredPreviewPosition';
+import type {AnchorPosition} from './getAnchoredPreviewPosition';
 
 type ReceiptPreviewProps = {
     /** Path to the image to be opened in the preview */
@@ -31,9 +33,12 @@ type ReceiptPreviewProps = {
 
     /** Transaction object related to the preview */
     transactionItem: Transaction;
+
+    /** Window position of the hovered cell. When set, the preview is anchored to the right of the row instead of the fixed upper-left corner. */
+    anchorPosition?: AnchorPosition;
 };
 
-function ReceiptPreview({source, hovered, isEReceipt = false, transactionItem}: ReceiptPreviewProps) {
+function ReceiptPreview({source, hovered, isEReceipt = false, transactionItem, anchorPosition}: ReceiptPreviewProps) {
     const isDistanceEReceipt = isDistanceRequest(transactionItem) && !isManualDistanceRequest(transactionItem);
     const isPerDiemEReceipt = isPerDiemRequest(transactionItem) && !hasReceiptSource(transactionItem) && !!transactionItem.transactionID;
     const styles = useThemeStyles();
@@ -43,8 +48,10 @@ function ReceiptPreview({source, hovered, isEReceipt = false, transactionItem}:
     const [shouldShow, debounceShouldShow, setShouldShow] = useDebouncedState(false, CONST.TIMING.SHOW_HOVER_PREVIEW_DELAY);
     const {shouldUseNarrowLayout} = useResponsiveLayoutOnWideRHP();
     const hasMeasured = useRef(false);
-    const {windowHeight} = useWindowDimensions();
+    const {windowWidth, windowHeight} = useWindowDimensions();
     const [isLoading, setIsLoading] = useState(true);
+    // Measured preview height, used to clamp the vertical position so a tall receipt never runs off the bottom edge.
+    const [previewHeight, setPreviewHeight] = useState(0);
 
     const handleDistanceEReceiptLayout = (e: LayoutChangeEvent) => {
         if (hasMeasured.current) {
@@ -110,11 +117,14 @@ function ReceiptPreview({source, hovered, isEReceipt = false, transactionItem}:
     const shouldShowDistanceEReceipt = isDistanceEReceipt && !isEReceipt && !isPerDiemEReceipt;
     const sourceObject = typeof source === 'string' ? {uri: source} : source;
 
+    const anchoredPositionStyle = getAnchoredPreviewPosition(anchorPosition, windowWidth, windowHeight, previewHeight);
+
     return ReactDOM.createPortal(
         <Animated.View
             entering={FadeIn.duration(CONST.TIMING.SHOW_HOVER_PREVIEW_ANIMATION_DURATION)}
             exiting={FadeOut.duration(CONST.TIMING.SHOW_HOVER_PREVIEW_ANIMATION_DURATION)}
-            style={[styles.receiptPreview, styles.flexColumn, styles.alignItemsCenter, styles.justifyContentStart]}
+            onLayout={(e) => setPreviewHeight(e.nativeEvent.layout.height)}
+            style={[styles.receiptPreview, styles.flexColumn, styles.alignItemsCenter, styles.justifyContentStart, anchoredPositionStyle]}
         >
             {shouldShowImage ? (
                 <View style={[styles.w100]}>
diff --git a/src/pages/home/RecentlyAddedSection/index.tsx b/src/pages/home/RecentlyAddedSection/index.tsx
index ea96a8f51bd6..acfdb52e253f 100644
--- a/src/pages/home/RecentlyAddedSection/index.tsx
+++ b/src/pages/home/RecentlyAddedSection/index.tsx
@@ -1,9 +1,7 @@
 import {useIsFocused} from '@react-navigation/native';
-import React, {useRef, useState} from 'react';
+import React from 'react';
 import {View} from 'react-native';
 import Icon from '@components/Icon';
-import PopoverMenu from '@components/PopoverMenu';
-import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
 import Text from '@components/Text';
 import {useWideRHPActions} from '@components/WideRHPContextProvider';
 import WidgetContainer from '@components/WidgetContainer';
@@ -12,7 +10,6 @@ import useIsAnonymousUser from '@hooks/useIsAnonymousUser';
 import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
 import useLocalize from '@hooks/useLocalize';
 import useOnyx from '@hooks/useOnyx';
-import usePopoverPosition from '@hooks/usePopoverPosition';
 import useResponsiveLayout from '@hooks/useResponsiveLayout';
 import useStyleUtils from '@hooks/useStyleUtils';
 import useTheme from '@hooks/useTheme';
@@ -23,7 +20,7 @@ import Navigation from '@libs/Navigation/Navigation';
 import {buildQueryStringFromFilterFormValues} from '@libs/SearchQueryUtils';
 import type {TransactionThreadNavigationDescriptor} from '@libs/TransactionThreadNavigationUtils';
 import {getReportIDToOpenForExpense} from '@libs/TransactionThreadNavigationUtils';
-import type {AnchorPosition} from '@styles/index';
+import WidgetHeaderMenu from '@pages/home/common/WidgetHeaderMenu';
 import variables from '@styles/variables';
 import CONST from '@src/CONST';
 import ONYXKEYS from '@src/ONYXKEYS';
@@ -35,15 +32,6 @@ import {useRecentlyAddedData} from './useRecentlyAddedData';
 
 const HEADER_RECEIPT_ICON_SIZE = 16;
 
-// The overflow button is sized to its icon so it doesn't inflate the centered widget header row;
-// hitSlop keeps a comfortable tap target without adding visual height.
-const OVERFLOW_MENU_HIT_SLOP = {top: 10, bottom: 10, left: 10, right: 10};
-
-const OVERFLOW_MENU_ANCHOR_ALIGNMENT = {
-    horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
-    vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
-} as const;
-
 function RecentlyAddedSection() {
     const {transactions} = useRecentlyAddedData();
     const {translate} = useLocalize();
@@ -54,16 +42,12 @@ function RecentlyAddedSection() {
     // The hovered receipt preview is a portal on document.body, so it isn't dismissed by navigation alone.
     // Once the screen blurs (e.g. after opening an expense), we hide the preview instead of leaving it floating over the RHP.
     const isFocused = useIsFocused();
-    const icons = useMemoizedLazyExpensifyIcons(['ThreeDots', 'Receipt']);
-    const {calculatePopoverPosition} = usePopoverPosition();
+    const icons = useMemoizedLazyExpensifyIcons(['Receipt']);
     const {markReportIDAsExpense} = useWideRHPActions();
     const {email: currentUserEmail, accountID: currentUserAccountID} = useCurrentUserPersonalDetails();
     const isAnonymousUser = useIsAnonymousUser();
     const [betas] = useOnyx(ONYXKEYS.BETAS);
     const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
-    const [isOverflowMenuVisible, setIsOverflowMenuVisible] = useState(false);
-    const [overflowMenuPosition, setOverflowMenuPosition] = useState<AnchorPosition>();
-    const overflowMenuButtonRef = useRef<View>(null);
 
     const hasExpenses = transactions.length > 0;
 
@@ -96,13 +80,6 @@ function RecentlyAddedSection() {
         });
     };
 
-    const openOverflowMenu = () => {
-        calculatePopoverPosition(overflowMenuButtonRef, OVERFLOW_MENU_ANCHOR_ALIGNMENT).then((position) => {
-            setOverflowMenuPosition(position);
-            setIsOverflowMenuVisible(true);
-        });
-    };
-
     const navigateToExpensesPage = () => {
         Navigation.navigate(
             ROUTES.SEARCH_ROOT.getRoute({
@@ -118,38 +95,18 @@ function RecentlyAddedSection() {
     }
 
     const overflowMenu = hasExpenses ? (
-        <>
-            <PressableWithoutFeedback
-                ref={overflowMenuButtonRef}
-                testID="recentlyAddedOverflowMenu"
-                accessibilityLabel={translate('common.more')}
-                sentryLabel="RecentlyAddedOverflowMenu"
-                onPress={openOverflowMenu}
-                hitSlop={OVERFLOW_MENU_HIT_SLOP}
-                style={[styles.alignItemsCenter, styles.justifyContentCenter, styles.threeDotsMenuIconWidth]}
-            >
-                <Icon
-                    src={icons.ThreeDots}
-                    fill={theme.icon}
-                />
-            </PressableWithoutFeedback>
-            <PopoverMenu
-                isVisible={isOverflowMenuVisible}
-                anchorRef={overflowMenuButtonRef}
-                anchorPosition={overflowMenuPosition ?? {horizontal: 0, vertical: 0}}
-                anchorAlignment={OVERFLOW_MENU_ANCHOR_ALIGNMENT}
-                onClose={() => setIsOverflowMenuVisible(false)}
-                onItemSelected={() => setIsOverflowMenuVisible(false)}
-                menuItems={[
-                    {
-                        text: translate('homePage.recentlyAddedSection.viewAll'),
-                        icon: icons.Receipt,
-                        onSelected: navigateToExpensesPage,
-                        shouldCallAfterModalHide: true,
-                    },
-                ]}
-            />
-        </>
+        <WidgetHeaderMenu
+            testID="recentlyAddedOverflowMenu"
+            sentryLabel="RecentlyAddedOverflowMenu"
+            menuItems={[
+                {
+                    text: translate('homePage.recentlyAddedSection.viewAll'),
+                    icon: icons.Receipt,
+                    onSelected: navigateToExpensesPage,
+                    shouldCallAfterModalHide: true,
+                },
+            ]}
+        />
     ) : undefined;
 
     return (
diff --git a/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx b/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx
index 613872fb8a18..3829919d34e3 100644
--- a/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx
+++ b/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx
@@ -1,7 +1,6 @@
 import React from 'react';
 import {View} from 'react-native';
 import BlockingView from '@components/BlockingViews/BlockingView';
-import Button from '@components/Button';
 import {CHART_CONTENT_MIN_HEIGHT} from '@components/Charts/VictoryTheme';
 import SearchChartView from '@components/Search/SearchChartView';
 import WidgetContainer from '@components/WidgetContainer';
@@ -11,6 +10,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
 import useTheme from '@hooks/useTheme';
 import useThemeStyles from '@hooks/useThemeStyles';
 import Navigation from '@libs/Navigation/Navigation';
+import WidgetHeaderMenu from '@pages/home/common/WidgetHeaderMenu';
 import variables from '@styles/variables';
 import CONST from '@src/CONST';
 import ROUTES from '@src/ROUTES';
@@ -35,15 +35,17 @@ function SpendOverTimeSectionContent() {
             title={translate('search.spendOverTime')}
             titleRightContent={
                 state === SPEND_OVER_TIME_STATE.READY ? (
-                    <Button
-                        small
-                        text={translate('common.view')}
-                        onPress={() => Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query}))}
-                        iconRight={icons.Expand}
-                        shouldShowRightIcon
-                        textStyles={styles.pb0}
-                        style={styles.widgetItemButton}
-                        iconWrapperStyles={styles.justifyContentCenter}
+                    <WidgetHeaderMenu
+                        testID="spendOverTimeOverflowMenu"
+                        sentryLabel="SpendOverTimeOverflowMenu"
+                        menuItems={[
+                            {
+                                text: translate('common.view'),
+                                icon: icons.Expand,
+                                onSelected: () => Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query})),
+                                shouldCallAfterModalHide: true,
+                            },
+                        ]}
                     />
                 ) : null
             }
diff --git a/src/pages/home/common/WidgetHeaderMenu/index.tsx b/src/pages/home/common/WidgetHeaderMenu/index.tsx
new file mode 100644
index 000000000000..b858b87b8786
--- /dev/null
+++ b/src/pages/home/common/WidgetHeaderMenu/index.tsx
@@ -0,0 +1,90 @@
+import React, {useRef, useState} from 'react';
+import type {View} from 'react-native';
+import Icon from '@components/Icon';
+import type {PopoverMenuItem} from '@components/PopoverMenu';
+import PopoverMenu from '@components/PopoverMenu';
+import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
+import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
+import useLocalize from '@hooks/useLocalize';
+import usePopoverPosition from '@hooks/usePopoverPosition';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import type {AnchorPosition} from '@styles/index';
+import variables from '@styles/variables';
+import CONST from '@src/CONST';
+
+const ANCHOR_ALIGNMENT = {
+    horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
+    vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
+} as const;
+
+type WidgetHeaderMenuProps = {
+    /** Items shown in the popover opened by the three-dots trigger */
+    menuItems: PopoverMenuItem[];
+
+    /** Accessibility label for the trigger. Defaults to the shared "More" copy. */
+    accessibilityLabel?: string;
+
+    /** Test ID for the trigger */
+    testID?: string;
+
+    /** Sentry label for the trigger */
+    sentryLabel?: string;
+};
+
+/**
+ * Standardized widget header action: a Medium Ghost three-dots icon button that opens a popover menu.
+ * The 40px trigger overflows the card header rather than growing it (see `widgetHeaderMenuButtonWrapper`),
+ * so every card header keeps the same height whether or not it has a menu.
+ */
+function WidgetHeaderMenu({menuItems, accessibilityLabel, testID, sentryLabel}: WidgetHeaderMenuProps) {
+    const styles = useThemeStyles();
+    const theme = useTheme();
+    const {translate} = useLocalize();
+    const icons = useMemoizedLazyExpensifyIcons(['ThreeDots']);
+    const {calculatePopoverPosition} = usePopoverPosition();
+    const buttonRef = useRef<View>(null);
+    const [isVisible, setIsVisible] = useState(false);
+    const [position, setPosition] = useState<AnchorPosition>();
+
+    const openMenu = () => {
+        calculatePopoverPosition(buttonRef, ANCHOR_ALIGNMENT).then((nextPosition) => {
+            setPosition(nextPosition);
+            setIsVisible(true);
+        });
+    };
+
+    return (
+        <>
+            <PressableWithFeedback
+                ref={buttonRef}
+                testID={testID}
+                accessibilityLabel={accessibilityLabel ?? translate('common.more')}
+                sentryLabel={sentryLabel}
+                onPress={openMenu}
+                wrapperStyle={styles.widgetHeaderMenuButtonWrapper}
+                style={styles.widgetHeaderMenuButton}
+                hoverStyle={styles.widgetHeaderMenuButtonHovered}
+            >
+                <Icon
+                    src={icons.ThreeDots}
+                    fill={theme.icon}
+                    width={variables.iconSizeSmall}
+                    height={variables.iconSizeSmall}
+                />
+            </PressableWithFeedback>
+            <PopoverMenu
+                isVisible={isVisible}
+                anchorRef={buttonRef}
+                anchorPosition={position ?? {horizontal: 0, vertical: 0}}
+                anchorAlignment={ANCHOR_ALIGNMENT}
+                onClose={() => setIsVisible(false)}
+                onItemSelected={() => setIsVisible(false)}
+                menuItems={menuItems}
+            />
+        </>
+    );
+}
+
+export default WidgetHeaderMenu;
+export type {WidgetHeaderMenuProps};
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 7eaa0ff0b33f..6cc2dc7cc47d 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -3882,6 +3882,25 @@ const staticStyles = (theme: ThemeColors) =>
             minWidth: 68,
         },
 
+        widgetHeaderMenuButton: {
+            width: variables.componentSizeNormal,
+            height: variables.componentSizeNormal,
+            alignItems: 'center',
+            justifyContent: 'center',
+            borderRadius: variables.buttonBorderRadius,
+        },
+
+        widgetHeaderMenuButtonHovered: {
+            backgroundColor: theme.hoverComponentBG,
+        },
+
+        widgetHeaderMenuButtonWrapper: {
+            // The 40px ghost button overflows the header instead of growing it: these negative margins shrink its
+            // vertical footprint to the title line-height so every card header keeps the same height.
+            marginTop: (variables.widgetHeaderTitleLineHeight - variables.componentSizeNormal) / 2,
+            marginBottom: (variables.widgetHeaderTitleLineHeight - variables.componentSizeNormal) / 2,
+        },
+
         widgetItemSubtitle: {
             ...FontUtils.fontFamily.platform.EXP_NEUE,
             fontSize: variables.fontSizeLabel,
@@ -5910,9 +5929,10 @@ const staticStyles = (theme: ThemeColors) =>
 
         receiptPreview: {
             position: 'absolute',
+            // Fallback corner; ReceiptPreview overrides left/top to sit beside the hovered thumbnail.
             left: 60,
             top: 60,
-            width: 380,
+            width: variables.receiptPreviewWidth,
             maxHeight: 'calc(100vh - 120px)',
             borderRadius: variables.componentBorderRadiusLarge,
             borderWidth: 1,
@@ -6829,7 +6849,7 @@ const plainStyles = (theme: ThemeColors) =>
             ({
                 ...FontUtils.fontFamily.platform.EXP_NEUE_BOLD,
                 fontSize: 17,
-                lineHeight: 20,
+                lineHeight: variables.widgetHeaderTitleLineHeight,
                 color,
             }) satisfies TextStyle,
 
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index 96acddbeb2fc..fc055475cde3 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -111,6 +111,8 @@ export default {
     receiptPaneRHPMaxWidth: 465,
     receiptPreviewMaxWidth: 440,
     receiptPreviewMaxHeight: 440,
+    receiptPreviewWidth: 380,
+    widgetHeaderTitleLineHeight: 20,
     homePageLeftColumnMaxWidth: 680,
     homePageRightColumnMaxWidth: 488,
     minScanTooltipWidth: 320,
diff --git a/tests/unit/components/ReceiptPreviewPositionTest.ts b/tests/unit/components/ReceiptPreviewPositionTest.ts
new file mode 100644
index 000000000000..4d99e295a1c4
--- /dev/null
+++ b/tests/unit/components/ReceiptPreviewPositionTest.ts
@@ -0,0 +1,83 @@
+import getAnchoredPreviewPosition, {
+    RECEIPT_PREVIEW_EDGE_MARGIN,
+    RECEIPT_PREVIEW_GAP,
+    RECEIPT_PREVIEW_MIN_VISIBLE_HEIGHT,
+    RECEIPT_PREVIEW_WIDTH,
+} from '@components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition';
+
+const WINDOW_WIDTH = 1440;
+const WINDOW_HEIGHT = 900;
+
+describe('getAnchoredPreviewPosition', () => {
+    it('falls back to the static style (undefined) when there is no anchor', () => {
+        expect(getAnchoredPreviewPosition(undefined, WINDOW_WIDTH, WINDOW_HEIGHT)).toBeUndefined();
+    });
+
+    it('places the preview just to the right of the hovered thumbnail', () => {
+        const anchor = {top: 300, left: 120, width: 68, height: 64};
+
+        const position = getAnchoredPreviewPosition(anchor, WINDOW_WIDTH, WINDOW_HEIGHT);
+
+        expect(position?.left).toBe(anchor.left + anchor.width + RECEIPT_PREVIEW_GAP);
+    });
+
+    it('aligns the preview top with the hovered row before it has been measured', () => {
+        const anchor = {top: 420, left: 120, width: 68, height: 64};
+
+        expect(getAnchoredPreviewPosition(anchor, WINDOW_WIDTH, WINDOW_HEIGHT)?.top).toBe(420);
+    });
+
+    it('flips to the left of the thumbnail when there is not enough room on the right', () => {
+        // A thumbnail hugging the right edge leaves no room for a 380px-wide preview to its right.
+        const anchor = {top: 300, left: WINDOW_WIDTH - 80, width: 68, height: 64};
+
+        const position = getAnchoredPreviewPosition(anchor, WINDOW_WIDTH, WINDOW_HEIGHT);
+
+        expect(position?.left).toBe(anchor.left - RECEIPT_PREVIEW_WIDTH - RECEIPT_PREVIEW_GAP);
+        expect(position?.left).toBeLessThan(anchor.left);
+    });
+
+    it('clamps a row near the top so the preview stays a margin below the viewport top', () => {
+        const anchor = {top: 4, left: 120, width: 68, height: 64};
+
+        expect(getAnchoredPreviewPosition(anchor, WINDOW_WIDTH, WINDOW_HEIGHT)?.top).toBe(RECEIPT_PREVIEW_EDGE_MARGIN);
+    });
+
+    it('clamps a row near the bottom so the preview keeps a visible slice on-screen before it has been measured', () => {
+        const anchor = {top: WINDOW_HEIGHT - 10, left: 120, width: 68, height: 64};
+
+        expect(getAnchoredPreviewPosition(anchor, WINDOW_WIDTH, WINDOW_HEIGHT)?.top).toBe(WINDOW_HEIGHT - RECEIPT_PREVIEW_MIN_VISIBLE_HEIGHT);
+    });
+
+    it("aligns a measured preview's bottom-left corner with the right of the thumbnail (grows upward)", () => {
+        const previewHeight = 200;
+        const anchor = {top: 300, left: 120, width: 68, height: 64};
+
+        const position = getAnchoredPreviewPosition(anchor, WINDOW_WIDTH, WINDOW_HEIGHT, previewHeight);
+        const thumbnailBottom = anchor.top + anchor.height;
+
+        expect(position?.left).toBe(anchor.left + anchor.width + RECEIPT_PREVIEW_GAP);
+        expect(position?.top).toBe(thumbnailBottom - previewHeight);
+        // Bottom edge lands on the thumbnail's bottom, so the preview sits above-right of the row.
+        expect((position?.top ?? 0) + previewHeight).toBe(thumbnailBottom);
+    });
+
+    it('keeps a measured preview on-screen for a row near the bottom by growing upward', () => {
+        const previewHeight = 600;
+        const anchor = {top: WINDOW_HEIGHT - 80, left: 120, width: 68, height: 64};
+
+        const top = getAnchoredPreviewPosition(anchor, WINDOW_WIDTH, WINDOW_HEIGHT, previewHeight)?.top ?? 0;
+        const thumbnailBottom = anchor.top + anchor.height;
+
+        expect(top).toBe(thumbnailBottom - previewHeight);
+        expect(top).toBeGreaterThanOrEqual(RECEIPT_PREVIEW_EDGE_MARGIN);
+        expect(top + previewHeight).toBeLessThanOrEqual(WINDOW_HEIGHT);
+    });
+
+    it('pins a preview taller than the space above the row to the top margin', () => {
+        const previewHeight = WINDOW_HEIGHT + 200;
+        const anchor = {top: 500, left: 120, width: 68, height: 64};
+
+        expect(getAnchoredPreviewPosition(anchor, WINDOW_WIDTH, WINDOW_HEIGHT, previewHeight)?.top).toBe(RECEIPT_PREVIEW_EDGE_MARGIN);
+    });
+});

From 14b25799af9ec3b443ba8be91eb041cc3dc2a193 Mon Sep 17 00:00:00 2001
From: Adam Grzybowski <adam.grzybowski@swmansion.com>
Date: Wed, 1 Jul 2026 17:05:55 +0200
Subject: [PATCH 2/6] Address PR review: clamp preview to viewport, rename
 WidgetHeaderMenu, fix button spacing

- Cap the receipt preview's bottom to the viewport so a row scrolled
  partially off the bottom keeps the preview fully on-screen
- Rename WidgetHeaderMenu/index.tsx to WidgetHeaderMenu.tsx (CONSISTENCY-9)
  and drop the unused WidgetHeaderMenuProps export (knip)
- Add a negative right margin so the header menu icon's right spacing
  matches its top spacing
---
 .../ReceiptPreview/getAnchoredPreviewPosition.ts    |  5 +++--
 src/pages/home/RecentlyAddedSection/index.tsx       |  2 +-
 .../SpendOverTimeSectionContent.tsx                 |  2 +-
 .../{index.tsx => WidgetHeaderMenu.tsx}             |  1 -
 src/styles/index.ts                                 |  4 +++-
 tests/unit/components/ReceiptPreviewPositionTest.ts | 13 ++++++++++++-
 6 files changed, 20 insertions(+), 7 deletions(-)
 rename src/pages/home/common/WidgetHeaderMenu/{index.tsx => WidgetHeaderMenu.tsx} (98%)

diff --git a/src/components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition.ts b/src/components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition.ts
index c8f1480a20b1..5a074baa653f 100644
--- a/src/components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition.ts
+++ b/src/components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition.ts
@@ -39,8 +39,9 @@ function getAnchoredPreviewPosition(anchorPosition: AnchorPosition | undefined,
     }
 
     // Align the preview's bottom edge with the thumbnail's bottom (bottom-left corner sits at the thumbnail's
-    // right side), then clamp to the top margin so a preview taller than the space above the row stays on-screen.
-    const thumbnailBottom = anchorPosition.top + anchorPosition.height;
+    // right side). Cap the thumbnail bottom to the viewport so a row scrolled partially off the bottom doesn't
+    // push the preview below the screen, then clamp to the top margin so a tall preview stays on-screen.
+    const thumbnailBottom = Math.min(anchorPosition.top + anchorPosition.height, windowHeight - RECEIPT_PREVIEW_EDGE_MARGIN);
     const top = Math.max(RECEIPT_PREVIEW_EDGE_MARGIN, thumbnailBottom - previewHeight);
 
     return {left, top};
diff --git a/src/pages/home/RecentlyAddedSection/index.tsx b/src/pages/home/RecentlyAddedSection/index.tsx
index acfdb52e253f..f2469b139309 100644
--- a/src/pages/home/RecentlyAddedSection/index.tsx
+++ b/src/pages/home/RecentlyAddedSection/index.tsx
@@ -20,7 +20,7 @@ import Navigation from '@libs/Navigation/Navigation';
 import {buildQueryStringFromFilterFormValues} from '@libs/SearchQueryUtils';
 import type {TransactionThreadNavigationDescriptor} from '@libs/TransactionThreadNavigationUtils';
 import {getReportIDToOpenForExpense} from '@libs/TransactionThreadNavigationUtils';
-import WidgetHeaderMenu from '@pages/home/common/WidgetHeaderMenu';
+import WidgetHeaderMenu from '@pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu';
 import variables from '@styles/variables';
 import CONST from '@src/CONST';
 import ONYXKEYS from '@src/ONYXKEYS';
diff --git a/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx b/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx
index 3829919d34e3..e0b6c435cf4f 100644
--- a/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx
+++ b/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx
@@ -10,7 +10,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
 import useTheme from '@hooks/useTheme';
 import useThemeStyles from '@hooks/useThemeStyles';
 import Navigation from '@libs/Navigation/Navigation';
-import WidgetHeaderMenu from '@pages/home/common/WidgetHeaderMenu';
+import WidgetHeaderMenu from '@pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu';
 import variables from '@styles/variables';
 import CONST from '@src/CONST';
 import ROUTES from '@src/ROUTES';
diff --git a/src/pages/home/common/WidgetHeaderMenu/index.tsx b/src/pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu.tsx
similarity index 98%
rename from src/pages/home/common/WidgetHeaderMenu/index.tsx
rename to src/pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu.tsx
index b858b87b8786..21f8c6c55e1c 100644
--- a/src/pages/home/common/WidgetHeaderMenu/index.tsx
+++ b/src/pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu.tsx
@@ -87,4 +87,3 @@ function WidgetHeaderMenu({menuItems, accessibilityLabel, testID, sentryLabel}:
 }
 
 export default WidgetHeaderMenu;
-export type {WidgetHeaderMenuProps};
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 6cc2dc7cc47d..7458a74674fe 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -3896,9 +3896,11 @@ const staticStyles = (theme: ThemeColors) =>
 
         widgetHeaderMenuButtonWrapper: {
             // The 40px ghost button overflows the header instead of growing it: these negative margins shrink its
-            // vertical footprint to the title line-height so every card header keeps the same height.
+            // vertical footprint to the title line-height so every card header keeps the same height. The matching
+            // negative right margin keeps the icon's spacing to the card's right edge equal to its top spacing.
             marginTop: (variables.widgetHeaderTitleLineHeight - variables.componentSizeNormal) / 2,
             marginBottom: (variables.widgetHeaderTitleLineHeight - variables.componentSizeNormal) / 2,
+            marginRight: (variables.widgetHeaderTitleLineHeight - variables.componentSizeNormal) / 2,
         },
 
         widgetItemSubtitle: {
diff --git a/tests/unit/components/ReceiptPreviewPositionTest.ts b/tests/unit/components/ReceiptPreviewPositionTest.ts
index 4d99e295a1c4..dba8a39f89ab 100644
--- a/tests/unit/components/ReceiptPreviewPositionTest.ts
+++ b/tests/unit/components/ReceiptPreviewPositionTest.ts
@@ -64,7 +64,7 @@ describe('getAnchoredPreviewPosition', () => {
 
     it('keeps a measured preview on-screen for a row near the bottom by growing upward', () => {
         const previewHeight = 600;
-        const anchor = {top: WINDOW_HEIGHT - 80, left: 120, width: 68, height: 64};
+        const anchor = {top: WINDOW_HEIGHT - 200, left: 120, width: 68, height: 64};
 
         const top = getAnchoredPreviewPosition(anchor, WINDOW_WIDTH, WINDOW_HEIGHT, previewHeight)?.top ?? 0;
         const thumbnailBottom = anchor.top + anchor.height;
@@ -74,6 +74,17 @@ describe('getAnchoredPreviewPosition', () => {
         expect(top + previewHeight).toBeLessThanOrEqual(WINDOW_HEIGHT);
     });
 
+    it('caps the preview to the viewport when the hovered row is scrolled partially off the bottom', () => {
+        const previewHeight = 200;
+        // measureInWindow can report a bottom below the viewport for a row clipped by a scroll container.
+        const anchor = {top: WINDOW_HEIGHT - 20, left: 120, width: 68, height: 64};
+
+        const top = getAnchoredPreviewPosition(anchor, WINDOW_WIDTH, WINDOW_HEIGHT, previewHeight)?.top ?? 0;
+
+        expect(top).toBe(WINDOW_HEIGHT - RECEIPT_PREVIEW_EDGE_MARGIN - previewHeight);
+        expect(top + previewHeight).toBeLessThanOrEqual(WINDOW_HEIGHT);
+    });
+
     it('pins a preview taller than the space above the row to the top margin', () => {
         const previewHeight = WINDOW_HEIGHT + 200;
         const anchor = {top: 500, left: 120, width: 68, height: 64};

From 5e4449da8bd9fdd88fe9d902e63a256d48ec4126 Mon Sep 17 00:00:00 2001
From: Adam Grzybowski <adam.grzybowski@swmansion.com>
Date: Thu, 2 Jul 2026 13:36:01 +0200
Subject: [PATCH 3/6] Move AnchorPosition type to common TransactionItemRow
 types

---
 src/components/TransactionItemRow/DataCells/ReceiptCell.tsx  | 3 ++-
 .../ReceiptPreview/getAnchoredPreviewPosition.ts             | 4 +---
 src/components/TransactionItemRow/ReceiptPreview/index.tsx   | 2 +-
 src/components/TransactionItemRow/types.ts                   | 5 ++++-
 4 files changed, 8 insertions(+), 6 deletions(-)

diff --git a/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx b/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx
index 30dfd4516665..5569464f3ab6 100644
--- a/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx
+++ b/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx
@@ -4,6 +4,7 @@ import {View} from 'react-native';
 import type {ViewStyle} from 'react-native';
 import ReceiptImage from '@components/ReceiptImage';
 import ReceiptPreview from '@components/TransactionItemRow/ReceiptPreview';
+import type {AnchorPosition} from '@components/TransactionItemRow/types';
 import useHover from '@hooks/useHover';
 import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
 import useStyleUtils from '@hooks/useStyleUtils';
@@ -43,7 +44,7 @@ function ReceiptCell({
     const cellRef = useRef<View>(null);
     // The preview is a document.body portal, so it needs the hovered cell's window position to
     // anchor itself beside the row instead of sitting fixed in the upper-left corner.
-    const [previewAnchor, setPreviewAnchor] = useState<{top: number; left: number; width: number; height: number}>();
+    const [previewAnchor, setPreviewAnchor] = useState<AnchorPosition>();
 
     const handleMouseEnter = () => {
         if (!shouldMountPreview) {
diff --git a/src/components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition.ts b/src/components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition.ts
index 5a074baa653f..3929ef489977 100644
--- a/src/components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition.ts
+++ b/src/components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition.ts
@@ -1,3 +1,4 @@
+import type {AnchorPosition} from '@components/TransactionItemRow/types';
 import variables from '@styles/variables';
 
 /** Width of the preview, shared with `styles.receiptPreview.width` via `variables.receiptPreviewWidth`. */
@@ -12,8 +13,6 @@ const RECEIPT_PREVIEW_EDGE_MARGIN = 24;
 /** Minimum slice of the preview kept on-screen so a row near the bottom doesn't push it fully out of view. */
 const RECEIPT_PREVIEW_MIN_VISIBLE_HEIGHT = 160;
 
-type AnchorPosition = {top: number; left: number; width: number; height: number};
-
 /**
  * Anchors the preview's bottom-left corner to the right of the hovered thumbnail, so the preview grows upward
  * from the row. If there isn't room on the right, it flips to the left of the thumbnail. The top is clamped to a
@@ -49,4 +48,3 @@ function getAnchoredPreviewPosition(anchorPosition: AnchorPosition | undefined,
 
 export default getAnchoredPreviewPosition;
 export {RECEIPT_PREVIEW_WIDTH, RECEIPT_PREVIEW_GAP, RECEIPT_PREVIEW_EDGE_MARGIN, RECEIPT_PREVIEW_MIN_VISIBLE_HEIGHT};
-export type {AnchorPosition};
diff --git a/src/components/TransactionItemRow/ReceiptPreview/index.tsx b/src/components/TransactionItemRow/ReceiptPreview/index.tsx
index 8a570f11a035..86ee34729fbc 100644
--- a/src/components/TransactionItemRow/ReceiptPreview/index.tsx
+++ b/src/components/TransactionItemRow/ReceiptPreview/index.tsx
@@ -7,6 +7,7 @@ import ActivityIndicator from '@components/ActivityIndicator';
 import DistanceEReceipt from '@components/DistanceEReceipt';
 import EReceiptWithSizeCalculation from '@components/EReceiptWithSizeCalculation';
 import type {ImageOnLoadEvent} from '@components/Image/types';
+import type {AnchorPosition} from '@components/TransactionItemRow/types';
 import useDebouncedState from '@hooks/useDebouncedState';
 import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP';
 import useThemeStyles from '@hooks/useThemeStyles';
@@ -19,7 +20,6 @@ import CONST from '@src/CONST';
 import type {Transaction} from '@src/types/onyx';
 import type {ReceiptSource} from '@src/types/onyx/Transaction';
 import getAnchoredPreviewPosition from './getAnchoredPreviewPosition';
-import type {AnchorPosition} from './getAnchoredPreviewPosition';
 
 type ReceiptPreviewProps = {
     /** Path to the image to be opened in the preview */
diff --git a/src/components/TransactionItemRow/types.ts b/src/components/TransactionItemRow/types.ts
index 74dfc5da4bcb..c02620229f6d 100644
--- a/src/components/TransactionItemRow/types.ts
+++ b/src/components/TransactionItemRow/types.ts
@@ -117,6 +117,9 @@ type TransactionItemRowProps = {
     canEditTag?: boolean;
 };
 
+/** Window position of the hovered cell used to anchor the receipt preview beside the row. */
+type AnchorPosition = {top: number; left: number; width: number; height: number};
+
 /**
  * Data computed by the dispatcher and consumed by the Narrow variant.
  */
@@ -145,4 +148,4 @@ type TransactionItemRowWideComputedData = Omit<TransactionItemRowNarrowComputedD
     isMarkAsDone: boolean;
 };
 
-export type {TransactionWithOptionalSearchFields, TransactionItemRowProps, TransactionItemRowNarrowComputedData, TransactionItemRowWideComputedData};
+export type {AnchorPosition, TransactionWithOptionalSearchFields, TransactionItemRowProps, TransactionItemRowNarrowComputedData, TransactionItemRowWideComputedData};

From 686cd8cc6bee2d71f7fc7354b3b41cab57e7b984 Mon Sep 17 00:00:00 2001
From: Adam Grzybowski <adam.grzybowski@swmansion.com>
Date: Fri, 3 Jul 2026 16:05:27 +0200
Subject: [PATCH 4/6] Run fmt

---
 .../TransactionItemRow/DataCells/ReceiptCell.tsx         | 1 +
 .../ReceiptPreview/getAnchoredPreviewPosition.ts         | 1 +
 .../TransactionItemRow/ReceiptPreview/index.tsx          | 4 +++-
 src/pages/home/RecentlyAddedSection/index.tsx            | 2 ++
 .../SpendOverTimeSection/SpendOverTimeSectionContent.tsx | 2 ++
 .../home/common/WidgetHeaderMenu/WidgetHeaderMenu.tsx    | 9 +++++++--
 6 files changed, 16 insertions(+), 3 deletions(-)

diff --git a/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx b/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx
index 3a267b520f42..ce2d030a2146 100644
--- a/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx
+++ b/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx
@@ -1,6 +1,7 @@
 import ReceiptImage from '@components/ReceiptImage';
 import ReceiptPreview from '@components/TransactionItemRow/ReceiptPreview';
 import type {AnchorPosition} from '@components/TransactionItemRow/types';
+
 import useHover from '@hooks/useHover';
 import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
 import useStyleUtils from '@hooks/useStyleUtils';
diff --git a/src/components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition.ts b/src/components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition.ts
index 3929ef489977..9eee1e74de7c 100644
--- a/src/components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition.ts
+++ b/src/components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition.ts
@@ -1,4 +1,5 @@
 import type {AnchorPosition} from '@components/TransactionItemRow/types';
+
 import variables from '@styles/variables';
 
 /** Width of the preview, shared with `styles.receiptPreview.width` via `variables.receiptPreviewWidth`. */
diff --git a/src/components/TransactionItemRow/ReceiptPreview/index.tsx b/src/components/TransactionItemRow/ReceiptPreview/index.tsx
index 3ffe2ec3410d..bf061fa44121 100644
--- a/src/components/TransactionItemRow/ReceiptPreview/index.tsx
+++ b/src/components/TransactionItemRow/ReceiptPreview/index.tsx
@@ -3,6 +3,7 @@ import DistanceEReceipt from '@components/DistanceEReceipt';
 import EReceiptWithSizeCalculation from '@components/EReceiptWithSizeCalculation';
 import type {ImageOnLoadEvent} from '@components/Image/types';
 import type {AnchorPosition} from '@components/TransactionItemRow/types';
+
 import useDebouncedState from '@hooks/useDebouncedState';
 import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP';
 import useThemeStyles from '@hooks/useThemeStyles';
@@ -17,7 +18,6 @@ import Image from '@src/components/Image';
 import CONST from '@src/CONST';
 import type {Transaction} from '@src/types/onyx';
 import type {ReceiptSource} from '@src/types/onyx/Transaction';
-import getAnchoredPreviewPosition from './getAnchoredPreviewPosition';
 
 import type {LayoutChangeEvent} from 'react-native';
 
@@ -26,6 +26,8 @@ import ReactDOM from 'react-dom';
 import {StyleSheet, View} from 'react-native';
 import Animated, {FadeIn, FadeOut} from 'react-native-reanimated';
 
+import getAnchoredPreviewPosition from './getAnchoredPreviewPosition';
+
 type ReceiptPreviewProps = {
     /** Path to the image to be opened in the preview */
     source: ReceiptSource;
diff --git a/src/pages/home/RecentlyAddedSection/index.tsx b/src/pages/home/RecentlyAddedSection/index.tsx
index 96898f040b85..cf392bc04c0f 100644
--- a/src/pages/home/RecentlyAddedSection/index.tsx
+++ b/src/pages/home/RecentlyAddedSection/index.tsx
@@ -19,7 +19,9 @@ import Navigation from '@libs/Navigation/Navigation';
 import {buildQueryStringFromFilterFormValues} from '@libs/SearchQueryUtils';
 import type {TransactionThreadNavigationDescriptor} from '@libs/TransactionThreadNavigationUtils';
 import {getReportIDToOpenForExpense} from '@libs/TransactionThreadNavigationUtils';
+
 import WidgetHeaderMenu from '@pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu';
+
 import variables from '@styles/variables';
 
 import CONST from '@src/CONST';
diff --git a/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx b/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx
index b976fad3fbea..66bc4b37af6b 100644
--- a/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx
+++ b/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx
@@ -10,7 +10,9 @@ import useTheme from '@hooks/useTheme';
 import useThemeStyles from '@hooks/useThemeStyles';
 
 import Navigation from '@libs/Navigation/Navigation';
+
 import WidgetHeaderMenu from '@pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu';
+
 import variables from '@styles/variables';
 
 import CONST from '@src/CONST';
diff --git a/src/pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu.tsx b/src/pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu.tsx
index 21f8c6c55e1c..7205a67b88b6 100644
--- a/src/pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu.tsx
+++ b/src/pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu.tsx
@@ -1,18 +1,23 @@
-import React, {useRef, useState} from 'react';
-import type {View} from 'react-native';
 import Icon from '@components/Icon';
 import type {PopoverMenuItem} from '@components/PopoverMenu';
 import PopoverMenu from '@components/PopoverMenu';
 import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
+
 import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
 import useLocalize from '@hooks/useLocalize';
 import usePopoverPosition from '@hooks/usePopoverPosition';
 import useTheme from '@hooks/useTheme';
 import useThemeStyles from '@hooks/useThemeStyles';
+
 import type {AnchorPosition} from '@styles/index';
 import variables from '@styles/variables';
+
 import CONST from '@src/CONST';
 
+import type {View} from 'react-native';
+
+import React, {useRef, useState} from 'react';
+
 const ANCHOR_ALIGNMENT = {
     horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
     vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,

From 5143e695cfd36f0b0619d88a430cb869dcaaaeca Mon Sep 17 00:00:00 2001
From: Adam Grzybowski <adam.grzybowski@swmansion.com>
Date: Mon, 6 Jul 2026 13:22:05 +0200
Subject: [PATCH 5/6] Build WidgetHeaderMenu on ThreeDotsMenu

Reuse ThreeDotsMenu (with shouldSelfPosition) so widget header menus get the
shared More tooltip and resize-aware popover positioning. Add opt-in props to
ThreeDotsMenu for the ghost-button look: iconWidth/iconHeight, iconHoverStyle,
shouldChangeFillOnOpen, and testID.
---
 src/components/ThreeDotsMenu/index.tsx        | 18 ++++-
 src/components/ThreeDotsMenu/types.ts         | 15 ++++
 .../WidgetHeaderMenu/WidgetHeaderMenu.tsx     | 77 ++++---------------
 3 files changed, 49 insertions(+), 61 deletions(-)

diff --git a/src/components/ThreeDotsMenu/index.tsx b/src/components/ThreeDotsMenu/index.tsx
index abf10edd2052..5245e35be926 100644
--- a/src/components/ThreeDotsMenu/index.tsx
+++ b/src/components/ThreeDotsMenu/index.tsx
@@ -34,6 +34,11 @@ function ThreeDotsMenu({
     icon,
     iconFill,
     iconStyles,
+    iconHoverStyle,
+    iconWidth,
+    iconHeight,
+    shouldChangeFillOnOpen = true,
+    testID,
     onIconPress = () => {},
     menuItems,
     anchorPosition,
@@ -140,6 +145,13 @@ function ThreeDotsMenu({
         });
     }, [windowWidth, windowHeight, shouldSelfPosition, getMenuPosition, isPopupMenuVisible]);
 
+    const getIconFill = () => {
+        if (!shouldChangeFillOnOpen) {
+            return iconFill ?? theme.icon;
+        }
+        return (iconFill ?? isPopupMenuVisible) ? theme.success : theme.icon;
+    };
+
     const TooltipToRender = shouldShowProductTrainingTooltip ? EducationalTooltip : Tooltip;
     const tooltipProps = shouldShowProductTrainingTooltip
         ? {
@@ -172,14 +184,18 @@ function ThreeDotsMenu({
                         }}
                         ref={buttonRef}
                         style={[styles.touchableButtonImage, styles.threeDotsMenuIconWidth, iconStyles]}
+                        hoverStyle={iconHoverStyle}
                         role={getButtonRole(isNested)}
                         isNested={isNested}
                         accessibilityLabel={translate(iconTooltip)}
                         sentryLabel={sentryLabel}
+                        testID={testID}
                     >
                         <Icon
                             src={icon ?? expensifyIcons.ThreeDots}
-                            fill={(iconFill ?? isPopupMenuVisible) ? theme.success : theme.icon}
+                            fill={getIconFill()}
+                            width={iconWidth}
+                            height={iconHeight}
                         />
                     </PressableWithoutFeedback>
                 </TooltipToRender>
diff --git a/src/components/ThreeDotsMenu/types.ts b/src/components/ThreeDotsMenu/types.ts
index b9df040f0b2c..92d8ce9e357e 100644
--- a/src/components/ThreeDotsMenu/types.ts
+++ b/src/components/ThreeDotsMenu/types.ts
@@ -18,9 +18,24 @@ type ThreeDotsMenuProps = WithSentryLabel & {
     /** Any additional styles to pass to the icon container. */
     iconStyles?: StyleProp<ViewStyle>;
 
+    /** Hover style applied to the trigger (e.g. a ghost-button background). */
+    iconHoverStyle?: StyleProp<ViewStyle>;
+
     /** The fill color to pass into the icon. */
     iconFill?: string;
 
+    /** The width of the trigger icon. Defaults to the standard icon size. */
+    iconWidth?: number;
+
+    /** The height of the trigger icon. Defaults to the standard icon size. */
+    iconHeight?: number;
+
+    /** Whether the trigger icon turns green while the menu is open. Defaults to true. */
+    shouldChangeFillOnOpen?: boolean;
+
+    /** Test ID for the trigger */
+    testID?: string;
+
     /** Function to call on icon press */
     onIconPress?: (() => void) | ((e?: GestureResponderEvent | KeyboardEvent | undefined) => void);
 
diff --git a/src/pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu.tsx b/src/pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu.tsx
index 7205a67b88b6..dd9836d90878 100644
--- a/src/pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu.tsx
+++ b/src/pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu.tsx
@@ -1,22 +1,13 @@
-import Icon from '@components/Icon';
 import type {PopoverMenuItem} from '@components/PopoverMenu';
-import PopoverMenu from '@components/PopoverMenu';
-import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
+import ThreeDotsMenu from '@components/ThreeDotsMenu';
 
-import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
-import useLocalize from '@hooks/useLocalize';
-import usePopoverPosition from '@hooks/usePopoverPosition';
-import useTheme from '@hooks/useTheme';
 import useThemeStyles from '@hooks/useThemeStyles';
 
-import type {AnchorPosition} from '@styles/index';
 import variables from '@styles/variables';
 
 import CONST from '@src/CONST';
 
-import type {View} from 'react-native';
-
-import React, {useRef, useState} from 'react';
+import React from 'react';
 
 const ANCHOR_ALIGNMENT = {
     horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
@@ -27,9 +18,6 @@ type WidgetHeaderMenuProps = {
     /** Items shown in the popover opened by the three-dots trigger */
     menuItems: PopoverMenuItem[];
 
-    /** Accessibility label for the trigger. Defaults to the shared "More" copy. */
-    accessibilityLabel?: string;
-
     /** Test ID for the trigger */
     testID?: string;
 
@@ -38,56 +26,25 @@ type WidgetHeaderMenuProps = {
 };
 
 /**
- * Standardized widget header action: a Medium Ghost three-dots icon button that opens a popover menu.
- * The 40px trigger overflows the card header rather than growing it (see `widgetHeaderMenuButtonWrapper`),
- * so every card header keeps the same height whether or not it has a menu.
+ * Widget header three-dots menu: a Medium Ghost trigger whose negative margins let it overflow the header
+ * rather than grow it, so every card header keeps the same height. Built on `ThreeDotsMenu`.
  */
-function WidgetHeaderMenu({menuItems, accessibilityLabel, testID, sentryLabel}: WidgetHeaderMenuProps) {
+function WidgetHeaderMenu({menuItems, testID, sentryLabel}: WidgetHeaderMenuProps) {
     const styles = useThemeStyles();
-    const theme = useTheme();
-    const {translate} = useLocalize();
-    const icons = useMemoizedLazyExpensifyIcons(['ThreeDots']);
-    const {calculatePopoverPosition} = usePopoverPosition();
-    const buttonRef = useRef<View>(null);
-    const [isVisible, setIsVisible] = useState(false);
-    const [position, setPosition] = useState<AnchorPosition>();
-
-    const openMenu = () => {
-        calculatePopoverPosition(buttonRef, ANCHOR_ALIGNMENT).then((nextPosition) => {
-            setPosition(nextPosition);
-            setIsVisible(true);
-        });
-    };
 
     return (
-        <>
-            <PressableWithFeedback
-                ref={buttonRef}
-                testID={testID}
-                accessibilityLabel={accessibilityLabel ?? translate('common.more')}
-                sentryLabel={sentryLabel}
-                onPress={openMenu}
-                wrapperStyle={styles.widgetHeaderMenuButtonWrapper}
-                style={styles.widgetHeaderMenuButton}
-                hoverStyle={styles.widgetHeaderMenuButtonHovered}
-            >
-                <Icon
-                    src={icons.ThreeDots}
-                    fill={theme.icon}
-                    width={variables.iconSizeSmall}
-                    height={variables.iconSizeSmall}
-                />
-            </PressableWithFeedback>
-            <PopoverMenu
-                isVisible={isVisible}
-                anchorRef={buttonRef}
-                anchorPosition={position ?? {horizontal: 0, vertical: 0}}
-                anchorAlignment={ANCHOR_ALIGNMENT}
-                onClose={() => setIsVisible(false)}
-                onItemSelected={() => setIsVisible(false)}
-                menuItems={menuItems}
-            />
-        </>
+        <ThreeDotsMenu
+            menuItems={menuItems}
+            shouldSelfPosition
+            anchorAlignment={ANCHOR_ALIGNMENT}
+            iconStyles={[styles.widgetHeaderMenuButton, styles.widgetHeaderMenuButtonWrapper]}
+            iconHoverStyle={styles.widgetHeaderMenuButtonHovered}
+            iconWidth={variables.iconSizeSmall}
+            iconHeight={variables.iconSizeSmall}
+            shouldChangeFillOnOpen={false}
+            testID={testID}
+            sentryLabel={sentryLabel}
+        />
     );
 }
 

From 7a3557c2c29b6733197b201aa4aa87ad17c65153 Mon Sep 17 00:00:00 2001
From: Adam Grzybowski <adam.grzybowski@swmansion.com>
Date: Mon, 6 Jul 2026 14:19:50 +0200
Subject: [PATCH 6/6] Fix RecentlyAddedSection overflow menu test hang

Stabilize the usePopoverPosition mock reference so ThreeDotsMenu's
layout effect doesn't loop infinitely once the popover opens.
---
 tests/ui/RecentlyAddedSectionTest.tsx | 9 ++++-----
 1 file changed, 4 insertions(+), 5 deletions(-)

diff --git a/tests/ui/RecentlyAddedSectionTest.tsx b/tests/ui/RecentlyAddedSectionTest.tsx
index 3f6ec178172a..97b9575f00f2 100644
--- a/tests/ui/RecentlyAddedSectionTest.tsx
+++ b/tests/ui/RecentlyAddedSectionTest.tsx
@@ -39,11 +39,10 @@ jest.mock('@react-navigation/native', () => {
     };
 });
 
-jest.mock('@hooks/usePopoverPosition', () =>
-    jest.fn(() => ({
-        calculatePopoverPosition: jest.fn(() => Promise.resolve({horizontal: 0, vertical: 0, width: 0, height: 0})),
-    })),
-);
+jest.mock('@hooks/usePopoverPosition', () => {
+    const calculatePopoverPosition = jest.fn(() => Promise.resolve({horizontal: 0, vertical: 0, width: 0, height: 0}));
+    return jest.fn(() => ({calculatePopoverPosition}));
+});
 
 jest.mock('@hooks/useLocalize', () =>
     jest.fn(() => ({
