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) => (