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}
>
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;
+ /** Hover style applied to the trigger (e.g. a ghost-button background). */
+ iconHoverStyle?: StyleProp;
+
/** 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/components/TransactionItemRow/DataCells/ReceiptCell.tsx b/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx
index ec1f95fe1bca..ce2d030a2146 100644
--- a/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx
+++ b/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx
@@ -1,5 +1,6 @@
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';
@@ -18,7 +19,7 @@ import type {Transaction} from '@src/types/onyx';
import type {ViewStyle} from 'react-native';
import {Str} from 'expensify-common';
-import React, {useState} from 'react';
+import React, {useRef, useState} from 'react';
import {View} from 'react-native';
function ReceiptCell({
@@ -46,11 +47,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(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();
const handleMouseEnter = () => {
if (!shouldMountPreview) {
setShouldMountPreview(true);
}
+ cellRef.current?.measureInWindow((left, top, width, height) => setPreviewAnchor({left, top, width, height}));
bind.onMouseEnter();
};
@@ -69,6 +75,7 @@ function ReceiptCell({
return (
)}
diff --git a/src/components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition.ts b/src/components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition.ts
new file mode 100644
index 000000000000..9eee1e74de7c
--- /dev/null
+++ b/src/components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition.ts
@@ -0,0 +1,51 @@
+import type {AnchorPosition} from '@components/TransactionItemRow/types';
+
+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;
+
+/**
+ * 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). 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};
+}
+
+export default getAnchoredPreviewPosition;
+export {RECEIPT_PREVIEW_WIDTH, RECEIPT_PREVIEW_GAP, RECEIPT_PREVIEW_EDGE_MARGIN, RECEIPT_PREVIEW_MIN_VISIBLE_HEIGHT};
diff --git a/src/components/TransactionItemRow/ReceiptPreview/index.tsx b/src/components/TransactionItemRow/ReceiptPreview/index.tsx
index cd92ce192175..bf061fa44121 100644
--- a/src/components/TransactionItemRow/ReceiptPreview/index.tsx
+++ b/src/components/TransactionItemRow/ReceiptPreview/index.tsx
@@ -2,6 +2,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';
@@ -25,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;
@@ -37,9 +40,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();
@@ -49,8 +55,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) {
@@ -116,11 +124,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(
setPreviewHeight(e.nativeEvent.layout.height)}
+ style={[styles.receiptPreview, styles.flexColumn, styles.alignItemsCenter, styles.justifyContentStart, anchoredPositionStyle]}
>
{shouldShowImage ? (
diff --git a/src/components/TransactionItemRow/types.ts b/src/components/TransactionItemRow/types.ts
index 7b0b96d5c4a4..80f9a8757161 100644
--- a/src/components/TransactionItemRow/types.ts
+++ b/src/components/TransactionItemRow/types.ts
@@ -120,6 +120,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.
*/
@@ -148,4 +151,4 @@ type TransactionItemRowWideComputedData = Omit();
- const overflowMenuButtonRef = useRef(null);
const hasExpenses = transactions.length > 0;
@@ -108,13 +93,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({
@@ -130,38 +108,18 @@ function RecentlyAddedSection() {
}
const overflowMenu = hasExpenses ? (
- <>
-
-
-
- setIsOverflowMenuVisible(false)}
- onItemSelected={() => setIsOverflowMenuVisible(false)}
- 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 81580fcd41e4..66bc4b37af6b 100644
--- a/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx
+++ b/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx
@@ -1,5 +1,4 @@
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';
@@ -12,6 +11,8 @@ 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';
@@ -41,15 +42,17 @@ function SpendOverTimeSectionContent() {
title={translate('search.spendOverTime')}
titleRightContent={
state === SPEND_OVER_TIME_STATE.READY ? (
-