diff --git a/assets/images/coins.svg b/assets/images/coins.svg index 3cf1c0e38204..3c12b4720526 100644 --- a/assets/images/coins.svg +++ b/assets/images/coins.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/images/credit-card-multiple.svg b/assets/images/credit-card-multiple.svg new file mode 100644 index 000000000000..93573995e18a --- /dev/null +++ b/assets/images/credit-card-multiple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/multifactorAuthentication/fingerprint.svg b/assets/images/multifactorAuthentication/fingerprint.svg index f6712ca78e14..6dbc03eb27e6 100644 --- a/assets/images/multifactorAuthentication/fingerprint.svg +++ b/assets/images/multifactorAuthentication/fingerprint.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/images/paycheck.svg b/assets/images/paycheck.svg new file mode 100644 index 000000000000..30e03272ec5d --- /dev/null +++ b/assets/images/paycheck.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/user-arrow-left.svg b/assets/images/user-arrow-left.svg new file mode 100644 index 000000000000..987b13b3cbe6 --- /dev/null +++ b/assets/images/user-arrow-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/CONST/index.ts b/src/CONST/index.ts index c069657e93a9..8011c3a6059d 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -277,6 +277,9 @@ const CONST = { POPOVER_DATE_RANGE_WIDTH: 672, POPOVER_DATE_MAX_HEIGHT: 366, POPOVER_DATE_MIN_HEIGHT: 322, + ADVANCED_FILTERS_POPOVER_HEIGHT: 520, + ADVANCED_FILTERS_POPOVER_WIDTH: 582, + ADVANCED_FILTERS_CONTENT_WIDTH: 331, TOOLTIP_ANIMATION_DURATION: 500, DROPDOWN_SCROLL_THRESHOLD: 5, // Multiplier for gyroscope animation in order to make it a bit more subtle @@ -8469,6 +8472,7 @@ const CONST = { ADVANCED_FILTER_ITEM: 'Search-AdvancedFilterItem', SAVE_SEARCH_BUTTON: 'Search-SaveSearchButton', VIEW_RESULTS_BUTTON: 'Search-ViewResultsButton', + CLEAR_FILTERS_BUTTON: 'Search-ClearFiltersButton', ACTION_CELL_VIEW: 'Search-ActionCellView', ACTION_CELL_PAY: 'Search-ActionCellPay', ACTION_CELL_ACTION: 'Search-ActionCellAction', diff --git a/src/components/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts index bc7e78ddce1f..c76e7a5d80f6 100644 --- a/src/components/Icon/chunks/expensify-icons.chunk.ts +++ b/src/components/Icon/chunks/expensify-icons.chunk.ts @@ -70,6 +70,7 @@ import Copy from '@assets/images/copy.svg'; import CreditCardExclamation from '@assets/images/credit-card-exclamation.svg'; import CreditCardHourglass from '@assets/images/credit-card-hourglass.svg'; import CreditCardLock from '@assets/images/credit-card-lock.svg'; +import CreditCardMultiple from '@assets/images/credit-card-multiple.svg'; import CreditCardWithPlaneHourglass from '@assets/images/credit-card-with-plane-hourglass.svg'; import CreditCardWithPlane from '@assets/images/credit-card-with-plane.svg'; import CreditCard from '@assets/images/creditcard.svg'; @@ -186,6 +187,7 @@ import OfflineCloud from '@assets/images/offline-cloud.svg'; import Offline from '@assets/images/offline.svg'; import Paperclip from '@assets/images/paperclip.svg'; import Pause from '@assets/images/pause.svg'; +import Paycheck from '@assets/images/paycheck.svg'; import Pencil from '@assets/images/pencil.svg'; import Percent from '@assets/images/percent.svg'; import Phone from '@assets/images/phone.svg'; @@ -249,6 +251,7 @@ import TreasureChest from '@assets/images/treasure-chest.svg'; import Unlock from '@assets/images/unlock.svg'; import UploadAlt from '@assets/images/upload-alt.svg'; import Upload from '@assets/images/upload.svg'; +import UserArrowLeft from '@assets/images/user-arrow-left.svg'; import UserCheck from '@assets/images/user-check.svg'; import UserEye from '@assets/images/user-eye.svg'; import UserLock from '@assets/images/user-lock.svg'; @@ -320,6 +323,7 @@ const Expensicons = { ConnectionComplete, Copy, CreditCard, + CreditCardMultiple, Crop, CreditCardHourglass, CreditCardExclamation, @@ -417,6 +421,7 @@ const Expensicons = { OdometerEnd, Paperclip, Pause, + Paycheck, Pencil, Percent, Phone, @@ -462,6 +467,7 @@ const Expensicons = { Upload, UploadAlt, User, + UserArrowLeft, UserCheck, Users, VideoSlash, diff --git a/src/components/PopoverWithMeasuredContent/index.tsx b/src/components/PopoverWithMeasuredContent/index.tsx index 9c063dce4982..29f369ab9da4 100644 --- a/src/components/PopoverWithMeasuredContent/index.tsx +++ b/src/components/PopoverWithMeasuredContent/index.tsx @@ -11,7 +11,7 @@ import type PopoverWithMeasuredContentProps from './types'; * This component is a perf optimization, it return BOTTOM_DOCKED early, for small screens avoiding Popover measurement logic calculations. * It defers rendering of PopoverWithMeasuredContentBase to idle time to avoid blocking more priority UI updates with measurements. */ -function PopoverWithMeasuredContent({shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode, ...props}: PopoverWithMeasuredContentProps) { +function PopoverWithMeasuredContent({shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode, smallScreenModalType, ...props}: PopoverWithMeasuredContentProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -32,7 +32,7 @@ function PopoverWithMeasuredContent({shouldWrapModalChildrenInScrollViewIfBottom return ( & { /** Whether to skip re-measurement when becoming visible (for components with static dimensions) */ shouldSkipRemeasurement?: boolean; + + /** The modal type for modal when rendered in small screen */ + smallScreenModalType?: BaseModalProps['type']; }; export default PopoverWithMeasuredContentProps; diff --git a/src/components/SafeTriangle/index.native.tsx b/src/components/SafeTriangle/index.native.tsx new file mode 100644 index 000000000000..3e39c794c481 --- /dev/null +++ b/src/components/SafeTriangle/index.native.tsx @@ -0,0 +1,11 @@ +import type SafeTriangleProps from './types'; + +/** + * A component that provides a "safe triangle" wrapper. + * On native platforms, hover interactions are not applicable, so this is a no-op wrapper. + */ +function SafeTriangle({children}: SafeTriangleProps) { + return children; +} + +export default SafeTriangle; diff --git a/src/components/SafeTriangle/index.tsx b/src/components/SafeTriangle/index.tsx new file mode 100644 index 000000000000..64ad15112770 --- /dev/null +++ b/src/components/SafeTriangle/index.tsx @@ -0,0 +1,194 @@ +import React, {useEffect, useRef, useState} from 'react'; +import {View} from 'react-native'; +import {Polygon, Svg} from 'react-native-svg'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {isMobile} from '@libs/Browser'; +import htmlDivElementRef from '@src/types/utils/htmlDivElementRef'; +import type SafeTriangleProps from './types'; + +type Point = [number, number]; + +type SafeTriangleOverlayProps = { + submenuRef: React.RefObject; + containerRef: React.RefObject; +}; + +type Rect = { + top: number; + left: number; + width: number; + height: number; +}; + +/** Time in ms before the safe triangle is cleared after cursor stops moving toward submenu */ +const SAFE_TRIANGLE_CLEAR_DELAY_MS = 50; + +const OFFSET = 2; + +function isPointInPolygon(point: Point, polygon: Point[]) { + const [x, y] = point; + let isInside = false; + const length = polygon.length; + for (let i = 0, j = length - 1; i < length; j = i++) { + const [xi, yi] = polygon.at(i) ?? [0, 0]; + const [xj, yj] = polygon.at(j) ?? [0, 0]; + const intersect = yi >= y !== yj >= y && x <= ((xj - xi) * (y - yi)) / (yj - yi) + xi; + if (intersect) { + isInside = !isInside; + } + } + return isInside; +} + +function SafeTriangleOverlay({submenuRef, containerRef}: SafeTriangleOverlayProps) { + const styles = useThemeStyles(); + + const [points, setPoints] = useState(null); + const [svgRect, setSvgRect] = useState(null); + + const apexRef = useRef(null); + const lastCursorPosition = useRef(null); + const lastCursorTime = useRef(0); + const timeoutRef = useRef(undefined); + + const getCursorSpeed = (x: number, y: number): number | null => { + const currentTime = performance.now(); + const elapsedTime = currentTime - lastCursorTime.current; + + if (lastCursorPosition.current === null || elapsedTime === 0) { + lastCursorPosition.current = [x, y]; + lastCursorTime.current = currentTime; + return null; + } + + const deltaX = x - lastCursorPosition.current[0]; + const deltaY = y - lastCursorPosition.current[1]; + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + const speed = distance / elapsedTime; // px / ms + + lastCursorPosition.current = [x, y]; + lastCursorTime.current = currentTime; + + return speed; + }; + + const clearTriangle = () => { + setPoints(null); + setSvgRect(null); + apexRef.current = null; + }; + + const onMouseMove = (event: MouseEvent) => { + clearTimeout(timeoutRef.current); + + const {clientX, clientY} = event; + const speed = getCursorSpeed(clientX, clientY); + + if (!submenuRef.current) { + clearTriangle(); + return; + } + + const rect = submenuRef.current.getBoundingClientRect(); + if (!rect) { + clearTriangle(); + return; + } + + // If speed is slow, update the apex to the current cursor position + if (speed === null || speed < 0.1) { + apexRef.current = [clientX, clientY]; + } + + const [x, y] = apexRef.current ?? [clientX, clientY]; + + // Create a polygon from apex to the submenu's left edge + const cursorPoint: Point = [0, y - rect.top]; + // We subtract OFFSET from x-coordinates to account for the offset in the container's left style + const topLeftSubMenuPoint: Point = [rect.left - x - OFFSET, 0]; + const bottomLeftSubMenuPoint: Point = [rect.left - x - OFFSET, rect.bottom - rect.top]; + + const polygon = [cursorPoint, topLeftSubMenuPoint, bottomLeftSubMenuPoint]; + + // Check if the current mouse position is within the safe triangle + // The polygon points are relative to [x + OFFSET, rect.top], so we adjust the mouse position accordingly + const isSafe = isPointInPolygon([clientX - x + OFFSET, clientY - rect.top], polygon); + + if (isSafe) { + const pointsString = polygon.map((p) => p.join(',')).join(' '); + setPoints(pointsString); + setSvgRect({ + top: rect.top, + left: x + OFFSET, + height: rect.height, + width: rect.left - x - OFFSET, + }); + timeoutRef.current = setTimeout(clearTriangle, SAFE_TRIANGLE_CLEAR_DELAY_MS); + } else { + clearTriangle(); + } + }; + + useEffect(() => { + const container = htmlDivElementRef(containerRef).current; + if (!container) { + return; + } + + container.addEventListener('mousemove', onMouseMove, true); + + return () => { + container.removeEventListener('mousemove', onMouseMove, true); + clearTimeout(timeoutRef.current); + }; + }, [onMouseMove, containerRef]); + + if (!points || !svgRect) { + return null; + } + + return ( + + + + ); +} + +/** + * A component that creates a "safe triangle" area between the cursor and a submenu. + * This prevents the submenu from switching when the user moves the cursor + * diagonally towards the submenu by rendering an invisible SVG overlay. + */ +function SafeTriangle({submenuRef, children}: SafeTriangleProps) { + const styles = useThemeStyles(); + const containerRef = useRef(null); + + if (isMobile()) { + return children; + } + + return ( + + {children} + + + ); +} + +export default SafeTriangle; diff --git a/src/components/SafeTriangle/types.ts b/src/components/SafeTriangle/types.ts new file mode 100644 index 000000000000..5173cbb3acb7 --- /dev/null +++ b/src/components/SafeTriangle/types.ts @@ -0,0 +1,8 @@ +import type {View} from 'react-native'; + +type SafeTriangleProps = { + submenuRef: React.RefObject; + children: React.ReactNode; +}; + +export default SafeTriangleProps; diff --git a/src/components/Search/FilterComponents/CardSelector.tsx b/src/components/Search/FilterComponents/CardSelector.tsx index 5f7e280426f4..ab786d71123f 100644 --- a/src/components/Search/FilterComponents/CardSelector.tsx +++ b/src/components/Search/FilterComponents/CardSelector.tsx @@ -2,9 +2,10 @@ import React, {useEffect} from 'react'; import {View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import type {SearchFilterSelectionListProps} from '@components/Search/types'; import CardListItem from '@components/SelectionList/ListItem/CardListItem'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; -import type {Section} from '@components/SelectionList/SelectionListWithSections/types'; +import type {TextInputOptions} from '@components/SelectionList/types'; import {useCompanyCardFeedIcons} from '@hooks/useCompanyCardIcons'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; @@ -23,12 +24,12 @@ import ONYXKEYS from '@src/ONYXKEYS'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import ListFilterView from './ListFilterViewWrapper'; -type CardSelectorProps = { +type CardSelectorProps = SearchFilterSelectionListProps & { value: string[] | undefined; onChange: (cards: string[]) => void; }; -function CardSelector({value = [], onChange}: CardSelectorProps) { +function CardSelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: CardSelectorProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -41,7 +42,6 @@ function CardSelector({value = [], onChange}: CardSelectorProps) { const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES); const [workspaceCardFeeds, workspaceCardFeedsMetadata] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); - const [searchAdvancedFiltersForm, searchAdvancedFiltersFormMetadata] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); const personalDetails = usePersonalDetails(); useEffect(() => { @@ -72,36 +72,30 @@ function CardSelector({value = [], onChange}: CardSelectorProps) { !!item.cardName?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase()) || (item.isVirtual && translate('workspace.expensifyCard.virtual').toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase())); - let sections: Array> = []; - let itemCount = 0; - let sectionHeaderCount = 0; - - if (searchAdvancedFiltersForm) { - const selectedData = [...individualCardsSectionData, ...closedCardsSectionData].filter((item) => item.isSelected && searchFunction(item)); - const unselectedIndividualCardsData = individualCardsSectionData.filter((item) => !item.isSelected && searchFunction(item)); - const unselectedClosedCardsData = closedCardsSectionData.filter((item) => !item.isSelected && searchFunction(item)); - - itemCount = selectedData.length + unselectedIndividualCardsData.length + unselectedClosedCardsData.length; - sectionHeaderCount = unselectedClosedCardsData.length > 0 ? 1 : 0; - - sections = [ - { - title: undefined, - data: selectedData, - sectionIndex: 0, - }, - { - title: undefined, - data: unselectedIndividualCardsData, - sectionIndex: 1, - }, - { - title: translate('search.filters.card.closedCards'), - data: unselectedClosedCardsData, - sectionIndex: 2, - }, - ]; - } + const selectedData = [...individualCardsSectionData, ...closedCardsSectionData].filter((item) => item.isSelected && searchFunction(item)); + const unselectedIndividualCardsData = individualCardsSectionData.filter((item) => !item.isSelected && searchFunction(item)); + const unselectedClosedCardsData = closedCardsSectionData.filter((item) => !item.isSelected && searchFunction(item)); + + const itemCount = selectedData.length + unselectedIndividualCardsData.length + unselectedClosedCardsData.length; + const sectionHeaderCount = unselectedClosedCardsData.length > 0 ? 1 : 0; + + const sections = [ + { + title: undefined, + data: selectedData, + sectionIndex: 0, + }, + { + title: undefined, + data: unselectedIndividualCardsData, + sectionIndex: 1, + }, + { + title: translate('search.filters.card.closedCards'), + data: unselectedClosedCardsData, + sectionIndex: 2, + }, + ]; const updateNewCards = (item: CardFilterItem) => { if (!item.keyForList) { @@ -117,14 +111,18 @@ function CardSelector({value = [], onChange}: CardSelectorProps) { } }; - const textInputOptions = { + const textInputOptions: TextInputOptions = { value: searchTerm, label: translate('common.search'), onChangeText: setSearchTerm, headerMessage: debouncedSearchTerm.trim() && sections.every((section) => !section.data.length) ? translate('common.noResultsFound') : '', + style: { + containerStyle: selectionListTextInputStyle, + }, + disableAutoFocus: !autoFocus, }; - const isLoadingOnyxData = isLoadingOnyxValue(userCardListMetadata, workspaceCardFeedsMetadata, searchAdvancedFiltersFormMetadata); + const isLoadingOnyxData = isLoadingOnyxValue(userCardListMetadata, workspaceCardFeedsMetadata); const shouldShowLoadingState = isLoadingOnyxData || (!areCardsLoaded && !isOffline); const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'SearchFiltersCardPage', isLoadingFromOnyx: isLoadingOnyxData}; @@ -154,6 +152,8 @@ function CardSelector({value = [], onChange}: CardSelectorProps) { textInputOptions={textInputOptions} shouldStopPropagation canSelectMultiple + style={selectionListStyle} + footerContent={footer} /> )} diff --git a/src/components/Search/FilterComponents/CategorySelector.tsx b/src/components/Search/FilterComponents/CategorySelector.tsx index 57ef195d60fe..27ce0a2626fb 100644 --- a/src/components/Search/FilterComponents/CategorySelector.tsx +++ b/src/components/Search/FilterComponents/CategorySelector.tsx @@ -1,5 +1,6 @@ import React from 'react'; import type {OnyxCollection} from 'react-native-onyx'; +import type {SearchFilterSelectionListProps} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; @@ -11,12 +12,12 @@ import {getEmptyObject} from '@src/types/utils/EmptyObject'; import getEmptyArray from '@src/types/utils/getEmptyArray'; import MultiSelect from './MultiSelect'; -type CategorySelectorProps = { +type CategorySelectorProps = SearchFilterSelectionListProps & { value: string[] | undefined; onChange: (categories: string[]) => void; }; -function CategorySelector({value = [], onChange}: CategorySelectorProps) { +function CategorySelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: CategorySelectorProps) { const {translate} = useLocalize(); const [policyIDs = getEmptyArray()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: filterPolicyIDSelector}); const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); @@ -77,7 +78,11 @@ function CategorySelector({value = [], onChange}: CategorySelectorProps) { value={selectedCategoriesItems} items={categoryItems} isSearchable={categoryItems.length >= CONST.STANDARD_LIST_ITEM_LIMIT} + autoFocus={autoFocus} searchPlaceholder={translate('common.category')} + selectionListTextInputStyle={selectionListTextInputStyle} + selectionListStyle={selectionListStyle} + footer={footer} onChange={(categories) => onChange(categories.map((category) => category.value))} /> ); diff --git a/src/components/Search/FilterComponents/CurrencySelector.tsx b/src/components/Search/FilterComponents/CurrencySelector.tsx index 9a8e1a3374ec..84ff911b10df 100644 --- a/src/components/Search/FilterComponents/CurrencySelector.tsx +++ b/src/components/Search/FilterComponents/CurrencySelector.tsx @@ -1,14 +1,15 @@ import React from 'react'; import {useCurrencyListActions, useCurrencyListState} from '@components/CurrencyListContextProvider'; +import type {SearchFilterSelectionListProps} from '@components/Search/types'; import {getCurrencyOptions} from '@libs/SearchUIUtils'; import MultiSelect from './MultiSelect'; -type CurrencySelectorProps = { +type CurrencySelectorProps = SearchFilterSelectionListProps & { value: string[] | undefined; onChange: (item: string[]) => void; }; -function CurrencySelector({value = [], onChange}: CurrencySelectorProps) { +function CurrencySelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: CurrencySelectorProps) { const {currencyList} = useCurrencyListState(); const {getCurrencySymbol} = useCurrencyListActions(); const currencyOptions = getCurrencyOptions(currencyList, getCurrencySymbol); @@ -18,7 +19,11 @@ function CurrencySelector({value = [], onChange}: CurrencySelectorProps) { onChange(currencies.map((currency) => currency.value))} /> ); diff --git a/src/components/Search/FilterComponents/ExportedToSelector.tsx b/src/components/Search/FilterComponents/ExportedToSelector.tsx index e167f637da8c..07378885dca2 100644 --- a/src/components/Search/FilterComponents/ExportedToSelector.tsx +++ b/src/components/Search/FilterComponents/ExportedToSelector.tsx @@ -2,6 +2,7 @@ import React from 'react'; import {View} from 'react-native'; import type {TupleToUnion} from 'type-fest'; import Icon from '@components/Icon'; +import type {SearchFilterSelectionListProps} from '@components/Search/types'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -20,7 +21,7 @@ import getEmptyArray from '@src/types/utils/getEmptyArray'; import type IconAsset from '@src/types/utils/IconAsset'; import MultiSelect from './MultiSelect'; -type ExportedToSelectorProps = { +type ExportedToSelectorProps = SearchFilterSelectionListProps & { value: string[] | undefined; onChange: (exportedTo: string[]) => void; }; @@ -30,7 +31,7 @@ const STANDARD_EXPORT_TEMPLATE_ID_TO_DISPLAY_LABEL: Record = { [CONST.REPORT.EXPORT_OPTIONS.EXPENSE_LEVEL_EXPORT]: CONST.REPORT.EXPORT_OPTION_LABELS.EXPENSE_LEVEL_EXPORT, }; -function ExportedToSelector({value = [], onChange}: ExportedToSelectorProps) { +function ExportedToSelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: ExportedToSelectorProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const StyleUtils = useStyleUtils(); @@ -136,6 +137,10 @@ function ExportedToSelector({value = [], onChange}: ExportedToSelectorProps) { value={selectedExportedTo} items={exportedToPickerOptions} isSearchable={exportedToPickerOptions.length >= CONST.STANDARD_LIST_ITEM_LIMIT} + autoFocus={autoFocus} + selectionListTextInputStyle={selectionListTextInputStyle} + selectionListStyle={selectionListStyle} + footer={footer} onChange={(exportedTo) => onChange(exportedTo.map((e) => e.value))} /> ); diff --git a/src/components/Search/FilterComponents/FeedSelector.tsx b/src/components/Search/FilterComponents/FeedSelector.tsx index 7b53977b0f7c..18028166c34b 100644 --- a/src/components/Search/FilterComponents/FeedSelector.tsx +++ b/src/components/Search/FilterComponents/FeedSelector.tsx @@ -1,15 +1,16 @@ import React, {useEffect} from 'react'; import useFilterFeedData from '@components/Search/hooks/useFilterFeedData'; +import type {SearchFilterSelectionListProps} from '@components/Search/types'; import useNetwork from '@hooks/useNetwork'; import {openSearchCardFiltersPage} from '@libs/actions/Search'; import MultiSelect from './MultiSelect'; -type FeedSelectorProps = { +type FeedSelectorProps = SearchFilterSelectionListProps & { value: string[] | undefined; onChange: (item: string[]) => void; }; -function FeedSelector({value, onChange}: FeedSelectorProps) { +function FeedSelector({value, selectionListStyle, footer, onChange}: FeedSelectorProps) { const {isOffline} = useNetwork(); const {feedOptions, feedValue} = useFilterFeedData(value); @@ -24,6 +25,8 @@ function FeedSelector({value, onChange}: FeedSelectorProps) { onChange(feeds.map((feed) => feed.value))} /> ); diff --git a/src/components/Search/FilterComponents/InSelector.tsx b/src/components/Search/FilterComponents/InSelector.tsx index f75003429607..56ecbe1b0139 100644 --- a/src/components/Search/FilterComponents/InSelector.tsx +++ b/src/components/Search/FilterComponents/InSelector.tsx @@ -1,8 +1,10 @@ import React, {useEffect} from 'react'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {useOptionsList} from '@components/OptionListContextProvider'; +import type {SearchFilterSelectionListProps} from '@components/Search/types'; import InviteMemberListItem from '@components/SelectionList/ListItem/InviteMemberListItem'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; +import type {TextInputOptions} from '@components/SelectionList/types'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; @@ -22,7 +24,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import passthroughPolicyTagListSelector from '@src/selectors/PolicyTagList'; import ListFilterView from './ListFilterViewWrapper'; -type InSelectorProps = { +type InSelectorProps = SearchFilterSelectionListProps & { value: string[] | undefined; onChange: (ins: string[]) => void; }; @@ -39,7 +41,7 @@ function getSelectedOptionData(option: Option & Pick): O return {...option, isSelected: true, keyForList: option.keyForList ?? option.reportID}; } -function InSelector({value = [], onChange}: InSelectorProps) { +function InSelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: InSelectorProps) { const {translate} = useLocalize(); const personalDetails = usePersonalDetails(); const {options, areOptionsInitialized} = useOptionsList(); @@ -152,11 +154,15 @@ function InSelector({value = [], onChange}: InSelectorProps) { const isLoadingNewOptions = !!isSearchingForReports; const shouldShowLoadingPlaceholder = !areOptionsInitialized || !value || !personalDetails; - const textInputOptions = { + const textInputOptions: TextInputOptions = { value: searchTerm, label: translate('common.search'), onChangeText: setSearchTerm, headerMessage, + style: { + containerStyle: selectionListTextInputStyle, + }, + disableAutoFocus: !autoFocus, }; const itemCount = sections.flatMap((section) => section.data).length; @@ -176,6 +182,8 @@ function InSelector({value = [], onChange}: InSelectorProps) { isLoadingNewOptions={isLoadingNewOptions} shouldShowLoadingPlaceholder={shouldShowLoadingPlaceholder} shouldShowTextInput + style={selectionListStyle} + footerContent={footer} /> ); diff --git a/src/components/Search/FilterComponents/MultiSelect.tsx b/src/components/Search/FilterComponents/MultiSelect.tsx index 585072ed130f..a9d95f113275 100644 --- a/src/components/Search/FilterComponents/MultiSelect.tsx +++ b/src/components/Search/FilterComponents/MultiSelect.tsx @@ -2,9 +2,11 @@ import React, {useState} from 'react'; import type {ReactNode} from 'react'; import {View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; +import type {SearchFilterSelectionListProps} from '@components/Search/types'; import SelectionList from '@components/SelectionList'; import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem'; import type {ListItem} from '@components/SelectionList/ListItem/types'; +import type {TextInputOptions} from '@components/SelectionList/types'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; @@ -21,7 +23,7 @@ type MultiSelectItem = { leftElement?: ReactNode; }; -type MultiSelectProps = { +type MultiSelectProps = SearchFilterSelectionListProps & { /** The list of all items to show up in the list */ items: Array>; @@ -39,9 +41,23 @@ type MultiSelectProps = { /** Whether the data for the popover is loading */ loading?: boolean; + + /** Whether the text input should be auto-focused or not. Defaults to true. */ + autoFocus?: boolean; }; -function MultiSelect({loading, value, items, isSearchable, searchPlaceholder, onChange}: MultiSelectProps) { +function MultiSelect({ + loading, + value, + items, + isSearchable, + searchPlaceholder, + selectionListTextInputStyle, + selectionListStyle, + autoFocus = true, + footer, + onChange, +}: MultiSelectProps) { const theme = useTheme(); const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -77,11 +93,15 @@ function MultiSelect({loading, value, items, isSearchable, sea } }; - const textInputOptions = { + const textInputOptions: TextInputOptions = { value: searchTerm, label: isSearchable ? (searchPlaceholder ?? translate('common.search')) : undefined, onChangeText: setSearchTerm, headerMessage, + style: { + containerStyle: selectionListTextInputStyle, + }, + disableAutoFocus: !autoFocus, }; const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'MultiSelectDataLoading'}; @@ -106,7 +126,8 @@ function MultiSelect({loading, value, items, isSearchable, sea ListItem={MultiSelectListItem} onSelectRow={updateSelectedItems} textInputOptions={textInputOptions} - style={{contentContainerStyle: [styles.pb0]}} + style={{contentContainerStyle: [styles.pb0], ...selectionListStyle}} + footerContent={footer} /> )} diff --git a/src/components/Search/FilterComponents/ReportField/ReportFieldList.tsx b/src/components/Search/FilterComponents/ReportField/ReportFieldList.tsx index c013bb9491ba..3daac4752d80 100644 --- a/src/components/Search/FilterComponents/ReportField/ReportFieldList.tsx +++ b/src/components/Search/FilterComponents/ReportField/ReportFieldList.tsx @@ -4,22 +4,24 @@ import type {PolicyReportField} from '@src/types/onyx'; type ReportFieldListProps = { field: PolicyReportField; - value: string; - onChange: (newValue: string) => void; + value: string | undefined; + allowDeselect?: boolean; + onChange: (newValue: string | undefined) => void; }; -function ReportFieldList({field, value, onChange}: ReportFieldListProps) { +function ReportFieldList({field, value, allowDeselect, onChange}: ReportFieldListProps) { const items = field.values.map((fieldValue) => ({ value: fieldValue, text: fieldValue, })); - const selectedValue = {text: value, value}; + const selectedValue = value ? {text: value, value} : undefined; return ( onChange(item.value)} + allowDeselect={allowDeselect} + onChange={(item) => onChange(item?.value)} hasHeader /> ); diff --git a/src/components/Search/FilterComponents/ReportField/ReportFieldText.tsx b/src/components/Search/FilterComponents/ReportField/ReportFieldText.tsx index 73a082a986d3..98ca3365f13c 100644 --- a/src/components/Search/FilterComponents/ReportField/ReportFieldText.tsx +++ b/src/components/Search/FilterComponents/ReportField/ReportFieldText.tsx @@ -6,7 +6,7 @@ import type {PolicyReportField} from '@src/types/onyx'; type ReportFieldTextProps = { field: PolicyReportField; - value: string; + value: string | undefined; onChange: (newValue: string) => void; }; @@ -20,7 +20,7 @@ function ReportFieldText({field, value, onChange}: ReportFieldTextProps) { onChangeText={onChange} accessibilityLabel={field.name} role={CONST.ROLE.PRESENTATION} - containerStyles={[styles.ph5, styles.mb2]} + containerStyles={[styles.ph5, styles.pv2]} /> ); } diff --git a/src/components/Search/FilterComponents/ReportField/index.tsx b/src/components/Search/FilterComponents/ReportField/index.tsx index 9cbb07164099..6d446634a043 100644 --- a/src/components/Search/FilterComponents/ReportField/index.tsx +++ b/src/components/Search/FilterComponents/ReportField/index.tsx @@ -1,5 +1,5 @@ import React, {useImperativeHandle, useRef, useState} from 'react'; -import {View} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; import type {OnyxCollection} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -35,13 +35,16 @@ type ReportFieldBaseProps = { ref: React.Ref; values: ReportFieldValues | undefined; selectedField: PolicyReportField | null; + allowDeselectSingleSelection?: boolean; + style?: StyleProp; onFieldSelected: (field: PolicyReportField | null) => void; }; type SelectedReportFieldProps = { ref: React.Ref; field: PolicyReportField; - value: string; + value: string | undefined; + allowDeselectSingleSelection?: boolean; }; type SelectedDateReportFieldProps = { @@ -61,8 +64,8 @@ function getFilterKey(fieldName: string) { return `${CONST.SEARCH.REPORT_FIELD.DEFAULT_PREFIX}${suffix}` as const; } -function SelectedReportField({ref, field, value: initialValue}: SelectedReportFieldProps) { - const [value, setValue] = useState(initialValue); +function SelectedReportField({ref, field, value: initialValue, allowDeselectSingleSelection}: SelectedReportFieldProps) { + const [value, setValue] = useState(initialValue); const fieldType = field.type as Exclude, typeof CONST.REPORT_FIELD_TYPES.FORMULA | typeof CONST.REPORT_FIELD_TYPES.DATE>; const UpdateReportFieldComponent = { @@ -88,6 +91,7 @@ function SelectedReportField({ref, field, value: initialValue}: SelectedReportFi ); @@ -129,7 +133,6 @@ function SelectedDateReportField({ref, field, value: initialValue, selectedDateM isDateModifierSelected: () => !!selectedDateModifier, applySelectedFieldAndGoBack: () => { dateFilterRef.current?.save(); - onDateModifierSelected(null); }, resetSelectedFieldAndGoBack: () => { if (selectedDateModifier === CONST.SEARCH.DATE_MODIFIERS.RANGE) { @@ -174,7 +177,7 @@ function SelectedDateReportField({ref, field, value: initialValue, selectedDateM ); } -function ReportFieldBase({ref, values: initialValues = {}, selectedField, onFieldSelected}: ReportFieldBaseProps) { +function ReportFieldBase({ref, values: initialValues = {}, selectedField, allowDeselectSingleSelection, style, onFieldSelected}: ReportFieldBaseProps) { const {translate, localeCompare} = useLocalize(); const styles = useThemeStyles(); const policyReportFieldsSelector = (policies: OnyxCollection) => createAllPolicyReportFieldsSelector(policies, localeCompare); @@ -187,7 +190,7 @@ function ReportFieldBase({ref, values: initialValues = {}, selectedField, onFiel const getValue = (fieldName: string) => { const filterKey = getFilterKey(fieldName); - return values[filterKey]?.trim() ?? ''; + return values[filterKey]?.trim(); }; const getDateValue = (fieldName: string) => { @@ -257,7 +260,7 @@ function ReportFieldBase({ref, values: initialValues = {}, selectedField, onFiel if (selectedField) { return ( - + <> {!selectedDateModifier && ( )} - + ); } @@ -293,7 +297,7 @@ function ReportFieldBase({ref, values: initialValues = {}, selectedField, onFiel }); return ( - + {listItems.map((item) => ( = { value: T; }; -type SingleSelectProps = { +type SingleSelectProps = SearchFilterSelectionListProps & { /** The list of all items to show up in the list */ items: Array>; @@ -21,7 +22,7 @@ type SingleSelectProps = { value: SingleSelectItem | undefined; /** Function to call when changes are applied */ - onChange: (item: SingleSelectItem) => void; + onChange: (item: SingleSelectItem | undefined) => void; /** Whether the search input should be displayed */ isSearchable?: boolean; @@ -29,15 +30,13 @@ type SingleSelectProps = { /** Search input place holder */ searchPlaceholder?: string; - /** Custom styles for the SelectionList */ - selectionListStyle?: SelectionListStyle; - /** Whether SelectionList of popup should stay mounted when popup is not visible. */ shouldShowList?: boolean; /** Custom height for each item in the list */ itemHeight?: number; + allowDeselect?: boolean; hasTitle?: boolean; hasHeader?: boolean; }; @@ -47,12 +46,15 @@ function SingleSelect({ items, isSearchable, searchPlaceholder, + selectionListTextInputStyle, selectionListStyle, shouldShowList = true, hasTitle, hasHeader, - onChange, itemHeight, + footer, + allowDeselect, + onChange, }: SingleSelectProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -96,15 +98,23 @@ function SingleSelect({ return; } + if (allowDeselect && newItem.value === selectedItem?.value) { + setSelectedItem(undefined); + onChange(undefined); + return; + } setSelectedItem(newItem); onChange(newItem); }; - const textInputOptions = { + const textInputOptions: TextInputOptions = { value: searchTerm, label: isSearchable ? (searchPlaceholder ?? translate('common.search')) : undefined, onChangeText: setSearchTerm, headerMessage: noResultsFound ? translate('common.noResultsFound') : undefined, + style: { + containerStyle: selectionListTextInputStyle, + }, }; return ( @@ -130,6 +140,7 @@ function SingleSelect({ shouldUpdateFocusedIndex={isSearchable} initiallyFocusedItemKey={isSearchable ? value?.value : undefined} shouldShowLoadingPlaceholder={!noResultsFound} + footerContent={footer} /> diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index aa3fb23c7892..12651370d21f 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -1,5 +1,6 @@ import React from 'react'; import type {OnyxCollection} from 'react-native-onyx'; +import type {SearchFilterSelectionListProps} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {getCleanedTagName, getTagNamesFromTagsLists} from '@libs/PolicyUtils'; @@ -12,12 +13,12 @@ import {getEmptyObject} from '@src/types/utils/EmptyObject'; import getEmptyArray from '@src/types/utils/getEmptyArray'; import MultiSelect from './MultiSelect'; -type TagSelectorProps = { +type TagSelectorProps = SearchFilterSelectionListProps & { value: string[] | undefined; onChange: (tags: string[]) => void; }; -function TagSelector({value = [], onChange}: TagSelectorProps) { +function TagSelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: TagSelectorProps) { const {translate} = useLocalize(); const [policyIDs = getEmptyArray()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: filterPolicyIDSelector}); const [allPolicyTagLists = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, {selector: passthroughPolicyTagListSelector}); @@ -53,6 +54,10 @@ function TagSelector({value = [], onChange}: TagSelectorProps) { value={selectedTagsItems} items={tagItems} isSearchable={tagItems.length >= CONST.STANDARD_LIST_ITEM_LIMIT} + autoFocus={autoFocus} + selectionListTextInputStyle={selectionListTextInputStyle} + selectionListStyle={selectionListStyle} + footer={footer} onChange={(tags) => onChange(tags.map((tag) => tag.value))} /> ); diff --git a/src/components/Search/FilterComponents/TaxRateSelector.tsx b/src/components/Search/FilterComponents/TaxRateSelector.tsx index 20d0566a03fc..749f9dd820c1 100644 --- a/src/components/Search/FilterComponents/TaxRateSelector.tsx +++ b/src/components/Search/FilterComponents/TaxRateSelector.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type {SearchFilterSelectionListProps} from '@components/Search/types'; import useOnyx from '@hooks/useOnyx'; import {getAllTaxRates} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; @@ -7,12 +8,12 @@ import {filterPolicyIDSelector} from '@src/selectors/Search'; import type {Policy} from '@src/types/onyx'; import MultiSelect from './MultiSelect'; -type TaxRateSelectorProps = { +type TaxRateSelectorProps = SearchFilterSelectionListProps & { value: string[] | undefined; onChange: (taxRates: string[]) => void; }; -function TaxRateSelector({value = [], onChange}: TaxRateSelectorProps) { +function TaxRateSelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: TaxRateSelectorProps) { const [policyIDs] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: filterPolicyIDSelector}); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); @@ -37,6 +38,10 @@ function TaxRateSelector({value = [], onChange}: TaxRateSelectorProps) { value={selectedTaxRates} items={taxItems} isSearchable={taxItems.length >= CONST.STANDARD_LIST_ITEM_LIMIT} + autoFocus={autoFocus} + selectionListTextInputStyle={selectionListTextInputStyle} + selectionListStyle={selectionListStyle} + footer={footer} onChange={(taxRates) => onChange(taxRates.map((taxRate) => taxRate.value))} /> ); diff --git a/src/components/Search/FilterComponents/TypeSelector.tsx b/src/components/Search/FilterComponents/TypeSelector.tsx index 0170b30fd89e..e59b78aa0468 100644 --- a/src/components/Search/FilterComponents/TypeSelector.tsx +++ b/src/components/Search/FilterComponents/TypeSelector.tsx @@ -1,5 +1,6 @@ import React from 'react'; import type {OnyxCollection} from 'react-native-onyx'; +import type {SearchFilterSelectionListProps} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {getTypeOptions} from '@libs/SearchUIUtils'; @@ -9,7 +10,7 @@ import {emailSelector} from '@src/selectors/Session'; import type {Policy} from '@src/types/onyx'; import SingleSelect from './SingleSelect'; -type TypeSelectorProps = { +type TypeSelectorProps = SearchFilterSelectionListProps & { value: string | undefined; onChange: (item: string) => void; }; @@ -43,7 +44,7 @@ function typeOptionsPoliciesSelector(policies: OnyxCollection): OnyxColl return result; } -function TypeSelector({value = CONST.SEARCH.DATA_TYPES.EXPENSE, onChange}: TypeSelectorProps) { +function TypeSelector({value = CONST.SEARCH.DATA_TYPES.EXPENSE, selectionListStyle, footer, onChange}: TypeSelectorProps) { const {translate} = useLocalize(); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: typeOptionsPoliciesSelector}); const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); @@ -55,7 +56,14 @@ function TypeSelector({value = CONST.SEARCH.DATA_TYPES.EXPENSE, onChange}: TypeS // text is only needed when the list is searchable value={{value, text: ''}} items={types} - onChange={(item) => onChange(item.value)} + selectionListStyle={selectionListStyle} + footer={footer} + onChange={(item) => { + if (!item) { + return; + } + onChange(item.value); + }} /> ); } diff --git a/src/components/Search/FilterComponents/UserSelector.tsx b/src/components/Search/FilterComponents/UserSelector.tsx index c153f4428781..8e55f2af07c3 100644 --- a/src/components/Search/FilterComponents/UserSelector.tsx +++ b/src/components/Search/FilterComponents/UserSelector.tsx @@ -1,5 +1,6 @@ import React, {useRef} from 'react'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import type {SearchFilterSelectionListProps} from '@components/Search/types'; import SelectionList from '@components/SelectionList'; import UserSelectionListItem from '@components/SelectionList/ListItem/UserSelectionListItem'; import type {ListItem, SelectionListHandle} from '@components/SelectionList/types'; @@ -13,17 +14,18 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ListFilterWrapper from './ListFilterViewWrapper'; -type UserSelectorProps = { +type UserSelectorProps = SearchFilterSelectionListProps & { value: string[] | undefined; + autoFocus?: boolean; onChange: (options: string[]) => void; }; -function UserSelector({value = [], onChange}: UserSelectorProps) { +function UserSelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus = true, footer, onChange}: UserSelectorProps) { const selectionListRef = useRef | null>(null); const styles = useThemeStyles(); const {translate} = useLocalize(); const personalDetails = usePersonalDetails(); - const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); + const shouldFocusInputOnScreenFocus = autoFocus && canFocusInputOnScreenFocus(); const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); const initialSelectedAccountIDs = value.reduce>((acc, id) => { const participant = personalDetails?.[id]; @@ -72,6 +74,9 @@ function UserSelector({value = [], onChange}: UserSelectorProps) { onChangeText: setSearchTerm, headerMessage, disableAutoFocus: !shouldFocusInputOnScreenFocus, + style: { + containerStyle: selectionListTextInputStyle, + }, } : undefined; @@ -89,7 +94,8 @@ function UserSelector({value = [], onChange}: UserSelectorProps) { onSelectRow={selectUser} isLoadingNewOptions={isLoadingNewOptions} shouldShowLoadingPlaceholder={!areOptionsInitialized} - style={{contentContainerStyle: [styles.pb0]}} + style={{contentContainerStyle: [styles.pb0], ...selectionListStyle}} + footerContent={footer} /> ); diff --git a/src/components/Search/FilterComponents/WorkspaceSelector.tsx b/src/components/Search/FilterComponents/WorkspaceSelector.tsx index 53d0f65ae6a0..00ddc12e2a8e 100644 --- a/src/components/Search/FilterComponents/WorkspaceSelector.tsx +++ b/src/components/Search/FilterComponents/WorkspaceSelector.tsx @@ -1,16 +1,18 @@ import React from 'react'; +import type {SearchFilterSelectionListProps} from '@components/Search/types'; import useAdvancedSearchFilters from '@hooks/useAdvancedSearchFilters'; import type {Icon} from '@src/types/onyx/OnyxCommon'; import type {MultiSelectItem} from './MultiSelect'; import MultiSelect from './MultiSelect'; -type WorkspaceSelectorProps = { +type WorkspaceSelectorProps = SearchFilterSelectionListProps & { policyIDQuery: string[] | undefined; value: string[] | undefined; + autoFocus?: boolean; onChange: (item: string[]) => void; }; -function WorkspaceSelector({policyIDQuery, value, onChange}: WorkspaceSelectorProps) { +function WorkspaceSelector({policyIDQuery, value, selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: WorkspaceSelectorProps) { const {workspaces, shouldShowWorkspaceSearchInput} = useAdvancedSearchFilters(); const workspaceOptions: Array> = workspaces .flatMap((section) => section.data) @@ -28,8 +30,12 @@ function WorkspaceSelector({policyIDQuery, value, onChange}: WorkspaceSelectorPr onChange(policyIDs.map((id) => id.value))} isSearchable={shouldShowWorkspaceSearchInput} + selectionListTextInputStyle={selectionListTextInputStyle} + selectionListStyle={selectionListStyle} + footer={footer} /> ); } diff --git a/src/components/Search/FilterComponents/index.tsx b/src/components/Search/FilterComponents/index.tsx index 4812cebd9d4a..90773737f3d8 100644 --- a/src/components/Search/FilterComponents/index.tsx +++ b/src/components/Search/FilterComponents/index.tsx @@ -1,11 +1,11 @@ import React from 'react'; import type {OnyxEntry} from 'react-native-onyx'; -import type {SearchAmountFilterKeys, SearchDateFilterKeys} from '@components/Search/types'; +import type {SearchAmountFilterKeys, SearchDateFilterKeys, SearchFilterSelectionListProps} from '@components/Search/types'; import TextInput from '@components/TextInput'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {FILTER_LABEL_MAP, getMultiSelectFilterOptions, getSingleSelectFilterOptions} from '@libs/SearchUIUtils'; +import {FILTER_VIEW_MAP, getMultiSelectFilterOptions, getSingleSelectFilterOptions} from '@libs/SearchUIUtils'; import type {SearchFilter} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -25,11 +25,12 @@ import UserSelector from './UserSelector'; import WorkspaceSelector from './WorkspaceSelector'; type FilterKeys = Exclude; -type FilterComponentsProps = { +type FilterComponentsProps = SearchFilterSelectionListProps & { filterKey: FilterKeys; value: SearchAdvancedFiltersForm[FilterKeys] | undefined; policyIDQuery: string[] | undefined; - onChange: (value: SearchAdvancedFiltersForm[FilterKeys]) => void; + allowDeselectSingleSelection?: boolean; + onChange: (value: SearchAdvancedFiltersForm[FilterKeys] | undefined) => void; }; type TextInputFilterComponentsProps = { @@ -41,14 +42,16 @@ type TextInputFilterComponentsProps = { | typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.TITLE | typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_ID; value: string | undefined; + autoFocus?: boolean; onChange: (value: string) => void; }; type SingleSelectFilterKeys = typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.BILLABLE | typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.REIMBURSABLE | typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_TYPE; -type SingleSelectFilterComponentsProps = { +type SingleSelectFilterComponentsProps = SearchFilterSelectionListProps & { filterKey: SingleSelectFilterKeys; value: SearchAdvancedFiltersForm[SingleSelectFilterKeys] | undefined; - onChange: (value: SearchAdvancedFiltersForm[SingleSelectFilterKeys]) => void; + allowDeselect: boolean; + onChange: (value: SearchAdvancedFiltersForm[SingleSelectFilterKeys] | undefined) => void; }; type MultiSelectFilterKeys = @@ -57,17 +60,17 @@ type MultiSelectFilterKeys = | typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE | typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_STATUS | typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS; -type MultiSelectFilterComponentsProps = { +type MultiSelectFilterComponentsProps = SearchFilterSelectionListProps & { filterKey: MultiSelectFilterKeys; value: SearchAdvancedFiltersForm[MultiSelectFilterKeys] | undefined; onChange: (values: SearchAdvancedFiltersForm[MultiSelectFilterKeys]) => void; }; -function TextInputFilterComponents({filterKey, value, onChange}: TextInputFilterComponentsProps) { +function TextInputFilterComponents({filterKey, value, autoFocus, onChange}: TextInputFilterComponentsProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const label = translate(FILTER_LABEL_MAP[filterKey]); + const label = translate(FILTER_VIEW_MAP[filterKey].labelKey); return ( ); } -function SingleSelectFilterComponents({filterKey, value, onChange}: SingleSelectFilterComponentsProps) { +function SingleSelectFilterComponents({filterKey, value, selectionListTextInputStyle, selectionListStyle, footer, allowDeselect, onChange}: SingleSelectFilterComponentsProps) { const {translate} = useLocalize(); const items = getSingleSelectFilterOptions(filterKey, translate); @@ -88,12 +92,16 @@ function SingleSelectFilterComponents({filterKey, value, onChange}: SingleSelect option.value === value)} - onChange={(item) => onChange(item.value)} + selectionListTextInputStyle={selectionListTextInputStyle} + selectionListStyle={selectionListStyle} + footer={footer} + allowDeselect={allowDeselect} + onChange={(item) => onChange(item?.value)} /> ); } -function MultiSelectFilterComponents({filterKey, value = [], onChange}: MultiSelectFilterComponentsProps) { +function MultiSelectFilterComponents({filterKey, value = [], selectionListStyle, footer, onChange}: MultiSelectFilterComponentsProps) { const {translate} = useLocalize(); const typeSelector = (searchAdvancedFiltersForm: OnyxEntry) => { return searchAdvancedFiltersForm?.type; @@ -109,6 +117,8 @@ function MultiSelectFilterComponents({filterKey, value = [], onChange}: MultiSel { if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS) { onChange(selectedItems.length > 0 ? selectedItems.map((item) => item.value) : CONST.SEARCH.STATUS.EXPENSE.ALL); @@ -120,7 +130,17 @@ function MultiSelectFilterComponents({filterKey, value = [], onChange}: MultiSel ); } -function FilterComponents({filterKey, value, policyIDQuery, onChange}: FilterComponentsProps) { +function FilterComponents({ + filterKey, + value, + policyIDQuery, + selectionListTextInputStyle, + selectionListStyle, + autoFocus, + footer, + allowDeselectSingleSelection, + onChange, +}: FilterComponentsProps) { switch (filterKey) { case CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED: case CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID: @@ -141,6 +161,10 @@ function FilterComponents({filterKey, value, policyIDQuery, onChange}: FilterCom return ( ); @@ -149,6 +173,8 @@ function FilterComponents({filterKey, value, policyIDQuery, onChange}: FilterCom return ( ); @@ -164,6 +190,7 @@ function FilterComponents({filterKey, value, policyIDQuery, onChange}: FilterCom key={filterKey} filterKey={filterKey} value={value as string | undefined} + autoFocus={autoFocus} onChange={onChange} /> ); @@ -174,6 +201,10 @@ function FilterComponents({filterKey, value, policyIDQuery, onChange}: FilterCom ); @@ -186,6 +217,10 @@ function FilterComponents({filterKey, value, policyIDQuery, onChange}: FilterCom key={filterKey} filterKey={filterKey} value={value as SingleSelectFilterComponentsProps['value'] | undefined} + selectionListTextInputStyle={selectionListTextInputStyle} + selectionListStyle={selectionListStyle} + footer={footer} + allowDeselect={!!allowDeselectSingleSelection} onChange={onChange} /> ); @@ -200,6 +235,9 @@ function FilterComponents({filterKey, value, policyIDQuery, onChange}: FilterCom key={filterKey} filterKey={filterKey} value={value} + selectionListTextInputStyle={selectionListTextInputStyle} + selectionListStyle={selectionListStyle} + footer={footer} onChange={onChange} /> ); @@ -212,6 +250,10 @@ function FilterComponents({filterKey, value, policyIDQuery, onChange}: FilterCom ); @@ -220,6 +262,10 @@ function FilterComponents({filterKey, value, policyIDQuery, onChange}: FilterCom ); diff --git a/src/components/Search/FilterDropdowns/AdvancedFilters/AdvancedFiltersFullscreen.tsx b/src/components/Search/FilterDropdowns/AdvancedFilters/AdvancedFiltersFullscreen.tsx new file mode 100644 index 000000000000..061abe5c0ae4 --- /dev/null +++ b/src/components/Search/FilterDropdowns/AdvancedFilters/AdvancedFiltersFullscreen.tsx @@ -0,0 +1,100 @@ +import React, {useState} from 'react'; +import {View} from 'react-native'; +import Button from '@components/Button'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapperContainer from '@components/ScreenWrapper/ScreenWrapperContainer'; +import type {PopoverComponentProps} from '@components/Search/FilterDropdowns/FilterPopupButton'; +import useUpdateFilterQuery from '@components/Search/hooks/useUpdateFilterQuery'; +import type {SearchQueryJSON} from '@components/Search/types'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {getAdvancedFiltersToReset} from '@libs/SearchQueryUtils'; +import {FILTER_VIEW_MAP} from '@libs/SearchUIUtils'; +import type {SearchFilter} from '@libs/SearchUIUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import FilterList from './FilterList'; +import SelectedFilterContent from './SelectedFilterContent'; + +type AdvancedFiltersFullscreenProps = { + queryJSON: SearchQueryJSON; + closeOverlay: PopoverComponentProps['closeOverlay']; +}; + +function AdvancedFiltersFullscreen({queryJSON, closeOverlay}: AdvancedFiltersFullscreenProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); + + const [selectedFilter, setSelectedFilter] = useState(null); + const [values, setValues] = useState>(searchAdvancedFiltersForm ?? {}); + + const updateFilterQueryParams = useUpdateFilterQuery(queryJSON, true); + + const advancedFiltersToReset = searchAdvancedFiltersForm ? getAdvancedFiltersToReset(searchAdvancedFiltersForm) : undefined; + + return ( + + { + if (selectedFilter) { + setSelectedFilter(null); + return; + } + closeOverlay(); + }} + /> + + {!!selectedFilter && ( + + { + setValues((prevValues) => ({...prevValues, ...newValues})); + setSelectedFilter(null); + }} + /> + + )} + {!selectedFilter && ( + <> + {!isEmptyObject(advancedFiltersToReset) && ( + @@ -208,36 +102,9 @@ function DropdownButton({ )} )} - - {/* Dropdown overlay */} - - {popoverContent} - - + /> ); } -export type {PopoverComponentProps, DropdownButtonProps}; -export default withViewportOffsetTop(DropdownButton); +export default DropdownButton; +export type {DropdownButtonProps}; diff --git a/src/components/Search/FilterDropdowns/FilterPopupButton.tsx b/src/components/Search/FilterDropdowns/FilterPopupButton.tsx new file mode 100644 index 000000000000..adddb13d4259 --- /dev/null +++ b/src/components/Search/FilterDropdowns/FilterPopupButton.tsx @@ -0,0 +1,156 @@ +import {willAlertModalBecomeVisibleSelector} from '@selectors/Modal'; +import type {ReactNode, RefObject} from 'react'; +import React, {useRef, useState} from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent'; +import type PopoverWithMeasuredContentProps from '@components/PopoverWithMeasuredContent/types'; +import withViewportOffsetTop from '@components/withViewportOffsetTop'; +import useOnyx from '@hooks/useOnyx'; +import usePopoverPosition from '@hooks/usePopoverPosition'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; + +type PopoverComponentProps = { + isExpanded: boolean; + closeOverlay: () => void; + setPopoverWidth?: (width: number | undefined) => void; +}; + +type ButtonComponentProps = { + onPress: () => void; + ref: RefObject; + isExpanded: boolean; +}; + +type FilterPopupButtonProps = { + /** The viewport's offset */ + viewportOffsetTop: number; + + /** Wrapper style for the outer view */ + wrapperStyle?: StyleProp; + + outerModalStyle?: ViewStyle; + popoverWidth?: number; + popoverAnchorAlignment?: AnchorAlignment; + smallScreenModalType?: PopoverWithMeasuredContentProps['smallScreenModalType']; + + /** The component to render in the popover */ + PopoverComponent: (props: PopoverComponentProps) => ReactNode; + + /** The component to render as the button */ + renderButton: (props: ButtonComponentProps) => ReactNode; +}; + +const ANCHOR_ORIGIN = { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, +}; + +function FilterPopupButton({ + viewportOffsetTop, + popoverWidth, + wrapperStyle, + outerModalStyle, + smallScreenModalType, + popoverAnchorAlignment: popoverAnchorAlignmentProp, + PopoverComponent, + renderButton, +}: FilterPopupButtonProps) { + // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to distinguish RHP and narrow layout + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {windowHeight} = useWindowDimensions(); + const triggerRef = useRef(null); + const anchorRef = useRef(null); + const [isOverlayVisible, setIsOverlayVisible] = useState(false); + const [customPopoverWidth, setCustomPopoverWidth] = useState(undefined); + const {calculatePopoverPosition} = usePopoverPosition(); + + const [popoverTriggerPosition, setPopoverTriggerPosition] = useState({ + horizontal: 0, + vertical: 0, + }); + + const [willAlertModalBecomeVisible] = useOnyx(ONYXKEYS.MODAL, {selector: willAlertModalBecomeVisibleSelector}); + + const popoverAnchorAlignment = popoverAnchorAlignmentProp ?? ANCHOR_ORIGIN; + + /** + * Toggle the overlay between open & closed + */ + const toggleOverlay = () => { + setIsOverlayVisible((previousValue) => { + if (!previousValue && willAlertModalBecomeVisible) { + return false; + } + + return !previousValue; + }); + }; + + /** + * Calculate popover position and toggle overlay + */ + const calculatePopoverPositionAndToggleOverlay = () => { + calculatePopoverPosition(anchorRef, popoverAnchorAlignment).then((position) => { + setPopoverTriggerPosition({...position, vertical: position.vertical}); + toggleOverlay(); + }); + }; + + const actualPopoverWidth = customPopoverWidth ?? popoverWidth ?? CONST.POPOVER_DROPDOWN_WIDTH; + const containerStyles = isSmallScreenWidth ? styles.w100 : {width: actualPopoverWidth}; + + const popoverContent = PopoverComponent({closeOverlay: toggleOverlay, isExpanded: isOverlayVisible, setPopoverWidth: setCustomPopoverWidth}); + + return ( + + {/* Dropdown Trigger */} + {renderButton({ref: triggerRef, onPress: calculatePopoverPositionAndToggleOverlay, isExpanded: isOverlayVisible})} + + {/* Dropdown overlay */} + + {popoverContent} + + + ); +} + +export type {PopoverComponentProps, FilterPopupButtonProps}; +export default withViewportOffsetTop(FilterPopupButton); diff --git a/src/components/Search/SearchList/BaseSearchList/index.tsx b/src/components/Search/SearchList/BaseSearchList/index.tsx index 8af495aaacb5..7ac21fc26c9f 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.tsx @@ -2,17 +2,21 @@ import {useIsFocused} from '@react-navigation/native'; import {FlashList} from '@shopify/flash-list'; import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import type {GestureResponderEvent, NativeSyntheticEvent} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import Animated from 'react-native-reanimated'; import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; import {useEditingCellState} from '@components/TransactionItemRow/EditableCell'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; +import useOnyx from '@hooks/useOnyx'; import useStableIndexedHandler from '@hooks/useStableIndexedHandler'; import {isMobileChrome} from '@libs/Browser'; import {addKeyDownPressListener, removeKeyDownPressListener} from '@libs/KeyboardShortcut/KeyDownPressListener'; import {isFocusRestoreInProgress} from '@libs/NavigationFocusReturn'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Modal} from '@src/types/onyx'; import type BaseSearchListProps from './types'; const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList); @@ -42,6 +46,9 @@ function BaseSearchList({ const isFocused = useIsFocused(); const {focusedCellId, isEditingCell} = useEditingCellState(); + const modalVisibilitySelector = (modal: OnyxEntry) => modal?.isVisible; + const [isModalVisible] = useOnyx(ONYXKEYS.MODAL, {selector: modalVisibilitySelector}); + const setHasKeyBeenPressed = useCallback(() => { if (hasKeyBeenPressed.current) { return; @@ -54,7 +61,7 @@ function BaseSearchList({ const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({ initialFocusedIndex: -1, maxIndex: flattenedItemsLength - 1, - isActive: isFocused, + isActive: isFocused && !isModalVisible, onFocusedIndexChange: (index: number) => { scrollToIndex?.(index); }, @@ -118,7 +125,7 @@ function BaseSearchList({ captureOnInputs: true, shouldBubble: false, shouldPreventDefault: false, - isActive: isFocused && focusedIndex >= 0, + isActive: isFocused && focusedIndex >= 0 && !isModalVisible, // Propagation is controlled manually in selectFocusedOption based on editing state shouldStopPropagation: false, }); diff --git a/src/components/Search/SearchPageHeader/DatePickerFilterPopup.tsx b/src/components/Search/SearchPageHeader/DatePickerFilterPopup.tsx index 7db38d1c2304..cdcfa30b78af 100644 --- a/src/components/Search/SearchPageHeader/DatePickerFilterPopup.tsx +++ b/src/components/Search/SearchPageHeader/DatePickerFilterPopup.tsx @@ -1,6 +1,6 @@ import React from 'react'; import DateSelectPopup from '@components/Search/FilterDropdowns/DateSelectPopup'; -import type {PopoverComponentProps} from '@components/Search/FilterDropdowns/DropdownButton'; +import type {PopoverComponentProps} from '@components/Search/FilterDropdowns/FilterPopupButton'; import type {SearchDateFilterKeys} from '@components/Search/types'; import type {SearchDateValues} from '@libs/SearchQueryUtils'; import {getDatePresets} from '@libs/SearchUIUtils'; diff --git a/src/components/Search/SearchPageHeader/SearchAdvancedFiltersButton.tsx b/src/components/Search/SearchPageHeader/SearchAdvancedFiltersButton.tsx index f701468e8fa4..e63068085d15 100644 --- a/src/components/Search/SearchPageHeader/SearchAdvancedFiltersButton.tsx +++ b/src/components/Search/SearchPageHeader/SearchAdvancedFiltersButton.tsx @@ -2,6 +2,10 @@ import React from 'react'; import Button from '@components/Button'; import Icon from '@components/Icon'; import {PressableWithFeedback} from '@components/Pressable'; +import AdvancedFilters from '@components/Search/FilterDropdowns/AdvancedFilters'; +import useFullscreenAdvancedFilters from '@components/Search/FilterDropdowns/AdvancedFilters/useFullscreenAdvancedFilters'; +import FilterPopupButton from '@components/Search/FilterDropdowns/FilterPopupButton'; +import type {PopoverComponentProps} from '@components/Search/FilterDropdowns/FilterPopupButton'; import type {SearchQueryJSON} from '@components/Search/types'; import useFilterFormValues from '@hooks/useFilterFormValues'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -10,10 +14,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchFilterSync from '@hooks/useSearchFilterSync'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {updateAdvancedFilters} from '@libs/actions/Search'; -import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; type SearchAdvancedFiltersButtonProp = { queryJSON: SearchQueryJSON; @@ -23,44 +24,70 @@ function SearchAdvancedFiltersButton({queryJSON}: SearchAdvancedFiltersButtonPro const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); - const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); + const fullscreen = useFullscreenAdvancedFilters(); + const {isMediumScreenWidth} = useResponsiveLayout(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Filter']); const filterFormValues = useFilterFormValues(queryJSON); useSearchFilterSync(queryJSON, filterFormValues); - const openAdvancedFilters = () => { - updateAdvancedFilters(filterFormValues); - Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS.getRoute()); - }; + const filtersPopup = ({closeOverlay}: PopoverComponentProps) => ( + + ); - if (shouldUseNarrowLayout || isMediumScreenWidth) { + if (fullscreen || isMediumScreenWidth) { return ( - - - + ( + + + + )} + smallScreenModalType={fullscreen ? CONST.MODAL.MODAL_TYPE.CENTERED_SWIPEABLE_TO_RIGHT : undefined} + // The default outerModalStyle in FilterPopupButton apply a max height to avoid keyboard. We don't want + // that in fullscreen because the keyboard avoiding logic will be handled by the ScreenWrapperContainer in AdvancedFiltersFullscreen. + outerModalStyle={fullscreen ? {} : undefined} + /> ); } return ( -