Skip to content
Merged
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
150 changes: 72 additions & 78 deletions src/components/Search/SearchMultipleSelectionPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React, {useCallback, useEffect, useMemo, useState} from 'react';
// eslint-disable-next-line no-restricted-imports
import SelectionList from '@components/SelectionListWithSections';
import MultiSelectListItem from '@components/SelectionListWithSections/MultiSelectListItem';
import React, {useEffect, useState} from 'react';
import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem';
import SelectionList from '@components/SelectionList/SelectionListWithSections';
import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
import Navigation from '@libs/Navigation/Navigation';
Expand Down Expand Up @@ -33,94 +32,89 @@ function SearchMultipleSelectionPicker({items, initiallySelectedItems, pickerTit
setSelectedItems(initiallySelectedItems ?? []);
}, [initiallySelectedItems]);

const {sections, noResultsFound} = useMemo(() => {
const selectedItemsSection = selectedItems
.filter((item) => item?.name.toLowerCase().includes(debouncedSearchTerm?.toLowerCase()))
.sort((a, b) => sortOptionsWithEmptyValue(a.value.toString(), b.value.toString(), localeCompare))
.map((item) => ({
text: item.name,
keyForList: item.name,
isSelected: true,
value: item.value,
}));
const remainingItemsSection = items
.filter(
(item) =>
!selectedItems.some((selectedItem) => selectedItem.value.toString() === item.value.toString()) && item?.name?.toLowerCase().includes(debouncedSearchTerm?.toLowerCase()),
)
.sort((a, b) => sortOptionsWithEmptyValue(a.value.toString(), b.value.toString(), localeCompare))
.map((item) => ({
text: item.name,
keyForList: item.name,
isSelected: false,
value: item.value,
}));
const isEmpty = !selectedItemsSection.length && !remainingItemsSection.length;
return {
sections: isEmpty
? []
: [
{
title: undefined,
data: selectedItemsSection,
shouldShow: selectedItemsSection.length > 0,
},
{
title: pickerTitle,
data: remainingItemsSection,
shouldShow: remainingItemsSection.length > 0,
},
],
noResultsFound: isEmpty,
};
}, [selectedItems, items, pickerTitle, debouncedSearchTerm, localeCompare]);
const selectedItemsSection = selectedItems
.filter((item) => item?.name.toLowerCase().includes(debouncedSearchTerm?.toLowerCase()))
.sort((a, b) => sortOptionsWithEmptyValue(a.value.toString(), b.value.toString(), localeCompare))
.map((item) => ({
text: item.name,
keyForList: item.name,
isSelected: true,
value: item.value,
}));

const onSelectItem = useCallback(
(item: Partial<OptionData & SearchMultipleSelectionPickerItem>) => {
if (!item.text || !item.keyForList || !item.value) {
return;
}
if (item.isSelected) {
setSelectedItems(selectedItems?.filter((selectedItem) => selectedItem.name !== item.keyForList));
} else {
setSelectedItems([...(selectedItems ?? []), {name: item.text, value: item.value}]);
}
},
[selectedItems],
);
const remainingItemsSection = items
.filter(
(item) =>
!selectedItems.some((selectedItem) => selectedItem.value.toString() === item.value.toString()) && item?.name?.toLowerCase().includes(debouncedSearchTerm?.toLowerCase()),
)
.sort((a, b) => sortOptionsWithEmptyValue(a.value.toString(), b.value.toString(), localeCompare))
.map((item) => ({
text: item.name,
keyForList: item.name,
isSelected: false,
value: item.value,
}));

const resetChanges = useCallback(() => {
const noResultsFound = !selectedItemsSection.length && !remainingItemsSection.length;
const sections = noResultsFound
? []
: [
{
title: undefined,
data: selectedItemsSection,
sectionIndex: 0,
},
{
title: pickerTitle,
data: remainingItemsSection,
sectionIndex: 1,
},
];

const onSelectItem = (item: Partial<OptionData & SearchMultipleSelectionPickerItem>) => {
if (!item.text || !item.keyForList || !item.value) {
return;
}
if (item.isSelected) {
setSelectedItems(selectedItems?.filter((selectedItem) => selectedItem.name !== item.keyForList));
} else {
setSelectedItems([...(selectedItems ?? []), {name: item.text, value: item.value}]);
}
};

const resetChanges = () => {
setSelectedItems([]);
}, []);
};

const applyChanges = useCallback(() => {
const applyChanges = () => {
onSaveSelection(selectedItems.map((item) => item.value).flat());
Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS.getRoute());
}, [onSaveSelection, selectedItems]);
};

