Skip to content

Commit bdce78b

Browse files
authored
Merge pull request #89392 from Expensify/claude-fixCategoryFilterScrollJump
Stop scroll jump when selecting items in category filter
2 parents fd95532 + 7c662c0 commit bdce78b

5 files changed

Lines changed: 43 additions & 27 deletions

File tree

src/components/Search/SearchMultipleSelectionPicker.tsx

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, {useState} from 'react';
1+
import React, {useEffect, useState} from 'react';
22
import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem';
33
import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections';
44
import useDebouncedState from '@hooks/useDebouncedState';
@@ -33,36 +33,49 @@ function SearchMultipleSelectionPicker<T extends string | string[]>({
3333
const {translate, localeCompare} = useLocalize();
3434
const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState('');
3535

36-
const [selectedItemIDs, setSelectedItemIDs] = useState(() => new Set((initiallySelectedItems ?? []).map((item) => item.value.toString())));
36+
const [initialSelectedIDs] = useState(() => new Set((initiallySelectedItems ?? []).map((item) => item.value.toString())));
37+
const [selectedItemIDs, setSelectedItemIDs] = useState(() => initialSelectedIDs);
38+
// Clear after mount to prevent FlashList from auto-scrolling when data changes
39+
// cause the key to transition from "not found" to "found" (e.g., clearing a search).
40+
// Deferred by one frame so FlashList processes the initial scroll first.
41+
const [initiallyFocusedKey, setInitiallyFocusedKey] = useState(() => {
42+
let minItem: SearchMultipleSelectionPickerItem<T> | undefined;
43+
for (const item of items) {
44+
if (initialSelectedIDs.has(item.value.toString())) {
45+
if (!minItem || sortOptionsWithEmptyValue(item.value.toString(), minItem.value.toString(), localeCompare) < 0) {
46+
minItem = item;
47+
}
48+
}
49+
}
50+
return minItem?.name;
51+
});
52+
useEffect(() => {
53+
const id = requestAnimationFrame(() => {
54+
setInitiallyFocusedKey(undefined);
55+
});
56+
return () => cancelAnimationFrame(id);
57+
}, []);
3758

3859
const searchLower = debouncedSearchTerm.toLowerCase();
39-
const selectedSectionData: Array<{text: string; keyForList: string; isSelected: boolean; value: T; leftElement?: React.ReactNode}> = [];
40-
const remainingSectionData: typeof selectedSectionData = [];
60+
const sectionData: Array<{text: string; keyForList: string; isSelected: boolean; value: T; leftElement?: React.ReactNode}> = [];
4161
for (const item of items) {
4262
if (!item.name.toLowerCase().includes(searchLower)) {
4363
continue;
4464
}
4565
const isSelected = selectedItemIDs.has(item.value.toString());
46-
(isSelected ? selectedSectionData : remainingSectionData).push({text: item.name, keyForList: item.name, isSelected, value: item.value, leftElement: item.leftElement});
66+
sectionData.push({text: item.name, keyForList: item.name, isSelected, value: item.value, leftElement: item.leftElement});
4767
}
4868

49-
const sortByValue = (a: {value: string | string[]}, b: {value: string | string[]}) => sortOptionsWithEmptyValue(a.value.toString(), b.value.toString(), localeCompare);
50-
selectedSectionData.sort(sortByValue);
51-
remainingSectionData.sort(sortByValue);
69+
sectionData.sort((a, b) => sortOptionsWithEmptyValue(a.value.toString(), b.value.toString(), localeCompare));
5270

53-
const noResultsFound = !selectedSectionData.length && !remainingSectionData.length;
71+
const noResultsFound = !sectionData.length;
5472
const sections = noResultsFound
5573
? []
5674
: [
57-
{
58-
title: undefined,
59-
data: selectedSectionData,
60-
sectionIndex: 0,
61-
},
6275
{
6376
title: pickerTitle,
64-
data: remainingSectionData,
65-
sectionIndex: 1,
77+
data: sectionData,
78+
sectionIndex: 0,
6679
},
6780
];
6881

@@ -101,6 +114,8 @@ function SearchMultipleSelectionPicker<T extends string | string[]>({
101114
<SelectionListWithSections
102115
sections={sections}
103116
ListItem={MultiSelectListItem}
117+
initiallyFocusedItemKey={initiallyFocusedKey}
118+
shouldClearInputOnSelect={false}
104119
shouldShowTextInput={shouldShowTextInput}
105120
textInputOptions={textInputOptions}
106121
onSelectRow={onSelectItem}

src/components/SelectionList/BaseSelectionList.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ function BaseSelectionList<TItem extends ListItem>({
158158
}, []);
159159

160160
const scrollToIndex = useCallback(
161-
(index: number) => {
161+
(index: number, animated = true) => {
162162
// Bounds check: ensure index is valid for current data
163163
if (index < 0 || index >= data.length) {
164164
return;
@@ -168,7 +168,7 @@ function BaseSelectionList<TItem extends ListItem>({
168168
return;
169169
}
170170
try {
171-
listRef.current.scrollToIndex({index});
171+
listRef.current.scrollToIndex({index, animated});
172172
} catch (error) {
173173
// FlashList may throw if layout for this index doesn't exist yet
174174
// This can happen when data changes rapidly (e.g., during search filtering)

src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
7171
shouldDebounceScrolling = false,
7272
shouldUpdateFocusedIndex = false,
7373
shouldScrollToFocusedIndex = true,
74+
shouldClearInputOnSelect = true,
7475
shouldSingleExecuteRowSelect = false,
7576
shouldPreventDefaultFocusOnSelectRow = false,
7677
isRowMultilineSupported = false,
@@ -103,7 +104,7 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
103104
hasKeyBeenPressed.current = true;
104105
};
105106

106-
const scrollToIndex = (index: number) => {
107+
const scrollToIndex = (index: number, animated = true) => {
107108
if (index < 0 || index >= flattenedData.length || !listRef.current) {
108109
return;
109110
}
@@ -112,7 +113,7 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
112113
return;
113114
}
114115
try {
115-
listRef.current.scrollToIndex({index});
116+
listRef.current.scrollToIndex({index, animated});
116117
} catch (error) {
117118
// FlashList may throw if layout for this index doesn't exist yet
118119
// This can happen when data changes rapidly (e.g., during search filtering)
@@ -164,7 +165,7 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
164165
scrollToIndex(0);
165166
}
166167

167-
if (shouldShowTextInput) {
168+
if (shouldShowTextInput && shouldClearInputOnSelect) {
168169
textInputOptions?.onChangeText?.('');
169170
}
170171
}

src/components/SelectionList/hooks/useSearchFocusSync.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ type UseSearchFocusSyncParams<TItem extends ListItem, TData = TItem> = {
2222
shouldUpdateFocusedIndex: boolean;
2323

2424
/** Function to scroll to an index */
25-
scrollToIndex: (index: number) => void;
25+
scrollToIndex: (index: number, animated?: boolean) => void;
2626

2727
/** Function to set the focused index */
2828
setFocusedIndex: (index: number) => void;
@@ -71,7 +71,7 @@ function useSearchFocusSync<TItem extends ListItem, TData = TItem>({
7171
const foundSelectedItemIndex = data.findIndex(isItemSelected);
7272

7373
if (foundSelectedItemIndex !== -1 && !canSelectMultiple) {
74-
scrollToIndex(foundSelectedItemIndex);
74+
scrollToIndex(foundSelectedItemIndex, false);
7575
setFocusedIndex(foundSelectedItemIndex);
7676
return;
7777
}
@@ -90,7 +90,7 @@ function useSearchFocusSync<TItem extends ListItem, TData = TItem>({
9090
}
9191

9292
// Scroll to top of list and focus on first focusable item (not header)
93-
scrollToIndex(0);
93+
scrollToIndex(0, false);
9494
setFocusedIndex(firstFocusableIndex);
9595
}, [
9696
canSelectMultiple,

src/components/SelectionList/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ type BaseSelectionListProps<TItem extends ListItem> = {
110110
/** Configuration for the confirm button */
111111
confirmButtonOptions?: ConfirmButtonOptions<TItem>;
112112

113+
/** Whether to clear the text input when a row is selected */
114+
shouldClearInputOnSelect?: boolean;
115+
113116
/** Whether hover style should be disabled */
114117
shouldDisableHoverStyle?: boolean;
115118

@@ -172,9 +175,6 @@ type SelectionListProps<TItem extends ListItem> = Partial<ChildrenProps> &
172175
/** Whether to place customListHeader in the list so it scrolls with data */
173176
shouldHeaderBeInsideList?: boolean;
174177

175-
/** Whether to clear the text input when a row is selected */
176-
shouldClearInputOnSelect?: boolean;
177-
178178
/** Whether to highlight the selected item */
179179
shouldHighlightSelectedItem?: boolean;
180180

0 commit comments

Comments
 (0)