From 59387b9c2bd98a5c97d118b1f563a5ea5d6944a1 Mon Sep 17 00:00:00 2001 From: Puneet Lath <puneet@expensify.com> Date: Tue, 27 Feb 2024 16:03:21 -0500 Subject: [PATCH] Revert "Fix/16184 base options selector refactoring" --- .../OptionsSelector/BaseOptionsSelector.js | 1078 ++++++++--------- .../OptionsSelector/index.android.js | 1 + src/pages/NewChatPage.tsx | 1 + tests/perf-test/OptionsSelector.perf-test.js | 14 - 4 files changed, 529 insertions(+), 565 deletions(-) diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 23451b8e1a09..690897d548ce 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -1,7 +1,6 @@ -import {useIsFocused} from '@react-navigation/native'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import React, {Component} from 'react'; import {ScrollView, View} from 'react-native'; import _ from 'underscore'; import ArrowKeyFocusManager from '@components/ArrowKeyFocusManager'; @@ -12,9 +11,11 @@ import OptionsList from '@components/OptionsList'; import ReferralProgramCTA from '@components/ReferralProgramCTA'; import ShowMoreButton from '@components/ShowMoreButton'; import TextInput from '@components/TextInput'; -import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; +import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import withNavigationFocus from '@components/withNavigationFocus'; +import withTheme, {withThemePropTypes} from '@components/withTheme'; +import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeStyles'; +import compose from '@libs/compose'; import getPlatform from '@libs/getPlatform'; import KeyboardShortcut from '@libs/KeyboardShortcut'; import setSelection from '@libs/setSelection'; @@ -34,6 +35,9 @@ const propTypes = { /** List styles for OptionsList */ listStyles: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), + /** Whether navigation is focused */ + isFocused: PropTypes.bool.isRequired, + /** Whether referral CTA should be displayed */ shouldShowReferralCTA: PropTypes.bool, @@ -41,9 +45,13 @@ const propTypes = { referralContentType: PropTypes.string, ...optionsSelectorPropTypes, + ...withLocalizePropTypes, + ...withThemeStylesPropTypes, + ...withThemePropTypes, }; const defaultProps = { + shouldDelayFocus: false, shouldShowReferralCTA: false, referralContentType: CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND, safeAreaPaddingBottomStyle: {}, @@ -53,652 +61,620 @@ const defaultProps = { ...optionsSelectorDefaultProps, }; -function BaseOptionsSelector(props) { - const isFocused = useIsFocused(); - const {translate} = useLocalize(); - const themeStyles = useThemeStyles(); - - const getInitiallyFocusedIndex = useCallback( - (allOptions) => { - let defaultIndex; - if (props.shouldTextInputAppearBelowOptions) { - defaultIndex = allOptions.length; - } else if (props.focusedIndex >= 0) { - defaultIndex = props.focusedIndex; - } else { - defaultIndex = props.selectedOptions.length; +class BaseOptionsSelector extends Component { + constructor(props) { + super(props); + + this.updateFocusedIndex = this.updateFocusedIndex.bind(this); + this.scrollToIndex = this.scrollToIndex.bind(this); + this.selectRow = this.selectRow.bind(this); + this.selectFocusedOption = this.selectFocusedOption.bind(this); + this.addToSelection = this.addToSelection.bind(this); + this.updateSearchValue = this.updateSearchValue.bind(this); + this.incrementPage = this.incrementPage.bind(this); + this.sliceSections = this.sliceSections.bind(this); + this.calculateAllVisibleOptionsCount = this.calculateAllVisibleOptionsCount.bind(this); + this.handleFocusIn = this.handleFocusIn.bind(this); + this.handleFocusOut = this.handleFocusOut.bind(this); + this.debouncedUpdateSearchValue = _.debounce(this.updateSearchValue, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + this.relatedTarget = null; + this.accessibilityRoles = _.values(CONST.ROLE); + this.isWebOrDesktop = [CONST.PLATFORM.DESKTOP, CONST.PLATFORM.WEB].includes(getPlatform()); + + const allOptions = this.flattenSections(); + const sections = this.sliceSections(); + const focusedIndex = this.getInitiallyFocusedIndex(allOptions); + this.focusedOption = allOptions[focusedIndex]; + + this.state = { + sections, + allOptions, + focusedIndex, + shouldDisableRowSelection: false, + errorMessage: '', + paginationPage: 1, + disableEnterShortCut: false, + value: '', + }; + } + + componentDidMount() { + this.subscribeToEnterShortcut(); + this.subscribeToCtrlEnterShortcut(); + this.subscribeActiveElement(); + + if (this.props.isFocused && this.props.autoFocus && this.textInput) { + this.focusTimeout = setTimeout(() => { + this.textInput.focus(); + }, CONST.ANIMATED_TRANSITION); + } + + this.scrollToIndex(this.props.selectedOptions.length ? 0 : this.state.focusedIndex, false); + } + + componentDidUpdate(prevProps, prevState) { + if (prevState.disableEnterShortCut !== this.state.disableEnterShortCut) { + // Unregister the shortcut before registering a new one to avoid lingering shortcut listener + this.unsubscribeEnter(); + if (!this.state.disableEnterShortCut) { + this.subscribeToEnterShortcut(); } - if (_.isUndefined(props.initiallyFocusedOptionKey)) { - return defaultIndex; + } + + if (prevProps.isFocused !== this.props.isFocused) { + // Unregister the shortcut before registering a new one to avoid lingering shortcut listener + this.unSubscribeFromKeyboardShortcut(); + if (this.props.isFocused) { + this.subscribeToEnterShortcut(); + this.subscribeToCtrlEnterShortcut(); } + } + + // Screen coming back into focus, for example + // when doing Cmd+Shift+K, then Cmd+K, then Cmd+Shift+K. + // Only applies to platforms that support keyboard shortcuts + if (this.isWebOrDesktop && !prevProps.isFocused && this.props.isFocused && this.props.autoFocus && this.textInput) { + setTimeout(() => { + this.textInput.focus(); + }, CONST.ANIMATED_TRANSITION); + } + + if (prevState.paginationPage !== this.state.paginationPage) { + const newSections = this.sliceSections(); + + this.setState({ + sections: newSections, + }); + } - const indexOfInitiallyFocusedOption = _.findIndex(allOptions, (option) => option.keyForList === props.initiallyFocusedOptionKey); - - return indexOfInitiallyFocusedOption; - }, - [props.shouldTextInputAppearBelowOptions, props.initiallyFocusedOptionKey, props.selectedOptions.length, props.focusedIndex], - ); - - const isWebOrDesktop = [CONST.PLATFORM.DESKTOP, CONST.PLATFORM.WEB].includes(getPlatform()); - const accessibilityRoles = _.values(CONST.ROLE); - - const [disabledOptionsIndexes, setDisabledOptionsIndexes] = useState([]); - const [sections, setSections] = useState(); - const [shouldDisableRowSelection, setShouldDisableRowSelection] = useState(false); - const [errorMessage, setErrorMessage] = useState(''); - const [value, setValue] = useState(''); - const [paginationPage, setPaginationPage] = useState(1); - const [disableEnterShortCut, setDisableEnterShortCut] = useState(false); - - const relatedTarget = useRef(null); - const listRef = useRef(); - const textInputRef = useRef(); - const enterSubscription = useRef(); - const CTRLEnterSubscription = useRef(); - const focusTimeout = useRef(); - const prevLocale = useRef(props.preferredLocale); - const prevPaginationPage = useRef(paginationPage); - const prevSelectedOptions = useRef(props.selectedOptions); - const prevValue = useRef(value); - - useImperativeHandle(props.forwardedRef, () => textInputRef.current); + if (prevState.focusedIndex !== this.state.focusedIndex) { + this.focusedOption = this.state.allOptions[this.state.focusedIndex]; + } + + if (_.isEqual(this.props.sections, prevProps.sections)) { + return; + } + + const newSections = this.sliceSections(); + const newOptions = this.flattenSections(); + + if (prevProps.preferredLocale !== this.props.preferredLocale) { + this.setState({ + sections: newSections, + allOptions: newOptions, + }); + return; + } + const newFocusedIndex = this.props.selectedOptions.length; + const isNewFocusedIndex = newFocusedIndex !== this.state.focusedIndex; + const prevFocusedOption = _.find(newOptions, (option) => this.focusedOption && option.keyForList === this.focusedOption.keyForList); + const prevFocusedOptionIndex = prevFocusedOption ? _.findIndex(newOptions, (option) => this.focusedOption && option.keyForList === this.focusedOption.keyForList) : undefined; + // eslint-disable-next-line react/no-did-update-set-state + this.setState( + { + sections: newSections, + allOptions: newOptions, + focusedIndex: prevFocusedOptionIndex || (_.isNumber(this.props.focusedIndex) ? this.props.focusedIndex : newFocusedIndex), + }, + () => { + // If we just toggled an option on a multi-selection page or cleared the search input, scroll to top + if (this.props.selectedOptions.length !== prevProps.selectedOptions.length || (!!prevState.value && !this.state.value)) { + this.scrollToIndex(0); + return; + } + + // Otherwise, scroll to the focused index (as long as it's in range) + if (this.state.allOptions.length <= this.state.focusedIndex || !isNewFocusedIndex) { + return; + } + this.scrollToIndex(this.state.focusedIndex); + }, + ); + } + + componentWillUnmount() { + if (this.focusTimeout) { + clearTimeout(this.focusTimeout); + } + + this.unSubscribeFromKeyboardShortcut(); + } /** - * Flattens the sections into a single array of options. - * Each object in this array is enhanced to have: - * - * 1. A `sectionIndex`, which represents the index of the section it came from - * 2. An `index`, which represents the index of the option within the section it came from. - * - * @returns {Array<Object>} + * @param {Array<Object>} allOptions + * @returns {Number} */ - const flattenSections = useCallback(() => { - const calcAllOptions = []; - const calcDisabledOptionsIndexes = []; - let index = 0; - _.each(props.sections, (section, sectionIndex) => { - _.each(section.data, (option, optionIndex) => { - calcAllOptions.push({ - ...option, - sectionIndex, - index: optionIndex, - }); - if (section.isDisabled || option.isDisabled) { - calcDisabledOptionsIndexes.push(index); - } - index += 1; - }); - }); + getInitiallyFocusedIndex(allOptions) { + let defaultIndex; + if (this.props.shouldTextInputAppearBelowOptions) { + defaultIndex = allOptions.length; + } else if (this.props.focusedIndex >= 0) { + defaultIndex = this.props.focusedIndex; + } else { + defaultIndex = this.props.selectedOptions.length; + } + if (_.isUndefined(this.props.initiallyFocusedOptionKey)) { + return defaultIndex; + } - setDisabledOptionsIndexes(calcDisabledOptionsIndexes); - return calcAllOptions; - }, [props.sections]); + const indexOfInitiallyFocusedOption = _.findIndex(allOptions, (option) => option.keyForList === this.props.initiallyFocusedOptionKey); - // eslint-disable-next-line react-hooks/exhaustive-deps - const initialAllOptions = useMemo(() => flattenSections(), []); - const [allOptions, setAllOptions] = useState(initialAllOptions); - const [focusedIndex, setFocusedIndex] = useState(getInitiallyFocusedIndex(initialAllOptions)); - const [focusedOption, setFocusedOption] = useState(allOptions[focusedIndex]); + return indexOfInitiallyFocusedOption; + } /** * Maps sections to render only allowed count of them per section. * * @returns {Objects[]} */ - const sliceSections = useCallback( - () => - _.map(props.sections, (section) => { - if (_.isEmpty(section.data)) { - return section; - } - - const pagination = paginationPage || 1; + sliceSections() { + return _.map(this.props.sections, (section) => { + if (_.isEmpty(section.data)) { + return section; + } - return { - ...section, - data: section.data.slice(0, CONST.MAX_OPTIONS_SELECTOR_PAGE_LENGTH * pagination), - }; - }), - [paginationPage, props.sections], - ); + return { + ...section, + data: section.data.slice(0, CONST.MAX_OPTIONS_SELECTOR_PAGE_LENGTH * lodashGet(this.state, 'paginationPage', 1)), + }; + }); + } /** - * Completes the follow-up actions after a row is selected + * Calculates all currently visible options based on the sections that are currently being shown + * and the number of items of those sections. * - * @param {Object} option - * @param {Object} ref - * @returns {Promise} + * @returns {Number} */ - const selectRow = useCallback( - (option, ref) => - new Promise((resolve) => { - if (props.shouldShowTextInput && props.shouldPreventDefaultFocusOnSelectRow) { - if (relatedTarget.current && ref === relatedTarget.current) { - textInputRef.current.focus(); - relatedTarget.current = null; - } - if (textInputRef.current.isFocused()) { - setSelection(textInputRef.current, 0, value.length); - } - } - const selectedOption = props.onSelectRow(option); - resolve(selectedOption); - - if (!props.canSelectMultipleOptions) { - return; - } - - // Focus the first unselected item from the list (i.e: the best result according to the current search term) - setFocusedIndex(props.selectedOptions.length); - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [props.shouldShowTextInput, props.shouldPreventDefaultFocusOnSelectRow, value.length, props.canSelectMultipleOptions, props.selectedOptions.length], - ); - - const selectFocusedOption = useCallback( - (e) => { - const focusedItemKey = lodashGet(e, ['target', 'attributes', 'id', 'value']); - const localFocusedOption = focusedItemKey ? _.find(allOptions, (option) => option.keyForList === focusedItemKey) : allOptions[focusedIndex]; + calculateAllVisibleOptionsCount() { + let count = 0; - if (!localFocusedOption || !isFocused) { - return; - } + _.forEach(this.state.sections, (section) => { + count += lodashGet(section, 'data.length', 0); + }); - if (props.canSelectMultipleOptions) { - selectRow(localFocusedOption); - } else if (!shouldDisableRowSelection) { - setShouldDisableRowSelection(true); + return count; + } - let result = selectRow(localFocusedOption); - if (!(result instanceof Promise)) { - result = Promise.resolve(); - } + updateSearchValue(value) { + this.setState({ + paginationPage: 1, + errorMessage: value.length > this.props.maxLength ? ['common.error.characterLimitExceedCounter', {length: value.length, limit: this.props.maxLength}] : '', + value, + }); - setTimeout(() => { - result.finally(() => { - setShouldDisableRowSelection(false); - }); - }, 500); - } - }, - [props.canSelectMultipleOptions, focusedIndex, allOptions, isFocused, selectRow, shouldDisableRowSelection], - ); + this.props.onChangeText(value); + } - const handleFocusIn = () => { + handleFocusIn() { const activeElement = document.activeElement; - setDisableEnterShortCut(activeElement && accessibilityRoles.includes(activeElement.role) && activeElement.role !== CONST.ROLE.PRESENTATION); - }; + this.setState({ + disableEnterShortCut: activeElement && this.accessibilityRoles.includes(activeElement.role) && activeElement.role !== CONST.ROLE.PRESENTATION, + }); + } - const handleFocusOut = () => { - setDisableEnterShortCut(false); - }; + handleFocusOut() { + this.setState({ + disableEnterShortCut: false, + }); + } - const subscribeActiveElement = () => { - if (!isWebOrDesktop) { + subscribeActiveElement() { + if (!this.isWebOrDesktop) { return; } - document.addEventListener('focusin', handleFocusIn); - document.addEventListener('focusout', handleFocusOut); - }; + document.addEventListener('focusin', this.handleFocusIn); + document.addEventListener('focusout', this.handleFocusOut); + } - const subscribeToEnterShortcut = () => { + unSubscribeActiveElement() { + if (!this.isWebOrDesktop) { + return; + } + document.removeEventListener('focusin', this.handleFocusIn); + document.removeEventListener('focusout', this.handleFocusOut); + } + + subscribeToEnterShortcut() { const enterConfig = CONST.KEYBOARD_SHORTCUTS.ENTER; - enterSubscription.current = KeyboardShortcut.subscribe( + this.unsubscribeEnter = KeyboardShortcut.subscribe( enterConfig.shortcutKey, - selectFocusedOption, + this.selectFocusedOption, enterConfig.descriptionKey, enterConfig.modifiers, true, - () => !allOptions[focusedIndex], + () => !this.state.allOptions[this.state.focusedIndex], ); - }; + } - const subscribeToCtrlEnterShortcut = () => { + subscribeToCtrlEnterShortcut() { const CTRLEnterConfig = CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER; - CTRLEnterSubscription.current = KeyboardShortcut.subscribe( + this.unsubscribeCTRLEnter = KeyboardShortcut.subscribe( CTRLEnterConfig.shortcutKey, () => { - if (props.canSelectMultipleOptions) { - props.onConfirmSelection(); + if (this.props.canSelectMultipleOptions) { + this.props.onConfirmSelection(); return; } - const localFocusedOption = allOptions[focusedIndex]; - if (!localFocusedOption) { + const focusedOption = this.state.allOptions[this.state.focusedIndex]; + if (!focusedOption) { return; } - selectRow(localFocusedOption); + this.selectRow(focusedOption); }, CTRLEnterConfig.descriptionKey, CTRLEnterConfig.modifiers, true, ); - }; + } - const unSubscribeFromKeyboardShortcut = () => { - if (enterSubscription.current) { - enterSubscription.current(); + unSubscribeFromKeyboardShortcut() { + if (this.unsubscribeEnter) { + this.unsubscribeEnter(); } - if (CTRLEnterSubscription.current) { - CTRLEnterSubscription.current(); + if (this.unsubscribeCTRLEnter) { + this.unsubscribeCTRLEnter(); } - }; + } - const selectOptions = useCallback(() => { - if (props.canSelectMultipleOptions) { - props.onConfirmSelection(); - return; - } + selectFocusedOption(e) { + const focusedItemKey = lodashGet(e, ['target', 'attributes', 'id', 'value']); + const focusedOption = focusedItemKey ? _.find(this.state.allOptions, (option) => option.keyForList === focusedItemKey) : this.state.allOptions[this.state.focusedIndex]; - const localFocusedOption = allOptions[focusedIndex]; - if (!localFocusedOption) { + if (!focusedOption || !this.props.isFocused) { return; } - selectRow(localFocusedOption); - // we don't need to include the whole props object as the dependency - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allOptions, focusedIndex, props.canSelectMultipleOptions, props.onConfirmSelection, selectRow]); - - useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, { - shouldBubble: !allOptions[focusedIndex], - captureOnInputs: true, - }); - useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, selectOptions, {captureOnInputs: true}); + if (this.props.canSelectMultipleOptions) { + this.selectRow(focusedOption); + } else if (!this.state.shouldDisableRowSelection) { + this.setState({shouldDisableRowSelection: true}); - /** - * Scrolls to the focused index within the SectionList - * - * @param {Number} index - * @param {Boolean} animated - */ - const scrollToIndex = useCallback( - (index, animated = true) => { - const option = allOptions[index]; - if (!listRef.current || !option) { - return; + let result = this.selectRow(focusedOption); + if (!(result instanceof Promise)) { + result = Promise.resolve(); } - const itemIndex = option.index; - const sectionIndex = option.sectionIndex; - - if (!lodashGet(sections, `[${sectionIndex}].data[${itemIndex}]`, null)) { - return; - } - - // 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}); - }, - [allOptions, sections], - ); - - useEffect(() => { - subscribeToEnterShortcut(); - subscribeToCtrlEnterShortcut(); - subscribeActiveElement(); - - if (props.isFocused && props.autoFocus && textInputRef.current) { - focusTimeout.current = setTimeout(() => { - textInputRef.current.focus(); - }, CONST.ANIMATED_TRANSITION); - } - - scrollToIndex(props.selectedOptions.length ? 0 : focusedIndex, false); - - return () => { - if (focusTimeout.current) { - clearTimeout(focusTimeout.current); - } - - unSubscribeFromKeyboardShortcut(); - }; - // we want to run this effect only once, when the component is mounted - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - // Unregister the shortcut before registering a new one to avoid lingering shortcut listener - enterSubscription.current(); - if (!disableEnterShortCut) { - subscribeToEnterShortcut(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [disableEnterShortCut]); - - useEffect(() => { - if (props.isFocused) { - subscribeToEnterShortcut(); - subscribeToCtrlEnterShortcut(); - } else { - unSubscribeFromKeyboardShortcut(); + setTimeout(() => { + result.finally(() => { + this.setState({shouldDisableRowSelection: false}); + }); + }, 500); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.isFocused]); - - useEffect(() => { - const newSections = sliceSections(); + } - if (prevPaginationPage.current !== paginationPage) { - prevPaginationPage.current = paginationPage; - setSections(newSections); + focus() { + if (!this.textInput) { + return; } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [paginationPage]); - useEffect(() => { - setFocusedOption(allOptions[focusedIndex]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [focusedIndex]); + this.textInput.focus(); + } - // eslint-disable-next-line rulesdir/prefer-early-return - useEffect(() => { - // Screen coming back into focus, for example - // when doing Cmd+Shift+K, then Cmd+K, then Cmd+Shift+K. - // Only applies to platforms that support keyboard shortcuts - if (isWebOrDesktop && isFocused && props.autoFocus && textInputRef.current) { - setTimeout(() => { - textInputRef.current.focus(); - }, CONST.ANIMATED_TRANSITION); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isFocused, props.autoFocus]); + /** + * Flattens the sections into a single array of options. + * Each object in this array is enhanced to have: + * + * 1. A `sectionIndex`, which represents the index of the section it came from + * 2. An `index`, which represents the index of the option within the section it came from. + * + * @returns {Array<Object>} + */ + flattenSections() { + const allOptions = []; + this.disabledOptionsIndexes = []; + let index = 0; + _.each(this.props.sections, (section, sectionIndex) => { + _.each(section.data, (option, optionIndex) => { + allOptions.push({ + ...option, + sectionIndex, + index: optionIndex, + }); + if (section.isDisabled || option.isDisabled) { + this.disabledOptionsIndexes.push(index); + } + index += 1; + }); + }); + return allOptions; + } - useEffect(() => { - const newSections = sliceSections(); - const newOptions = flattenSections(); + /** + * @param {Number} index + */ + updateFocusedIndex(index) { + this.setState({focusedIndex: index}, () => this.scrollToIndex(index)); + } - if (prevLocale.current !== props.preferredLocale) { - prevLocale.current = props.preferredLocale; - setAllOptions(newOptions); - setSections(newSections); + /** + * Scrolls to the focused index within the SectionList + * + * @param {Number} index + * @param {Boolean} animated + */ + scrollToIndex(index, animated = true) { + const option = this.state.allOptions[index]; + if (!this.list || !option) { return; } - const newFocusedIndex = props.selectedOptions.length; - const prevFocusedOption = _.find(newOptions, (option) => focusedOption && option.keyForList === focusedOption.keyForList); - const prevFocusedOptionIndex = prevFocusedOption ? _.findIndex(newOptions, (option) => focusedOption && option.keyForList === focusedOption.keyForList) : undefined; - - setSections(newSections); - setAllOptions(newOptions); - setFocusedIndex(prevFocusedOptionIndex || (_.isNumber(props.focusedIndex) ? props.focusedIndex : newFocusedIndex)); - // we want to run this effect only when the sections change - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.sections]); - - useEffect(() => { - // If we just toggled an option on a multi-selection page or cleared the search input, scroll to top - if (props.selectedOptions.length !== prevSelectedOptions.current.length || (!!prevValue.current && !value)) { - prevSelectedOptions.current = props.selectedOptions; - prevValue.current = value; - scrollToIndex(0); - return; - } + const itemIndex = option.index; + const sectionIndex = option.sectionIndex; - // Otherwise, scroll to the focused index (as long as it's in range) - if (allOptions.length <= focusedIndex) { + if (!lodashGet(this.state.sections, `[${sectionIndex}].data[${itemIndex}]`, null)) { return; } - scrollToIndex(focusedIndex); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allOptions.length, focusedIndex, props.focusedIndex, props.selectedOptions, value]); - - const updateSearchValue = useCallback( - (searchValue) => { - setValue(searchValue); - setErrorMessage( - searchValue.length > props.maxLength - ? translate('common.error.characterLimitExceedCounter', { - length: searchValue.length, - limit: props.maxLength, - }) - : '', - ); - props.onChangeText(searchValue); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [props.onChangeText, props.maxLength, translate], - ); - - const debouncedUpdateSearchValue = _.debounce(updateSearchValue, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + + this.list.scrollToLocation({sectionIndex, itemIndex, animated}); + } /** - * Calculates all currently visible options based on the sections that are currently being shown - * and the number of items of those sections. + * Completes the follow-up actions after a row is selected * - * @returns {Number} + * @param {Object} option + * @param {Object} ref + * @returns {Promise} */ - const calculateAllVisibleOptionsCount = useCallback(() => { - let count = 0; - - _.forEach(sections, (section) => { - count += lodashGet(section, 'data.length', 0); - }); + selectRow(option, ref) { + return new Promise((resolve) => { + if (this.props.shouldShowTextInput && this.props.shouldPreventDefaultFocusOnSelectRow) { + if (this.relatedTarget && ref === this.relatedTarget) { + this.textInput.focus(); + this.relatedTarget = null; + } + if (this.textInput.isFocused()) { + setSelection(this.textInput, 0, this.state.value.length); + } + } + const selectedOption = this.props.onSelectRow(option); + resolve(selectedOption); - return count; - }, [sections]); + if (!this.props.canSelectMultipleOptions) { + return; + } - /** - * @param {Number} index - */ - const updateFocusedIndex = useCallback((index) => { - setFocusedIndex(index); - }, []); + // Focus the first unselected item from the list (i.e: the best result according to the current search term) + this.setState({ + focusedIndex: this.props.selectedOptions.length, + }); + }); + } /** * Completes the follow-up action after clicking on multiple select button * @param {Object} option */ - const addToSelection = useCallback( - (option) => { - if (props.shouldShowTextInput && props.shouldPreventDefaultFocusOnSelectRow) { - textInputRef.current.focus(); - if (textInputRef.current.isFocused()) { - setSelection(textInputRef.current, 0, value.length); - } + addToSelection(option) { + if (this.props.shouldShowTextInput && this.props.shouldPreventDefaultFocusOnSelectRow) { + this.textInput.focus(); + if (this.textInput.isFocused()) { + setSelection(this.textInput, 0, this.state.value.length); } - props.onAddToSelection(option); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [props.onAddToSelection, props.shouldShowTextInput, props.shouldPreventDefaultFocusOnSelectRow, value.length], - ); + } + this.props.onAddToSelection(option); + } /** * Increments a pagination page to show more items */ - const incrementPage = useCallback(() => { - setPaginationPage((prev) => prev + 1); - }, []); - - const shouldShowShowMoreButton = allOptions.length > CONST.MAX_OPTIONS_SELECTOR_PAGE_LENGTH * paginationPage; - const shouldShowFooter = !props.isReadOnly && (props.shouldShowConfirmButton || props.footerContent) && !(props.canSelectMultipleOptions && _.isEmpty(props.selectedOptions)); - const defaultConfirmButtonText = _.isUndefined(props.confirmButtonText) ? translate('common.confirm') : props.confirmButtonText; - const shouldShowDefaultConfirmButton = !props.footerContent && defaultConfirmButtonText; - const safeAreaPaddingBottomStyle = shouldShowFooter ? undefined : props.safeAreaPaddingBottomStyle; - const listContainerStyles = props.listContainerStyles || [themeStyles.flex1]; - const optionHoveredStyle = props.optionHoveredStyle || themeStyles.hoveredComponentBG; - - const textInput = ( - <TextInput - ref={textInputRef} - label={props.textInputLabel} - accessibilityLabel={props.textInputLabel} - role={CONST.ROLE.PRESENTATION} - onChangeText={debouncedUpdateSearchValue} - errorText={errorMessage} - onSubmitEditing={selectFocusedOption} - placeholder={props.placeholderText} - maxLength={props.maxLength + CONST.ADDITIONAL_ALLOWED_CHARACTERS} - keyboardType={props.keyboardType} - onBlur={(e) => { - if (!props.shouldPreventDefaultFocusOnSelectRow) { - return; - } - relatedTarget.current = e.relatedTarget; - }} - selectTextOnFocus - blurOnSubmit={Boolean(allOptions.length)} - spellCheck={false} - shouldInterceptSwipe={props.shouldTextInputInterceptSwipe} - isLoading={props.isLoadingNewOptions} - iconLeft={props.textIconLeft} - testID="options-selector-input" - /> - ); - const optionsList = ( - <OptionsList - ref={listRef} - optionHoveredStyle={optionHoveredStyle} - onSelectRow={props.onSelectRow ? selectRow : undefined} - sections={props.sections} - focusedIndex={focusedIndex} - selectedOptions={props.selectedOptions} - disableFocusOptions={props.disableFocusOptions} - canSelectMultipleOptions={props.canSelectMultipleOptions} - shouldShowMultipleOptionSelectorAsButton={props.shouldShowMultipleOptionSelectorAsButton} - multipleOptionSelectorButtonText={props.multipleOptionSelectorButtonText} - onAddToSelection={addToSelection} - hideSectionHeaders={props.hideSectionHeaders} - headerMessage={errorMessage ? '' : props.headerMessage} - boldStyle={props.boldStyle} - showTitleTooltip={props.showTitleTooltip} - isDisabled={props.isDisabled} - shouldHaveOptionSeparator={props.shouldHaveOptionSeparator} - highlightSelectedOptions={props.highlightSelectedOptions} - onLayout={() => { - if (props.selectedOptions.length === 0) { - scrollToIndex(focusedIndex, false); - } + incrementPage() { + this.setState((prev) => ({ + paginationPage: prev.paginationPage + 1, + })); + } + + render() { + const shouldShowShowMoreButton = this.state.allOptions.length > CONST.MAX_OPTIONS_SELECTOR_PAGE_LENGTH * this.state.paginationPage; + const shouldShowFooter = + !this.props.isReadOnly && (this.props.shouldShowConfirmButton || this.props.footerContent) && !(this.props.canSelectMultipleOptions && _.isEmpty(this.props.selectedOptions)); + const defaultConfirmButtonText = _.isUndefined(this.props.confirmButtonText) ? this.props.translate('common.confirm') : this.props.confirmButtonText; + const shouldShowDefaultConfirmButton = !this.props.footerContent && defaultConfirmButtonText; + const safeAreaPaddingBottomStyle = shouldShowFooter ? undefined : this.props.safeAreaPaddingBottomStyle; + const listContainerStyles = this.props.listContainerStyles || [this.props.themeStyles.flex1]; + const optionHoveredStyle = this.props.optionHoveredStyle || this.props.themeStyles.hoveredComponentBG; + + const textInput = ( + <TextInput + ref={(el) => (this.textInput = el)} + label={this.props.textInputLabel} + accessibilityLabel={this.props.textInputLabel} + role={CONST.ROLE.PRESENTATION} + onChangeText={this.debouncedUpdateSearchValue} + errorText={this.state.errorMessage} + onSubmitEditing={this.selectFocusedOption} + placeholder={this.props.placeholderText} + maxLength={this.props.maxLength + CONST.ADDITIONAL_ALLOWED_CHARACTERS} + keyboardType={this.props.keyboardType} + onBlur={(e) => { + if (!this.props.shouldPreventDefaultFocusOnSelectRow) { + return; + } + this.relatedTarget = e.relatedTarget; + }} + selectTextOnFocus + blurOnSubmit={Boolean(this.state.allOptions.length)} + spellCheck={false} + shouldInterceptSwipe={this.props.shouldTextInputInterceptSwipe} + isLoading={this.props.isLoadingNewOptions} + iconLeft={this.props.textIconLeft} + testID="options-selector-input" + /> + ); + const optionsList = ( + <OptionsList + ref={(el) => (this.list = el)} + optionHoveredStyle={optionHoveredStyle} + onSelectRow={this.props.onSelectRow ? this.selectRow : undefined} + sections={this.state.sections} + focusedIndex={this.state.focusedIndex} + disableFocusOptions={this.props.disableFocusOptions} + selectedOptions={this.props.selectedOptions} + canSelectMultipleOptions={this.props.canSelectMultipleOptions} + shouldShowMultipleOptionSelectorAsButton={this.props.shouldShowMultipleOptionSelectorAsButton} + multipleOptionSelectorButtonText={this.props.multipleOptionSelectorButtonText} + onAddToSelection={this.addToSelection} + hideSectionHeaders={this.props.hideSectionHeaders} + headerMessage={this.state.errorMessage ? '' : this.props.headerMessage} + boldStyle={this.props.boldStyle} + showTitleTooltip={this.props.showTitleTooltip} + isDisabled={this.props.isDisabled} + shouldHaveOptionSeparator={this.props.shouldHaveOptionSeparator} + highlightSelectedOptions={this.props.highlightSelectedOptions} + onLayout={() => { + if (this.props.selectedOptions.length === 0) { + this.scrollToIndex(this.state.focusedIndex, false); + } - if (props.onLayout) { - props.onLayout(); + if (this.props.onLayout) { + this.props.onLayout(); + } + }} + contentContainerStyles={[safeAreaPaddingBottomStyle, ...this.props.contentContainerStyles]} + sectionHeaderStyle={this.props.sectionHeaderStyle} + listContainerStyles={listContainerStyles} + listStyles={this.props.listStyles} + isLoading={!this.props.shouldShowOptions} + showScrollIndicator={this.props.showScrollIndicator} + isRowMultilineSupported={this.props.isRowMultilineSupported} + isLoadingNewOptions={this.props.isLoadingNewOptions} + shouldPreventDefaultFocusOnSelectRow={this.props.shouldPreventDefaultFocusOnSelectRow} + nestedScrollEnabled={this.props.nestedScrollEnabled} + bounces={!this.props.shouldTextInputAppearBelowOptions || !this.props.shouldAllowScrollingChildren} + renderFooterContent={ + shouldShowShowMoreButton && ( + <ShowMoreButton + containerStyle={{...this.props.themeStyles.mt2, ...this.props.themeStyles.mb5}} + currentCount={CONST.MAX_OPTIONS_SELECTOR_PAGE_LENGTH * this.state.paginationPage} + totalCount={this.state.allOptions.length} + onPress={this.incrementPage} + /> + ) } - }} - contentContainerStyles={[safeAreaPaddingBottomStyle, ...props.contentContainerStyles]} - sectionHeaderStyle={props.sectionHeaderStyle} - listContainerStyles={listContainerStyles} - listStyles={props.listStyles} - isLoading={!props.shouldShowOptions} - showScrollIndicator={props.showScrollIndicator} - isRowMultilineSupported={props.isRowMultilineSupported} - isLoadingNewOptions={props.isLoadingNewOptions} - shouldPreventDefaultFocusOnSelectRow={props.shouldPreventDefaultFocusOnSelectRow} - nestedScrollEnabled={props.nestedScrollEnabled} - bounces={!props.shouldTextInputAppearBelowOptions || !props.shouldAllowScrollingChildren} - renderFooterContent={() => - shouldShowShowMoreButton && ( - <ShowMoreButton - containerStyle={{...themeStyles.mt2, ...themeStyles.mb5}} - currentCount={CONST.MAX_OPTIONS_SELECTOR_PAGE_LENGTH * paginationPage} - totalCount={allOptions.length} - onPress={incrementPage} - /> - ) - } - /> - ); - - const optionsAndInputsBelowThem = ( - <> - <View style={[themeStyles.flexGrow0, themeStyles.flexShrink1, themeStyles.flexBasisAuto, themeStyles.w100, themeStyles.flexRow]}>{optionsList}</View> - <View style={props.shouldUseStyleForChildren ? [themeStyles.ph5, themeStyles.pv5, themeStyles.flexGrow1, themeStyles.flexShrink0] : []}> - {props.children} - {props.shouldShowTextInput && textInput} - </View> - </> - ); - - return ( - <ArrowKeyFocusManager - disabledIndexes={disabledOptionsIndexes} - focusedIndex={focusedIndex} - maxIndex={calculateAllVisibleOptionsCount() - 1} - onFocusedIndexChanged={props.disableArrowKeysActions ? () => {} : updateFocusedIndex} - shouldResetIndexOnEndReached={false} - > - <View style={[themeStyles.flexGrow1, themeStyles.flexShrink1, themeStyles.flexBasisAuto]}> - {/* - * The OptionsList component uses a SectionList which uses a VirtualizedList internally. - * VirtualizedList cannot be directly nested within ScrollViews of the same orientation. - * To work around this, we wrap the OptionsList component with a horizontal ScrollView. - */} - {props.shouldTextInputAppearBelowOptions && props.shouldAllowScrollingChildren && ( - <ScrollView contentContainerStyle={[themeStyles.flexGrow1]}> - <ScrollView - horizontal - bounces={false} - contentContainerStyle={[themeStyles.flex1, themeStyles.flexColumn]} - > - {optionsAndInputsBelowThem} + /> + ); + + const optionsAndInputsBelowThem = ( + <> + <View + style={[ + this.props.themeStyles.flexGrow0, + this.props.themeStyles.flexShrink1, + this.props.themeStyles.flexBasisAuto, + this.props.themeStyles.w100, + this.props.themeStyles.flexRow, + ]} + > + {optionsList} + </View> + <View + style={ + this.props.shouldUseStyleForChildren + ? [this.props.themeStyles.ph5, this.props.themeStyles.pv5, this.props.themeStyles.flexGrow1, this.props.themeStyles.flexShrink0] + : [] + } + > + {this.props.children} + {this.props.shouldShowTextInput && textInput} + </View> + </> + ); + + return ( + <ArrowKeyFocusManager + disabledIndexes={this.disabledOptionsIndexes} + focusedIndex={this.state.focusedIndex} + maxIndex={this.calculateAllVisibleOptionsCount() - 1} + onFocusedIndexChanged={this.props.disableArrowKeysActions ? () => {} : this.updateFocusedIndex} + shouldResetIndexOnEndReached={false} + > + <View style={[this.props.themeStyles.flexGrow1, this.props.themeStyles.flexShrink1, this.props.themeStyles.flexBasisAuto]}> + {/* + * The OptionsList component uses a SectionList which uses a VirtualizedList internally. + * VirtualizedList cannot be directly nested within ScrollViews of the same orientation. + * To work around this, we wrap the OptionsList component with a horizontal ScrollView. + */} + {this.props.shouldTextInputAppearBelowOptions && this.props.shouldAllowScrollingChildren && ( + <ScrollView contentContainerStyle={[this.props.themeStyles.flexGrow1]}> + <ScrollView + horizontal + bounces={false} + contentContainerStyle={[this.props.themeStyles.flex1, this.props.themeStyles.flexColumn]} + > + {optionsAndInputsBelowThem} + </ScrollView> </ScrollView> - </ScrollView> + )} + + {this.props.shouldTextInputAppearBelowOptions && !this.props.shouldAllowScrollingChildren && optionsAndInputsBelowThem} + + {!this.props.shouldTextInputAppearBelowOptions && ( + <> + <View style={this.props.shouldUseStyleForChildren ? [this.props.themeStyles.ph5, this.props.themeStyles.pb3] : []}> + {this.props.children} + {this.props.shouldShowTextInput && textInput} + {Boolean(this.props.textInputAlert) && ( + <FormHelpMessage + message={this.props.textInputAlert} + style={[this.props.themeStyles.mb3]} + isError={false} + /> + )} + </View> + {optionsList} + </> + )} + </View> + {this.props.shouldShowReferralCTA && ( + <View style={[this.props.themeStyles.ph5, this.props.themeStyles.pb5, this.props.themeStyles.flexShrink0]}> + <ReferralProgramCTA referralContentType={this.props.referralContentType} /> + </View> )} - {props.shouldTextInputAppearBelowOptions && !props.shouldAllowScrollingChildren && optionsAndInputsBelowThem} - - {!props.shouldTextInputAppearBelowOptions && ( - <> - <View style={props.shouldUseStyleForChildren ? [themeStyles.ph5, themeStyles.pb3] : []}> - {props.children} - {props.shouldShowTextInput && textInput} - {Boolean(props.textInputAlert) && ( - <FormHelpMessage - message={props.textInputAlert} - style={[themeStyles.mb3]} - isError={false} - /> - )} - </View> - {optionsList} - </> + {shouldShowFooter && ( + <FixedFooter> + {shouldShowDefaultConfirmButton && ( + <Button + success + style={[this.props.themeStyles.w100]} + text={defaultConfirmButtonText} + onPress={this.props.onConfirmSelection} + pressOnEnter + enterKeyEventListenerPriority={1} + /> + )} + {this.props.footerContent} + </FixedFooter> )} - </View> - {props.shouldShowReferralCTA && ( - <View style={[themeStyles.ph5, themeStyles.pb5, themeStyles.flexShrink0]}> - <ReferralProgramCTA referralContentType={props.referralContentType} /> - </View> - )} - - {shouldShowFooter && ( - <FixedFooter> - {shouldShowDefaultConfirmButton && ( - <Button - success - style={[themeStyles.w100]} - text={defaultConfirmButtonText} - onPress={props.onConfirmSelection} - pressOnEnter - enterKeyEventListenerPriority={1} - /> - )} - {props.footerContent} - </FixedFooter> - )} - </ArrowKeyFocusManager> - ); + </ArrowKeyFocusManager> + ); + } } BaseOptionsSelector.defaultProps = defaultProps; BaseOptionsSelector.propTypes = propTypes; -const BaseOptionsSelectorWithRef = forwardRef((props, ref) => ( - <BaseOptionsSelector - // eslint-disable-next-line react/jsx-props-no-spreading - {...props} - forwardedRef={ref} - /> -)); - -BaseOptionsSelectorWithRef.displayName = 'BaseOptionsSelectorWithRef'; - -export default BaseOptionsSelectorWithRef; +export default compose(withLocalize, withNavigationFocus, withThemeStyles, withTheme)(BaseOptionsSelector); diff --git a/src/components/OptionsSelector/index.android.js b/src/components/OptionsSelector/index.android.js index 9f7c924e427f..ace5a5614ffb 100644 --- a/src/components/OptionsSelector/index.android.js +++ b/src/components/OptionsSelector/index.android.js @@ -6,6 +6,7 @@ const OptionsSelector = forwardRef((props, ref) => ( // eslint-disable-next-line react/jsx-props-no-spreading {...props} ref={ref} + shouldDelayFocus /> )); diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index 054f229ac16a..72393e89ae1a 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -273,6 +273,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, isSearchingF textInputLabel={translate('optionsSelector.nameEmailOrPhoneNumber')} safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} isLoadingNewOptions={isSearchingForReports} + autoFocus={false} /> </View> {isSmallScreenWidth && <OfflineIndicator />} diff --git a/tests/perf-test/OptionsSelector.perf-test.js b/tests/perf-test/OptionsSelector.perf-test.js index 260a9da06c6b..6104ded05c6a 100644 --- a/tests/perf-test/OptionsSelector.perf-test.js +++ b/tests/perf-test/OptionsSelector.perf-test.js @@ -5,20 +5,6 @@ import _ from 'underscore'; import OptionsSelector from '@src/components/OptionsSelector'; import variables from '@src/styles/variables'; -jest.mock('@react-navigation/native', () => { - const actualNav = jest.requireActual('@react-navigation/native'); - return { - ...actualNav, - useNavigation: () => ({ - navigate: jest.fn(), - addListener: () => jest.fn(), - }), - useIsFocused: () => ({ - navigate: jest.fn(), - }), - }; -}); - jest.mock('../../src/components/withLocalize', () => (Component) => { function WrappedComponent(props) { return (