From 908038260d76cae83f3734a2e4a6fb0c61153d50 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Thu, 21 Dec 2023 14:51:25 +0100 Subject: [PATCH 01/20] migrate everything that is possible at the moment --- src/components/OfflineWithFeedback.tsx | 2 +- src/components/SectionList/index.tsx | 23 +- src/components/SectionList/types.ts | 4 +- src/components/SelectionList/BaseListItem.tsx | 137 +++++ .../SelectionList/BaseSelectionList.tsx | 525 ++++++++++++++++++ .../SelectionList/RadioListItem.tsx | 45 ++ src/components/SelectionList/UserListItem.tsx | 53 ++ src/components/SelectionList/types.ts | 221 ++++++++ src/components/SubscriptAvatar.tsx | 1 + src/hooks/useKeyboardShortcut.ts | 2 +- 10 files changed, 997 insertions(+), 16 deletions(-) create mode 100644 src/components/SelectionList/BaseListItem.tsx create mode 100644 src/components/SelectionList/BaseSelectionList.tsx create mode 100644 src/components/SelectionList/RadioListItem.tsx create mode 100644 src/components/SelectionList/UserListItem.tsx create mode 100644 src/components/SelectionList/types.ts diff --git a/src/components/OfflineWithFeedback.tsx b/src/components/OfflineWithFeedback.tsx index 5fcf1fe7442b..4522595826af 100644 --- a/src/components/OfflineWithFeedback.tsx +++ b/src/components/OfflineWithFeedback.tsx @@ -18,7 +18,7 @@ import MessagesRow from './MessagesRow'; type OfflineWithFeedbackProps = ChildrenProps & { /** The type of action that's pending */ - pendingAction: OnyxCommon.PendingAction; + pendingAction?: OnyxCommon.PendingAction; /** Determine whether to hide the component's children if deletion is pending */ shouldHideOnDelete?: boolean; diff --git a/src/components/SectionList/index.tsx b/src/components/SectionList/index.tsx index 1c89b50468dd..ef178a3bb1e3 100644 --- a/src/components/SectionList/index.tsx +++ b/src/components/SectionList/index.tsx @@ -1,16 +1,15 @@ -import React, {forwardRef} from 'react'; -import {SectionList as RNSectionList} from 'react-native'; -import ForwardedSectionList from './types'; +import React, {ForwardedRef, forwardRef} from 'react'; +import {SectionList as RNSectionList, SectionListProps} from 'react-native'; // eslint-disable-next-line react/function-component-definition -const SectionList: ForwardedSectionList = (props, ref) => ( - -); - -SectionList.displayName = 'SectionList'; +function SectionList(props: SectionListProps, ref: ForwardedRef>) { + return ( + + ); +} export default forwardRef(SectionList); diff --git a/src/components/SectionList/types.ts b/src/components/SectionList/types.ts index 093cb8f4e77c..84f38171a4f5 100644 --- a/src/components/SectionList/types.ts +++ b/src/components/SectionList/types.ts @@ -1,8 +1,8 @@ import {ForwardedRef} from 'react'; import {SectionList, SectionListProps} from 'react-native'; -type ForwardedSectionList = { - (props: SectionListProps, ref: ForwardedRef): React.ReactNode; +type ForwardedSectionList = { + (props: SectionListProps, ref: ForwardedRef>): React.ReactNode; displayName: string; }; diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx new file mode 100644 index 000000000000..34bf920183d1 --- /dev/null +++ b/src/components/SelectionList/BaseListItem.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import {View} from 'react-native'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import RadioListItem from './RadioListItem'; +import {baseListItemPropTypes} from './selectionListPropTypes'; +import {BaseListItemProps} from './types'; +import UserListItem from './UserListItem'; + +function BaseListItem({ + item, + isFocused = false, + isDisabled = false, + showTooltip, + shouldPreventDefaultFocusOnSelectRow = false, + canSelectMultiple = false, + onSelectRow, + onDismissError = () => {}, + keyForList, +}: BaseListItemProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const isUserItem = !item.rightElement === undefined; + + return ( + onDismissError(item)} + pendingAction={item.pendingAction} + errors={item.errors} + errorRowStyles={styles.ph5} + > + onSelectRow(item)} + disabled={isDisabled} + accessibilityLabel={item.text} + role={CONST.ROLE.BUTTON} + hoverDimmingValue={1} + hoverStyle={styles.hoveredComponentBG} + dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} + onMouseDown={shouldPreventDefaultFocusOnSelectRow ? (e) => e.preventDefault() : undefined} + testID={keyForList} + > + + {canSelectMultiple && ( + + + {item.isSelected && ( + + )} + + + )} + {item.rightElement === undefined ? ( + + ) : ( + + )} + {!canSelectMultiple && item.isSelected && ( + + + + + + )} + + {item.invitedSecondaryLogin && ( + + {translate('workspace.people.invitedBySecondaryLogin', {secondaryLogin: item.invitedSecondaryLogin})} + + )} + + + ); +} + +BaseListItem.displayName = 'BaseListItem'; +BaseListItem.propTypes = baseListItemPropTypes; + +export default BaseListItem; diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx new file mode 100644 index 000000000000..7712185b7f3e --- /dev/null +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -0,0 +1,525 @@ +import {useFocusEffect, useIsFocused} from '@react-navigation/native'; +import React, {ForwardedRef, forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import { + GestureResponderEvent, + LayoutChangeEvent, + SectionList as RNSectionList, + TextInput as RNTextInput, + SectionListData, + SectionListRenderItemInfo, + View, + ViewStyle, +} from 'react-native'; +import ArrowKeyFocusManager from '@components/ArrowKeyFocusManager'; +import Button from '@components/Button'; +import Checkbox from '@components/Checkbox'; +import FixedFooter from '@components/FixedFooter'; +import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import SafeAreaConsumer from '@components/SafeAreaConsumer'; +import SectionList from '@components/SectionList'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; +import withKeyboardState, {keyboardStatePropTypes} from '@components/withKeyboardState'; +import useActiveElement from '@hooks/useActiveElement'; +import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Log from '@libs/Log'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import BaseListItem from './BaseListItem'; +import {propTypes as selectionListPropTypes} from './selectionListPropTypes'; +import {BaseSelectionListProps, RadioItem, Section, User} from './types'; + +const propTypes = { + ...keyboardStatePropTypes, + ...selectionListPropTypes, +}; + +function BaseSelectionList( + { + sections, + canSelectMultiple = false, + onSelectRow, + onSelectAll, + onDismissError, + textInputLabel = '', + textInputPlaceholder = '', + textInputValue = '', + textInputMaxLength, + inputMode = CONST.INPUT_MODE.TEXT, + onChangeText, + initiallyFocusedOptionKey = '', + onScroll, + onScrollBeginDrag, + headerMessage = '', + confirmButtonText = '', + onConfirm = () => {}, + headerContent, + footerContent, + showScrollIndicator = false, + showLoadingPlaceholder = false, + showConfirmButton = false, + shouldPreventDefaultFocusOnSelectRow = false, + isKeyboardShown = false, + containerStyle = {}, + disableKeyboardShortcuts = false, + children, + shouldStopPropagation = false, + shouldUseDynamicMaxToRenderPerBatch = false, + }: BaseSelectionListProps, + inputRef: ForwardedRef, +) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const listRef = useRef>(null); + const textInputRef = useRef(null); + const focusTimeoutRef = useRef(null); + const shouldShowTextInput = !!textInputLabel; + const shouldShowSelectAll = !!onSelectAll; + const activeElement = useActiveElement(); + const isFocused = useIsFocused(); + const [maxToRenderPerBatch, setMaxToRenderPerBatch] = useState(shouldUseDynamicMaxToRenderPerBatch ? 0 : CONST.MAX_TO_RENDER_PER_BATCH.DEFAULT); + const [isInitialRender, setIsInitialRender] = useState(true); + const wrapperStyles = useMemo(() => ({opacity: isInitialRender ? 0 : 1}), [isInitialRender]); + + /** + * Iterates through the sections and items inside each section, and builds 3 arrays along the way: + * - `allOptions`: Contains all the items in the list, flattened, regardless of section + * - `disabledOptionsIndexes`: Contains the indexes of all the disabled items in the list, to be used by the ArrowKeyFocusManager + * - `itemLayouts`: Contains the layout information for each item, header and footer in the list, + * so we can calculate the position of any given item when scrolling programmatically + * + * @return {{itemLayouts: [{offset: number, length: number}], disabledOptionsIndexes: *[], allOptions: *[]}} + */ + const flattenedSections = useMemo(() => { + const allOptions: Array = []; + + const disabledOptionsIndexes: number[] = []; + let disabledIndex = 0; + + let offset = 0; + const itemLayouts = [{length: 0, offset}]; + + const selectedOptions: Array = []; + + sections.forEach((section, sectionIndex) => { + const sectionHeaderHeight = variables.optionsListSectionHeaderHeight; + itemLayouts.push({length: sectionHeaderHeight, offset}); + offset += sectionHeaderHeight; + + section.data.forEach((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]); + + // If `initiallyFocusedOptionKey` is not passed, we fall back to `-1`, to avoid showing the highlight on the first member + const [focusedIndex, setFocusedIndex] = useState(() => flattenedSections.allOptions.findIndex((option) => option.keyForList === initiallyFocusedOptionKey)); + + // Disable `Enter` shortcut if the active element is a button or checkbox + const disableEnterShortcut = activeElement.role && (activeElement.role === CONST.ROLE.BUTTON ?? activeElement.role === CONST.ROLE.CHECKBOX); + + /** + * 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 = useCallback( + (index: number, animated = true) => { + 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 (sections[i].data) { + adjustedSectionIndex--; + } + } + + listRef.current.scrollToLocation({sectionIndex: adjustedSectionIndex, itemIndex, animated, viewOffset: variables.contentHeaderHeight}); + }, + + // eslint-disable-next-line react-hooks/exhaustive-deps + [flattenedSections.allOptions], + ); + + /** + * Logic to run when a row is selected, either with click/press or keyboard hotkeys. + * + * @param item - the list item + * @param shouldUnfocusRow - flag to decide if we should unfocus all rows. True when selecting a row with click or press (not keyboard) + */ + const selectRow = (item: RadioItem | User, shouldUnfocusRow = false) => { + // 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 do nothing. + // If the list has multiple sections (e.g. Workspace Invite list), and `shouldUnfocusRow` is false, + // 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; + + if (!shouldUnfocusRow) { + setFocusedIndex(selectedOptionsCount); + } + + if (!item.isSelected) { + // If we're selecting an item, scroll to it's position at the top, so we can see it + scrollToIndex(Math.max(selectedOptionsCount - 1, 0), true); + } + } + + if (shouldUnfocusRow) { + // Unfocus all rows when selecting row with click/press + setFocusedIndex(-1); + } + } + + onSelectRow(item); + + if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow && textInputRef.current) { + textInputRef.current.focus(); + } + }; + + const selectAllRow = () => { + if (onSelectAll) { + onSelectAll(); + } + if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow && textInputRef.current) { + textInputRef.current.focus(); + } + }; + + const selectFocusedOption = (e?: GestureResponderEvent | KeyboardEvent) => { + // const focusedItemKey = lodashGet(e, ['target', 'attributes', 'data-testid', 'value']); + const focusedItemKey = e?.target. + const focusedOption = focusedItemKey ? flattenedSections.allOptions.find((option) => option.keyForList === focusedItemKey) : flattenedSections.allOptions[focusedIndex]; + + if (!focusedOption || focusedOption.isDisabled) { + return; + } + + selectRow(focusedOption); + }; + + /** + * 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 data - This is the same as the data we pass into the component + * @param 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 + */ + const getItemLayout = (data: Array> | null, flatDataArrayIndex: number) => { + const targetItem = flattenedSections.itemLayouts[flatDataArrayIndex]; + + if (!targetItem) { + return { + length: 0, + offset: 0, + index: flatDataArrayIndex, + }; + } + + return { + length: targetItem.length, + offset: targetItem.offset, + index: flatDataArrayIndex, + }; + }; + + const renderSectionHeader = ({section}: {section: SectionListData}) => { + if (!section.title || !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}: SectionListRenderItemInfo) => { + const indexOffset = section.indexOffset ? section.indexOffset : 0; + const normalizedIndex = index + indexOffset; + const isDisabled = section.isDisabled ?? item.isDisabled; + const isItemFocused = !isDisabled && focusedIndex === normalizedIndex; + // We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade. + const showTooltip = normalizedIndex < 10; + + return ( + selectRow(item, true)} + onDismissError={onDismissError} + shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} + keyForList={item.keyForList} + /> + ); + }; + + const scrollToFocusedIndexOnFirstRender = useCallback( + (nativeEvent: LayoutChangeEvent) => { + if (shouldUseDynamicMaxToRenderPerBatch) { + // const listHeight = lodashGet(nativeEvent, 'layout.height', 0); + // const itemHeight = lodashGet(nativeEvent, 'layout.y', 0); + const listHeight = nativeEvent.nativeEvent.layout.height; + const itemHeight = nativeEvent.nativeEvent.layout.y; + setMaxToRenderPerBatch((Math.ceil(listHeight / itemHeight) || 0) + CONST.MAX_TO_RENDER_PER_BATCH.DEFAULT); + } + + if (!isInitialRender) { + return; + } + scrollToIndex(focusedIndex, false); + setIsInitialRender(false); + }, + [focusedIndex, isInitialRender, scrollToIndex, shouldUseDynamicMaxToRenderPerBatch], + ); + + const updateAndScrollToFocusedIndex = useCallback( + (newFocusedIndex: number) => { + setFocusedIndex(newFocusedIndex); + scrollToIndex(newFocusedIndex, true); + }, + [scrollToIndex], + ); + + /** Focuses the text input when the component comes into focus and after any navigation animations finish. */ + useFocusEffect( + useCallback(() => { + if (shouldShowTextInput) { + focusTimeoutRef.current = setTimeout(() => { + if (!textInputRef.current) { + return; + } + textInputRef.current.focus(); + }, CONST.ANIMATED_TRANSITION); + } + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; + }, [shouldShowTextInput]), + ); + + useEffect(() => { + // do not change focus on the first render, as it should focus on the selected item + if (isInitialRender) { + return; + } + + // set the focus on the first item when the sections list is changed + if (sections.length > 0) { + updateAndScrollToFocusedIndex(0); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sections]); + + /** Selects row when pressing Enter */ + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, { + captureOnInputs: true, + shouldBubble: !flattenedSections.allOptions[focusedIndex], + shouldStopPropagation, + isActive: !disableKeyboardShortcuts && !disableEnterShortcut && isFocused, + }); + + /** Calls confirm action when pressing CTRL (CMD) + Enter */ + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, onConfirm, { + captureOnInputs: true, + shouldBubble: !flattenedSections.allOptions[focusedIndex], + isActive: !disableKeyboardShortcuts && Boolean(onConfirm) && isFocused, + }); + + return ( + + {/* */} + + {({safeAreaPaddingBottomStyle}) => ( + + {shouldShowTextInput && ( + + { + if (inputRef && typeof inputRef !== 'function') { + // eslint-disable-next-line no-param-reassign + inputRef.current = el; + } + textInputRef.current = el; + }} + label={textInputLabel} + accessibilityLabel={textInputLabel} + role={CONST.ROLE.PRESENTATION} + value={textInputValue} + placeholder={textInputPlaceholder} + maxLength={textInputMaxLength} + onChangeText={onChangeText} + inputMode={inputMode} + selectTextOnFocus + spellCheck={false} + onSubmitEditing={selectFocusedOption} + blurOnSubmit={Boolean(flattenedSections.allOptions.length)} + /> + + )} + {headerMessage && ( + + {headerMessage} + + )} + {headerContent} + {flattenedSections.allOptions.length === 0 && showLoadingPlaceholder ? ( + + ) : ( + <> + {!headerMessage && canSelectMultiple && shouldShowSelectAll && ( + e.preventDefault() : undefined} + > + + + {translate('workspace.people.selectAll')} + + + )} + item.keyForList} + extraData={focusedIndex} + indicatorStyle="white" + keyboardShouldPersistTaps="always" + showsVerticalScrollIndicator={showScrollIndicator} + initialNumToRender={12} + maxToRenderPerBatch={maxToRenderPerBatch} + windowSize={5} + viewabilityConfig={{viewAreaCoveragePercentThreshold: 95}} + testID="selection-list" + onLayout={scrollToFocusedIndexOnFirstRender} + style={!maxToRenderPerBatch && styles.opacity0} + /> + {children} + + )} + {showConfirmButton && ( + +