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 ? ( -