diff --git a/src/components/Search/FilterComponents/InSelector.tsx b/src/components/Search/FilterComponents/InSelector.tsx
index 30242c8e2c64..26072c9f6de8 100644
--- a/src/components/Search/FilterComponents/InSelector.tsx
+++ b/src/components/Search/FilterComponents/InSelector.tsx
@@ -7,6 +7,7 @@ import type {TextInputOptions} from '@components/SelectionList/types';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useDebouncedState from '@hooks/useDebouncedState';
import useFilteredOptions from '@hooks/useFilteredOptions';
+import useInitialValue from '@hooks/useInitialValue';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap';
@@ -16,7 +17,7 @@ import useSortedActions from '@hooks/useSortedActions';
import {searchInServer} from '@libs/actions/Report';
import {canUseTouchScreen} from '@libs/DeviceCapabilities';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
-import {createOptionFromReport, filterAndOrderOptions, formatSectionsFromSearchTerm, getAlternateText, getSearchOptions} from '@libs/OptionsListUtils';
+import {createOptionFromReport, filterAndOrderOptions, getAlternateText, getSearchOptions} from '@libs/OptionsListUtils';
import type {Option, OptionWithKey, SelectionListSections} from '@libs/OptionsListUtils/types';
import type {OptionData} from '@libs/ReportUtils';
import {expensifyLoginsSelector} from '@libs/UserUtils';
@@ -74,24 +75,27 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle
const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID);
const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector});
- const selectedOptions: OptionData[] = value.map((id) => {
+ const buildReportOption = (id: string, isSelected: boolean): OptionData => {
const privateIsArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${id}`];
const reportData = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${id}`];
const reportPolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${reportData?.policyID}`];
- const report = getSelectedOptionData(
- createOptionFromReport(
- {...reportData, reportID: id},
- personalDetails,
- privateIsArchived,
- reportPolicy,
- sortedActions,
- reportAttributesDerived,
- undefined,
- undefined,
- undefined,
- isTrackIntentUser,
+ const report = {
+ ...getSelectedOptionData(
+ createOptionFromReport(
+ {...reportData, reportID: id},
+ personalDetails,
+ privateIsArchived,
+ reportPolicy,
+ sortedActions,
+ reportAttributesDerived,
+ undefined,
+ undefined,
+ undefined,
+ isTrackIntentUser,
+ ),
),
- );
+ isSelected,
+ };
const isReportArchived = !!privateIsArchived;
const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${reportData?.policyID}`];
const reportPolicyTags = policyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(report?.policyID)}`];
@@ -101,7 +105,14 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle
{isReportArchived, personalDetails, policy, reportAttributesDerived, policyTags: reportPolicyTags, conciergeReportID, isTrackIntentUser},
);
return {...report, alternateText};
- });
+ };
+
+ const selectedOptions: OptionData[] = value.map((id) => buildReportOption(id, true));
+
+ // Snapshot the reports that were already selected when the filter first opened. On a long list these stay
+ // pinned to the top so they're easy to find, but items toggled during the current session are NOT re-pinned:
+ // they keep their position so selecting doesn't scroll/jump the list.
+ const initialValue = useInitialValue(() => value);
const defaultOptions =
isLoading || !ready || !options
@@ -130,25 +141,30 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle
const sections: SelectionListSections = [];
if (!isLoading) {
- const formattedResults = formatSectionsFromSearchTerm(
- cleanSearchTerm,
- selectedOptions,
- chatOptions.recentReports,
- chatOptions.personalDetails,
- privateIsArchivedMap,
- currentUserAccountID,
- allPolicies,
- personalDetails,
- false,
- undefined,
- reportAttributesDerived,
- );
+ // Only float the initially-selected reports to the top of a long list. Gate on the *unfiltered* list size so the
+ // decision doesn't flip as the user types, and key the pinned section on the snapshot (instead of the live
+ // `value`) so items toggled during this session stay put.
+ const shouldMoveSelectedToTop = defaultOptions.recentReports.length >= CONST.STANDARD_LIST_ITEM_LIMIT;
+ const pinnedReportIDs = shouldMoveSelectedToTop ? initialValue : [];
+ const pinnedReportIDSet = new Set(pinnedReportIDs);
+
+ // Keep the pinned reports at the top whether or not a search value is entered, for consistency. When a term is
+ // typed, only surface the pinned reports that still match the filtered results.
+ const matchedReportIDs = new Set(chatOptions.recentReports.map((report) => report.reportID));
+ const visiblePinnedReportIDs = cleanSearchTerm === '' ? pinnedReportIDs : pinnedReportIDs.filter((id) => matchedReportIDs.has(id));
+ const pinnedSelectedOptions = visiblePinnedReportIDs.map((id) => buildReportOption(id, value.includes(id)));
- sections.push(formattedResults.section);
+ sections.push({
+ title: undefined,
+ sectionIndex: 0,
+ data: pinnedSelectedOptions,
+ });
- const visibleReportsWhenSearchTermNonEmpty = chatOptions.recentReports.map((report) => (value.includes(report.reportID) ? getSelectedOptionData(report) : report));
- const visibleReportsWhenSearchTermEmpty = chatOptions.recentReports.filter((report) => !value.includes(report.reportID));
- const reportsFiltered = cleanSearchTerm === '' ? visibleReportsWhenSearchTermEmpty : visibleReportsWhenSearchTermNonEmpty;
+ // Drop the pinned reports from the main section (they already appear in the top section above) and mark the
+ // remaining reports selected in place based on the live `value`, so the checkmark toggles without reordering.
+ const reportsFiltered = chatOptions.recentReports
+ .filter((report) => !pinnedReportIDSet.has(report.reportID))
+ .map((report) => (value.includes(report.reportID) ? getSelectedOptionData(report) : report));
sections.push({
data: reportsFiltered,
@@ -205,6 +221,9 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle
onSelectRow={handleParticipantSelection}
ListItem={InviteMemberListItem}
canSelectMultiple
+ shouldClearInputOnSelect={false}
+ shouldUpdateFocusedIndex
+ shouldPreventAutoScrollOnSelect
shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()}
textInputOptions={textInputOptions}
isLoadingNewOptions={isLoadingNewOptions}
diff --git a/tests/ui/InSelectorTest.tsx b/tests/ui/InSelectorTest.tsx
new file mode 100644
index 000000000000..cad034175c5d
--- /dev/null
+++ b/tests/ui/InSelectorTest.tsx
@@ -0,0 +1,150 @@
+import {render} from '@testing-library/react-native';
+
+import InSelector from '@components/Search/FilterComponents/InSelector';
+import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections';
+
+import CONST from '@src/CONST';
+
+import React from 'react';
+
+// The list of reports returned by the (mocked) options pipeline. Tests mutate these to switch between a
+// long list (pinning enabled) and a short list (pinning disabled), and to simulate a search filtering the list.
+// `mockDefaultReports` is the unfiltered base list (drives the "is the list long enough to pin" gate); `mockRecentReports`
+// is what the filter returns for the current search term.
+let mockRecentReports: Array<{reportID: string; keyForList: string; text: string}> = [];
+let mockDefaultReports: Array<{reportID: string; keyForList: string; text: string}> = [];
+let mockSearchTerm = '';
+
+jest.mock('@components/SelectionList/SelectionListWithSections', () => jest.fn(() => null));
+jest.mock('@components/SelectionList/ListItem/InviteMemberListItem', () => jest.fn(() => null));
+jest.mock('@components/Search/FilterComponents/ListFilterViewWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children));
+
+jest.mock('@components/OnyxListItemProvider', () => ({usePersonalDetails: jest.fn(() => ({}))}));
+jest.mock('@hooks/useOnyx', () => jest.fn(() => [undefined]));
+jest.mock('@hooks/useDebouncedState', () => jest.fn(() => [mockSearchTerm, mockSearchTerm, jest.fn()]));
+jest.mock('@hooks/useFilteredOptions', () => jest.fn(() => ({options: {recentReports: [], personalDetails: []}, isLoading: false})));
+jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({email: 'me@expensify.com', accountID: 999})));
+jest.mock('@hooks/useSortedActions', () => jest.fn(() => ({})));
+jest.mock('@hooks/useReportAttributes', () => jest.fn(() => ({})));
+jest.mock('@hooks/usePrivateIsArchivedMap', () => jest.fn(() => ({})));
+jest.mock('@hooks/useLocalize', () =>
+ jest.fn(() => ({
+ translate: (key: string) => key,
+ })),
+);
+jest.mock('@libs/actions/Report', () => ({searchInServer: jest.fn()}));
+jest.mock('@libs/DeviceCapabilities', () => ({canUseTouchScreen: () => false}));
+jest.mock('@libs/OptionsListUtils', () => ({
+ // Build a minimal option object from the report id the component passes in.
+ createOptionFromReport: (report: {reportID: string}) => ({reportID: report.reportID, keyForList: report.reportID, text: `Report ${report.reportID}`}),
+ getSearchOptions: () => ({options: {recentReports: mockDefaultReports, personalDetails: []}}),
+ filterAndOrderOptions: () => ({recentReports: mockRecentReports, personalDetails: []}),
+ getAlternateText: () => '',
+}));
+
+describe('InSelector', () => {
+ const mockedSelectionList = jest.mocked(SelectionListWithSections);
+
+ const buildRecentReports = (count: number) =>
+ Array.from({length: count}, (_, index) => ({
+ reportID: `r${index}`,
+ keyForList: `r${index}`,
+ text: `Report ${index}`,
+ }));
+
+ const getSections = () => mockedSelectionList.mock.lastCall?.[0].sections ?? [];
+
+ beforeEach(() => {
+ mockedSelectionList.mockClear();
+ mockSearchTerm = '';
+ // A long list so the "move selected to top" behavior is enabled.
+ mockRecentReports = buildRecentReports(CONST.STANDARD_LIST_ITEM_LIMIT + 2);
+ mockDefaultReports = mockRecentReports;
+ });
+
+ it('floats the initially-selected report to the top of a long list', () => {
+ render(
+ ,
+ );
+
+ const sections = getSections();
+ // Top section holds only the pre-selected report.
+ expect(sections.at(0)?.data.map((item) => item.keyForList)).toEqual(['r10']);
+ // Main section keeps every other report in its natural order, with the pinned one removed.
+ expect(sections.at(1)?.data.map((item) => item.keyForList)).not.toContain('r10');
+ expect(sections.at(1)?.data).toHaveLength(CONST.STANDARD_LIST_ITEM_LIMIT + 1);
+ });
+
+ it('keeps a report in place when it is toggled after first render (does not jump to the top)', () => {
+ const {rerender} = render(
+ ,
+ );
+
+ // Simulate the user toggling another report on: the parent re-renders the filter with the updated value.
+ rerender(
+ ,
+ );
+
+ const sections = getSections();
+ // Only the originally pre-selected report stays pinned at the top.
+ expect(sections.at(0)?.data.map((item) => item.keyForList)).toEqual(['r10']);
+
+ // The newly toggled report is marked selected but stays in its natural position (index 3) instead of jumping up.
+ const mainSection = sections.at(1)?.data ?? [];
+ expect(mainSection.findIndex((item) => item.keyForList === 'r3')).toBe(3);
+ expect(mainSection.find((item) => item.keyForList === 'r3')?.isSelected).toBe(true);
+ });
+
+ it('keeps the initially-selected report pinned at the top while searching, and drops non-matching pinned reports', () => {
+ // Simulate the user typing a search term that narrows the list to a few matching reports (r3, r10, r11).
+ // r10 is pinned and matches -> it stays at the top. r5 is pinned but doesn't match -> it is dropped.
+ mockSearchTerm = 'report';
+ mockRecentReports = [
+ {reportID: 'r3', keyForList: 'r3', text: 'Report 3'},
+ {reportID: 'r10', keyForList: 'r10', text: 'Report 10'},
+ {reportID: 'r11', keyForList: 'r11', text: 'Report 11'},
+ ];
+
+ render(
+ ,
+ );
+
+ const sections = getSections();
+ // The pinned report that still matches the search stays at the top; the non-matching pinned report is dropped.
+ expect(sections.at(0)?.data.map((item) => item.keyForList)).toEqual(['r10']);
+ // The main section shows the remaining matches with the pinned one removed (no duplicate).
+ expect(sections.at(1)?.data.map((item) => item.keyForList)).toEqual(['r3', 'r11']);
+ });
+
+ it('does not pin selected reports to the top of a short list', () => {
+ mockRecentReports = buildRecentReports(5);
+ mockDefaultReports = mockRecentReports;
+
+ render(
+ ,
+ );
+
+ const sections = getSections();
+ // No pinned section for a short list.
+ expect(sections.at(0)?.data).toHaveLength(0);
+ // Every report stays in place; the selected one is simply marked in position.
+ const mainSection = sections.at(1)?.data ?? [];
+ expect(mainSection.map((item) => item.keyForList)).toEqual(['r0', 'r1', 'r2', 'r3', 'r4']);
+ expect(mainSection.find((item) => item.keyForList === 'r2')?.isSelected).toBe(true);
+ });
+});