From 45a5ddba2b4722f4a2062b53afb37c611d821e7e Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Thu, 1 Oct 2026 21:03:31 +0530 Subject: [PATCH 01/31] Add page navigation to multi-page PDF receipts On desktop the receipt preview already renders the full PDF over the page 1 thumbnail for hover zoom. The page count badge becomes a pill with previous/next buttons that scroll that PDF, once it has loaded. --- src/CONST/index.ts | 5 + .../ConfirmationReceiptThumbnail.tsx | 2 +- .../ReceiptHoverZoom/useReceiptHoverZoom.ts | 8 ++ .../MoneyRequestReceiptView.tsx | 30 +++++- .../ReceiptPDFOverlay/index.tsx | 4 +- .../ReceiptPDFOverlay/types.ts | 6 ++ .../ReportActionItem/ReceiptPageNavigator.tsx | 80 +++++++++++++++ .../ReportActionItemImage.tsx | 16 ++- src/languages/de.ts | 2 +- src/languages/el.ts | 2 +- src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 2 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 2 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 2 +- src/styles/index.ts | 13 +++ .../MoneyRequestReceiptViewTest.tsx | 99 +++++++++++++++++-- 21 files changed, 260 insertions(+), 25 deletions(-) create mode 100644 src/components/ReportActionItem/ReceiptPageNavigator.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ab33238b5ed7..c5fb8ff0c0f9 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1609,6 +1609,9 @@ const CONST = { // Remote PDFs are rendered at this multiple of their on-screen size so the magnified // view shows real pixels instead of an upscaled low-resolution raster. HOVER_ZOOM_SCALE: 2.5, + + // Elements with this data attribute pause the hover zoom while the pointer is over them + HOVER_ZOOM_EXCLUDED_ELEMENT: 'receipt-hover-zoom-excluded', }, RECEIPT_PREVIEW_TOP_BOTTOM_MARGIN: 120, REPORT: { @@ -9361,6 +9364,8 @@ const CONST = { }, RECEIPT: { IMAGE: 'Receipt-Image', + PREVIOUS_PAGE_BUTTON: 'Receipt-PreviousPageButton', + NEXT_PAGE_BUTTON: 'Receipt-NextPageButton', ENLARGE_BUTTON: 'Receipt-EnlargeButton', ADD_ATTACHMENT_BUTTON: 'Receipt-AddAttachmentButton', }, diff --git a/src/components/MoneyRequestConfirmationListFooter/ConfirmationReceiptThumbnail.tsx b/src/components/MoneyRequestConfirmationListFooter/ConfirmationReceiptThumbnail.tsx index e81a4cf2760d..a1ce102112d8 100644 --- a/src/components/MoneyRequestConfirmationListFooter/ConfirmationReceiptThumbnail.tsx +++ b/src/components/MoneyRequestConfirmationListFooter/ConfirmationReceiptThumbnail.tsx @@ -199,7 +199,7 @@ function ConfirmationReceiptThumbnail({ ))} {shouldShowReceiptPageCount && ( )} diff --git a/src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts b/src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts index bd8da21a3e3c..d81ba370a467 100644 --- a/src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts +++ b/src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts @@ -1,5 +1,7 @@ import {hasHoverSupport} from '@libs/DeviceCapabilities'; +import CONST from '@src/CONST'; + import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; @@ -56,6 +58,12 @@ function useReceiptHoverZoom({isEnabled, scale, hoverContainerRef}: UseReceiptHo setIsHovering(false); }; const updateZoomOrigin = (event: PointerEvent) => { + if (event.target instanceof Element && event.target.closest(`[data-${CONST.RECEIPT.HOVER_ZOOM_EXCLUDED_ELEMENT}]`)) { + if (hovering) { + endZoom(); + } + return; + } if (!bounds) { bounds = target.getBoundingClientRect(); } diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx index c4a1e95bc1c5..db319bc9cd2b 100644 --- a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx +++ b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx @@ -104,6 +104,7 @@ import {View} from 'react-native'; import HoveredDistanceEReceipt from './HoveredDistanceEReceipt'; import {isElementHovered, resetButtonHoverState} from './receiptHoverUtils'; +import ReceiptPageNavigator from './ReceiptPageNavigator'; import ReportActionItemImage from './ReportActionItemImage'; type MoneyRequestReceiptViewProps = { @@ -241,6 +242,9 @@ function MoneyRequestReceiptView({ const displayedReceiptSource = ReceiptStorage.resolve(transaction?.receipt?.localSource) ?? transaction?.receipt?.source; const prevDisplayedReceiptSource = usePrevious(displayedReceiptSource); + const [selectedReceiptPage, setSelectedReceiptPage] = useState<{filename?: string; page: number}>({page: 1}); + const [loadedReceiptPDFFilename, setLoadedReceiptPDFFilename] = useState(); + useEffect(() => { if (!displayedReceiptSource || prevDisplayedReceiptSource === displayedReceiptSource) { return; @@ -609,6 +613,19 @@ function MoneyRequestReceiptView({ // Show the count badge only after a multi-page PDF receipt loads. const shouldShowReceiptPageCount = receiptPageCount > 1 && Str.isPDF(receiptURIs?.filename ?? '') && !isLoading && !(isMapDistanceRequest && isPendingReceiptRegeneration); + + // Pages can only be flipped where ReportActionItemImage renders the real PDF over the thumbnail (hover-capable + // devices). Elsewhere only a page 1 thumbnail exists, so the static count badge stays. + const canFlipReceiptPages = shouldShowReceiptPageCount && canZoomReceipt && deviceHasHoverSupport && !isMapDistanceRequest; + + // Page and load state are keyed to the filename, not the source URL: the URL changes from local to remote + // mid-scan without the PDF being reloaded, while a replaced receipt gets a new filename and starts over. + const receiptFilename = receiptURIs?.filename; + const receiptPage = selectedReceiptPage.filename === receiptFilename ? selectedReceiptPage.page : 1; + + // Large PDFs take a while to load, and until then only the page 1 thumbnail is visible + const shouldShowReceiptPageNavigator = canFlipReceiptPages && !!receiptFilename && loadedReceiptPDFFilename === receiptFilename; + const receiptPendingAction = isDistanceRequest ? getPendingFieldAction('waypoints') : getPendingFieldAction('receipt'); const isReceiptOfflinePending = isOffline && !!receiptPendingAction; const receiptAuditMessagesRow = ( @@ -752,6 +769,8 @@ function MoneyRequestReceiptView({ shouldUseThumbnailImage={!fillSpace} shouldUseFullHeight={fillSpace} canZoomReceipt={canZoomReceipt} + pdfPage={canFlipReceiptPages ? receiptPage : undefined} + onPDFLoadSuccess={() => setLoadedReceiptPDFFilename(receiptFilename)} thumbnail={receiptURIs?.thumbnail} fileExtension={receiptURIs?.fileExtension} isThumbnail={receiptURIs?.isThumbnail} @@ -771,9 +790,16 @@ function MoneyRequestReceiptView({ )} - {shouldShowReceiptPageCount && ( + {shouldShowReceiptPageNavigator && ( + setSelectedReceiptPage({filename: receiptFilename, page})} + /> + )} + {shouldShowReceiptPageCount && !shouldShowReceiptPageNavigator && ( )} diff --git a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx index 6e64cd2d4184..b699391303aa 100644 --- a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx +++ b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx @@ -27,7 +27,7 @@ const oversampleContainerStyle = { transformOrigin: 'top left', }; -function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure}: ReceiptPDFOverlayProps) { +function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure, onLoadSuccess, page}: ReceiptPDFOverlayProps) { const styles = useThemeStyles(); const [session] = useOnyx(ONYXKEYS.SESSION); const [maxCanvasArea] = useOnyx(ONYXKEYS.MAX_CANVAS_AREA); @@ -80,6 +80,8 @@ function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure contentContainerStyle={styles.bgTransparent} shouldShowErrorComponent={false} LoadingComponent={null} + page={page} + onLoadSuccess={onLoadSuccess} onLoadError={() => { setFailedURL(fileURL); onLoadFailure?.(); diff --git a/src/components/ReportActionItem/ReceiptPDFOverlay/types.ts b/src/components/ReportActionItem/ReceiptPDFOverlay/types.ts index 4e88be683bc9..7b17ddc3b840 100644 --- a/src/components/ReportActionItem/ReceiptPDFOverlay/types.ts +++ b/src/components/ReportActionItem/ReceiptPDFOverlay/types.ts @@ -7,6 +7,12 @@ type ReceiptPDFOverlayProps = { /** Called when the PDF fails to load so the caller can fall back to the thumbnail */ onLoadFailure?: () => void; + + /** Called once the PDF's pages are ready to display */ + onLoadSuccess?: () => void; + + /** 1-indexed page to show at the top of the overlay */ + page?: number; }; export default ReceiptPDFOverlayProps; diff --git a/src/components/ReportActionItem/ReceiptPageNavigator.tsx b/src/components/ReportActionItem/ReceiptPageNavigator.tsx new file mode 100644 index 000000000000..1d918912ef18 --- /dev/null +++ b/src/components/ReportActionItem/ReceiptPageNavigator.tsx @@ -0,0 +1,80 @@ +import Icon from '@components/Icon'; +import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import Text from '@components/Text'; +import Tooltip from '@components/Tooltip'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; + +import React from 'react'; +import {View} from 'react-native'; + +type ReceiptPageNavigatorProps = { + /** The 1-indexed page currently shown */ + page: number; + + /** Total number of pages in the receipt */ + pageCount: number; + + /** Called with the page to show when a navigation button is pressed */ + onChangePage: (page: number) => void; +}; + +/** "Page X of N" pill with previous/next buttons for flipping through a multi-page PDF receipt. */ +function ReceiptPageNavigator({page, pageCount, onChangePage}: ReceiptPageNavigatorProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'BackArrow']); + const isFirstPage = page <= 1; + const isLastPage = page >= pageCount; + + return ( + + + onChangePage(page - 1)} + accessibilityLabel={translate('common.previous')} + role={CONST.ROLE.BUTTON} + sentryLabel={CONST.SENTRY_LABEL.RECEIPT.PREVIOUS_PAGE_BUTTON} + > + + + + {translate('receipt.pageCount', {page, pageCount})} + + onChangePage(page + 1)} + accessibilityLabel={translate('common.next')} + role={CONST.ROLE.BUTTON} + sentryLabel={CONST.SENTRY_LABEL.RECEIPT.NEXT_PAGE_BUTTON} + > + + + + + ); +} + +export default ReceiptPageNavigator; diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx index 97bf88d70d18..20a9693c3564 100644 --- a/src/components/ReportActionItem/ReportActionItemImage.tsx +++ b/src/components/ReportActionItem/ReportActionItemImage.tsx @@ -98,6 +98,12 @@ type ReportActionItemImageProps = { /** Whether the receipt can be hover-zoomed. When true, remote PDFs render the actual PDF on top of the thumbnail so magnification stays sharp (web only). */ canZoomReceipt?: boolean; + /** 1-indexed page of a multi-page PDF to show in the high-res overlay */ + pdfPage?: number; + + /** Called once the high-res PDF overlay has loaded its pages */ + onPDFLoadSuccess?: () => void; + /** Callback to be called when the image loads */ onLoad?: (event?: {nativeEvent: {width: number; height: number}}) => void; @@ -130,6 +136,8 @@ function ReportActionItemImage({ report: reportProp, shouldUseThumbnailImage, canZoomReceipt = false, + pdfPage, + onPDFLoadSuccess, onLoad, onLoadFailure, }: ReportActionItemImageProps) { @@ -225,9 +233,10 @@ function ReportActionItemImage({ // view stays sharp. The thumbnail stays underneath as an instant preview and as a fallback if the PDF fails. // Map/route distance requests are excluded: their hover overlay is a DistanceEReceipt card, not the PDF. // isMapBasedDistanceRequest covers map, GPS, and manual-typed transactions that still carry waypoints. + // Page navigation also needs the real PDF for local files, whose thumbnail only shows page 1. const pdfSourceURL = typeof originalImageSource === 'string' && !!originalImageSource ? originalImageSource : undefined; - const isRemotePDF = !!isPDF && !effectiveIsLocalFile && !isEReceipt && !isMapBasedDistanceRequest(transaction) && !!pdfSourceURL; - const shouldOverlayHighResPDF = canZoomReceipt && isRemotePDF && hasHoverSupport(); + const isOverlayablePDF = !!isPDF && !isEReceipt && !isMapBasedDistanceRequest(transaction) && !!pdfSourceURL; + const shouldOverlayHighResPDF = canZoomReceipt && isOverlayablePDF && hasHoverSupport() && (!effectiveIsLocalFile || pdfPage !== undefined); const renderReceiptContent = (receiptImage: React.ReactNode) => shouldOverlayHighResPDF ? ( @@ -239,7 +248,10 @@ function ReportActionItemImage({ > diff --git a/src/languages/de.ts b/src/languages/de.ts index 2f3d34504b5b..a109c4d2bd77 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1314,7 +1314,7 @@ const translations: TranslationDeepObject = { phrase1: 'Beleg hinzufügen', phrase2: 'oder ziehe eine hierher und lege sie ab', }, - pageCount: ({pageCount}: {pageCount: number}) => `Seite 1 von ${pageCount}`, + pageCount: ({page, pageCount}: {page: number; pageCount: number}) => `Seite ${page} von ${pageCount}`, }, quickAction: { scanReceipt: 'Beleg scannen', diff --git a/src/languages/el.ts b/src/languages/el.ts index 238f89657a23..e73cb29e857d 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -1359,7 +1359,7 @@ const translations: TranslationDeepObject = { phrase1: 'Προσθήκη απόδειξης', phrase2: 'ή σύρετε και αποθέστε ένα εδώ', }, - pageCount: ({pageCount}: {pageCount: number}) => `Σελίδα 1 από ${pageCount}`, + pageCount: ({page, pageCount}: {page: number; pageCount: number}) => `Σελίδα ${page} από ${pageCount}`, }, quickAction: { scanReceipt: 'Σαρώστε απόδειξη', diff --git a/src/languages/en.ts b/src/languages/en.ts index cb890829c5c9..ff6e1b7abf05 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1409,7 +1409,7 @@ const translations = { addAdditionalReceipt: 'Add additional receipt', scanFailed: "The receipt couldn't be scanned, as it's missing a merchant, date, or amount.", crop: 'Crop', - pageCount: ({pageCount}: {pageCount: number}) => `Page 1 of ${pageCount}`, + pageCount: ({page, pageCount}: {page: number; pageCount: number}) => `Page ${page} of ${pageCount}`, addAReceipt: { phrase1: 'Add a receipt', phrase2: 'or drag and drop one here', diff --git a/src/languages/es.ts b/src/languages/es.ts index 642c1014be4a..e38a40b357d3 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1304,7 +1304,7 @@ const translations: TranslationDeepObject = { addAdditionalReceipt: 'Añadir recibo adicional', scanFailed: 'El recibo no pudo ser escaneado, ya que falta el comerciante, la fecha o el importe.', crop: 'Recortar', - pageCount: ({pageCount}: {pageCount: number}) => `Página 1 de ${pageCount}`, + pageCount: ({page, pageCount}: {page: number; pageCount: number}) => `Página ${page} de ${pageCount}`, addAReceipt: { phrase1: 'Añade un recibo', phrase2: 'o arrastra y suelta uno aquí', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index af474c05af12..d35062d58298 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1319,7 +1319,7 @@ const translations: TranslationDeepObject = { phrase1: 'Ajouter un reçu', phrase2: 'ou faites-en glisser un ici', }, - pageCount: ({pageCount}: {pageCount: number}) => `Page 1 sur ${pageCount}`, + pageCount: ({page, pageCount}: {page: number; pageCount: number}) => `Page ${page} sur ${pageCount}`, }, quickAction: { scanReceipt: 'Scanner le reçu', diff --git a/src/languages/it.ts b/src/languages/it.ts index b2f3d995a997..95786d5f9ebc 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1311,7 +1311,7 @@ const translations: TranslationDeepObject = { phrase1: 'Aggiungi una ricevuta', phrase2: 'o trascinalo qui', }, - pageCount: ({pageCount}: {pageCount: number}) => `Pagina 1 di ${pageCount}`, + pageCount: ({page, pageCount}: {page: number; pageCount: number}) => `Pagina ${page} di ${pageCount}`, }, quickAction: { scanReceipt: 'Scansiona ricevuta', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 390da1996527..af8b5c898a3c 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1296,7 +1296,7 @@ const translations: TranslationDeepObject = { phrase1: '領収書を追加', phrase2: 'または、ここにファイルをドラッグ&ドロップしてください', }, - pageCount: ({pageCount}: {pageCount: number}) => `${pageCount} ページ中 1 ページ`, + pageCount: ({page, pageCount}: {page: number; pageCount: number}) => `${pageCount} ページ中 ${page} ページ目`, }, quickAction: { scanReceipt: 'レシートをスキャン', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 55be6dafc89d..dc406cb65506 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1312,7 +1312,7 @@ const translations: TranslationDeepObject = { phrase1: 'Voeg een bon toe', phrase2: 'of sleep ze hier naartoe', }, - pageCount: ({pageCount}: {pageCount: number}) => `Pagina 1 van ${pageCount}`, + pageCount: ({page, pageCount}: {page: number; pageCount: number}) => `Pagina ${page} van ${pageCount}`, }, quickAction: { scanReceipt: 'Bon scannen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 5252fad2cb51..c37154a1446e 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1345,7 +1345,7 @@ const translations: TranslationDeepObject = { phrase1: 'Dodaj paragon', phrase2: 'lub przeciągnij i upuść tutaj', }, - pageCount: ({pageCount}: {pageCount: number}) => `Strona 1 z ${pageCount}`, + pageCount: ({page, pageCount}: {page: number; pageCount: number}) => `Strona ${page} z ${pageCount}`, }, quickAction: { scanReceipt: 'Zeskanuj paragon', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index eb7a31765c67..a755b6432e77 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1311,7 +1311,7 @@ const translations: TranslationDeepObject = { phrase1: 'Adicionar um recibo', phrase2: 'ou arraste e solte um aqui', }, - pageCount: ({pageCount}: {pageCount: number}) => `Página 1 de ${pageCount}`, + pageCount: ({page, pageCount}: {page: number; pageCount: number}) => `Página ${page} de ${pageCount}`, }, quickAction: { scanReceipt: 'Digitalizar recibo', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index b3c2cbfc5bb0..ec2a7a5945ab 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1259,7 +1259,7 @@ const translations: TranslationDeepObject = { phrase1: '添加收据', phrase2: '或将文件拖放到此处', }, - pageCount: ({pageCount}: {pageCount: number}) => `第 1 页,共 ${pageCount} 页`, + pageCount: ({page, pageCount}: {page: number; pageCount: number}) => `第 ${page} 页,共 ${pageCount} 页`, }, quickAction: { scanReceipt: '扫描收据', diff --git a/src/styles/index.ts b/src/styles/index.ts index 5639a99fbb52..49e6f67cc595 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4154,6 +4154,19 @@ const staticStyles = (theme: ThemeColors) => marginLeft: 0, }, + receiptPageNavigator: { + position: 'absolute', + bottom: 12, + left: 12, + flexDirection: 'row', + alignItems: 'center', + minHeight: 28, + paddingHorizontal: 12, + gap: 8, + borderRadius: variables.componentBorderRadiusRounded, + backgroundColor: theme.badgeDefaultBG, + }, + receiptActionButton: { width: 40, height: 40, diff --git a/tests/ui/components/MoneyRequestReceiptViewTest.tsx b/tests/ui/components/MoneyRequestReceiptViewTest.tsx index bd9ab3b1cd58..ae2355641ddd 100644 --- a/tests/ui/components/MoneyRequestReceiptViewTest.tsx +++ b/tests/ui/components/MoneyRequestReceiptViewTest.tsx @@ -53,22 +53,46 @@ jest.mock( }, ); -const mockReceiptImage = {shouldCompleteLoad: true}; +const mockReceiptImage: {shouldCompleteLoad: boolean; shouldLoadPDF: boolean; lastPDFPage?: number} = {shouldCompleteLoad: true, shouldLoadPDF: false}; jest.mock('@components/ReportActionItem/ReportActionItemImage', () => { const {useEffect} = jest.requireActual('react'); - function MockReportActionItemImage({onLoad}: {onLoad?: () => void}) { + function MockReportActionItemImage({onLoad, onPDFLoadSuccess, pdfPage}: {onLoad?: () => void; onPDFLoadSuccess?: () => void; pdfPage?: number}) { + useEffect(() => { + mockReceiptImage.lastPDFPage = pdfPage; + }, [pdfPage]); useEffect(() => { if (!mockReceiptImage.shouldCompleteLoad) { return; } onLoad?.(); }, [onLoad]); + const shouldReportPDFLoad = mockReceiptImage.shouldLoadPDF && pdfPage !== undefined; + useEffect(() => { + if (!shouldReportPDFLoad) { + return; + } + onPDFLoadSuccess?.(); + // Report once per load, the way the real PDF overlay does + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shouldReportPDFLoad]); return null; } return MockReportActionItemImage; }); +const mockDeviceCapabilities = {hasHoverSupport: false}; + +jest.mock('@libs/DeviceCapabilities', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@libs/DeviceCapabilities'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + hasHoverSupport: () => mockDeviceCapabilities.hasHoverSupport, + }; +}); + jest.mock('@src/languages/IntlStore', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access const en: Record = require('@src/languages/en').default; @@ -266,6 +290,9 @@ describe('MoneyRequestReceiptView', () => { beforeEach(async () => { mockReceiptImage.shouldCompleteLoad = true; + mockReceiptImage.shouldLoadPDF = false; + mockReceiptImage.lastPDFPage = undefined; + mockDeviceCapabilities.hasHoverSupport = false; jest.clearAllMocks(); await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${TEST_PARENT_REPORT_ID}`, { @@ -327,7 +354,7 @@ describe('MoneyRequestReceiptView', () => { ); await waitForBatchedUpdatesWithAct(); - expect(screen.queryByText(translateLocal('receipt.pageCount', {pageCount: 3}))).toBeNull(); + expect(screen.queryByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeNull(); }); it('shows the page count for a multi-page PDF receipt', async () => { @@ -343,7 +370,7 @@ describe('MoneyRequestReceiptView', () => { ); await waitForBatchedUpdatesWithAct(); - expect(screen.getByText(translateLocal('receipt.pageCount', {pageCount: 3}))).toBeTruthy(); + expect(screen.getByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeTruthy(); }); it('does not show the page count for a single page PDF receipt', async () => { @@ -362,7 +389,7 @@ describe('MoneyRequestReceiptView', () => { ); await waitForBatchedUpdatesWithAct(); - expect(screen.queryByText(translateLocal('receipt.pageCount', {pageCount: 1}))).toBeNull(); + expect(screen.queryByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 1}))).toBeNull(); }); // An optimistic merge that swaps a PDF for an image can leave the PDF's count behind, so the @@ -383,7 +410,7 @@ describe('MoneyRequestReceiptView', () => { ); await waitForBatchedUpdatesWithAct(); - expect(screen.queryByText(translateLocal('receipt.pageCount', {pageCount: 3}))).toBeNull(); + expect(screen.queryByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeNull(); }); // An image receipt carries no page count at all, which is also what a PDF uploaded before the @@ -401,7 +428,7 @@ describe('MoneyRequestReceiptView', () => { ); await waitForBatchedUpdatesWithAct(); - expect(screen.queryByText(translateLocal('receipt.pageCount', {pageCount: 3}))).toBeNull(); + expect(screen.queryByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeNull(); }); // The regenerated receipt makes the old count stale @@ -422,7 +449,63 @@ describe('MoneyRequestReceiptView', () => { ); await waitForBatchedUpdatesWithAct(); - expect(screen.queryByText(translateLocal('receipt.pageCount', {pageCount: 3}))).toBeNull(); + expect(screen.queryByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeNull(); + }); + }); + + describe('receipt page navigation', () => { + const renderMultiPagePDFReceipt = async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TEST_TRANSACTION_ID}`, transactionWithMultiPagePDFReceipt); + }); + await waitForBatchedUpdatesWithAct(); + + render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + }; + + it('flips pages once the PDF has loaded on a hover-capable device', async () => { + mockDeviceCapabilities.hasHoverSupport = true; + mockReceiptImage.shouldLoadPDF = true; + await renderMultiPagePDFReceipt(); + + expect(screen.getByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeTruthy(); + expect(mockReceiptImage.lastPDFPage).toBe(1); + + fireEvent.press(screen.getByLabelText(translateLocal('common.next'))); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText(translateLocal('receipt.pageCount', {page: 2, pageCount: 3}))).toBeTruthy(); + expect(mockReceiptImage.lastPDFPage).toBe(2); + + fireEvent.press(screen.getByLabelText(translateLocal('common.previous'))); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeTruthy(); + expect(mockReceiptImage.lastPDFPage).toBe(1); + }); + + // Until the PDF loads only the page 1 thumbnail is visible, so flipping would change the label but not the page + it('keeps the static badge while the PDF is still loading', async () => { + mockDeviceCapabilities.hasHoverSupport = true; + await renderMultiPagePDFReceipt(); + + expect(screen.getByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeTruthy(); + expect(screen.queryByLabelText(translateLocal('common.next'))).toBeNull(); + }); + + // Without hover support the PDF is never rendered over the thumbnail, so there is nothing to flip + it('keeps the static badge on devices without hover support', async () => { + mockReceiptImage.shouldLoadPDF = true; + await renderMultiPagePDFReceipt(); + + expect(screen.getByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeTruthy(); + expect(screen.queryByLabelText(translateLocal('common.next'))).toBeNull(); + expect(mockReceiptImage.lastPDFPage).toBeUndefined(); }); }); From bfc9bbffba3796bf6fd2a52c51c963e41ce0b1e3 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Thu, 1 Oct 2026 21:10:38 +0530 Subject: [PATCH 02/31] Rename overlay flag to pass spellcheck --- src/components/ReportActionItem/ReportActionItemImage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx index 20a9693c3564..f4624bb2a0f3 100644 --- a/src/components/ReportActionItem/ReportActionItemImage.tsx +++ b/src/components/ReportActionItem/ReportActionItemImage.tsx @@ -235,8 +235,8 @@ function ReportActionItemImage({ // isMapBasedDistanceRequest covers map, GPS, and manual-typed transactions that still carry waypoints. // Page navigation also needs the real PDF for local files, whose thumbnail only shows page 1. const pdfSourceURL = typeof originalImageSource === 'string' && !!originalImageSource ? originalImageSource : undefined; - const isOverlayablePDF = !!isPDF && !isEReceipt && !isMapBasedDistanceRequest(transaction) && !!pdfSourceURL; - const shouldOverlayHighResPDF = canZoomReceipt && isOverlayablePDF && hasHoverSupport() && (!effectiveIsLocalFile || pdfPage !== undefined); + const canOverlayPDF = !!isPDF && !isEReceipt && !isMapBasedDistanceRequest(transaction) && !!pdfSourceURL; + const shouldOverlayHighResPDF = canZoomReceipt && canOverlayPDF && hasHoverSupport() && (!effectiveIsLocalFile || pdfPage !== undefined); const renderReceiptContent = (receiptImage: React.ReactNode) => shouldOverlayHighResPDF ? ( From 174b5fb62c42c733b317b9765d4b58f3c15307ee Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Thu, 1 Oct 2026 21:37:43 +0530 Subject: [PATCH 03/31] Render only the visible receipt page when flipping pages PDFPreviewer measures every page before showing any, which took tens of seconds on long PDFs. Page navigation now renders just the requested page with react-pdf, so it no longer needs the react-fast-pdf change. --- .../ReceiptPDFOverlay/index.tsx | 158 +++++++++++++++--- .../ReceiptPDFOverlay/types.ts | 2 +- tests/ui/components/ReceiptPDFOverlayTest.tsx | 138 +++++++++++++++ 3 files changed, 276 insertions(+), 22 deletions(-) create mode 100644 tests/ui/components/ReceiptPDFOverlayTest.tsx diff --git a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx index b699391303aa..9775a6cd8fcd 100644 --- a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx +++ b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx @@ -1,3 +1,5 @@ +import {Document, Thumbnail} from '@components/PDFThumbnail'; + import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -10,7 +12,7 @@ import {retrieveMaxCanvasArea, retrieveMaxCanvasHeight, retrieveMaxCanvasWidth} import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import React, {useEffect, useState} from 'react'; +import React, {useCallback, useEffect, useState} from 'react'; import {PDFPreviewer} from 'react-fast-pdf'; import {View} from 'react-native'; @@ -27,12 +29,86 @@ const oversampleContainerStyle = { transformOrigin: 'top left', }; +// PDFPreviewer pads each page by this much, so the single page uses it too to stay framed like the thumbnail +const PDF_PAGE_BORDER = 9; + +const DOCUMENT_OPTIONS = { + cMapUrl: '/cmaps/', + cMapPacked: true, +}; + +type CanvasLimits = { + maxCanvasWidth?: number; + maxCanvasHeight?: number; + maxCanvasArea?: number; +}; + +/** + * Lowers the render resolution when the default one would exceed the browser's canvas limits, which otherwise + * leaves the canvas blank on very tall pages. Mirrors PDFPreviewer's calculation. + */ +function getDevicePixelRatio(width: number, height: number, {maxCanvasWidth, maxCanvasHeight, maxCanvasArea}: CanvasLimits): number | undefined { + if (!maxCanvasWidth || !maxCanvasHeight || !maxCanvasArea || !width || !height) { + return undefined; + } + const ratio = Math.min(maxCanvasHeight / height, maxCanvasWidth / width, Math.sqrt(maxCanvasArea / (width * height))); + return ratio > window.devicePixelRatio ? undefined : ratio; +} + +type ReceiptPDFPageProps = CanvasLimits & { + /** 1-indexed page to render */ + pageNumber: number; + + /** Width to render the page at */ + width: number; + + /** Called once the page has been drawn */ + onRenderSuccess?: () => void; +}; + +function ReceiptPDFPage({pageNumber, width, onRenderSuccess, ...canvasLimits}: ReceiptPDFPageProps) { + const [aspectRatio, setAspectRatio] = useState(); + const devicePixelRatio = aspectRatio ? getDevicePixelRatio(width, width * aspectRatio, canvasLimits) : undefined; + + return ( +
+ setAspectRatio(originalHeight / originalWidth)} + onRenderSuccess={onRenderSuccess} + /> +
+ ); +} + function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure, onLoadSuccess, page}: ReceiptPDFOverlayProps) { const styles = useThemeStyles(); const [session] = useOnyx(ONYXKEYS.SESSION); const [maxCanvasArea] = useOnyx(ONYXKEYS.MAX_CANVAS_AREA); const [maxCanvasHeight] = useOnyx(ONYXKEYS.MAX_CANVAS_HEIGHT); const [maxCanvasWidth] = useOnyx(ONYXKEYS.MAX_CANVAS_WIDTH); + const [oversampleWidth, setOversampleWidth] = useState(0); + const isSinglePage = page !== undefined; + + // The page last drawn stays on screen while the next one renders, so the thumbnail underneath never flashes through + const [renderedPage, setRenderedPage] = useState(page); + + // A ResizeObserver reports the size as soon as it starts observing, unlike onLayout added after mount + const observeOversampleWidth = useCallback( + (container: HTMLDivElement | null) => { + if (!container || !isSinglePage) { + return undefined; + } + const observer = new ResizeObserver(([entry]) => setOversampleWidth(entry.contentRect.width)); + observer.observe(container); + return () => observer.disconnect(); + }, + [isSinglePage], + ); useEffect(() => { // Verify the per-browser canvas limits have been calculated, mirroring PDFView. @@ -60,6 +136,15 @@ function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure return null; } + const handleLoadError = () => { + setFailedURL(fileURL); + onLoadFailure?.(); + }; + + // Matches PDFPreviewer's page width on small screens: the full width of the oversized container plus its border + const pageWidth = oversampleWidth + PDF_PAGE_BORDER * 2; + const canvasLimits = {maxCanvasWidth, maxCanvasHeight, maxCanvasArea}; + return ( {/*
is required here because `transformOrigin` is a CSS-only property unsupported by React Native's View. The oversample-then-scale technique relies on it to anchor the downscale to the top-left corner. */} -
- { - setFailedURL(fileURL); - onLoadFailure?.(); - }} - /> +
+ {page === undefined ? ( + + ) : ( + // Only the visible page is loaded. PDFPreviewer measures every page before showing any, which takes + // tens of seconds on long PDFs and isn't needed when one page is shown at a time. + oversampleWidth > 0 && ( + + {renderedPage !== undefined && ( + + )} + {page !== renderedPage && ( + setRenderedPage(page)} + {...canvasLimits} + /> + )} + + ) + )}
); diff --git a/src/components/ReportActionItem/ReceiptPDFOverlay/types.ts b/src/components/ReportActionItem/ReceiptPDFOverlay/types.ts index 7b17ddc3b840..25078e13bc7b 100644 --- a/src/components/ReportActionItem/ReceiptPDFOverlay/types.ts +++ b/src/components/ReportActionItem/ReceiptPDFOverlay/types.ts @@ -8,7 +8,7 @@ type ReceiptPDFOverlayProps = { /** Called when the PDF fails to load so the caller can fall back to the thumbnail */ onLoadFailure?: () => void; - /** Called once the PDF's pages are ready to display */ + /** Called once the PDF has loaded and its pages can be shown */ onLoadSuccess?: () => void; /** 1-indexed page to show at the top of the overlay */ diff --git a/tests/ui/components/ReceiptPDFOverlayTest.tsx b/tests/ui/components/ReceiptPDFOverlayTest.tsx new file mode 100644 index 000000000000..eb56974a6d8d --- /dev/null +++ b/tests/ui/components/ReceiptPDFOverlayTest.tsx @@ -0,0 +1,138 @@ +import {act, render, screen} from '@testing-library/react-native'; + +import type ReceiptPDFOverlayProps from '@components/ReportActionItem/ReceiptPDFOverlay/types'; + +import type {ComponentType, ReactNode} from 'react'; + +import React from 'react'; + +type MockThumbnailProps = {pageNumber: number; width: number; onRenderSuccess?: () => void}; + +const mockPDFPreviewer = jest.fn(() => null); +const mockRenderedThumbnails: MockThumbnailProps[] = []; + +jest.mock('react-fast-pdf', () => ({ + PDFPreviewer: () => mockPDFPreviewer(), +})); + +jest.mock('@components/PDFThumbnail', () => { + const {useEffect} = jest.requireActual('react'); + const {Text: MockText} = jest.requireActual<{Text: ComponentType<{children: ReactNode}>}>('react-native'); + function MockDocument({children, onLoadSuccess}: {children: ReactNode; onLoadSuccess?: () => void}) { + useEffect(() => { + onLoadSuccess?.(); + // Report once, like a document that finished loading + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return children; + } + function MockThumbnail({pageNumber, width, onRenderSuccess}: MockThumbnailProps) { + mockRenderedThumbnails.push({pageNumber, width, onRenderSuccess}); + return {`page-${pageNumber}`}; + } + return {Document: MockDocument, Thumbnail: MockThumbnail}; +}); + +jest.mock('@userActions/CanvasSize', () => ({ + retrieveMaxCanvasArea: jest.fn(), + retrieveMaxCanvasHeight: jest.fn(), + retrieveMaxCanvasWidth: jest.fn(), +})); + +// jest-expo resolves the native variant first, and this test covers the web implementation +const {default: ReceiptPDFOverlay} = jest.requireActual<{default: ComponentType}>('@components/ReportActionItem/ReceiptPDFOverlay/index.tsx'); + +const SOURCE_URL = 'https://example.com/receipt.pdf'; +const OVERSAMPLE_WIDTH = 500; + +// Like the browser's, reports the observed element's size as soon as observing starts +class MockResizeObserver { + private readonly callback: (entries: Array<{contentRect: {width: number}}>) => void; + + constructor(callback: (entries: Array<{contentRect: {width: number}}>) => void) { + this.callback = callback; + } + + observe() { + this.callback([{contentRect: {width: OVERSAMPLE_WIDTH}}]); + } + + disconnect() {} +} + +function renderOverlay(props: Omit) { + // createNodeMock gives the container's ref a node to observe, as the DOM would + return render( + , + {createNodeMock: () => ({})}, + ); +} + +describe('ReceiptPDFOverlay', () => { + beforeAll(() => { + Object.defineProperty(global, 'ResizeObserver', {value: MockResizeObserver, writable: true}); + }); + + beforeEach(() => { + mockPDFPreviewer.mockClear(); + mockRenderedThumbnails.length = 0; + }); + + it('keeps rendering the full PDFPreviewer when no page is requested', () => { + renderOverlay({}); + + expect(mockPDFPreviewer).toHaveBeenCalled(); + expect(screen.queryByText('page-1')).toBeNull(); + }); + + it('renders only the requested page, sized like PDFPreviewer, and reports the load', () => { + const onLoadSuccess = jest.fn(); + renderOverlay({page: 1, onLoadSuccess}); + + expect(mockPDFPreviewer).not.toHaveBeenCalled(); + expect(screen.getByText('page-1')).toBeTruthy(); + expect(onLoadSuccess).toHaveBeenCalledTimes(1); + expect(mockRenderedThumbnails.at(-1)?.width).toBe(OVERSAMPLE_WIDTH + 18); + }); + + it('keeps the previous page on screen until the next one has rendered', () => { + const {rerender} = renderOverlay({page: 1}); + + rerender( + , + ); + + expect(screen.getByText('page-1')).toBeTruthy(); + expect(screen.getByText('page-2')).toBeTruthy(); + + act(() => { + mockRenderedThumbnails.findLast((thumbnail) => thumbnail.pageNumber === 2)?.onRenderSuccess?.(); + }); + + expect(screen.queryByText('page-1')).toBeNull(); + expect(screen.getByText('page-2')).toBeTruthy(); + }); + + // Page navigation switches on after the receipt loads, so the overlay first mounts without a page + it('loads the page when navigation switches on after mount, without any hover or layout event', () => { + const {rerender} = renderOverlay({}); + + rerender( + , + ); + + expect(screen.getByText('page-1')).toBeTruthy(); + }); +}); From 0323b8ebf278c599d56043dca2574bf86b748ee6 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Thu, 1 Oct 2026 21:40:13 +0530 Subject: [PATCH 04/31] Reset the last drawn receipt page when the receipt is replaced --- .../ReceiptPDFOverlay/index.tsx | 11 ++++++---- tests/ui/components/ReceiptPDFOverlayTest.tsx | 21 +++++++++++++++++-- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx index 9775a6cd8fcd..6070b82b7a9b 100644 --- a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx +++ b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx @@ -94,9 +94,6 @@ function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure const [oversampleWidth, setOversampleWidth] = useState(0); const isSinglePage = page !== undefined; - // The page last drawn stays on screen while the next one renders, so the thumbnail underneath never flashes through - const [renderedPage, setRenderedPage] = useState(page); - // A ResizeObserver reports the size as soon as it starts observing, unlike onLayout added after mount const observeOversampleWidth = useCallback( (container: HTMLDivElement | null) => { @@ -131,6 +128,11 @@ function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure const [failedURL, setFailedURL] = useState(null); const hasFailed = failedURL !== null && failedURL === fileURL; + // The page last drawn stays on screen while the next one renders, so the thumbnail underneath never flashes + // through. Keyed to the URL so a replaced receipt doesn't briefly show the old page number from the new file. + const [renderedPageState, setRenderedPageState] = useState({fileURL, page}); + const renderedPage = renderedPageState.fileURL === fileURL ? renderedPageState.page : undefined; + // If the PDF can't be rendered, fall back to the thumbnail underneath by rendering nothing. if (hasFailed) { return null; @@ -196,7 +198,7 @@ function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure key={page} pageNumber={page} width={pageWidth} - onRenderSuccess={() => setRenderedPage(page)} + onRenderSuccess={() => setRenderedPageState({fileURL, page})} {...canvasLimits} /> )} @@ -209,3 +211,4 @@ function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure } export default ReceiptPDFOverlay; +export {PDF_PAGE_BORDER}; diff --git a/tests/ui/components/ReceiptPDFOverlayTest.tsx b/tests/ui/components/ReceiptPDFOverlayTest.tsx index eb56974a6d8d..e6ba330ba482 100644 --- a/tests/ui/components/ReceiptPDFOverlayTest.tsx +++ b/tests/ui/components/ReceiptPDFOverlayTest.tsx @@ -40,7 +40,9 @@ jest.mock('@userActions/CanvasSize', () => ({ })); // jest-expo resolves the native variant first, and this test covers the web implementation -const {default: ReceiptPDFOverlay} = jest.requireActual<{default: ComponentType}>('@components/ReportActionItem/ReceiptPDFOverlay/index.tsx'); +const {default: ReceiptPDFOverlay, PDF_PAGE_BORDER} = jest.requireActual<{default: ComponentType; PDF_PAGE_BORDER: number}>( + '@components/ReportActionItem/ReceiptPDFOverlay/index.tsx', +); const SOURCE_URL = 'https://example.com/receipt.pdf'; const OVERSAMPLE_WIDTH = 500; @@ -96,7 +98,7 @@ describe('ReceiptPDFOverlay', () => { expect(mockPDFPreviewer).not.toHaveBeenCalled(); expect(screen.getByText('page-1')).toBeTruthy(); expect(onLoadSuccess).toHaveBeenCalledTimes(1); - expect(mockRenderedThumbnails.at(-1)?.width).toBe(OVERSAMPLE_WIDTH + 18); + expect(mockRenderedThumbnails.at(-1)?.width).toBe(OVERSAMPLE_WIDTH + PDF_PAGE_BORDER * 2); }); it('keeps the previous page on screen until the next one has rendered', () => { @@ -121,6 +123,21 @@ describe('ReceiptPDFOverlay', () => { expect(screen.getByText('page-2')).toBeTruthy(); }); + it('does not carry the last drawn page over to a replaced receipt', () => { + const {rerender} = renderOverlay({page: 2}); + + rerender( + , + ); + + expect(screen.queryByText('page-2')).toBeNull(); + expect(screen.getByText('page-1')).toBeTruthy(); + }); + // Page navigation switches on after the receipt loads, so the overlay first mounts without a page it('loads the page when navigation switches on after mount, without any hover or layout event', () => { const {rerender} = renderOverlay({}); From d1c1b19fa31dc68bd9b66e14b1dee94323cfa434 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Thu, 1 Oct 2026 21:45:46 +0530 Subject: [PATCH 05/31] Move the PDF page border to CONST and drop a duplicate map check --- src/CONST/index.ts | 3 +++ .../ReportActionItem/MoneyRequestReceiptView.tsx | 2 +- .../ReportActionItem/ReceiptPDFOverlay/index.tsx | 10 +++------- tests/ui/components/ReceiptPDFOverlayTest.tsx | 8 ++++---- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index c5fb8ff0c0f9..70ade289c518 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1612,6 +1612,9 @@ const CONST = { // Elements with this data attribute pause the hover zoom while the pointer is over them HOVER_ZOOM_EXCLUDED_ELEMENT: 'receipt-hover-zoom-excluded', + + // react-fast-pdf pads each page by this much. The single-page PDF layer matches it to stay framed the same. + PDF_PAGE_BORDER: 9, }, RECEIPT_PREVIEW_TOP_BOTTOM_MARGIN: 120, REPORT: { diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx index db319bc9cd2b..7716a8081328 100644 --- a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx +++ b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx @@ -616,7 +616,7 @@ function MoneyRequestReceiptView({ // Pages can only be flipped where ReportActionItemImage renders the real PDF over the thumbnail (hover-capable // devices). Elsewhere only a page 1 thumbnail exists, so the static count badge stays. - const canFlipReceiptPages = shouldShowReceiptPageCount && canZoomReceipt && deviceHasHoverSupport && !isMapDistanceRequest; + const canFlipReceiptPages = shouldShowReceiptPageCount && canZoomReceipt && deviceHasHoverSupport; // Page and load state are keyed to the filename, not the source URL: the URL changes from local to remote // mid-scan without the PDF being reloaded, while a replaced receipt gets a new filename and starts over. diff --git a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx index 6070b82b7a9b..a59200577fec 100644 --- a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx +++ b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx @@ -29,9 +29,6 @@ const oversampleContainerStyle = { transformOrigin: 'top left', }; -// PDFPreviewer pads each page by this much, so the single page uses it too to stay framed like the thumbnail -const PDF_PAGE_BORDER = 9; - const DOCUMENT_OPTIONS = { cMapUrl: '/cmaps/', cMapPacked: true, @@ -71,7 +68,7 @@ function ReceiptPDFPage({pageNumber, width, onRenderSuccess, ...canvasLimits}: R const devicePixelRatio = aspectRatio ? getDevicePixelRatio(width, width * aspectRatio, canvasLimits) : undefined; return ( -
+
- {page === undefined ? ( + {!isSinglePage ? ( ({ })); // jest-expo resolves the native variant first, and this test covers the web implementation -const {default: ReceiptPDFOverlay, PDF_PAGE_BORDER} = jest.requireActual<{default: ComponentType; PDF_PAGE_BORDER: number}>( - '@components/ReportActionItem/ReceiptPDFOverlay/index.tsx', -); +const {default: ReceiptPDFOverlay} = jest.requireActual<{default: ComponentType}>('@components/ReportActionItem/ReceiptPDFOverlay/index.tsx'); const SOURCE_URL = 'https://example.com/receipt.pdf'; const OVERSAMPLE_WIDTH = 500; @@ -98,7 +98,7 @@ describe('ReceiptPDFOverlay', () => { expect(mockPDFPreviewer).not.toHaveBeenCalled(); expect(screen.getByText('page-1')).toBeTruthy(); expect(onLoadSuccess).toHaveBeenCalledTimes(1); - expect(mockRenderedThumbnails.at(-1)?.width).toBe(OVERSAMPLE_WIDTH + PDF_PAGE_BORDER * 2); + expect(mockRenderedThumbnails.at(-1)?.width).toBe(OVERSAMPLE_WIDTH + CONST.RECEIPT.PDF_PAGE_BORDER * 2); }); it('keeps the previous page on screen until the next one has rendered', () => { From 27fb16d67ad5bb7079736e25f9a88b9ddd1079d7 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Fri, 2 Oct 2026 18:00:03 +0530 Subject: [PATCH 06/31] Address review feedback on receipt page navigation Show the pill as soon as pages can be flipped, with its buttons disabled until the PDF loads, so it no longer grows when the arrows appear. Reserve the widest label's width, hide tooltips on disabled buttons, and fall back to the static badge when the PDF or a page fails to load. --- src/CONST/index.ts | 2 + .../ConfirmationReceiptThumbnail.tsx | 2 +- .../MoneyRequestReceiptView.tsx | 15 ++- .../ReceiptPDFOverlay/index.tsx | 17 ++- .../ReportActionItem/ReceiptPageNavigator.tsx | 121 ++++++++++++------ .../ReportActionItemImage.tsx | 9 +- src/styles/index.ts | 2 +- .../MoneyRequestReceiptViewTest.tsx | 44 +++++-- tests/ui/components/ReceiptPDFOverlayTest.tsx | 18 ++- 9 files changed, 169 insertions(+), 61 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 70ade289c518..9fbe039f2195 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1615,6 +1615,8 @@ const CONST = { // react-fast-pdf pads each page by this much. The single-page PDF layer matches it to stay framed the same. PDF_PAGE_BORDER: 9, + + FIRST_PDF_PAGE: 1, }, RECEIPT_PREVIEW_TOP_BOTTOM_MARGIN: 120, REPORT: { diff --git a/src/components/MoneyRequestConfirmationListFooter/ConfirmationReceiptThumbnail.tsx b/src/components/MoneyRequestConfirmationListFooter/ConfirmationReceiptThumbnail.tsx index a1ce102112d8..2eb6afa27ce8 100644 --- a/src/components/MoneyRequestConfirmationListFooter/ConfirmationReceiptThumbnail.tsx +++ b/src/components/MoneyRequestConfirmationListFooter/ConfirmationReceiptThumbnail.tsx @@ -199,7 +199,7 @@ function ConfirmationReceiptThumbnail({ ))} {shouldShowReceiptPageCount && ( )} diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx index 7716a8081328..ed25e18a391d 100644 --- a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx +++ b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx @@ -242,8 +242,9 @@ function MoneyRequestReceiptView({ const displayedReceiptSource = ReceiptStorage.resolve(transaction?.receipt?.localSource) ?? transaction?.receipt?.source; const prevDisplayedReceiptSource = usePrevious(displayedReceiptSource); - const [selectedReceiptPage, setSelectedReceiptPage] = useState<{filename?: string; page: number}>({page: 1}); + const [selectedReceiptPage, setSelectedReceiptPage] = useState<{filename?: string; page: number}>({page: CONST.RECEIPT.FIRST_PDF_PAGE}); const [loadedReceiptPDFFilename, setLoadedReceiptPDFFilename] = useState(); + const [failedReceiptPDFFilename, setFailedReceiptPDFFilename] = useState(); useEffect(() => { if (!displayedReceiptSource || prevDisplayedReceiptSource === displayedReceiptSource) { @@ -621,10 +622,12 @@ function MoneyRequestReceiptView({ // Page and load state are keyed to the filename, not the source URL: the URL changes from local to remote // mid-scan without the PDF being reloaded, while a replaced receipt gets a new filename and starts over. const receiptFilename = receiptURIs?.filename; - const receiptPage = selectedReceiptPage.filename === receiptFilename ? selectedReceiptPage.page : 1; + const receiptPage = selectedReceiptPage.filename === receiptFilename ? selectedReceiptPage.page : CONST.RECEIPT.FIRST_PDF_PAGE; - // Large PDFs take a while to load, and until then only the page 1 thumbnail is visible - const shouldShowReceiptPageNavigator = canFlipReceiptPages && !!receiptFilename && loadedReceiptPDFFilename === receiptFilename; + // Shown before the PDF loads, with its buttons disabled, so the badge doesn't change size once it can flip. + // A PDF that fails to load leaves only the page 1 thumbnail, so the static badge comes back. + const shouldShowReceiptPageNavigator = canFlipReceiptPages && !!receiptFilename && failedReceiptPDFFilename !== receiptFilename; + const isReceiptPDFLoading = loadedReceiptPDFFilename !== receiptFilename; const receiptPendingAction = isDistanceRequest ? getPendingFieldAction('waypoints') : getPendingFieldAction('receipt'); const isReceiptOfflinePending = isOffline && !!receiptPendingAction; @@ -771,6 +774,7 @@ function MoneyRequestReceiptView({ canZoomReceipt={canZoomReceipt} pdfPage={canFlipReceiptPages ? receiptPage : undefined} onPDFLoadSuccess={() => setLoadedReceiptPDFFilename(receiptFilename)} + onPDFLoadFailure={() => setFailedReceiptPDFFilename(receiptFilename)} thumbnail={receiptURIs?.thumbnail} fileExtension={receiptURIs?.fileExtension} isThumbnail={receiptURIs?.isThumbnail} @@ -794,12 +798,13 @@ function MoneyRequestReceiptView({ setSelectedReceiptPage({filename: receiptFilename, page})} /> )} {shouldShowReceiptPageCount && !shouldShowReceiptPageNavigator && ( )} diff --git a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx index a59200577fec..a2386a22ab82 100644 --- a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx +++ b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx @@ -29,6 +29,12 @@ const oversampleContainerStyle = { transformOrigin: 'top left', }; +const pageContainerStyle = { + position: 'absolute' as const, + top: CONST.RECEIPT.PDF_PAGE_BORDER, + left: 0, +}; + const DOCUMENT_OPTIONS = { cMapUrl: '/cmaps/', cMapPacked: true, @@ -61,14 +67,17 @@ type ReceiptPDFPageProps = CanvasLimits & { /** Called once the page has been drawn */ onRenderSuccess?: () => void; + + /** Called when the page fails to load or draw */ + onError: () => void; }; -function ReceiptPDFPage({pageNumber, width, onRenderSuccess, ...canvasLimits}: ReceiptPDFPageProps) { +function ReceiptPDFPage({pageNumber, width, onRenderSuccess, onError, ...canvasLimits}: ReceiptPDFPageProps) { const [aspectRatio, setAspectRatio] = useState(); const devicePixelRatio = aspectRatio ? getDevicePixelRatio(width, width * aspectRatio, canvasLimits) : undefined; return ( -
+
setAspectRatio(originalHeight / originalWidth)} onRenderSuccess={onRenderSuccess} + onLoadError={onError} + onRenderError={onError} />
); @@ -187,6 +198,7 @@ function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure key={renderedPage} pageNumber={renderedPage} width={pageWidth} + onError={handleLoadError} {...canvasLimits} /> )} @@ -196,6 +208,7 @@ function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure pageNumber={page} width={pageWidth} onRenderSuccess={() => setRenderedPageState({fileURL, page})} + onError={handleLoadError} {...canvasLimits} /> )} diff --git a/src/components/ReportActionItem/ReceiptPageNavigator.tsx b/src/components/ReportActionItem/ReceiptPageNavigator.tsx index 1d918912ef18..3eda87dd7f41 100644 --- a/src/components/ReportActionItem/ReceiptPageNavigator.tsx +++ b/src/components/ReportActionItem/ReceiptPageNavigator.tsx @@ -11,9 +11,56 @@ import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type IconAsset from '@src/types/utils/IconAsset'; import React from 'react'; -import {View} from 'react-native'; +import {StyleSheet, View} from 'react-native'; + +const PAGE_BUTTON_HIT_SLOP = {top: 8, bottom: 8, left: 8, right: 8}; + +type PageButtonProps = { + /** Arrow icon to show */ + icon: IconAsset; + + /** Tooltip and accessibility label */ + label: string; + + /** Whether the button can be pressed */ + isDisabled: boolean; + + /** Called when the button is pressed */ + onPress: () => void; + + /** Label used to identify the button in Sentry */ + sentryLabel: string; +}; + +function PageButton({icon, label, isDisabled, onPress, sentryLabel}: PageButtonProps) { + const theme = useTheme(); + + return ( + + + + + + ); +} type ReceiptPageNavigatorProps = { /** The 1-indexed page currently shown */ @@ -22,57 +69,57 @@ type ReceiptPageNavigatorProps = { /** Total number of pages in the receipt */ pageCount: number; + /** Whether the PDF is still loading, which keeps both buttons disabled */ + isLoading: boolean; + /** Called with the page to show when a navigation button is pressed */ onChangePage: (page: number) => void; }; /** "Page X of N" pill with previous/next buttons for flipping through a multi-page PDF receipt. */ -function ReceiptPageNavigator({page, pageCount, onChangePage}: ReceiptPageNavigatorProps) { +function ReceiptPageNavigator({page, pageCount, isLoading, onChangePage}: ReceiptPageNavigatorProps) { const styles = useThemeStyles(); - const theme = useTheme(); const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'BackArrow']); - const isFirstPage = page <= 1; - const isLastPage = page >= pageCount; + const labelStyle = [styles.badgeText, styles.textStrong, styles.badgeDefaultText]; return ( - - onChangePage(page - 1)} - accessibilityLabel={translate('common.previous')} - role={CONST.ROLE.BUTTON} - sentryLabel={CONST.SENTRY_LABEL.RECEIPT.PREVIOUS_PAGE_BUTTON} - > - - - - {translate('receipt.pageCount', {page, pageCount})} - - onChangePage(page + 1)} - accessibilityLabel={translate('common.next')} - role={CONST.ROLE.BUTTON} - sentryLabel={CONST.SENTRY_LABEL.RECEIPT.NEXT_PAGE_BUTTON} + onChangePage(page - 1)} + sentryLabel={CONST.SENTRY_LABEL.RECEIPT.PREVIOUS_PAGE_BUTTON} + /> + {/* The hidden widest label reserves the pill's width, so the buttons stay put as the page number changes */} + + - - - + {translate('receipt.pageCount', {page: pageCount, pageCount})} + + + + {translate('receipt.pageCount', {page, pageCount})} + + + + = pageCount} + onPress={() => onChangePage(page + 1)} + sentryLabel={CONST.SENTRY_LABEL.RECEIPT.NEXT_PAGE_BUTTON} + /> ); } diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx index f4624bb2a0f3..9ca447c19dd5 100644 --- a/src/components/ReportActionItem/ReportActionItemImage.tsx +++ b/src/components/ReportActionItem/ReportActionItemImage.tsx @@ -104,6 +104,9 @@ type ReportActionItemImageProps = { /** Called once the high-res PDF overlay has loaded its pages */ onPDFLoadSuccess?: () => void; + /** Called when the high-res PDF overlay fails, leaving only the thumbnail */ + onPDFLoadFailure?: () => void; + /** Callback to be called when the image loads */ onLoad?: (event?: {nativeEvent: {width: number; height: number}}) => void; @@ -138,6 +141,7 @@ function ReportActionItemImage({ canZoomReceipt = false, pdfPage, onPDFLoadSuccess, + onPDFLoadFailure, onLoad, onLoadFailure, }: ReportActionItemImageProps) { @@ -249,7 +253,10 @@ function ReportActionItemImage({ { + onLoadFailure?.(); + onPDFLoadFailure?.(); + }} page={pdfPage} onLoadSuccess={onPDFLoadSuccess} /> diff --git a/src/styles/index.ts b/src/styles/index.ts index 49e6f67cc595..82e8efc3eb4d 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4160,7 +4160,7 @@ const staticStyles = (theme: ThemeColors) => left: 12, flexDirection: 'row', alignItems: 'center', - minHeight: 28, + minHeight: variables.componentSizeSmall, paddingHorizontal: 12, gap: 8, borderRadius: variables.componentBorderRadiusRounded, diff --git a/tests/ui/components/MoneyRequestReceiptViewTest.tsx b/tests/ui/components/MoneyRequestReceiptViewTest.tsx index ae2355641ddd..feb5d1e8e860 100644 --- a/tests/ui/components/MoneyRequestReceiptViewTest.tsx +++ b/tests/ui/components/MoneyRequestReceiptViewTest.tsx @@ -53,11 +53,21 @@ jest.mock( }, ); -const mockReceiptImage: {shouldCompleteLoad: boolean; shouldLoadPDF: boolean; lastPDFPage?: number} = {shouldCompleteLoad: true, shouldLoadPDF: false}; +const mockReceiptImage: {shouldCompleteLoad: boolean; pdfLoadResult?: 'success' | 'failure'; lastPDFPage?: number} = {shouldCompleteLoad: true}; jest.mock('@components/ReportActionItem/ReportActionItemImage', () => { const {useEffect} = jest.requireActual('react'); - function MockReportActionItemImage({onLoad, onPDFLoadSuccess, pdfPage}: {onLoad?: () => void; onPDFLoadSuccess?: () => void; pdfPage?: number}) { + function MockReportActionItemImage({ + onLoad, + onPDFLoadSuccess, + onPDFLoadFailure, + pdfPage, + }: { + onLoad?: () => void; + onPDFLoadSuccess?: () => void; + onPDFLoadFailure?: () => void; + pdfPage?: number; + }) { useEffect(() => { mockReceiptImage.lastPDFPage = pdfPage; }, [pdfPage]); @@ -67,15 +77,16 @@ jest.mock('@components/ReportActionItem/ReportActionItemImage', () => { } onLoad?.(); }, [onLoad]); - const shouldReportPDFLoad = mockReceiptImage.shouldLoadPDF && pdfPage !== undefined; + const pdfLoadResult = pdfPage === undefined ? undefined : mockReceiptImage.pdfLoadResult; useEffect(() => { - if (!shouldReportPDFLoad) { - return; + if (pdfLoadResult === 'success') { + onPDFLoadSuccess?.(); + } else if (pdfLoadResult === 'failure') { + onPDFLoadFailure?.(); } - onPDFLoadSuccess?.(); // Report once per load, the way the real PDF overlay does // eslint-disable-next-line react-hooks/exhaustive-deps - }, [shouldReportPDFLoad]); + }, [pdfLoadResult]); return null; } return MockReportActionItemImage; @@ -290,7 +301,7 @@ describe('MoneyRequestReceiptView', () => { beforeEach(async () => { mockReceiptImage.shouldCompleteLoad = true; - mockReceiptImage.shouldLoadPDF = false; + mockReceiptImage.pdfLoadResult = undefined; mockReceiptImage.lastPDFPage = undefined; mockDeviceCapabilities.hasHoverSupport = false; jest.clearAllMocks(); @@ -470,7 +481,7 @@ describe('MoneyRequestReceiptView', () => { it('flips pages once the PDF has loaded on a hover-capable device', async () => { mockDeviceCapabilities.hasHoverSupport = true; - mockReceiptImage.shouldLoadPDF = true; + mockReceiptImage.pdfLoadResult = 'success'; await renderMultiPagePDFReceipt(); expect(screen.getByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeTruthy(); @@ -490,8 +501,19 @@ describe('MoneyRequestReceiptView', () => { }); // Until the PDF loads only the page 1 thumbnail is visible, so flipping would change the label but not the page - it('keeps the static badge while the PDF is still loading', async () => { + it('disables the page buttons while the PDF is still loading', async () => { + mockDeviceCapabilities.hasHoverSupport = true; + await renderMultiPagePDFReceipt(); + + expect(screen.getByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeTruthy(); + expect(screen.getByLabelText(translateLocal('common.next'))).toBeDisabled(); + expect(screen.getByLabelText(translateLocal('common.previous'))).toBeDisabled(); + }); + + // Only the page 1 thumbnail is left when the PDF can't load, so there is nothing to flip + it('falls back to the static badge when the PDF fails to load', async () => { mockDeviceCapabilities.hasHoverSupport = true; + mockReceiptImage.pdfLoadResult = 'failure'; await renderMultiPagePDFReceipt(); expect(screen.getByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeTruthy(); @@ -500,7 +522,7 @@ describe('MoneyRequestReceiptView', () => { // Without hover support the PDF is never rendered over the thumbnail, so there is nothing to flip it('keeps the static badge on devices without hover support', async () => { - mockReceiptImage.shouldLoadPDF = true; + mockReceiptImage.pdfLoadResult = 'success'; await renderMultiPagePDFReceipt(); expect(screen.getByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeTruthy(); diff --git a/tests/ui/components/ReceiptPDFOverlayTest.tsx b/tests/ui/components/ReceiptPDFOverlayTest.tsx index 36d370283a66..9b6c0070e9d0 100644 --- a/tests/ui/components/ReceiptPDFOverlayTest.tsx +++ b/tests/ui/components/ReceiptPDFOverlayTest.tsx @@ -8,7 +8,7 @@ import type {ComponentType, ReactNode} from 'react'; import React from 'react'; -type MockThumbnailProps = {pageNumber: number; width: number; onRenderSuccess?: () => void}; +type MockThumbnailProps = {pageNumber: number; width: number; onRenderSuccess?: () => void; onRenderError?: () => void}; const mockPDFPreviewer = jest.fn(() => null); const mockRenderedThumbnails: MockThumbnailProps[] = []; @@ -28,8 +28,8 @@ jest.mock('@components/PDFThumbnail', () => { }, []); return children; } - function MockThumbnail({pageNumber, width, onRenderSuccess}: MockThumbnailProps) { - mockRenderedThumbnails.push({pageNumber, width, onRenderSuccess}); + function MockThumbnail({pageNumber, width, onRenderSuccess, onRenderError}: MockThumbnailProps) { + mockRenderedThumbnails.push({pageNumber, width, onRenderSuccess, onRenderError}); return {`page-${pageNumber}`}; } return {Document: MockDocument, Thumbnail: MockThumbnail}; @@ -138,6 +138,18 @@ describe('ReceiptPDFOverlay', () => { expect(screen.getByText('page-1')).toBeTruthy(); }); + it('falls back to the thumbnail when a page fails to render', () => { + const onLoadFailure = jest.fn(); + renderOverlay({page: 1, onLoadFailure}); + + act(() => { + mockRenderedThumbnails.at(-1)?.onRenderError?.(); + }); + + expect(screen.queryByText('page-1')).toBeNull(); + expect(onLoadFailure).toHaveBeenCalledTimes(1); + }); + // Page navigation switches on after the receipt loads, so the overlay first mounts without a page it('loads the page when navigation switches on after mount, without any hover or layout event', () => { const {rerender} = renderOverlay({}); From 8db113825bfa418ad33c6041dd02850eef1a93f8 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Fri, 2 Oct 2026 18:07:49 +0530 Subject: [PATCH 07/31] Stop the receipt page label from truncating --- .../ReportActionItem/ReceiptPageNavigator.tsx | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/components/ReportActionItem/ReceiptPageNavigator.tsx b/src/components/ReportActionItem/ReceiptPageNavigator.tsx index 3eda87dd7f41..b5a3bd637084 100644 --- a/src/components/ReportActionItem/ReceiptPageNavigator.tsx +++ b/src/components/ReportActionItem/ReceiptPageNavigator.tsx @@ -14,7 +14,7 @@ import CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; import React from 'react'; -import {StyleSheet, View} from 'react-native'; +import {View} from 'react-native'; const PAGE_BUTTON_HIT_SLOP = {top: 8, bottom: 8, left: 8, right: 8}; @@ -95,23 +95,16 @@ function ReceiptPageNavigator({page, pageCount, isLoading, onChangePage}: Receip onPress={() => onChangePage(page - 1)} sentryLabel={CONST.SENTRY_LABEL.RECEIPT.PREVIOUS_PAGE_BUTTON} /> - {/* The hidden widest label reserves the pill's width, so the buttons stay put as the page number changes */} - + {/* The zero-height widest label keeps the column at least that wide, so the buttons stay put as the page + number changes, while the visible label is never narrowed below its own width */} + + {translate('receipt.pageCount', {page, pageCount})} {translate('receipt.pageCount', {page: pageCount, pageCount})} - - - {translate('receipt.pageCount', {page, pageCount})} - - Date: Fri, 2 Oct 2026 18:08:44 +0530 Subject: [PATCH 08/31] Keep the receipt page label on one line --- src/components/ReportActionItem/ReceiptPageNavigator.tsx | 2 +- src/styles/index.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/ReportActionItem/ReceiptPageNavigator.tsx b/src/components/ReportActionItem/ReceiptPageNavigator.tsx index b5a3bd637084..89f3c64f5b3f 100644 --- a/src/components/ReportActionItem/ReceiptPageNavigator.tsx +++ b/src/components/ReportActionItem/ReceiptPageNavigator.tsx @@ -81,7 +81,7 @@ function ReceiptPageNavigator({page, pageCount, isLoading, onChangePage}: Receip const styles = useThemeStyles(); const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'BackArrow']); - const labelStyle = [styles.badgeText, styles.textStrong, styles.badgeDefaultText]; + const labelStyle = [styles.badgeText, styles.textStrong, styles.badgeDefaultText, styles.receiptPageNavigatorLabel]; return ( backgroundColor: theme.badgeDefaultBG, }, + receiptPageNavigatorLabel: { + whiteSpace: 'nowrap', + }, + receiptActionButton: { width: 40, height: 40, From 4210fd3be8538121e6fb1ba0f8143cbe19fe22f0 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Fri, 2 Oct 2026 18:14:46 +0530 Subject: [PATCH 09/31] Tighten the receipt page pill spacing to match the mock --- src/styles/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/styles/index.ts b/src/styles/index.ts index 70b9e894689f..f8149199f122 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4161,8 +4161,8 @@ const staticStyles = (theme: ThemeColors) => flexDirection: 'row', alignItems: 'center', minHeight: variables.componentSizeSmall, - paddingHorizontal: 12, - gap: 8, + paddingHorizontal: 8, + gap: 4, borderRadius: variables.componentBorderRadiusRounded, backgroundColor: theme.badgeDefaultBG, }, From 76f4fe84f467bb4449408b464103d22d0cf5aa9f Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Fri, 2 Oct 2026 18:21:59 +0530 Subject: [PATCH 10/31] Let the receipt page pill size to its text instead of reserving width --- .../ReportActionItem/ReceiptPageNavigator.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/components/ReportActionItem/ReceiptPageNavigator.tsx b/src/components/ReportActionItem/ReceiptPageNavigator.tsx index 89f3c64f5b3f..16734278cc7d 100644 --- a/src/components/ReportActionItem/ReceiptPageNavigator.tsx +++ b/src/components/ReportActionItem/ReceiptPageNavigator.tsx @@ -95,17 +95,7 @@ function ReceiptPageNavigator({page, pageCount, isLoading, onChangePage}: Receip onPress={() => onChangePage(page - 1)} sentryLabel={CONST.SENTRY_LABEL.RECEIPT.PREVIOUS_PAGE_BUTTON} /> - {/* The zero-height widest label keeps the column at least that wide, so the buttons stay put as the page - number changes, while the visible label is never narrowed below its own width */} - - {translate('receipt.pageCount', {page, pageCount})} - - {translate('receipt.pageCount', {page: pageCount, pageCount})} - - + {translate('receipt.pageCount', {page, pageCount})} Date: Fri, 2 Oct 2026 18:37:30 +0530 Subject: [PATCH 11/31] Drop redundant useCallback now that React Compiler memoizes it --- .../ReceiptPDFOverlay/index.tsx | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx index a2386a22ab82..27dd27eb0b6c 100644 --- a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx +++ b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx @@ -12,7 +12,7 @@ import {retrieveMaxCanvasArea, retrieveMaxCanvasHeight, retrieveMaxCanvasWidth} import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {PDFPreviewer} from 'react-fast-pdf'; import {View} from 'react-native'; @@ -103,17 +103,14 @@ function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure const isSinglePage = page !== undefined; // A ResizeObserver reports the size as soon as it starts observing, unlike onLayout added after mount - const observeOversampleWidth = useCallback( - (container: HTMLDivElement | null) => { - if (!container || !isSinglePage) { - return undefined; - } - const observer = new ResizeObserver(([entry]) => setOversampleWidth(entry.contentRect.width)); - observer.observe(container); - return () => observer.disconnect(); - }, - [isSinglePage], - ); + const observeOversampleWidth = (container: HTMLDivElement | null) => { + if (!container || !isSinglePage) { + return undefined; + } + const observer = new ResizeObserver(([entry]) => setOversampleWidth(entry.contentRect.width)); + observer.observe(container); + return () => observer.disconnect(); + }; useEffect(() => { // Verify the per-browser canvas limits have been calculated, mirroring PDFView. From 544b76823372d88ec001afdd5f3eb28f4821fea9 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Fri, 2 Oct 2026 19:22:41 +0530 Subject: [PATCH 12/31] Fix stale loaded/failed state and real bugs from MelvinBot's review Key the page/loaded/failed state to the receipt's source instead of its filename, so a receipt replaced mid-scan (or by a same-named file) resets instead of leaving the pill enabled on stale data; clamp the page to the page count. Share the overlay-eligibility check with ReportActionItemImage via a new canOverlayReceiptPDF helper instead of re-deriving it by hand. Fix hitSlop having no effect on react-native-web's Pressable by giving the buttons a real tap target instead. Share the cMap document options with PDFThumbnail, share badge positioning between the two badge styles, reuse styles.textNoWrap, and clarify two doc comments and a variable name. --- src/components/PDFThumbnail/index.tsx | 16 +-- .../MoneyRequestReceiptView.tsx | 39 ++++--- .../ReceiptPDFOverlay/index.tsx | 15 +-- .../ReceiptPDFOverlay/types.ts | 2 +- .../ReportActionItem/ReceiptPageNavigator.tsx | 8 +- .../ReportActionItemImage.tsx | 8 +- src/libs/TransactionUtils/index.ts | 10 ++ src/styles/index.ts | 26 +++-- .../MoneyRequestReceiptViewTest.tsx | 107 ++++++++++++++---- tests/ui/components/ReceiptPDFOverlayTest.tsx | 22 +++- 10 files changed, 182 insertions(+), 71 deletions(-) diff --git a/src/components/PDFThumbnail/index.tsx b/src/components/PDFThumbnail/index.tsx index 280ed3b8d9f3..cf61cca534fc 100644 --- a/src/components/PDFThumbnail/index.tsx +++ b/src/components/PDFThumbnail/index.tsx @@ -16,6 +16,13 @@ import PDFThumbnailError from './PDFThumbnailError'; pdfjs.GlobalWorkerOptions.workerSrc = URL.createObjectURL(new Blob([pdfWorkerSource], {type: 'text/javascript'})); +// Use a root-relative URL so the CMap files (needed to render non-Latin fonts) resolve against +// the server root instead of the current deep route. Shared by every react-pdf in the app. +const PDF_DOCUMENT_OPTIONS = { + cMapUrl: '/cmaps/', + cMapPacked: true, +}; + function PDFThumbnail({previewSourceURL, style, enabled = true, onPassword, onLoadError, onLoadSuccess}: PDFThumbnailProps) { const styles = useThemeStyles(); const [failedToLoad, setFailedToLoad] = useState(false); @@ -25,12 +32,7 @@ function PDFThumbnail({previewSourceURL, style, enabled = true, onPassword, onLo } file={previewSourceURL} - options={{ - // Use a root-relative URL so the CMap files (needed to render non-Latin fonts) - // resolve against the server root instead of the current deep route. - cMapUrl: '/cmaps/', - cMapPacked: true, - }} + options={PDF_DOCUMENT_OPTIONS} externalLinkTarget="_blank" onPassword={onPassword} onLoad={() => { @@ -83,5 +85,5 @@ export default React.memo(PDFThumbnail); // Re-exported so other PDF-rendering components reuse this file's worker setup // instead of importing pdfjs-dist/react-pdf directly. -export {Document, Thumbnail}; +export {Document, Thumbnail, PDF_DOCUMENT_OPTIONS}; export type {PDFDocumentProxy}; diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx index ed25e18a391d..c8a0d6b49b04 100644 --- a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx +++ b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx @@ -60,6 +60,7 @@ import { } from '@libs/ReportUtils'; import trackExpenseCreationError from '@libs/telemetry/trackExpenseCreationError'; import { + canOverlayReceiptPDF, didReceiptScanSucceed as didReceiptScanSucceedTransactionUtils, hasEReceipt, hasPendingDistanceReceiptRegeneration, @@ -242,9 +243,13 @@ function MoneyRequestReceiptView({ const displayedReceiptSource = ReceiptStorage.resolve(transaction?.receipt?.localSource) ?? transaction?.receipt?.source; const prevDisplayedReceiptSource = usePrevious(displayedReceiptSource); - const [selectedReceiptPage, setSelectedReceiptPage] = useState<{filename?: string; page: number}>({page: CONST.RECEIPT.FIRST_PDF_PAGE}); - const [loadedReceiptPDFFilename, setLoadedReceiptPDFFilename] = useState(); - const [failedReceiptPDFFilename, setFailedReceiptPDFFilename] = useState(); + // The PDF page layer the pill controls, keyed to the receipt source it belongs to. displayedReceiptSource changes + // the instant the receipt's underlying file changes (a scan finishing, or the receipt being replaced), so + // comparing it against `source` resets stale page/status state in the same render, before anything re-fetches. + const [receiptPDFPagesState, setReceiptPDFPagesState] = useState<{source?: string; page: number; status: 'loading' | 'loaded' | 'failed'}>({ + page: CONST.RECEIPT.FIRST_PDF_PAGE, + status: 'loading', + }); useEffect(() => { if (!displayedReceiptSource || prevDisplayedReceiptSource === displayedReceiptSource) { @@ -616,18 +621,18 @@ function MoneyRequestReceiptView({ const shouldShowReceiptPageCount = receiptPageCount > 1 && Str.isPDF(receiptURIs?.filename ?? '') && !isLoading && !(isMapDistanceRequest && isPendingReceiptRegeneration); // Pages can only be flipped where ReportActionItemImage renders the real PDF over the thumbnail (hover-capable - // devices). Elsewhere only a page 1 thumbnail exists, so the static count badge stays. - const canFlipReceiptPages = shouldShowReceiptPageCount && canZoomReceipt && deviceHasHoverSupport; + // devices, using the same eligibility check ReportActionItemImage applies to its own overlay). Elsewhere only a + // page 1 thumbnail exists, so the static count badge stays. + const canFlipReceiptPages = shouldShowReceiptPageCount && canZoomReceipt && deviceHasHoverSupport && canOverlayReceiptPDF(displayedTransaction); - // Page and load state are keyed to the filename, not the source URL: the URL changes from local to remote - // mid-scan without the PDF being reloaded, while a replaced receipt gets a new filename and starts over. - const receiptFilename = receiptURIs?.filename; - const receiptPage = selectedReceiptPage.filename === receiptFilename ? selectedReceiptPage.page : CONST.RECEIPT.FIRST_PDF_PAGE; + const isCurrentReceiptPDFPagesState = receiptPDFPagesState.source === displayedReceiptSource; + const receiptPage = isCurrentReceiptPDFPagesState ? Math.min(receiptPDFPagesState.page, receiptPageCount || CONST.RECEIPT.FIRST_PDF_PAGE) : CONST.RECEIPT.FIRST_PDF_PAGE; // Shown before the PDF loads, with its buttons disabled, so the badge doesn't change size once it can flip. // A PDF that fails to load leaves only the page 1 thumbnail, so the static badge comes back. - const shouldShowReceiptPageNavigator = canFlipReceiptPages && !!receiptFilename && failedReceiptPDFFilename !== receiptFilename; - const isReceiptPDFLoading = loadedReceiptPDFFilename !== receiptFilename; + const hasReceiptPDFFailed = isCurrentReceiptPDFPagesState && receiptPDFPagesState.status === 'failed'; + const shouldShowReceiptPageNavigator = canFlipReceiptPages && !hasReceiptPDFFailed; + const isReceiptPDFLoading = !isCurrentReceiptPDFPagesState || receiptPDFPagesState.status !== 'loaded'; const receiptPendingAction = isDistanceRequest ? getPendingFieldAction('waypoints') : getPendingFieldAction('receipt'); const isReceiptOfflinePending = isOffline && !!receiptPendingAction; @@ -773,8 +778,14 @@ function MoneyRequestReceiptView({ shouldUseFullHeight={fillSpace} canZoomReceipt={canZoomReceipt} pdfPage={canFlipReceiptPages ? receiptPage : undefined} - onPDFLoadSuccess={() => setLoadedReceiptPDFFilename(receiptFilename)} - onPDFLoadFailure={() => setFailedReceiptPDFFilename(receiptFilename)} + onPDFLoadSuccess={() => + setReceiptPDFPagesState((prev) => ({ + source: displayedReceiptSource, + page: prev.source === displayedReceiptSource ? prev.page : CONST.RECEIPT.FIRST_PDF_PAGE, + status: 'loaded', + })) + } + onPDFLoadFailure={() => setReceiptPDFPagesState({source: displayedReceiptSource, page: CONST.RECEIPT.FIRST_PDF_PAGE, status: 'failed'})} thumbnail={receiptURIs?.thumbnail} fileExtension={receiptURIs?.fileExtension} isThumbnail={receiptURIs?.isThumbnail} @@ -799,7 +810,7 @@ function MoneyRequestReceiptView({ page={receiptPage} pageCount={receiptPageCount} isLoading={isReceiptPDFLoading} - onChangePage={(page) => setSelectedReceiptPage({filename: receiptFilename, page})} + onChangePage={(page) => setReceiptPDFPagesState({source: displayedReceiptSource, page, status: 'loaded'})} /> )} {shouldShowReceiptPageCount && !shouldShowReceiptPageNavigator && ( diff --git a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx index 27dd27eb0b6c..c23714c1747b 100644 --- a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx +++ b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx @@ -1,4 +1,4 @@ -import {Document, Thumbnail} from '@components/PDFThumbnail'; +import {Document, PDF_DOCUMENT_OPTIONS, Thumbnail} from '@components/PDFThumbnail'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -35,11 +35,6 @@ const pageContainerStyle = { left: 0, }; -const DOCUMENT_OPTIONS = { - cMapUrl: '/cmaps/', - cMapPacked: true, -}; - type CanvasLimits = { maxCanvasWidth?: number; maxCanvasHeight?: number; @@ -100,11 +95,11 @@ function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure const [maxCanvasHeight] = useOnyx(ONYXKEYS.MAX_CANVAS_HEIGHT); const [maxCanvasWidth] = useOnyx(ONYXKEYS.MAX_CANVAS_WIDTH); const [oversampleWidth, setOversampleWidth] = useState(0); - const isSinglePage = page !== undefined; + const shouldRenderOnlyCurrentPage = page !== undefined; // A ResizeObserver reports the size as soon as it starts observing, unlike onLayout added after mount const observeOversampleWidth = (container: HTMLDivElement | null) => { - if (!container || !isSinglePage) { + if (!container || !shouldRenderOnlyCurrentPage) { return undefined; } const observer = new ResizeObserver(([entry]) => setOversampleWidth(entry.contentRect.width)); @@ -163,7 +158,7 @@ function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure ref={observeOversampleWidth} style={oversampleContainerStyle} > - {!isSinglePage ? ( + {!shouldRenderOnlyCurrentPage ? ( 0 && ( void; - /** Called once the PDF has loaded and its pages can be shown */ + /** Called once the PDF has loaded and its pages can be shown. Only fires when `page` is set. */ onLoadSuccess?: () => void; /** 1-indexed page to show at the top of the overlay */ diff --git a/src/components/ReportActionItem/ReceiptPageNavigator.tsx b/src/components/ReportActionItem/ReceiptPageNavigator.tsx index 16734278cc7d..f53ecfba95ad 100644 --- a/src/components/ReportActionItem/ReceiptPageNavigator.tsx +++ b/src/components/ReportActionItem/ReceiptPageNavigator.tsx @@ -16,8 +16,6 @@ import type IconAsset from '@src/types/utils/IconAsset'; import React from 'react'; import {View} from 'react-native'; -const PAGE_BUTTON_HIT_SLOP = {top: 8, bottom: 8, left: 8, right: 8}; - type PageButtonProps = { /** Arrow icon to show */ icon: IconAsset; @@ -36,6 +34,7 @@ type PageButtonProps = { }; function PageButton({icon, label, isDisabled, onPress, sentryLabel}: PageButtonProps) { + const styles = useThemeStyles(); const theme = useTheme(); return ( @@ -43,13 +42,14 @@ function PageButton({icon, label, isDisabled, onPress, sentryLabel}: PageButtonP text={label} shouldRender={!isDisabled} > + {/* A real tap target around the glyph: react-native-web's Pressable doesn't support hitSlop */} void; /** Called when the high-res PDF overlay fails, leaving only the thumbnail */ @@ -235,11 +235,9 @@ function ReportActionItemImage({ // A remote PDF is shown as the server's low-resolution JPG thumbnail, which blurs when hover-zoomed. // Where zooming is available (web only), render the actual PDF on top of the thumbnail so the magnified // view stays sharp. The thumbnail stays underneath as an instant preview and as a fallback if the PDF fails. - // Map/route distance requests are excluded: their hover overlay is a DistanceEReceipt card, not the PDF. - // isMapBasedDistanceRequest covers map, GPS, and manual-typed transactions that still carry waypoints. // Page navigation also needs the real PDF for local files, whose thumbnail only shows page 1. const pdfSourceURL = typeof originalImageSource === 'string' && !!originalImageSource ? originalImageSource : undefined; - const canOverlayPDF = !!isPDF && !isEReceipt && !isMapBasedDistanceRequest(transaction) && !!pdfSourceURL; + const canOverlayPDF = !!isPDF && canOverlayReceiptPDF(transaction) && !!pdfSourceURL; const shouldOverlayHighResPDF = canZoomReceipt && canOverlayPDF && hasHoverSupport() && (!effectiveIsLocalFile || pdfPage !== undefined); const renderReceiptContent = (receiptImage: React.ReactNode) => diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 55710a43a93d..1479abc5799e 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -415,6 +415,15 @@ function hasReceiptSource(transaction: OnyxInputOrEntry): boolean { return !!transaction?.receipt?.source; } +/** + * Whether a receipt can be shown as a real PDF rendered over its thumbnail, rather than just the static thumbnail. + * E-receipts have no underlying PDF to render, and map-based distance receipts show the map image instead. + */ +function canOverlayReceiptPDF(transaction: OnyxInputOrEntry): boolean { + const isEReceipt = !hasReceiptSource(transaction) && hasEReceipt(transaction); + return !isEReceipt && !isMapBasedDistanceRequest(transaction); +} + /** Check if odometer image has the source file */ function hasOdometerImageSource(transaction: OnyxInputOrEntry, imageType: string): boolean { const odometerImage = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? transaction?.comment?.odometerStartImage : transaction?.comment?.odometerEndImage; @@ -1926,6 +1935,7 @@ export { getDeleteExpenseTitle, getDeleteConfirmationPrompt, hasReceiptSource, + canOverlayReceiptPDF, hasOdometerImageSource, shouldShowAttendees, getAllSortedTransactions, diff --git a/src/styles/index.ts b/src/styles/index.ts index f8149199f122..9ed4c9a6e306 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -305,6 +305,13 @@ const compactPopoverMenuItemBaseStyle = { const COMPOSER_SIZE_BUTTON_SIZE = 40; const COMPOSER_SIZE_BUTTON_MARGIN = 3; +// Shared by receiptPageCountBadge and receiptPageNavigator so the two can't drift apart. +const RECEIPT_PAGE_BADGE_POSITION = { + position: 'absolute' as const, + bottom: 12, + left: 12, +}; + const staticStyles = (theme: ThemeColors) => StyleSheet.create({ ...spacing, @@ -4148,27 +4155,26 @@ const staticStyles = (theme: ThemeColors) => // Float above the receipt to avoid zooming with it. receiptPageCountBadge: { - position: 'absolute', - bottom: 12, - left: 12, + ...RECEIPT_PAGE_BADGE_POSITION, marginLeft: 0, }, receiptPageNavigator: { - position: 'absolute', - bottom: 12, - left: 12, + ...RECEIPT_PAGE_BADGE_POSITION, flexDirection: 'row', alignItems: 'center', minHeight: variables.componentSizeSmall, - paddingHorizontal: 8, - gap: 4, + paddingHorizontal: 4, borderRadius: variables.componentBorderRadiusRounded, backgroundColor: theme.badgeDefaultBG, }, - receiptPageNavigatorLabel: { - whiteSpace: 'nowrap', + // A real tap target around the small arrow glyph; react-native-web's Pressable doesn't support hitSlop + receiptPageNavigatorButton: { + height: 24, + width: 24, + alignItems: 'center', + justifyContent: 'center', }, receiptActionButton: { diff --git a/tests/ui/components/MoneyRequestReceiptViewTest.tsx b/tests/ui/components/MoneyRequestReceiptViewTest.tsx index feb5d1e8e860..0524c3e5c148 100644 --- a/tests/ui/components/MoneyRequestReceiptViewTest.tsx +++ b/tests/ui/components/MoneyRequestReceiptViewTest.tsx @@ -53,7 +53,12 @@ jest.mock( }, ); -const mockReceiptImage: {shouldCompleteLoad: boolean; pdfLoadResult?: 'success' | 'failure'; lastPDFPage?: number} = {shouldCompleteLoad: true}; +// The real PDF overlay reports its own load completion on its own timing, independent of the generic image load +// (onLoad) and of re-renders. Tests drive that timing explicitly via reportPDFLoadSuccess/reportPDFLoadFailure, +// instead of this mock inferring "loaded" from a prop changing, which can't represent a load still in flight. +const mockReceiptImage: {shouldCompleteLoad: boolean; lastPDFPage?: number; reportPDFLoadSuccess?: () => void; reportPDFLoadFailure?: () => void} = { + shouldCompleteLoad: true, +}; jest.mock('@components/ReportActionItem/ReportActionItemImage', () => { const {useEffect} = jest.requireActual('react'); @@ -70,23 +75,20 @@ jest.mock('@components/ReportActionItem/ReportActionItemImage', () => { }) { useEffect(() => { mockReceiptImage.lastPDFPage = pdfPage; - }, [pdfPage]); + if (pdfPage === undefined) { + mockReceiptImage.reportPDFLoadSuccess = undefined; + mockReceiptImage.reportPDFLoadFailure = undefined; + return; + } + mockReceiptImage.reportPDFLoadSuccess = onPDFLoadSuccess; + mockReceiptImage.reportPDFLoadFailure = onPDFLoadFailure; + }, [pdfPage, onPDFLoadSuccess, onPDFLoadFailure]); useEffect(() => { if (!mockReceiptImage.shouldCompleteLoad) { return; } onLoad?.(); }, [onLoad]); - const pdfLoadResult = pdfPage === undefined ? undefined : mockReceiptImage.pdfLoadResult; - useEffect(() => { - if (pdfLoadResult === 'success') { - onPDFLoadSuccess?.(); - } else if (pdfLoadResult === 'failure') { - onPDFLoadFailure?.(); - } - // Report once per load, the way the real PDF overlay does - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pdfLoadResult]); return null; } return MockReportActionItemImage; @@ -301,8 +303,9 @@ describe('MoneyRequestReceiptView', () => { beforeEach(async () => { mockReceiptImage.shouldCompleteLoad = true; - mockReceiptImage.pdfLoadResult = undefined; mockReceiptImage.lastPDFPage = undefined; + mockReceiptImage.reportPDFLoadSuccess = undefined; + mockReceiptImage.reportPDFLoadFailure = undefined; mockDeviceCapabilities.hasHoverSupport = false; jest.clearAllMocks(); await act(async () => { @@ -480,51 +483,117 @@ describe('MoneyRequestReceiptView', () => { }; it('flips pages once the PDF has loaded on a hover-capable device', async () => { + // Given a multi-page PDF receipt on a device that supports hover, with its PDF already loaded mockDeviceCapabilities.hasHoverSupport = true; - mockReceiptImage.pdfLoadResult = 'success'; await renderMultiPagePDFReceipt(); + act(() => mockReceiptImage.reportPDFLoadSuccess?.()); + await waitForBatchedUpdatesWithAct(); expect(screen.getByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeTruthy(); expect(mockReceiptImage.lastPDFPage).toBe(1); + // When the user clicks next fireEvent.press(screen.getByLabelText(translateLocal('common.next'))); await waitForBatchedUpdatesWithAct(); + // Then the pill's label and the page requested from the PDF overlay both move to page 2 expect(screen.getByText(translateLocal('receipt.pageCount', {page: 2, pageCount: 3}))).toBeTruthy(); expect(mockReceiptImage.lastPDFPage).toBe(2); + // When the user clicks previous fireEvent.press(screen.getByLabelText(translateLocal('common.previous'))); await waitForBatchedUpdatesWithAct(); + // Then it goes back to page 1 expect(screen.getByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeTruthy(); expect(mockReceiptImage.lastPDFPage).toBe(1); }); - // Until the PDF loads only the page 1 thumbnail is visible, so flipping would change the label but not the page + it('clamps the current page if the page count drops below it', async () => { + // Given the user has flipped to the last page of a 3-page receipt + mockDeviceCapabilities.hasHoverSupport = true; + await renderMultiPagePDFReceipt(); + act(() => mockReceiptImage.reportPDFLoadSuccess?.()); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByLabelText(translateLocal('common.next'))); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByLabelText(translateLocal('common.next'))); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByText(translateLocal('receipt.pageCount', {page: 3, pageCount: 3}))).toBeTruthy(); + + // When the same receipt's reported page count drops to 2 (e.g. a later scan corrected it) + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TEST_TRANSACTION_ID}`, {receipt: {pageCount: 2}}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the pill clamps down to the new last page, rather than showing "Page 3 of 2" + expect(screen.getByText(translateLocal('receipt.pageCount', {page: 2, pageCount: 2}))).toBeTruthy(); + }); + + it('disables the page buttons again when the receipt is replaced by a same-named file still loading', async () => { + // Given the user has flipped to page 2 of a loaded receipt named receipt.pdf + mockDeviceCapabilities.hasHoverSupport = true; + await renderMultiPagePDFReceipt(); + act(() => mockReceiptImage.reportPDFLoadSuccess?.()); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByLabelText(translateLocal('common.next'))); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByText(translateLocal('receipt.pageCount', {page: 2, pageCount: 3}))).toBeTruthy(); + + // When that receipt is replaced by a new upload with the same filename (a common case: "receipt.pdf" + // uploaded twice) and a different source, before the new file's own load has been reported + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TEST_TRANSACTION_ID}`, { + receipt: {...transactionWithMultiPagePDFReceipt.receipt, source: 'https://example.com/receipt-replacement.pdf'}, + }); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the pill resets to page 1 with both buttons disabled, instead of staying on the old page 2 as if + // the new file were already loaded + expect(screen.getByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeTruthy(); + expect(screen.getByLabelText(translateLocal('common.next'))).toBeDisabled(); + expect(screen.getByLabelText(translateLocal('common.previous'))).toBeDisabled(); + }); + it('disables the page buttons while the PDF is still loading', async () => { + // Given a multi-page PDF receipt on a hover-capable device, whose PDF has not loaded yet mockDeviceCapabilities.hasHoverSupport = true; + + // When the receipt view renders await renderMultiPagePDFReceipt(); + // Then the pill shows right away (so it doesn't change size once it can flip), but both buttons are + // disabled, because until the PDF loads only the page 1 thumbnail exists and there is nothing to flip to expect(screen.getByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeTruthy(); expect(screen.getByLabelText(translateLocal('common.next'))).toBeDisabled(); expect(screen.getByLabelText(translateLocal('common.previous'))).toBeDisabled(); }); - // Only the page 1 thumbnail is left when the PDF can't load, so there is nothing to flip it('falls back to the static badge when the PDF fails to load', async () => { + // Given a multi-page PDF receipt on a hover-capable device mockDeviceCapabilities.hasHoverSupport = true; - mockReceiptImage.pdfLoadResult = 'failure'; await renderMultiPagePDFReceipt(); + // When its PDF fails to load + act(() => mockReceiptImage.reportPDFLoadFailure?.()); + await waitForBatchedUpdatesWithAct(); + + // Then the view falls back to the plain static badge instead of a pill stuck disabled forever, because + // only the page 1 thumbnail exists and there is nothing to flip to expect(screen.getByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeTruthy(); expect(screen.queryByLabelText(translateLocal('common.next'))).toBeNull(); }); - // Without hover support the PDF is never rendered over the thumbnail, so there is nothing to flip it('keeps the static badge on devices without hover support', async () => { - mockReceiptImage.pdfLoadResult = 'success'; + // Given a multi-page PDF receipt on a device without hover support + + // When the receipt view renders await renderMultiPagePDFReceipt(); + // Then the static badge shows instead of the pill, because without hover support the real PDF is never + // drawn over the thumbnail, so there is no page to flip to (and nothing to report as loaded) expect(screen.getByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeTruthy(); expect(screen.queryByLabelText(translateLocal('common.next'))).toBeNull(); expect(mockReceiptImage.lastPDFPage).toBeUndefined(); diff --git a/tests/ui/components/ReceiptPDFOverlayTest.tsx b/tests/ui/components/ReceiptPDFOverlayTest.tsx index 9b6c0070e9d0..ec389d0e4054 100644 --- a/tests/ui/components/ReceiptPDFOverlayTest.tsx +++ b/tests/ui/components/ReceiptPDFOverlayTest.tsx @@ -85,16 +85,24 @@ describe('ReceiptPDFOverlay', () => { }); it('keeps rendering the full PDFPreviewer when no page is requested', () => { + // Given a receipt whose preview has no page navigation (e.g. a single-page PDF, or a device without hover support) + // When the overlay renders with no `page` prop renderOverlay({}); + // Then it falls back to the full multi-page PDFPreviewer instead of rendering a single page expect(mockPDFPreviewer).toHaveBeenCalled(); expect(screen.queryByText('page-1')).toBeNull(); }); it('renders only the requested page, sized like PDFPreviewer, and reports the load', () => { + // Given a multi-page receipt with page navigation on const onLoadSuccess = jest.fn(); + + // When the overlay renders with a page requested renderOverlay({page: 1, onLoadSuccess}); + // Then it draws only that page (not the full PDFPreviewer, which would measure every page first), sized to + // match PDFPreviewer's framing, and tells the caller the PDF is ready so the pill's buttons can enable expect(mockPDFPreviewer).not.toHaveBeenCalled(); expect(screen.getByText('page-1')).toBeTruthy(); expect(onLoadSuccess).toHaveBeenCalledTimes(1); @@ -102,8 +110,10 @@ describe('ReceiptPDFOverlay', () => { }); it('keeps the previous page on screen until the next one has rendered', () => { + // Given page 1 is already drawn const {rerender} = renderOverlay({page: 1}); + // When the user clicks next, before page 2 has finished drawing rerender( { />, ); + // Then page 1 stays on screen alongside page 2, so the thumbnail underneath never flashes through expect(screen.getByText('page-1')).toBeTruthy(); expect(screen.getByText('page-2')).toBeTruthy(); @@ -119,13 +130,16 @@ describe('ReceiptPDFOverlay', () => { mockRenderedThumbnails.findLast((thumbnail) => thumbnail.pageNumber === 2)?.onRenderSuccess?.(); }); + // Then, once page 2 finishes drawing, page 1 is dropped expect(screen.queryByText('page-1')).toBeNull(); expect(screen.getByText('page-2')).toBeTruthy(); }); it('does not carry the last drawn page over to a replaced receipt', () => { + // Given the user is on page 2 of one receipt const {rerender} = renderOverlay({page: 2}); + // When that receipt is replaced by a different file (a new source URL) showing its own page 1 rerender( { />, ); + // Then page 2 of the old file is not left on screen under the new file's page 1 expect(screen.queryByText('page-2')).toBeNull(); expect(screen.getByText('page-1')).toBeTruthy(); }); it('falls back to the thumbnail when a page fails to render', () => { + // Given a page is being drawn const onLoadFailure = jest.fn(); renderOverlay({page: 1, onLoadFailure}); + // When that page fails to render (e.g. a corrupt page), rather than erroring silently act(() => { mockRenderedThumbnails.at(-1)?.onRenderError?.(); }); + // Then the overlay removes itself and tells the caller, so the static thumbnail underneath shows instead expect(screen.queryByText('page-1')).toBeNull(); expect(onLoadFailure).toHaveBeenCalledTimes(1); }); - // Page navigation switches on after the receipt loads, so the overlay first mounts without a page it('loads the page when navigation switches on after mount, without any hover or layout event', () => { + // Given the overlay mounted before page navigation turned on (it only turns on after the receipt loads) const {rerender} = renderOverlay({}); + // When page navigation switches on later, with no further hover or layout event to prompt a measurement rerender( { />, ); + // Then the page still loads, because the width is measured by a ResizeObserver, not a layout event expect(screen.getByText('page-1')).toBeTruthy(); }); }); From dcd96925bf0f1c41ca494dc2e51cc7d8fc80da17 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Fri, 2 Oct 2026 19:29:51 +0530 Subject: [PATCH 13/31] Fix CI typecheck failures receipt.source is typed string | number (ReceiptSource), not string, and canOverlayReceiptPDF's OnyxInputOrEntry param let null through to isMapBasedDistanceRequest, which doesn't accept it. My local tsc run had missed both because it only covered one of the 5 project configs CI checks. --- src/components/ReportActionItem/MoneyRequestReceiptView.tsx | 4 ++-- src/libs/TransactionUtils/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx index c8a0d6b49b04..9b59fca6df6b 100644 --- a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx +++ b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx @@ -86,7 +86,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; -import type {ReceiptError, TransactionPendingFieldsKey} from '@src/types/onyx/Transaction'; +import type {ReceiptError, ReceiptSource, TransactionPendingFieldsKey} from '@src/types/onyx/Transaction'; import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -246,7 +246,7 @@ function MoneyRequestReceiptView({ // The PDF page layer the pill controls, keyed to the receipt source it belongs to. displayedReceiptSource changes // the instant the receipt's underlying file changes (a scan finishing, or the receipt being replaced), so // comparing it against `source` resets stale page/status state in the same render, before anything re-fetches. - const [receiptPDFPagesState, setReceiptPDFPagesState] = useState<{source?: string; page: number; status: 'loading' | 'loaded' | 'failed'}>({ + const [receiptPDFPagesState, setReceiptPDFPagesState] = useState<{source?: ReceiptSource; page: number; status: 'loading' | 'loaded' | 'failed'}>({ page: CONST.RECEIPT.FIRST_PDF_PAGE, status: 'loading', }); diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 1479abc5799e..f75889224ec7 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -419,7 +419,7 @@ function hasReceiptSource(transaction: OnyxInputOrEntry): boolean { * Whether a receipt can be shown as a real PDF rendered over its thumbnail, rather than just the static thumbnail. * E-receipts have no underlying PDF to render, and map-based distance receipts show the map image instead. */ -function canOverlayReceiptPDF(transaction: OnyxInputOrEntry): boolean { +function canOverlayReceiptPDF(transaction: OnyxEntry): boolean { const isEReceipt = !hasReceiptSource(transaction) && hasEReceipt(transaction); return !isEReceipt && !isMapBasedDistanceRequest(transaction); } From 9e0a3226106315af3f55a20d59cd79ca48c014b4 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Sat, 3 Oct 2026 01:58:52 +0530 Subject: [PATCH 14/31] Apply design review: icon colors per state, stop pill jumping Icons now use the icon color statically, dim to 50% when disabled, and switch to the text color on hover, matching the mock's three states. The pill gets a minWidth slightly larger than its content and centers it, so it doesn't visibly jump sideways as the page number's digit count changes. --- .../ReportActionItem/ReceiptPageNavigator.tsx | 10 ++++++++-- src/styles/index.ts | 4 ++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/ReportActionItem/ReceiptPageNavigator.tsx b/src/components/ReportActionItem/ReceiptPageNavigator.tsx index f53ecfba95ad..d035a1357351 100644 --- a/src/components/ReportActionItem/ReceiptPageNavigator.tsx +++ b/src/components/ReportActionItem/ReceiptPageNavigator.tsx @@ -3,6 +3,7 @@ import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeed import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; +import useHover from '@hooks/useHover'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; @@ -36,6 +37,10 @@ type PageButtonProps = { function PageButton({icon, label, isDisabled, onPress, sentryLabel}: PageButtonProps) { const styles = useThemeStyles(); const theme = useTheme(); + const {hovered, bind} = useHover(); + + // Static: icon color. Hover: text color, to read as more prominent/active. Disabled: icon color, dimmed. + const iconFill = !isDisabled && hovered ? theme.text : theme.icon; return ( {/* A real tap target around the glyph: react-native-web's Pressable doesn't support hitSlop */} diff --git a/src/styles/index.ts b/src/styles/index.ts index 9ed4c9a6e306..518d1a0ad9c8 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4163,6 +4163,10 @@ const staticStyles = (theme: ThemeColors) => ...RECEIPT_PAGE_BADGE_POSITION, flexDirection: 'row', alignItems: 'center', + justifyContent: 'center', + // Slightly wider than the content, so the pill doesn't visibly jump sideways as the page number grows + // or shrinks a digit (e.g. 9 -> 10). Content still grows past this if a locale's label needs more room. + minWidth: 150, minHeight: variables.componentSizeSmall, paddingHorizontal: 4, borderRadius: variables.componentBorderRadiusRounded, From 898ff8eb66dd47961b9c8ac48a135af108b2bad4 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Sat, 3 Oct 2026 02:03:07 +0530 Subject: [PATCH 15/31] Shrink the receipt page pill's minWidth, it was too wide --- src/styles/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/index.ts b/src/styles/index.ts index 518d1a0ad9c8..603bd158d8a1 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4166,7 +4166,7 @@ const staticStyles = (theme: ThemeColors) => justifyContent: 'center', // Slightly wider than the content, so the pill doesn't visibly jump sideways as the page number grows // or shrinks a digit (e.g. 9 -> 10). Content still grows past this if a locale's label needs more room. - minWidth: 150, + minWidth: 108, minHeight: variables.componentSizeSmall, paddingHorizontal: 4, borderRadius: variables.componentBorderRadiusRounded, From 49b4326de0e0a628091c8d99d6584f8687d120df Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Mon, 5 Oct 2026 18:41:15 +0530 Subject: [PATCH 16/31] Stop blocking hover-zoom over the page nav pill Design asked for consistency with the existing add-receipt/expand buttons, which never blocked hover-zoom either. Removes the exclusion check added for this earlier. --- src/CONST/index.ts | 3 --- src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts | 8 -------- src/components/ReportActionItem/ReceiptPageNavigator.tsx | 5 +---- 3 files changed, 1 insertion(+), 15 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 9fbe039f2195..a873b83b43df 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1610,9 +1610,6 @@ const CONST = { // view shows real pixels instead of an upscaled low-resolution raster. HOVER_ZOOM_SCALE: 2.5, - // Elements with this data attribute pause the hover zoom while the pointer is over them - HOVER_ZOOM_EXCLUDED_ELEMENT: 'receipt-hover-zoom-excluded', - // react-fast-pdf pads each page by this much. The single-page PDF layer matches it to stay framed the same. PDF_PAGE_BORDER: 9, diff --git a/src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts b/src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts index d81ba370a467..bd8da21a3e3c 100644 --- a/src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts +++ b/src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts @@ -1,7 +1,5 @@ import {hasHoverSupport} from '@libs/DeviceCapabilities'; -import CONST from '@src/CONST'; - import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; @@ -58,12 +56,6 @@ function useReceiptHoverZoom({isEnabled, scale, hoverContainerRef}: UseReceiptHo setIsHovering(false); }; const updateZoomOrigin = (event: PointerEvent) => { - if (event.target instanceof Element && event.target.closest(`[data-${CONST.RECEIPT.HOVER_ZOOM_EXCLUDED_ELEMENT}]`)) { - if (hovering) { - endZoom(); - } - return; - } if (!bounds) { bounds = target.getBoundingClientRect(); } diff --git a/src/components/ReportActionItem/ReceiptPageNavigator.tsx b/src/components/ReportActionItem/ReceiptPageNavigator.tsx index d035a1357351..b643ca7d7b4b 100644 --- a/src/components/ReportActionItem/ReceiptPageNavigator.tsx +++ b/src/components/ReportActionItem/ReceiptPageNavigator.tsx @@ -90,10 +90,7 @@ function ReceiptPageNavigator({page, pageCount, isLoading, onChangePage}: Receip const labelStyle = [styles.badgeText, styles.textStrong, styles.badgeDefaultText, styles.textNoWrap]; return ( - + Date: Tue, 6 Oct 2026 01:39:40 +0530 Subject: [PATCH 17/31] Move the anti-jump minWidth to the label, per design minWidth on the whole pill still let it grow with the buttons attached, so it kept visibly resizing. Moved it to just the label (56px, centered, per shawnborton), so the buttons stay anchored to the pill's edges and only the label's own box grows or shrinks. --- .../ReportActionItem/ReceiptPageNavigator.tsx | 2 +- src/styles/index.ts | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/ReportActionItem/ReceiptPageNavigator.tsx b/src/components/ReportActionItem/ReceiptPageNavigator.tsx index b643ca7d7b4b..03bd97fbce3e 100644 --- a/src/components/ReportActionItem/ReceiptPageNavigator.tsx +++ b/src/components/ReportActionItem/ReceiptPageNavigator.tsx @@ -87,7 +87,7 @@ function ReceiptPageNavigator({page, pageCount, isLoading, onChangePage}: Receip const styles = useThemeStyles(); const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'BackArrow']); - const labelStyle = [styles.badgeText, styles.textStrong, styles.badgeDefaultText, styles.textNoWrap]; + const labelStyle = [styles.badgeText, styles.textStrong, styles.badgeDefaultText, styles.textNoWrap, styles.receiptPageNavigatorLabel]; return ( diff --git a/src/styles/index.ts b/src/styles/index.ts index 38e1caac9a33..2de7ac01141b 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4168,16 +4168,19 @@ const staticStyles = (theme: ThemeColors) => ...RECEIPT_PAGE_BADGE_POSITION, flexDirection: 'row', alignItems: 'center', - justifyContent: 'center', - // Slightly wider than the content, so the pill doesn't visibly jump sideways as the page number grows - // or shrinks a digit (e.g. 9 -> 10). Content still grows past this if a locale's label needs more room. - minWidth: 108, minHeight: variables.componentSizeSmall, paddingHorizontal: 4, borderRadius: variables.componentBorderRadiusRounded, backgroundColor: theme.badgeDefaultBG, }, + // Keeps the buttons from shifting sideways as the page number's digit count changes: only the label grows + // or shrinks, centered within this floor, and only past it if a locale's text genuinely needs more room. + receiptPageNavigatorLabel: { + minWidth: 56, + textAlign: 'center', + }, + // A real tap target around the small arrow glyph; react-native-web's Pressable doesn't support hitSlop receiptPageNavigatorButton: { height: 24, From 0dab55772d701d1a5090ba4349d9a95fe965c27b Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Wed, 7 Oct 2026 15:47:09 +0530 Subject: [PATCH 18/31] Block hover-zoom over the page nav pill after all Danny/Shawn/Dubielzyk reversed the earlier decision: zoom-tracking the cursor over the controls while flipping pages defeats their purpose, since you have to move away from the pill to see the page you just navigated to. Re-adds the exclusion removed in an earlier commit. --- src/CONST/index.ts | 3 +++ src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts | 10 ++++++++++ .../ReportActionItem/ReceiptPageNavigator.tsx | 5 ++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 117a34e7952c..aba2208ac19c 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1617,6 +1617,9 @@ const CONST = { PDF_PAGE_BORDER: 9, FIRST_PDF_PAGE: 1, + + // Elements with this data attribute pause the hover zoom while the pointer is over them + HOVER_ZOOM_EXCLUDED_ELEMENT: 'receipt-hover-zoom-excluded', }, RECEIPT_PREVIEW_TOP_BOTTOM_MARGIN: 120, REPORT: { diff --git a/src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts b/src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts index bd8da21a3e3c..b8b27b05732f 100644 --- a/src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts +++ b/src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts @@ -1,5 +1,7 @@ import {hasHoverSupport} from '@libs/DeviceCapabilities'; +import CONST from '@src/CONST'; + import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; @@ -56,6 +58,14 @@ function useReceiptHoverZoom({isEnabled, scale, hoverContainerRef}: UseReceiptHo setIsHovering(false); }; const updateZoomOrigin = (event: PointerEvent) => { + // The whole point of page navigation is to see the page you just flipped to, so zoom-tracking the + // cursor over the controls themselves (and having to move away to release it) defeats that purpose. + if (event.target instanceof Element && event.target.closest(`[data-${CONST.RECEIPT.HOVER_ZOOM_EXCLUDED_ELEMENT}]`)) { + if (hovering) { + endZoom(); + } + return; + } if (!bounds) { bounds = target.getBoundingClientRect(); } diff --git a/src/components/ReportActionItem/ReceiptPageNavigator.tsx b/src/components/ReportActionItem/ReceiptPageNavigator.tsx index 03bd97fbce3e..255162edce6d 100644 --- a/src/components/ReportActionItem/ReceiptPageNavigator.tsx +++ b/src/components/ReportActionItem/ReceiptPageNavigator.tsx @@ -90,7 +90,10 @@ function ReceiptPageNavigator({page, pageCount, isLoading, onChangePage}: Receip const labelStyle = [styles.badgeText, styles.textStrong, styles.badgeDefaultText, styles.textNoWrap, styles.receiptPageNavigatorLabel]; return ( - + Date: Wed, 7 Oct 2026 15:49:45 +0530 Subject: [PATCH 19/31] Bump the receipt page label's minWidth a bit --- src/styles/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/index.ts b/src/styles/index.ts index 1f1f7d262684..05ef4c13d445 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4194,7 +4194,7 @@ const staticStyles = (theme: ThemeColors) => // Keeps the buttons from shifting sideways as the page number's digit count changes: only the label grows // or shrinks, centered within this floor, and only past it if a locale's text genuinely needs more room. receiptPageNavigatorLabel: { - minWidth: 56, + minWidth: 64, textAlign: 'center', }, From 1aee12c9a87d44301dc9ee28a38ac63934b0a57b Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Wed, 7 Oct 2026 15:57:06 +0530 Subject: [PATCH 20/31] Adjust receipt page navigator label's minWidth from 64 to 60 for improved layout consistency --- src/styles/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/index.ts b/src/styles/index.ts index 05ef4c13d445..869de48f9408 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4194,7 +4194,7 @@ const staticStyles = (theme: ThemeColors) => // Keeps the buttons from shifting sideways as the page number's digit count changes: only the label grows // or shrinks, centered within this floor, and only past it if a locale's text genuinely needs more room. receiptPageNavigatorLabel: { - minWidth: 64, + minWidth: 60, textAlign: 'center', }, From cd16a8a4db80dfdb9265f3cc5cc96be69b4337fa Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Thu, 8 Oct 2026 00:03:29 +0530 Subject: [PATCH 21/31] Try a wider minWidth on the receipt page label --- src/styles/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/index.ts b/src/styles/index.ts index 869de48f9408..f4a4eec620b8 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4194,7 +4194,7 @@ const staticStyles = (theme: ThemeColors) => // Keeps the buttons from shifting sideways as the page number's digit count changes: only the label grows // or shrinks, centered within this floor, and only past it if a locale's text genuinely needs more room. receiptPageNavigatorLabel: { - minWidth: 60, + minWidth: 72, textAlign: 'center', }, From e66139121d00920a5503538d49020f1faac42aef Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Thu, 8 Oct 2026 00:04:56 +0530 Subject: [PATCH 22/31] Revert "Try a wider minWidth on the receipt page label" This reverts commit cd16a8a4db80dfdb9265f3cc5cc96be69b4337fa. --- src/styles/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/index.ts b/src/styles/index.ts index f4a4eec620b8..869de48f9408 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4194,7 +4194,7 @@ const staticStyles = (theme: ThemeColors) => // Keeps the buttons from shifting sideways as the page number's digit count changes: only the label grows // or shrinks, centered within this floor, and only past it if a locale's text genuinely needs more room. receiptPageNavigatorLabel: { - minWidth: 72, + minWidth: 60, textAlign: 'center', }, From d7259c115978517250ea1cf27799e3552a1424c1 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Thu, 8 Oct 2026 00:07:38 +0530 Subject: [PATCH 23/31] Mask the page label instead of reserving width for it minWidth on the label only set a floor; any count past it still grew the pill. Per huult's suggestion: the label is now an absolutely positioned overlay, so it never affects the row's layout. The pill's width comes only from the two buttons and a fixed spacer between them, so it never changes size. overflow: hidden masks a label too long to fit. --- .../ReportActionItem/ReceiptPageNavigator.tsx | 8 ++++-- src/styles/index.ts | 25 +++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/components/ReportActionItem/ReceiptPageNavigator.tsx b/src/components/ReportActionItem/ReceiptPageNavigator.tsx index 255162edce6d..cce64736baa0 100644 --- a/src/components/ReportActionItem/ReceiptPageNavigator.tsx +++ b/src/components/ReportActionItem/ReceiptPageNavigator.tsx @@ -87,7 +87,7 @@ function ReceiptPageNavigator({page, pageCount, isLoading, onChangePage}: Receip const styles = useThemeStyles(); const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'BackArrow']); - const labelStyle = [styles.badgeText, styles.textStrong, styles.badgeDefaultText, styles.textNoWrap, styles.receiptPageNavigatorLabel]; + const labelStyle = [styles.badgeText, styles.textStrong, styles.badgeDefaultText, styles.textNoWrap]; return ( onChangePage(page - 1)} sentryLabel={CONST.SENTRY_LABEL.RECEIPT.PREVIOUS_PAGE_BUTTON} /> - {translate('receipt.pageCount', {page, pageCount})} + onChangePage(page + 1)} sentryLabel={CONST.SENTRY_LABEL.RECEIPT.NEXT_PAGE_BUTTON} /> + {/* Absolutely positioned so its width never affects the row above; see receiptPageNavigator's comment. */} + + {translate('receipt.pageCount', {page, pageCount})} + ); } diff --git a/src/styles/index.ts b/src/styles/index.ts index 869de48f9408..8a6b43209688 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4181,6 +4181,9 @@ const staticStyles = (theme: ThemeColors) => marginLeft: 0, }, + // The label is an absolutely positioned overlay (see receiptPageNavigatorLabelContainer), so it never affects + // this row's own layout. Its width here comes only from the two buttons and the spacer between them, fixed + // regardless of the page number's digit count. overflow: hidden masks a label too long to fit. receiptPageNavigator: { ...RECEIPT_PAGE_BADGE_POSITION, flexDirection: 'row', @@ -4189,13 +4192,25 @@ const staticStyles = (theme: ThemeColors) => paddingHorizontal: 4, borderRadius: variables.componentBorderRadiusRounded, backgroundColor: theme.badgeDefaultBG, + overflow: 'hidden', }, - // Keeps the buttons from shifting sideways as the page number's digit count changes: only the label grows - // or shrinks, centered within this floor, and only past it if a locale's text genuinely needs more room. - receiptPageNavigatorLabel: { - minWidth: 60, - textAlign: 'center', + // Reserves room for the label between the two buttons, roughly the width of a typical "Page N of N". + receiptPageNavigatorSpacer: { + width: 60, + }, + + // Centers the label over the whole pill rather than just the spacer, so a longer label isn't cut off before + // it has the full pill's width to grow into. + receiptPageNavigatorLabelContainer: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', }, // A real tap target around the small arrow glyph; react-native-web's Pressable doesn't support hitSlop From 09adc7f4bf0be7e3de86f86eaf7d1c05f102fdf2 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Thu, 8 Oct 2026 00:14:45 +0530 Subject: [PATCH 24/31] Move page label before buttons in DOM order for screen readers --- src/components/ReportActionItem/ReceiptPageNavigator.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/ReportActionItem/ReceiptPageNavigator.tsx b/src/components/ReportActionItem/ReceiptPageNavigator.tsx index cce64736baa0..49d84a3f930a 100644 --- a/src/components/ReportActionItem/ReceiptPageNavigator.tsx +++ b/src/components/ReportActionItem/ReceiptPageNavigator.tsx @@ -94,6 +94,11 @@ function ReceiptPageNavigator({page, pageCount, isLoading, onChangePage}: Receip style={styles.receiptPageNavigator} dataSet={{[CONST.RECEIPT.HOVER_ZOOM_EXCLUDED_ELEMENT]: true}} > + {/* First in reading order, so a screen reader hits "Page X of Y" before the buttons either side of it. + Absolutely positioned (see receiptPageNavigator's comment), so this has no effect on visual order. */} + + {translate('receipt.pageCount', {page, pageCount})} + onChangePage(page + 1)} sentryLabel={CONST.SENTRY_LABEL.RECEIPT.NEXT_PAGE_BUTTON} /> - {/* Absolutely positioned so its width never affects the row above; see receiptPageNavigator's comment. */} - - {translate('receipt.pageCount', {page, pageCount})} - ); } From 67ac3222aaeec5be0490f102f459089ad2f627cc Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Thu, 8 Oct 2026 00:16:59 +0530 Subject: [PATCH 25/31] Announce page changes to screen readers via live region --- src/components/ReportActionItem/ReceiptPageNavigator.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/ReportActionItem/ReceiptPageNavigator.tsx b/src/components/ReportActionItem/ReceiptPageNavigator.tsx index 49d84a3f930a..e9c870be047c 100644 --- a/src/components/ReportActionItem/ReceiptPageNavigator.tsx +++ b/src/components/ReportActionItem/ReceiptPageNavigator.tsx @@ -95,8 +95,13 @@ function ReceiptPageNavigator({page, pageCount, isLoading, onChangePage}: Receip dataSet={{[CONST.RECEIPT.HOVER_ZOOM_EXCLUDED_ELEMENT]: true}} > {/* First in reading order, so a screen reader hits "Page X of Y" before the buttons either side of it. - Absolutely positioned (see receiptPageNavigator's comment), so this has no effect on visual order. */} - + Absolutely positioned (see receiptPageNavigator's comment), so this has no effect on visual order. + accessibilityLiveRegion announces the new page after a button press, since focus stays on the button. */} + {translate('receipt.pageCount', {page, pageCount})} Date: Fri, 9 Oct 2026 18:08:10 +0530 Subject: [PATCH 26/31] Address review: type the style objects, document FIRST_PDF_PAGE, drop redundant comment, fix page param in test --- src/CONST/index.ts | 1 + .../ReportActionItem/ReceiptPDFOverlay/index.tsx | 8 ++++---- src/components/ReportActionItem/ReceiptPageNavigator.tsx | 3 --- tests/ui/components/MoneyRequestReceiptViewTest.tsx | 2 +- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index dbd3c6cb3fed..b00831e7b3a0 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1629,6 +1629,7 @@ const CONST = { // react-fast-pdf pads each page by this much. The single-page PDF layer matches it to stay framed the same. PDF_PAGE_BORDER: 9, + // PDF page numbers are 1-indexed. This is the page a receipt opens on, and the page the static badge always shows. FIRST_PDF_PAGE: 1, // Elements with this data attribute pause the hover zoom while the pointer is over them diff --git a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx index c23714c1747b..df6582ee17b5 100644 --- a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx +++ b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx @@ -19,8 +19,8 @@ import {View} from 'react-native'; import type ReceiptPDFOverlayProps from './types'; const oversamplePercent = `${CONST.RECEIPT.HOVER_ZOOM_SCALE * 100}%`; -const oversampleContainerStyle = { - position: 'absolute' as const, +const oversampleContainerStyle: React.CSSProperties = { + position: 'absolute', top: 0, left: 0, width: oversamplePercent, @@ -29,8 +29,8 @@ const oversampleContainerStyle = { transformOrigin: 'top left', }; -const pageContainerStyle = { - position: 'absolute' as const, +const pageContainerStyle: React.CSSProperties = { + position: 'absolute', top: CONST.RECEIPT.PDF_PAGE_BORDER, left: 0, }; diff --git a/src/components/ReportActionItem/ReceiptPageNavigator.tsx b/src/components/ReportActionItem/ReceiptPageNavigator.tsx index e9c870be047c..74ff18550b87 100644 --- a/src/components/ReportActionItem/ReceiptPageNavigator.tsx +++ b/src/components/ReportActionItem/ReceiptPageNavigator.tsx @@ -94,9 +94,6 @@ function ReceiptPageNavigator({page, pageCount, isLoading, onChangePage}: Receip style={styles.receiptPageNavigator} dataSet={{[CONST.RECEIPT.HOVER_ZOOM_EXCLUDED_ELEMENT]: true}} > - {/* First in reading order, so a screen reader hits "Page X of Y" before the buttons either side of it. - Absolutely positioned (see receiptPageNavigator's comment), so this has no effect on visual order. - accessibilityLiveRegion announces the new page after a button press, since focus stays on the button. */} { ); await waitForBatchedUpdatesWithAct(); - expect(screen.queryByText(translateLocal('receipt.pageCount', {pageCount: 3}))).toBeNull(); + expect(screen.queryByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeNull(); }); }); From d528280237f70d66594880e08b0bd8ffdd07171a Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Fri, 9 Oct 2026 18:13:42 +0530 Subject: [PATCH 27/31] Size the page navigator pill to its label so long translations and focus outlines aren't clipped --- .../ReportActionItem/ReceiptPageNavigator.tsx | 15 ++++++------- src/styles/index.ts | 22 ++++--------------- 2 files changed, 11 insertions(+), 26 deletions(-) diff --git a/src/components/ReportActionItem/ReceiptPageNavigator.tsx b/src/components/ReportActionItem/ReceiptPageNavigator.tsx index 74ff18550b87..dd7562505da7 100644 --- a/src/components/ReportActionItem/ReceiptPageNavigator.tsx +++ b/src/components/ReportActionItem/ReceiptPageNavigator.tsx @@ -94,13 +94,6 @@ function ReceiptPageNavigator({page, pageCount, isLoading, onChangePage}: Receip style={styles.receiptPageNavigator} dataSet={{[CONST.RECEIPT.HOVER_ZOOM_EXCLUDED_ELEMENT]: true}} > - - {translate('receipt.pageCount', {page, pageCount})} - onChangePage(page - 1)} sentryLabel={CONST.SENTRY_LABEL.RECEIPT.PREVIOUS_PAGE_BUTTON} /> - + + {translate('receipt.pageCount', {page, pageCount})} + marginLeft: 0, }, - // The label is an absolutely positioned overlay (see receiptPageNavigatorLabelContainer), so it never affects - // this row's own layout. Its width here comes only from the two buttons and the spacer between them, fixed - // regardless of the page number's digit count. overflow: hidden masks a label too long to fit. + // The pill grows with its label, so longer translations are never clipped and the buttons' focus outlines stay visible. receiptPageNavigator: { ...RECEIPT_PAGE_BADGE_POSITION, flexDirection: 'row', @@ -4186,25 +4184,13 @@ const staticStyles = (theme: ThemeColors) => paddingHorizontal: 4, borderRadius: variables.componentBorderRadiusRounded, backgroundColor: theme.badgeDefaultBG, - overflow: 'hidden', - }, - - // Reserves room for the label between the two buttons, roughly the width of a typical "Page N of N". - receiptPageNavigatorSpacer: { - width: 60, }, - // Centers the label over the whole pill rather than just the spacer, so a longer label isn't cut off before - // it has the full pill's width to grow into. + // The minimum width, roughly that of a typical "Page N of N", keeps the pill from resizing as the page number gains a digit. receiptPageNavigatorLabelContainer: { - position: 'absolute', - top: 0, - bottom: 0, - left: 0, - right: 0, - flexDirection: 'row', - justifyContent: 'center', + minWidth: 60, alignItems: 'center', + justifyContent: 'center', }, // A real tap target around the small arrow glyph; react-native-web's Pressable doesn't support hitSlop From 3748623cdda96ae8e2ddaede67244168e656e86b Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Fri, 9 Oct 2026 18:14:39 +0530 Subject: [PATCH 28/31] Size the page navigator pill from its widest label instead of a fixed minimum --- .../ReportActionItem/ReceiptPageNavigator.tsx | 21 +++++++++++++------ src/styles/index.ts | 13 ++++++++++-- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/components/ReportActionItem/ReceiptPageNavigator.tsx b/src/components/ReportActionItem/ReceiptPageNavigator.tsx index dd7562505da7..00df1e8b9eb5 100644 --- a/src/components/ReportActionItem/ReceiptPageNavigator.tsx +++ b/src/components/ReportActionItem/ReceiptPageNavigator.tsx @@ -101,12 +101,21 @@ function ReceiptPageNavigator({page, pageCount, isLoading, onChangePage}: Receip onPress={() => onChangePage(page - 1)} sentryLabel={CONST.SENTRY_LABEL.RECEIPT.PREVIOUS_PAGE_BUTTON} /> - - {translate('receipt.pageCount', {page, pageCount})} + + {/* Hidden copy of the widest label (the last page) that sizes the pill, so it neither clips a long translation nor resizes between pages. */} + + {translate('receipt.pageCount', {page: pageCount, pageCount})} + + + {translate('receipt.pageCount', {page, pageCount})} + backgroundColor: theme.badgeDefaultBG, }, - // The minimum width, roughly that of a typical "Page N of N", keeps the pill from resizing as the page number gains a digit. receiptPageNavigatorLabelContainer: { - minWidth: 60, + alignItems: 'center', + justifyContent: 'center', + }, + + // Overlays the hidden widest label that sizes the pill + receiptPageNavigatorCurrentLabel: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, alignItems: 'center', justifyContent: 'center', }, From 033e501f969c3b7a32ec134a11e8d4b11515ebf6 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Fri, 9 Oct 2026 18:15:12 +0530 Subject: [PATCH 29/31] Use absoluteFillObject for the page navigator label overlay --- src/styles/index.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/styles/index.ts b/src/styles/index.ts index 19f2e5e41444..19b264130121 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4193,11 +4193,7 @@ const staticStyles = (theme: ThemeColors) => // Overlays the hidden widest label that sizes the pill receiptPageNavigatorCurrentLabel: { - position: 'absolute', - top: 0, - bottom: 0, - left: 0, - right: 0, + ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center', }, From 8050a9953d85ddc5214bf839c6d0d186ebe6c8f8 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Fri, 9 Oct 2026 18:17:04 +0530 Subject: [PATCH 30/31] Apply oxfmt to CategoryPicker and TransactionUtils --- src/components/CategoryPicker/index.tsx | 18 ++++++++++-------- src/libs/TransactionUtils/index.ts | 14 ++++++++------ 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/components/CategoryPicker/index.tsx b/src/components/CategoryPicker/index.tsx index 63507a8cf5a7..38ceeef243dd 100644 --- a/src/components/CategoryPicker/index.tsx +++ b/src/components/CategoryPicker/index.tsx @@ -105,14 +105,16 @@ function CategoryPicker({ }); const noneOption: OptionTree[] = shouldShowNoneOption - ? getNoneOption(debouncedSearchValue, !selectedCategory, translate).map((option): OptionTree => ({ - text: option.text, - keyForList: option.keyForList, - searchText: '', - tooltipText: option.text, - isDisabled: false, - isSelected: option.isSelected, - })) + ? getNoneOption(debouncedSearchValue, !selectedCategory, translate).map( + (option): OptionTree => ({ + text: option.text, + keyForList: option.keyForList, + searchText: '', + tooltipText: option.text, + isDisabled: false, + isSelected: option.isSelected, + }), + ) : []; const noneOptionSection = { title: '', diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 70862d993771..934f3b8f1761 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -1552,12 +1552,14 @@ function getChildTransactions(transactions: OnyxCollection, origina function createUnreportedExpenses(transactions: Array | undefined>): UnreportedExpenseListItemType[] { return transactions .filter((t): t is Transaction => t !== undefined) - .map((transaction): UnreportedExpenseListItemType => ({ - ...transaction, - isDisabled: isTransactionPendingDelete(transaction), - keyForList: transaction.transactionID, - errors: transaction.errors as Errors | undefined, - })); + .map( + (transaction): UnreportedExpenseListItemType => ({ + ...transaction, + isDisabled: isTransactionPendingDelete(transaction), + keyForList: transaction.transactionID, + errors: transaction.errors as Errors | undefined, + }), + ); } type GetEligibleTransactionsToAddParams = { From 973ee70fc0ae5030181b22101968e9ef7a5299a4 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Fri, 9 Oct 2026 18:22:15 +0530 Subject: [PATCH 31/31] Use StyleSheet.absoluteFill for the page navigator label overlay --- src/styles/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/index.ts b/src/styles/index.ts index 19b264130121..2724c82d7986 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4193,7 +4193,7 @@ const staticStyles = (theme: ThemeColors) => // Overlays the hidden widest label that sizes the pill receiptPageNavigatorCurrentLabel: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, alignItems: 'center', justifyContent: 'center', },