diff --git a/src/components/CountryPicker/CountrySelectorModal.js b/src/components/CountryPicker/CountrySelectorModal.js index 74575c0021f3..146b023bbf0c 100644 --- a/src/components/CountryPicker/CountrySelectorModal.js +++ b/src/components/CountryPicker/CountrySelectorModal.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import CONST from '../../CONST'; import useLocalize from '../../hooks/useLocalize'; import HeaderWithBackButton from '../HeaderWithBackButton'; -import SelectionListRadio from '../SelectionListRadio'; +import SelectionList from '../SelectionList'; import Modal from '../Modal'; import ScreenWrapper from '../ScreenWrapper'; import styles from '../../styles/styles'; @@ -73,7 +73,7 @@ function CountrySelectorModal({currentCountry, isVisible, onClose, onCountrySele title={translate('common.country')} onBackButtonPress={onClose} /> - diff --git a/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js b/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js index 44e35f6b27cb..00ab73966b4b 100644 --- a/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js +++ b/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js @@ -3,9 +3,9 @@ import PropTypes from 'prop-types'; import _ from 'underscore'; import HeaderWithBackButton from '../../HeaderWithBackButton'; import CONST from '../../../CONST'; -import SelectionListRadio from '../../SelectionListRadio'; +import SelectionList from '../../SelectionList'; import Modal from '../../Modal'; -import {radioListItemPropTypes} from '../../SelectionListRadio/selectionListRadioPropTypes'; +import {radioListItemPropTypes} from '../../SelectionList/selectionListPropTypes'; import useLocalize from '../../../hooks/useLocalize'; import ScreenWrapper from '../../ScreenWrapper'; import styles from '../../../styles/styles'; @@ -15,7 +15,7 @@ const propTypes = { isVisible: PropTypes.bool.isRequired, /** The list of years to render */ - years: PropTypes.arrayOf(PropTypes.shape(radioListItemPropTypes)).isRequired, + years: PropTypes.arrayOf(PropTypes.shape(radioListItemPropTypes.item)).isRequired, /** Currently selected year */ currentYear: PropTypes.number, @@ -69,7 +69,7 @@ function YearPickerModal(props) { title={translate('yearPickerPage.year')} onBackButtonPress={props.onClose} /> - { + const allOptions = []; + + const disabledOptionsIndexes = []; + let disabledIndex = 0; + + let offset = 0; + const itemLayouts = [{length: 0, offset}]; + + const selectedOptions = []; + + _.each(sections, (section, sectionIndex) => { + const sectionHeaderHeight = variables.optionsListSectionHeaderHeight; + itemLayouts.push({length: sectionHeaderHeight, offset}); + offset += sectionHeaderHeight; + + _.each(section.data, (item, optionIndex) => { + // Add item to the general flattened array + allOptions.push({ + ...item, + sectionIndex, + index: optionIndex, + }); + + // If disabled, add to the disabled indexes array + if (section.isDisabled || item.isDisabled) { + disabledOptionsIndexes.push(disabledIndex); + } + disabledIndex += 1; + + // Account for the height of the item in getItemLayout + const fullItemHeight = variables.optionRowHeight; + itemLayouts.push({length: fullItemHeight, offset}); + offset += fullItemHeight; + + if (item.isSelected) { + selectedOptions.push(item); + } + }); + + // We're not rendering any section footer, but we need to push to the array + // because React Native accounts for it in getItemLayout + itemLayouts.push({length: 0, offset}); + }); + + // We're not rendering the list footer, but we need to push to the array + // because React Native accounts for it in getItemLayout + itemLayouts.push({length: 0, offset}); + + if (selectedOptions.length > 1 && !canSelectMultiple) { + Log.alert( + 'Dev error: SelectionList - multiple items are selected but prop `canSelectMultiple` is false. Please enable `canSelectMultiple` or make your list have only 1 item with `isSelected: true`.', + ); + } + + return { + allOptions, + selectedOptions, + disabledOptionsIndexes, + itemLayouts, + allSelected: selectedOptions.length > 0 && selectedOptions.length === allOptions.length - disabledOptionsIndexes.length, + }; + }, [canSelectMultiple, sections]); + + const [focusedIndex, setFocusedIndex] = useState(() => { + const defaultIndex = 0; + + const indexOfInitiallyFocusedOption = _.findIndex(flattenedSections.allOptions, (option) => option.keyForList === initiallyFocusedOptionKey); + + if (indexOfInitiallyFocusedOption >= 0) { + return indexOfInitiallyFocusedOption; + } + + return defaultIndex; + }); + + /** + * Scrolls to the desired item index in the section list + * + * @param {Number} index - the index of the item to scroll to + * @param {Boolean} animated - whether to animate the scroll + */ + const scrollToIndex = (index, animated) => { + const item = flattenedSections.allOptions[index]; + + if (!listRef.current || !item) { + return; + } + + const itemIndex = item.index; + const sectionIndex = item.sectionIndex; + + // Note: react-native's SectionList automatically strips out any empty sections. + // So we need to reduce the sectionIndex to remove any empty sections in front of the one we're trying to scroll to. + // Otherwise, it will cause an index-out-of-bounds error and crash the app. + let adjustedSectionIndex = sectionIndex; + for (let i = 0; i < sectionIndex; i++) { + if (_.isEmpty(lodashGet(sections, `[${i}].data`))) { + adjustedSectionIndex--; + } + } + + listRef.current.scrollToLocation({sectionIndex: adjustedSectionIndex, itemIndex, animated}); + }; + + const selectRow = (item, index) => { + // In single-selection lists we don't care about updating the focused index, because the list is closed after selecting an item + if (canSelectMultiple) { + if (sections.length === 1) { + // If the list has only 1 section (e.g. Workspace Members list), we always focus the next available item + const nextAvailableIndex = _.findIndex(flattenedSections.allOptions, (option, i) => i > index && !option.isDisabled); + setFocusedIndex(nextAvailableIndex); + } else { + // If the list has multiple sections (e.g. Workspace Invite list), we focus the first one after all the selected (selected items are always at the top) + const selectedOptionsCount = item.isSelected ? flattenedSections.selectedOptions.length - 1 : flattenedSections.selectedOptions.length + 1; + setFocusedIndex(selectedOptionsCount); + } + } + + onSelectRow(item); + }; + + const selectFocusedOption = () => { + const focusedOption = flattenedSections.allOptions[focusedIndex]; + + if (!focusedOption || focusedOption.isDisabled) { + return; + } + + selectRow(focusedOption, focusedIndex); + }; + + /** + * This function is used to compute the layout of any given item in our list. + * We need to implement it so that we can programmatically scroll to items outside the virtual render window of the SectionList. + * + * @param {Array} data - This is the same as the data we pass into the component + * @param {Number} flatDataArrayIndex - This index is provided by React Native, and refers to a flat array with data from all the sections. This flat array has some quirks: + * + * 1. It ALWAYS includes a list header and a list footer, even if we don't provide/render those. + * 2. Each section includes a header, even if we don't provide/render one. + * + * For example, given a list with two sections, two items in each section, no header, no footer, and no section headers, the flat array might look something like this: + * + * [{header}, {sectionHeader}, {item}, {item}, {sectionHeader}, {item}, {item}, {footer}] + * + * @returns {Object} + */ + const getItemLayout = (data, flatDataArrayIndex) => { + const targetItem = flattenedSections.itemLayouts[flatDataArrayIndex]; + + return { + length: targetItem.length, + offset: targetItem.offset, + index: flatDataArrayIndex, + }; + }; + + const renderSectionHeader = ({section}) => { + if (!section.title || _.isEmpty(section.data)) { + return null; + } + + return ( + // Note: The `optionsListSectionHeader` style provides an explicit height to section headers. + // We do this so that we can reference the height in `getItemLayout` – + // we need to know the heights of all list items up-front in order to synchronously compute the layout of any given list item. + // So be aware that if you adjust the content of the section header (for example, change the font size), you may need to adjust this explicit height as well. + + {section.title} + + ); + }; + + const renderItem = ({item, index, section}) => { + const isDisabled = section.isDisabled; + const isFocused = !isDisabled && focusedIndex === index + lodashGet(section, 'indexOffset', 0); + + if (canSelectMultiple) { + return ( + selectRow(item, index)} + onDismissError={onDismissError} + /> + ); + } + + return ( + selectRow(item, index)} + /> + ); + }; + + /** Focuses the text input when the component mounts. If `props.shouldDelayFocus` is true, we wait for the animation to finish */ + useEffect(() => { + if (shouldShowTextInput) { + if (shouldDelayFocus) { + focusTimeoutRef.current = setTimeout(() => textInputRef.current.focus(), CONST.ANIMATED_TRANSITION); + } else { + textInputRef.current.focus(); + } + } + + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; + }, [shouldDelayFocus, shouldShowTextInput]); + + /** Selects row when pressing enter */ + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, { + captureOnInputs: true, + shouldBubble: () => !flattenedSections.allOptions[focusedIndex], + }); + + return ( + { + setFocusedIndex(newFocusedIndex); + scrollToIndex(newFocusedIndex, true); + }} + > + + {({safeAreaPaddingBottomStyle}) => ( + + {shouldShowTextInput && ( + + + + )} + {Boolean(headerMessage) && ( + + {headerMessage} + + )} + {flattenedSections.allOptions.length === 0 && showLoadingPlaceholder ? ( + + ) : ( + <> + {!headerMessage && canSelectMultiple && shouldShowSelectAll && ( + + + + {translate('workspace.people.selectAll')} + + + )} + item.keyForList} + extraData={focusedIndex} + indicatorStyle="white" + keyboardShouldPersistTaps="always" + showsVerticalScrollIndicator={showScrollIndicator} + initialNumToRender={12} + maxToRenderPerBatch={5} + windowSize={5} + viewabilityConfig={{viewAreaCoveragePercentThreshold: 95}} + testID="selection-list" + onLayout={() => { + if (!firstLayoutRef.current) { + return; + } + scrollToIndex(focusedIndex, false); + firstLayoutRef.current = false; + }} + /> + + )} + {shouldShowConfirmButton && ( + +