diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 4f4095388348..f9b017b3ee50 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -111,6 +111,7 @@ function BaseSelectionList({ const listRef = useRef | null>(null); const itemFocusTimeoutRef = useRef(null); const keyboardListenerRef = useRef | null>(null); + const suppressNextFocusScrollRef = useRef(false); const initialFocusedIndex = useMemo(() => data.findIndex((i) => i.keyForList === initiallyFocusedItemKey), [data, initiallyFocusedItemKey]); const [itemsToHighlight, setItemsToHighlight] = useState | null>(null); @@ -210,6 +211,10 @@ function BaseSelectionList({ disabledIndexes: dataDetails.disabledArrowKeyIndexes, isActive: isFocused, onFocusedIndexChange: (index: number) => { + if (suppressNextFocusScrollRef.current) { + suppressNextFocusScrollRef.current = false; + return; + } if (!shouldScrollToFocusedIndex) { return; } @@ -242,6 +247,9 @@ function BaseSelectionList({ } } if (shouldUpdateFocusedIndex && typeof indexToFocus === 'number') { + if (indexToFocus !== focusedIndex) { + suppressNextFocusScrollRef.current = true; + } setFocusedIndex(indexToFocus); } onSelectRow(item); @@ -254,6 +262,7 @@ function BaseSelectionList({ isFocused, canSelectMultiple, shouldUpdateFocusedIndex, + focusedIndex, onSelectRow, shouldShowTextInput, shouldClearInputOnSelect, @@ -524,6 +533,10 @@ function BaseSelectionList({ setFocusedIndex, }); + const suppressNextFocusScroll = useCallback(() => { + suppressNextFocusScrollRef.current = true; + }, []); + useSearchFocusSync({ searchValue: textInputOptions?.value, data, @@ -533,6 +546,8 @@ function BaseSelectionList({ shouldUpdateFocusedIndex, scrollToIndex, setFocusedIndex, + focusedIndex, + suppressNextFocusScroll, }); useEffect(() => { diff --git a/src/hooks/useInitiallyFocusedKey.ts b/src/hooks/useInitiallyFocusedKey.ts new file mode 100644 index 000000000000..7bee182ed302 --- /dev/null +++ b/src/hooks/useInitiallyFocusedKey.ts @@ -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; diff --git a/src/hooks/useWorkspaceList.ts b/src/hooks/useWorkspaceList.ts index 8ff888a32859..2e3e1d2b88ae 100644 --- a/src/hooks/useWorkspaceList.ts +++ b/src/hooks/useWorkspaceList.ts @@ -19,9 +19,19 @@ type UseWorkspaceListParams = { searchTerm: string; localeCompare: LocaleContextProps['localeCompare']; additionalFilter?: (policy: OnyxEntry) => 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)) { @@ -56,10 +66,13 @@ function useWorkspaceList({policies, currentUserLogin, selectedPolicyIDs, search const filteredAndSortedUserWorkspaces = useMemo( () => - 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(() => { diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersWorkspacePage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersWorkspacePage.tsx index ae95bcdd61c7..3cd8051a5622 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersWorkspacePage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersWorkspacePage.tsx @@ -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'; @@ -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>(null); - const [selectedOptions, setSelectedOptions] = useState(() => (searchAdvancedFiltersForm?.policyID ? Array.from(searchAdvancedFiltersForm?.policyID) : [])); const {data, shouldShowNoResultsFoundMessage, shouldShowSearchInput} = useWorkspaceList({ @@ -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) => { @@ -60,10 +61,6 @@ function SearchFiltersWorkspacePage() { if (optionIndex === -1 && option?.policyID) { setSelectedOptions([...selectedOptions, option.policyID]); - - requestAnimationFrame(() => { - selectionListRef.current?.scrollAndHighlightItem([option.keyForList]); - }); } else { const newSelectedOptions = [...selectedOptions.slice(0, optionIndex), ...selectedOptions.slice(optionIndex + 1)]; setSelectedOptions(newSelectedOptions); @@ -114,12 +111,13 @@ function SearchFiltersWorkspacePage() { /> ) : ( - ref={selectionListRef} data={data} ListItem={UserListItem} + initiallyFocusedItemKey={initiallyFocusedKey} onSelectRow={selectWorkspace} textInputOptions={textInputOptions} canSelectMultiple + shouldUpdateFocusedIndex shouldShowLoadingPlaceholder={isLoadingOnyxValue(policiesResult) || !didScreenTransitionEnd} disableMaintainingScrollPosition footerContent={