diff --git a/src/components/AutoCompleteSuggestions/index.tsx b/src/components/AutoCompleteSuggestions/index.tsx index 5972e85b172b..6184a2e9aa3d 100644 --- a/src/components/AutoCompleteSuggestions/index.tsx +++ b/src/components/AutoCompleteSuggestions/index.tsx @@ -13,6 +13,7 @@ import React, {useEffect} from 'react'; import type {AutoCompleteSuggestionsProps, MeasureParentContainerAndCursor} from './types'; import AutoCompleteSuggestionsPortal from './AutoCompleteSuggestionsPortal'; +import getBottomSuggestionPadding from './AutoCompleteSuggestionsPortal/getBottomSuggestionPadding'; import getLeftOffset from './getSuggestionsLeftOffset'; import getSuggestionsViewportBottom from './getSuggestionsViewportBottom'; @@ -43,11 +44,13 @@ function isSuggestionMenuRenderedAbove(isEnoughSpaceAboveForBigMenu: boolean, is } type IsEnoughSpaceToRenderMenuAboveCursor = Pick & { - contentHeight: number; + menuHeight: number; topInset: number; }; -function isEnoughSpaceToRenderMenuAboveCursor({y, cursorCoordinates, scrollValue, contentHeight, topInset}: IsEnoughSpaceToRenderMenuAboveCursor): boolean { - return y + (cursorCoordinates.y - scrollValue) > contentHeight + topInset + CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_BOX_MAX_SAFE_DISTANCE; +function isEnoughSpaceToRenderMenuAboveCursor({y, cursorCoordinates, scrollValue, menuHeight, topInset}: IsEnoughSpaceToRenderMenuAboveCursor): boolean { + const gapAboveCursor = Math.max(CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_BOX_MAX_SAFE_DISTANCE, getBottomSuggestionPadding(true)); + + return y + (cursorCoordinates.y - scrollValue) > menuHeight + gapAboveCursor + topInset; } const initialContainerState = { @@ -123,7 +126,7 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu y, cursorCoordinates, scrollValue, - contentHeight: contentMaxHeight, + menuHeight: StyleUtils.getAutoCompleteSuggestionContainerHeight(contentMaxHeight), topInset, }); @@ -134,7 +137,7 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu y, cursorCoordinates, scrollValue, - contentHeight: contentMinHeight, + menuHeight: StyleUtils.getAutoCompleteSuggestionContainerHeight(contentMinHeight), topInset, }); @@ -186,6 +189,7 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu isKeyboardAnimatingRef, isInLandscapeMode, insets, + StyleUtils, ]); // Prevent rendering if container dimensions are not set or if we have no suggestions diff --git a/src/components/DragAndDrop/Provider/index.tsx b/src/components/DragAndDrop/Provider/index.tsx index 71c710555d36..21d86b6fc702 100644 --- a/src/components/DragAndDrop/Provider/index.tsx +++ b/src/components/DragAndDrop/Provider/index.tsx @@ -1,6 +1,8 @@ import useDragAndDrop from '@hooks/useDragAndDrop'; import useThemeStyles from '@hooks/useThemeStyles'; +import {shouldAcceptDrop} from '@libs/DragAndDropUtils'; + import htmlDivElementRef from '@src/types/utils/htmlDivElementRef'; import viewRef from '@src/types/utils/viewRef'; @@ -13,10 +15,6 @@ import type {DragAndDropActionsContextType, DragAndDropProviderProps, DragAndDro import {DragAndDropActionsContext, DragAndDropStateContext} from './DragAndDropContext'; -function shouldAcceptDrop(event: DragEvent): boolean { - return !!event.dataTransfer?.types.some((type) => type === 'Files'); -} - function DragAndDropProvider({children, isDisabled = false, setIsDraggingOver = () => {}}: DragAndDropProviderProps) { const styles = useThemeStyles(); const dropZone = useRef(null); diff --git a/src/components/DropZone/DropZoneWrapper.tsx b/src/components/DropZone/DropZoneWrapper.tsx index d02aa9f3d165..8ca25175118b 100644 --- a/src/components/DropZone/DropZoneWrapper.tsx +++ b/src/components/DropZone/DropZoneWrapper.tsx @@ -1,6 +1,8 @@ import useDragAndDrop from '@hooks/useDragAndDrop'; import useThemeStyles from '@hooks/useThemeStyles'; +import {shouldAcceptDrop} from '@libs/DragAndDropUtils'; + import htmlDivElementRef from '@src/types/utils/htmlDivElementRef'; import viewRef from '@src/types/utils/viewRef'; @@ -21,7 +23,7 @@ function DropZoneWrapper({onDrop, children}: DropZoneWrapperProps) { const dropZone = useRef(null); const {isDraggingOver} = useDragAndDrop({ - shouldAcceptDrop: (event) => !!event.dataTransfer?.types.some((type) => type === 'Files'), + shouldAcceptDrop, onDrop, shouldStopPropagation: false, shouldHandleDragEvent: false, diff --git a/src/components/ReceiptScanDropZone/ReceiptScanDropTarget.tsx b/src/components/ReceiptScanDropZone/ReceiptScanDropTarget.tsx new file mode 100644 index 000000000000..e736b6b7514a --- /dev/null +++ b/src/components/ReceiptScanDropZone/ReceiptScanDropTarget.tsx @@ -0,0 +1,73 @@ +import DropZoneUI from '@components/DropZone/DropZoneUI'; + +import useDragAndDrop from '@hooks/useDragAndDrop'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useReceiptScanDrop from '@hooks/useReceiptScanDrop'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {shouldAcceptDrop} from '@libs/DragAndDropUtils'; + +import htmlDivElementRef from '@src/types/utils/htmlDivElementRef'; + +import type {StyleProp, View, ViewStyle} from 'react-native'; + +import React, {useEffect} from 'react'; +import {View as RNView} from 'react-native'; + +type ReceiptScanDropTargetProps = { + /** Ref to the element the drag events are bound to */ + targetRef: React.RefObject; + + dropWrapperStyle?: StyleProp; + + /** Reports the drag-over state back to the drop zone, which publishes it through DragAndDropStateContext */ + onDraggingOverChange: (isDraggingOver: boolean) => void; +}; + +/** + * Owns the receipt scan drag-and-drop logic and the drop overlay. + */ +function ReceiptScanDropTarget({targetRef, dropWrapperStyle, onDraggingOverChange}: ReceiptScanDropTargetProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['SmartScan']); + const {initScanRequest, auxiliaryUI, isDragDisabled} = useReceiptScanDrop(); + + const {isDraggingOver} = useDragAndDrop({ + dropZone: htmlDivElementRef(targetRef), + onDrop: initScanRequest, + shouldAcceptDrop, + isDisabled: isDragDisabled, + }); + + useEffect(() => { + onDraggingOverChange(isDraggingOver); + return () => onDraggingOverChange(false); + }, [isDraggingOver, onDraggingOverChange]); + + return ( + <> + {isDraggingOver && ( + + + + )} + {auxiliaryUI} + + ); +} + +export default ReceiptScanDropTarget; diff --git a/src/components/ReceiptScanDropZone/index.tsx b/src/components/ReceiptScanDropZone/index.tsx index ceb69a76f828..a7b145724db5 100644 --- a/src/components/ReceiptScanDropZone/index.tsx +++ b/src/components/ReceiptScanDropZone/index.tsx @@ -1,61 +1,44 @@ -import DropZoneUI from '@components/DropZone/DropZoneUI'; - -import useDragAndDrop from '@hooks/useDragAndDrop'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useReceiptScanDrop from '@hooks/useReceiptScanDrop'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import htmlDivElementRef from '@src/types/utils/htmlDivElementRef'; +import {DragAndDropStateContext} from '@components/DragAndDrop/Provider/DragAndDropContext'; +import type {ReactNode, RefObject} from 'react'; import type {StyleProp, View, ViewStyle} from 'react-native'; -import React from 'react'; -import {View as RNView} from 'react-native'; +import React, {useState} from 'react'; + +import ReceiptScanDropTarget from './ReceiptScanDropTarget'; type ReceiptScanDropZoneProps = { - targetRef: React.RefObject; - dropWrapperStyle?: StyleProp; -}; + /** The page content that receipts can be dropped onto */ + children: ReactNode; -function shouldAcceptDrop(event: DragEvent): boolean { - return !!event.dataTransfer?.types.some((type) => type === 'Files'); -} + /** Ref to the container receipts are dropped onto */ + dropZoneRef: RefObject; + + /** Whether the drop zone is disabled, keeping the scan logic unmounted */ + isDisabled?: boolean; -function ReceiptScanDropZone({targetRef, dropWrapperStyle}: ReceiptScanDropZoneProps) { - const styles = useThemeStyles(); - const theme = useTheme(); - const {translate} = useLocalize(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['SmartScan']); - const {initScanRequest, auxiliaryUI, isDragDisabled} = useReceiptScanDrop(); + dropWrapperStyle?: StyleProp; +}; - const {isDraggingOver} = useDragAndDrop({ - dropZone: htmlDivElementRef(targetRef), - onDrop: initScanRequest, - shouldAcceptDrop, - isDisabled: isDragDisabled, - }); +/** + * Turns the page into a receipt scan drop zone and publishes the drag state to it, so components inside can react to a + * file being dragged over the page. + */ +function ReceiptScanDropZone({children, dropZoneRef, isDisabled = false, dropWrapperStyle}: ReceiptScanDropZoneProps) { + const [isDraggingOver, setIsDraggingOver] = useState(false); return ( - <> - {isDraggingOver && ( - - - + // eslint-disable-next-line react/jsx-no-constructed-context-values + + {children} + {!isDisabled && ( + )} - {auxiliaryUI} - + ); } diff --git a/src/libs/DragAndDropUtils.ts b/src/libs/DragAndDropUtils.ts new file mode 100644 index 000000000000..f2c0b55c31da --- /dev/null +++ b/src/libs/DragAndDropUtils.ts @@ -0,0 +1,9 @@ +/** + * Whether the dragged content contains files, which is the only payload our drop zones handle. + */ +function shouldAcceptDrop(event: DragEvent): boolean { + return !!event.dataTransfer?.types.some((type) => type === 'Files'); +} + +// eslint-disable-next-line import/prefer-default-export +export {shouldAcceptDrop}; diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 07ed89225990..a5616ae2432a 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -237,140 +237,140 @@ function SearchPageNarrow({ ref={receiptDropTargetRef} style={styles.flex1} > - - - {!isMobileSelectionModeEnabled ? ( - - - - - - - - - - + + {!isMobileSelectionModeEnabled ? ( + + + + + + + + topBarOffset.set(StyleUtils.searchHeaderDefaultOffset)} /> - + topBarOffset.set(StyleUtils.searchHeaderDefaultOffset)} + /> + + + - - - - + + + - - ) : ( - <> - { - topBarOffset.set(StyleUtils.searchHeaderDefaultOffset); - clearSelectedTransactions(); - turnOffMobileSelectionMode(); - }} - /> - - - )} - - {useStaticRendering && ( - <> - {isInteractive && ( - - )} - {shouldRenderLayoutProbe && } - {!!searchOverlayContent && ( - - {searchOverlayContent} - - )} - - )} - {!useStaticRendering && ( + ) : ( <> - {shouldShowLoadingSkeleton ? ( - - ) : ( - - )} - {shouldRenderLayoutProbe && } - {!!searchOverlayContent && ( - - {searchOverlayContent} - - )} + { + topBarOffset.set(StyleUtils.searchHeaderDefaultOffset); + clearSelectedTransactions(); + turnOffMobileSelectionMode(); + }} + /> + )} + + {useStaticRendering && ( + <> + {isInteractive && ( + + )} + {shouldRenderLayoutProbe && } + {!!searchOverlayContent && ( + + {searchOverlayContent} + + )} + + )} + {!useStaticRendering && ( + <> + {shouldShowLoadingSkeleton ? ( + + ) : ( + + )} + {shouldRenderLayoutProbe && } + {!!searchOverlayContent && ( + + {searchOverlayContent} + + )} + + )} + + - - - - {(!useStaticRendering || isHeaderInteractive) && ( - - )} + + ); } diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index 615e02480a61..6e1e0647c7ef 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -69,7 +69,6 @@ function SearchPageWide({ const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const shouldReserveFooterSpace = hasSelectedTransactions || (shouldAllowFooterTotals && !!searchResults?.search?.count); const {saveScrollOffset} = useContext(ScrollOffsetContext); - const receiptDropTargetRef = useRef(null); const endSubmitNavigationSpans = useEndSubmitNavigationSpans({requireLayout: false}); @@ -94,56 +93,61 @@ function SearchPageWide({ const handleOnBackButtonPress = () => Navigation.goBack(ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery()})); const splitContainerAnimatedStyle = useSearchSidebarContentOffsetStyle(); + const receiptDropTargetRef = useRef(null); return ( - - - {!!queryJSON && ( - <> - - - - {shouldShowLoadingSkeleton ? ( - - ) : ( - - )} - {!!searchOverlayContent && {searchOverlayContent}} - - - - )} - - - {!!queryJSON && } + + {!!queryJSON && ( + <> + + + + {shouldShowLoadingSkeleton ? ( + + ) : ( + + )} + {!!searchOverlayContent && {searchOverlayContent}} + + + + )} + + + ); } diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index f5f33187a3b5..b8b991bf9aef 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -47,10 +47,13 @@ function HomePage() { const [isConciergeMenuVisible, setIsConciergeMenuVisible] = useState(false); return ( - - + - - + ); } diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 81dafe19eaf8..6c04e08ee1ed 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -1041,21 +1041,25 @@ function getBaseAutoCompleteSuggestionContainerStyle({left, bottom, width}: GetB const shouldPreventScroll = shouldPreventScrollOnAutoCompleteSuggestion(); +const suggestionContainerBorderWidth = 2; +const suggestionContainerChromeHeight = 2 * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_INNER_PADDING + (shouldPreventScroll ? suggestionContainerBorderWidth : 0); + /** * Gets the correct position for auto complete suggestion container */ function getAutoCompleteSuggestionContainerStyle(itemsHeight: number): ViewStyle { 'worklet'; - const borderWidth = 2; - const height = itemsHeight + 2 * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_INNER_PADDING + (shouldPreventScroll ? borderWidth : 0); - return { - height, + height: itemsHeight + suggestionContainerChromeHeight, minHeight: CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT, }; } +function getAutoCompleteSuggestionContainerHeight(itemsHeight: number): number { + return itemsHeight + suggestionContainerChromeHeight; +} + function getEmojiReactionBubbleTextStyle(isContextMenu = false): TextStyle { if (isContextMenu) { return { @@ -1413,6 +1417,7 @@ const staticStyleUtils = { displayIfTrue, getAmountFontSizeAndLineHeight, getAmountInputFontSize, + getAutoCompleteSuggestionContainerHeight, getAutoCompleteSuggestionContainerStyle, getAvatarBorderRadius, getAvatarBorderStyle, diff --git a/tests/unit/pages/HomePage.test.tsx b/tests/unit/pages/HomePage.test.tsx index 85e63d22442b..c9ac23465c51 100644 --- a/tests/unit/pages/HomePage.test.tsx +++ b/tests/unit/pages/HomePage.test.tsx @@ -80,8 +80,9 @@ jest.mock('@gorhom/portal', () => { }); jest.mock('@components/ReceiptScanDropZone', () => { - function MockReceiptScanDropZone() { - return null; + // The drop zone wraps the page content, so the mock has to keep rendering its children. + function MockReceiptScanDropZone({children}: {children: React.ReactNode}) { + return children; } return MockReceiptScanDropZone; });