const footerContent = useMemo(
() => (
<SearchFilterPageFooterButtons
applyChanges={applyChanges}
resetChanges={resetChanges}
/>
),
[resetChanges, applyChanges],
);
const textInputOptions = {
value: searchTerm,
label: translate('common.search'),
onChangeText: setSearchTerm,
headerMessage: noResultsFound ? translate('common.noResultsFound') : undefined,
};
return (
<SelectionList
sections={sections}
textInputValue={searchTerm}
onChangeText={setSearchTerm}
textInputLabel={shouldShowTextInput ? translate('common.search') : undefined}
ListItem={MultiSelectListItem}
shouldShowTextInput={shouldShowTextInput}
textInputOptions={textInputOptions}
onSelectRow={onSelectItem}
headerMessage={noResultsFound ? translate('common.noResultsFound') : undefined}
footerContent={footerContent}
shouldStopPropagation
showLoadingPlaceholder={!noResultsFound}
shouldStopPropagation
shouldShowTooltips
canSelectMultiple
ListItem={MultiSelectListItem}
disableMaintainingScrollPosition
footerContent={
<SearchFilterPageFooterButtons
applyChanges={applyChanges}
resetChanges={resetChanges}
/>
}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ function BaseSelectionListWithSections<TItem extends ListItem>({

const paddingBottomStyle = !isKeyboardShown && !footerContent && safeAreaPaddingBottomStyle;

const {flattenedData, disabledIndexes, itemsCount, selectedItems, initialFocusedIndex} = useFlattenedSections(sections, initiallyFocusedItemKey);
const {flattenedData, disabledIndexes, itemsCount, selectedItems, initialFocusedIndex, firstFocusableIndex} = useFlattenedSections(sections, initiallyFocusedItemKey);

const setHasKeyBeenPressed = () => {
if (hasKeyBeenPressed.current) {
Expand All @@ -90,11 +90,11 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
};

const scrollToIndex = (index: number) => {
if (index < 0 || index >= flattenedData.length) {
if (index < 0 || index >= flattenedData.length || !listRef.current) {
return;
}
const item = flattenedData.at(index);
if (!listRef.current || !item || getItemType(item) === CONST.SECTION_LIST_ITEM_TYPE.HEADER) {
if (!item) {
return;
}
try {
Expand Down Expand Up @@ -140,8 +140,14 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
if (!isScreenFocused) {
return;
}
if (canSelectMultiple && shouldShowTextInput) {
textInputOptions?.onChangeText?.('');
if (canSelectMultiple) {
if (sections.length > 1 && !isItemSelected(item)) {
scrollToIndex(0);
}

if (shouldShowTextInput) {
textInputOptions?.onChangeText?.('');
}
}
if (shouldUpdateFocusedIndex && typeof indexToFocus === 'number') {
setFocusedIndex(indexToFocus);
Expand Down Expand Up @@ -208,6 +214,7 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
shouldUpdateFocusedIndex,
scrollToIndex,
setFocusedIndex,
firstFocusableIndex,
});

const textInputComponent = () => {
Expand Down
9 changes: 9 additions & 0 deletions src/components/SelectionList/hooks/useFlattenedSections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ type UseFlattenedSectionsResult<TItem extends ListItem> = {

/** Index of initially focused item in flattenedData, or -1 if none */
initialFocusedIndex: number;

/** Index of the first focusable (non-header) item in flattenedData. Returns 0 if no items exist. */
firstFocusableIndex: number;
};

/**
Expand All @@ -43,6 +46,7 @@ function useFlattenedSections<TItem extends ListItem>(sections: Array<Section<TI
const selectedOptions: TItem[] = [];
const disabledIndices: number[] = [];
let focusedIndex = -1;
let firstNonHeaderIndex = -1;
let itemsTotalCount = 0;

for (const section of sections) {
Expand All @@ -68,6 +72,10 @@ function useFlattenedSections<TItem extends ListItem>(sections: Array<Section<TI
} as SectionListItem<TItem>;
data.push(itemData);

if (firstNonHeaderIndex === -1) {
firstNonHeaderIndex = currentIndex;
}

if (item.keyForList === initiallyFocusedItemKey && focusedIndex === -1) {
focusedIndex = currentIndex;
}
Expand All @@ -89,6 +97,7 @@ function useFlattenedSections<TItem extends ListItem>(sections: Array<Section<TI
itemsCount: itemsTotalCount,
selectedItems: selectedOptions,
initialFocusedIndex: focusedIndex,
firstFocusableIndex: firstNonHeaderIndex === -1 ? 0 : firstNonHeaderIndex,
};
}, [initiallyFocusedItemKey, sections]);
}
Expand Down
19 changes: 15 additions & 4 deletions src/components/SelectionList/hooks/useSearchFocusSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ type UseSearchFocusSyncParams<TItem extends ListItem, TData = TItem> = {

/** Function to set the focused index */
setFocusedIndex: (index: number) => void;

/** The first focusable index in the list (useful when index 0 is a header). Defaults to 0. */
firstFocusableIndex?: number;
};

/**
Expand All @@ -44,6 +47,7 @@ function useSearchFocusSync<TItem extends ListItem, TData = TItem>({
shouldUpdateFocusedIndex,
scrollToIndex,
setFocusedIndex,
firstFocusableIndex = 0,
}: UseSearchFocusSyncParams<TItem, TData>) {
const prevSearchValue = usePrevious(searchValue);
const prevSelectedOptionsCount = usePrevious(selectedOptionsCount);
Expand Down Expand Up @@ -76,12 +80,18 @@ function useSearchFocusSync<TItem extends ListItem, TData = TItem>({
// Remove focus (set focused index to -1) if:
// 1. If the search is idle or
// 2. If the user is just toggling options without changing the list content
// Otherwise (e.g. when filtering/typing), focus on the first item (0)
// Otherwise (e.g. when filtering/typing), scroll to top and focus on the first focusable item
const isSearchIdle = !prevSearchValue && !searchValue;
const newSelectedIndex = isSearchIdle || (selectedOptionsChanged && prevItemsLength === data.length) ? -1 : 0;
const shouldResetFocus = isSearchIdle || (selectedOptionsChanged && prevItemsLength === data.length);

if (shouldResetFocus) {
setFocusedIndex(-1);
return;
}

scrollToIndex(newSelectedIndex);
setFocusedIndex(newSelectedIndex);
// Scroll to top of list and focus on first focusable item (not header)
scrollToIndex(0);
setFocusedIndex(firstFocusableIndex);
}, [
canSelectMultiple,
data,
Expand All @@ -94,6 +104,7 @@ function useSearchFocusSync<TItem extends ListItem, TData = TItem>({
shouldUpdateFocusedIndex,
searchValue,
isItemSelected,
firstFocusableIndex,
]);
}

Expand Down
Loading