Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/components/SelectionList/BaseSelectionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ function BaseSelectionList<TItem extends ListItem>({
const listRef = useRef<FlashListRef<TItem> | null>(null);
const itemFocusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const keyboardListenerRef = useRef<ReturnType<typeof Keyboard.addListener> | null>(null);
const suppressNextFocusScrollRef = useRef(false);

const initialFocusedIndex = useMemo(() => data.findIndex((i) => i.keyForList === initiallyFocusedItemKey), [data, initiallyFocusedItemKey]);
const [itemsToHighlight, setItemsToHighlight] = useState<Set<string> | null>(null);
Expand Down Expand Up @@ -210,6 +211,10 @@ function BaseSelectionList<TItem extends ListItem>({
disabledIndexes: dataDetails.disabledArrowKeyIndexes,
isActive: isFocused,
onFocusedIndexChange: (index: number) => {
if (suppressNextFocusScrollRef.current) {
suppressNextFocusScrollRef.current = false;
return;
}
if (!shouldScrollToFocusedIndex) {
return;
}
Expand Down Expand Up @@ -242,6 +247,9 @@ function BaseSelectionList<TItem extends ListItem>({
}
}
if (shouldUpdateFocusedIndex && typeof indexToFocus === 'number') {
if (indexToFocus !== focusedIndex) {
suppressNextFocusScrollRef.current = true;
}
setFocusedIndex(indexToFocus);
}
onSelectRow(item);
Expand All @@ -254,6 +262,7 @@ function BaseSelectionList<TItem extends ListItem>({
isFocused,
canSelectMultiple,
shouldUpdateFocusedIndex,
focusedIndex,
onSelectRow,
shouldShowTextInput,
shouldClearInputOnSelect,
Expand Down Expand Up @@ -524,6 +533,10 @@ function BaseSelectionList<TItem extends ListItem>({
setFocusedIndex,
});

const suppressNextFocusScroll = useCallback(() => {
suppressNextFocusScrollRef.current = true;
}, []);

useSearchFocusSync({
searchValue: textInputOptions?.value,
data,
Expand All @@ -533,6 +546,8 @@ function BaseSelectionList<TItem extends ListItem>({
shouldUpdateFocusedIndex,
scrollToIndex,
setFocusedIndex,
focusedIndex,
suppressNextFocusScroll,
});

useEffect(() => {
Expand Down
25 changes: 25 additions & 0 deletions src/hooks/useInitiallyFocusedKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {useEffect, useState} from 'react';

/**
* Returns an initially focused key that is cleared after the first render cycle.
* This prevents FlashList from auto-scrolling when data changes cause the key
* to transition from "not found" to "found" (e.g., clearing a search).
*
* Note: We use setTimeout instead of requestAnimationFrame because FlashList has a bug
* where clearing the focused key via requestAnimationFrame causes the list to scroll
* to the end unexpectedly.
*/
function useInitiallyFocusedKey(computeKey: () => string | undefined): string | undefined {
const [initiallyFocusedKey, setInitiallyFocusedKey] = useState(computeKey);

useEffect(() => {
const id = setTimeout(() => {
setInitiallyFocusedKey(undefined);
});
return () => clearTimeout(id);
}, []);

return initiallyFocusedKey;
}

export default useInitiallyFocusedKey;
23 changes: 18 additions & 5 deletions src/hooks/useWorkspaceList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,19 @@ type UseWorkspaceListParams = {
searchTerm: string;
localeCompare: LocaleContextProps['localeCompare'];
additionalFilter?: (policy: OnyxEntry<Policy>) => boolean;
shouldSortSelectedToTop?: boolean;
};

function useWorkspaceList({policies, currentUserLogin, selectedPolicyIDs, searchTerm, shouldShowPendingDeletePolicy, localeCompare, additionalFilter}: UseWorkspaceListParams) {
function useWorkspaceList({
policies,
currentUserLogin,
selectedPolicyIDs,
searchTerm,
shouldShowPendingDeletePolicy,
localeCompare,
additionalFilter,
shouldSortSelectedToTop = true,
}: UseWorkspaceListParams) {
const icons = useMemoizedLazyExpensifyIcons(['FallbackWorkspaceAvatar']);
const usersWorkspaces = useMemo(() => {
if (!policies || isEmptyObject(policies)) {
Expand Down Expand Up @@ -56,10 +66,13 @@ function useWorkspaceList({policies, currentUserLogin, selectedPolicyIDs, search

const filteredAndSortedUserWorkspaces = useMemo<WorkspaceListItem[]>(
() =>
tokenizedSearch(usersWorkspaces, searchTerm, (policy) => [policy.text]).sort((policy1, policy2) =>
sortWorkspacesBySelected({policyID: policy1.policyID, name: policy1.text}, {policyID: policy2.policyID, name: policy2.text}, selectedPolicyIDs, localeCompare),
),
[searchTerm, usersWorkspaces, selectedPolicyIDs, localeCompare],
tokenizedSearch(usersWorkspaces, searchTerm, (policy) => [policy.text]).sort((policy1, policy2) => {
if (shouldSortSelectedToTop) {
return sortWorkspacesBySelected({policyID: policy1.policyID, name: policy1.text}, {policyID: policy2.policyID, name: policy2.text}, selectedPolicyIDs, localeCompare);
}
return localeCompare(policy1.text, policy2.text);
}),
[searchTerm, usersWorkspaces, selectedPolicyIDs, localeCompare, shouldSortSelectedToTop],
);

const sections = useMemo(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import {emailSelector} from '@selectors/Session';
import React, {useCallback, useMemo, useRef, useState} from 'react';
import React, {useCallback, useMemo, useState} from 'react';
import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import SearchFilterPageFooterButtons from '@components/Search/SearchFilterPageFooterButtons';
import SelectionList from '@components/SelectionList';
import UserListItem from '@components/SelectionList/ListItem/UserListItem';
import type {SelectionListHandle} from '@components/SelectionList/types';
import useDebouncedState from '@hooks/useDebouncedState';
import useInitiallyFocusedKey from '@hooks/useInitiallyFocusedKey';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
Expand Down Expand Up @@ -38,8 +38,6 @@ function SearchFiltersWorkspacePage() {
const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP);
const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState('');
const shouldShowLoadingIndicator = isLoadingApp && !isOffline;
const selectionListRef = useRef<SelectionListHandle<WorkspaceListItem>>(null);

const [selectedOptions, setSelectedOptions] = useState<string[]>(() => (searchAdvancedFiltersForm?.policyID ? Array.from(searchAdvancedFiltersForm?.policyID) : []));

const {data, shouldShowNoResultsFoundMessage, shouldShowSearchInput} = useWorkspaceList({
Expand All @@ -49,8 +47,11 @@ function SearchFiltersWorkspacePage() {
selectedPolicyIDs: selectedOptions,
searchTerm: debouncedSearchTerm,
localeCompare,
shouldSortSelectedToTop: false,
});

const initiallyFocusedKey = useInitiallyFocusedKey(() => data.find((item) => item.isSelected)?.keyForList);

const selectWorkspace = useCallback(
(option: WorkspaceListItem) => {
const optionIndex = selectedOptions.findIndex((selectedOption: string) => {
Expand All @@ -60,10 +61,6 @@ function SearchFiltersWorkspacePage() {

if (optionIndex === -1 && option?.policyID) {
setSelectedOptions([...selectedOptions, option.policyID]);

requestAnimationFrame(() => {
selectionListRef.current?.scrollAndHighlightItem([option.keyForList]);
});
Comment on lines -64 to -66
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MelvinBot is this safe to remove? Find PR that first introduced this

} else {
const newSelectedOptions = [...selectedOptions.slice(0, optionIndex), ...selectedOptions.slice(optionIndex + 1)];
setSelectedOptions(newSelectedOptions);
Expand Down Expand Up @@ -114,12 +111,13 @@ function SearchFiltersWorkspacePage() {
/>
) : (
<SelectionList<WorkspaceListItem>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MelvinBot shouldUpdateFocusedIndex is missing

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — added shouldUpdateFocusedIndex so the focus index updates correctly when toggling workspace selections (consistent with SearchMultipleSelectionPicker).

ref={selectionListRef}
data={data}
ListItem={UserListItem}
initiallyFocusedItemKey={initiallyFocusedKey}
onSelectRow={selectWorkspace}
textInputOptions={textInputOptions}
canSelectMultiple
shouldUpdateFocusedIndex
shouldShowLoadingPlaceholder={isLoadingOnyxValue(policiesResult) || !didScreenTransitionEnd}
disableMaintainingScrollPosition
Copy link
Copy Markdown
Contributor

@mkhutornyi mkhutornyi May 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MelvinBot add disableMaintainingScrollPosition prop back to fix below issue:

Typing something in search input and clear scrolls back to the previous scroll position

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — added disableMaintainingScrollPosition back in 783f5e6.

footerContent={
Expand Down
Loading