Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/components/ThreeDotsMenu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ function ThreeDotsMenu({
icon,
iconFill,
iconStyles,
iconHoverStyle,
iconWidth,
iconHeight,
shouldChangeFillOnOpen = true,
testID,
onIconPress = () => {},
menuItems,
anchorPosition,
Expand Down Expand Up @@ -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
? {
Expand Down Expand Up @@ -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>
Expand Down
15 changes: 15 additions & 0 deletions src/components/ThreeDotsMenu/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
10 changes: 9 additions & 1 deletion src/components/TransactionItemRow/DataCells/ReceiptCell.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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({
Expand Down Expand Up @@ -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<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<AnchorPosition>();

const handleMouseEnter = () => {
if (!shouldMountPreview) {
setShouldMountPreview(true);
}
cellRef.current?.measureInWindow((left, top, width, height) => setPreviewAnchor({left, top, width, height}));
bind.onMouseEnter();
};

Expand All @@ -69,6 +75,7 @@ function ReceiptCell({

return (
<View
ref={cellRef}
style={[
StyleUtils.getWidthAndHeightStyle(isLargeScreenWidth ? variables.w28 : variables.h36, isLargeScreenWidth ? variables.h32 : variables.w40),
StyleUtils.getBorderRadiusStyle(variables.componentBorderRadiusSmall),
Expand Down Expand Up @@ -103,6 +110,7 @@ function ReceiptCell({
hovered={hovered && shouldShowPreview}
isEReceipt={!!isEReceipt}
transactionItem={transactionItem}
anchorPosition={previewAnchor}
/>
)}
</View>
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
adamgrzybowski marked this conversation as resolved.

return {left, top};
}

export default getAnchoredPreviewPosition;
export {RECEIPT_PREVIEW_WIDTH, RECEIPT_PREVIEW_GAP, RECEIPT_PREVIEW_EDGE_MARGIN, RECEIPT_PREVIEW_MIN_VISIBLE_HEIGHT};
17 changes: 14 additions & 3 deletions src/components/TransactionItemRow/ReceiptPreview/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -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();
Expand All @@ -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) {
Expand Down Expand Up @@ -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(
<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]}>
Expand Down
5 changes: 4 additions & 1 deletion src/components/TransactionItemRow/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -148,4 +151,4 @@ type TransactionItemRowWideComputedData = Omit<TransactionItemRowNarrowComputedD
isMarkAsDone: boolean;
};

export type {TransactionWithOptionalSearchFields, TransactionItemRowProps, TransactionItemRowNarrowComputedData, TransactionItemRowWideComputedData};
export type {AnchorPosition, TransactionWithOptionalSearchFields, TransactionItemRowProps, TransactionItemRowNarrowComputedData, TransactionItemRowWideComputedData};
74 changes: 16 additions & 58 deletions src/pages/home/RecentlyAddedSection/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
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';
Expand All @@ -10,7 +8,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';
Expand All @@ -23,15 +20,16 @@ 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/WidgetHeaderMenu';

import variables from '@styles/variables';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';

import {useIsFocused} from '@react-navigation/native';
import React, {useRef, useState} from 'react';
import React from 'react';
import {View} from 'react-native';

import type {RecentlyAddedExpense} from './useRecentlyAddedData';
Expand All @@ -42,15 +40,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();
Expand All @@ -61,16 +50,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;

Expand Down Expand Up @@ -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({
Expand All @@ -130,38 +108,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 (
Expand Down
Loading
Loading