-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #21048 from thiagobrez/refactor/selection_list/rad…
…io_button_list refactor: SelectionListRadio
- Loading branch information
Showing
13 changed files
with
809 additions
and
129 deletions.
There are no files selected for viewing
268 changes: 268 additions & 0 deletions
268
src/components/SelectionListRadio/BaseSelectionListRadio.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,268 @@ | ||
import React, {useEffect, useRef, useState} from 'react'; | ||
import {View} from 'react-native'; | ||
import _ from 'underscore'; | ||
import lodashGet from 'lodash/get'; | ||
import SectionList from '../SectionList'; | ||
import Text from '../Text'; | ||
import styles from '../../styles/styles'; | ||
import TextInput from '../TextInput'; | ||
import ArrowKeyFocusManager from '../ArrowKeyFocusManager'; | ||
import CONST from '../../CONST'; | ||
import variables from '../../styles/variables'; | ||
import {propTypes as selectionListRadioPropTypes, defaultProps as selectionListRadioDefaultProps} from './selectionListRadioPropTypes'; | ||
import RadioListItem from './RadioListItem'; | ||
import useKeyboardShortcut from '../../hooks/useKeyboardShortcut'; | ||
import SafeAreaConsumer from '../SafeAreaConsumer'; | ||
import withKeyboardState, {keyboardStatePropTypes} from '../withKeyboardState'; | ||
|
||
const propTypes = { | ||
...keyboardStatePropTypes, | ||
...selectionListRadioPropTypes, | ||
}; | ||
|
||
function BaseSelectionListRadio(props) { | ||
const listRef = useRef(null); | ||
const textInputRef = useRef(null); | ||
const focusTimeoutRef = useRef(null); | ||
const shouldShowTextInput = Boolean(props.textInputLabel); | ||
|
||
/** | ||
* 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 getFlattenedSections = () => { | ||
const allOptions = []; | ||
|
||
const disabledOptionsIndexes = []; | ||
let disabledIndex = 0; | ||
|
||
let offset = 0; | ||
const itemLayouts = [{length: 0, offset}]; | ||
|
||
_.each(props.sections, (section, sectionIndex) => { | ||
// We're not rendering any section header, but we need to push to the array | ||
// because React Native accounts for it in getItemLayout | ||
const sectionHeaderHeight = 0; | ||
itemLayouts.push({length: sectionHeaderHeight, offset}); | ||
offset += sectionHeaderHeight; | ||
|
||
_.each(section.data, (option, optionIndex) => { | ||
// Add item to the general flattened array | ||
allOptions.push({ | ||
...option, | ||
sectionIndex, | ||
index: optionIndex, | ||
}); | ||
|
||
// If disabled, add to the disabled indexes array | ||
if (section.isDisabled || option.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; | ||
}); | ||
|
||
// 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}); | ||
|
||
return { | ||
allOptions, | ||
disabledOptionsIndexes, | ||
itemLayouts, | ||
}; | ||
}; | ||
|
||
const flattenedSections = getFlattenedSections(); | ||
|
||
const [focusedIndex, setFocusedIndex] = useState(() => { | ||
const defaultIndex = 0; | ||
|
||
const indexOfInitiallyFocusedOption = _.findIndex(flattenedSections.allOptions, (option) => option.keyForList === props.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(props.sections, `[${i}].data`))) { | ||
adjustedSectionIndex--; | ||
} | ||
} | ||
|
||
listRef.current.scrollToLocation({sectionIndex: adjustedSectionIndex, itemIndex, animated}); | ||
}; | ||
|
||
/** | ||
* 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 renderItem = ({item, index, section}) => { | ||
const isFocused = focusedIndex === index + lodashGet(section, 'indexOffset', 0); | ||
|
||
return ( | ||
<RadioListItem | ||
item={item} | ||
isFocused={isFocused} | ||
onSelectRow={props.onSelectRow} | ||
/> | ||
); | ||
}; | ||
|
||
/** Focuses the text input when the component mounts. If `props.shouldDelayFocus` is true, we wait for the animation to finish */ | ||
useEffect(() => { | ||
if (shouldShowTextInput) { | ||
if (props.shouldDelayFocus) { | ||
focusTimeoutRef.current = setTimeout(() => textInputRef.current.focus(), CONST.ANIMATED_TRANSITION); | ||
} else { | ||
textInputRef.current.focus(); | ||
} | ||
} | ||
|
||
return () => { | ||
if (!focusTimeoutRef.current) { | ||
return; | ||
} | ||
clearTimeout(focusTimeoutRef.current); | ||
}; | ||
}, [props.shouldDelayFocus, shouldShowTextInput]); | ||
|
||
useKeyboardShortcut( | ||
CONST.KEYBOARD_SHORTCUTS.ENTER, | ||
() => { | ||
const focusedOption = flattenedSections.allOptions[focusedIndex]; | ||
|
||
if (!focusedOption) { | ||
return; | ||
} | ||
|
||
props.onSelectRow(focusedOption); | ||
}, | ||
{ | ||
captureOnInputs: true, | ||
shouldBubble: () => !flattenedSections.allOptions[focusedIndex], | ||
}, | ||
); | ||
|
||
return ( | ||
<ArrowKeyFocusManager | ||
disabledIndexes={flattenedSections.disabledOptionsIndexes} | ||
focusedIndex={focusedIndex} | ||
maxIndex={flattenedSections.allOptions.length - 1} | ||
onFocusedIndexChanged={(newFocusedIndex) => { | ||
setFocusedIndex(newFocusedIndex); | ||
scrollToIndex(newFocusedIndex, true); | ||
}} | ||
> | ||
<SafeAreaConsumer> | ||
{({safeAreaPaddingBottomStyle}) => ( | ||
<View style={[styles.flex1, !props.isKeyboardShown && safeAreaPaddingBottomStyle]}> | ||
{shouldShowTextInput && ( | ||
<View style={[styles.ph5, styles.pv5]}> | ||
<TextInput | ||
ref={textInputRef} | ||
label={props.textInputLabel} | ||
value={props.textInputValue} | ||
placeholder={props.textInputPlaceholder} | ||
maxLength={props.textInputMaxLength} | ||
onChangeText={props.onChangeText} | ||
keyboardType={props.keyboardType} | ||
selectTextOnFocus | ||
/> | ||
</View> | ||
)} | ||
{Boolean(props.headerMessage) && ( | ||
<View style={[styles.ph5, styles.pb5]}> | ||
<Text style={[styles.textLabel, styles.colorMuted]}>{props.headerMessage}</Text> | ||
</View> | ||
)} | ||
<SectionList | ||
ref={listRef} | ||
sections={props.sections} | ||
renderItem={renderItem} | ||
getItemLayout={getItemLayout} | ||
onScroll={props.onScroll} | ||
onScrollBeginDrag={props.onScrollBeginDrag} | ||
keyExtractor={(item) => item.keyForList} | ||
extraData={focusedIndex} | ||
indicatorStyle="white" | ||
keyboardShouldPersistTaps="always" | ||
showsVerticalScrollIndicator={false} | ||
OP | ||
initialNumToRender={12} | ||
maxToRenderPerBatch={5} | ||
windowSize={5} | ||
viewabilityConfig={{viewAreaCoveragePercentThreshold: 95}} | ||
onLayout={() => scrollToIndex(focusedIndex, false)} | ||
/> | ||
</View> | ||
)} | ||
</SafeAreaConsumer> | ||
</ArrowKeyFocusManager> | ||
); | ||
} | ||
|
||
BaseSelectionListRadio.displayName = 'BaseSelectionListRadio'; | ||
BaseSelectionListRadio.propTypes = propTypes; | ||
BaseSelectionListRadio.defaultProps = selectionListRadioDefaultProps; | ||
|
||
export default withKeyboardState(BaseSelectionListRadio); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
import React from 'react'; | ||
import {View} from 'react-native'; | ||
import PropTypes from 'prop-types'; | ||
import PressableWithFeedback from '../Pressable/PressableWithFeedback'; | ||
import styles from '../../styles/styles'; | ||
import Text from '../Text'; | ||
import Icon from '../Icon'; | ||
import * as Expensicons from '../Icon/Expensicons'; | ||
import themeColors from '../../styles/themes/default'; | ||
import {radioListItemPropTypes} from './selectionListRadioPropTypes'; | ||
|
||
const propTypes = { | ||
/** The section list item */ | ||
item: PropTypes.shape(radioListItemPropTypes), | ||
|
||
/** Whether this item is focused (for arrow key controls) */ | ||
isFocused: PropTypes.bool, | ||
|
||
/** Callback to fire when the item is pressed */ | ||
onSelectRow: PropTypes.func, | ||
}; | ||
|
||
const defaultProps = { | ||
item: {}, | ||
isFocused: false, | ||
onSelectRow: () => {}, | ||
}; | ||
|
||
function RadioListItem(props) { | ||
return ( | ||
<PressableWithFeedback | ||
onPress={() => props.onSelectRow(props.item)} | ||
accessibilityLabel={props.item.text} | ||
accessibilityRole="button" | ||
hoverDimmingValue={1} | ||
hoverStyle={styles.hoveredComponentBG} | ||
focusStyle={styles.hoveredComponentBG} | ||
> | ||
<View style={[styles.flex1, styles.justifyContentBetween, styles.sidebarLinkInner, styles.optionRow, props.isFocused && styles.sidebarLinkActive]}> | ||
<View style={[styles.flex1, styles.alignItemsStart]}> | ||
<Text style={[styles.optionDisplayName, props.isFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText, props.item.isSelected && styles.sidebarLinkTextBold]}> | ||
{props.item.text} | ||
</Text> | ||
|
||
{Boolean(props.item.alternateText) && ( | ||
<Text style={[props.isFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText, styles.optionAlternateText, styles.textLabelSupporting]}> | ||
{props.item.alternateText} | ||
</Text> | ||
)} | ||
</View> | ||
|
||
{props.item.isSelected && ( | ||
<View | ||
style={[styles.flexRow, styles.alignItemsCenter]} | ||
accessible={false} | ||
> | ||
<View> | ||
<Icon | ||
src={Expensicons.Checkmark} | ||
fill={themeColors.success} | ||
/> | ||
</View> | ||
</View> | ||
)} | ||
</View> | ||
</PressableWithFeedback> | ||
); | ||
} | ||
|
||
RadioListItem.displayName = 'RadioListItem'; | ||
RadioListItem.propTypes = propTypes; | ||
RadioListItem.defaultProps = defaultProps; | ||
|
||
export default RadioListItem; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
import React, {forwardRef} from 'react'; | ||
import {Keyboard} from 'react-native'; | ||
import BaseSelectionListRadio from './BaseSelectionListRadio'; | ||
|
||
const SelectionListRadio = forwardRef((props, ref) => ( | ||
<BaseSelectionListRadio | ||
// eslint-disable-next-line react/jsx-props-no-spreading | ||
{...props} | ||
ref={ref} | ||
shouldDelayFocus | ||
onScrollBeginDrag={() => Keyboard.dismiss()} | ||
/> | ||
)); | ||
|
||
SelectionListRadio.displayName = 'SelectionListRadio'; | ||
|
||
export default SelectionListRadio; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
import React, {forwardRef} from 'react'; | ||
import {Keyboard} from 'react-native'; | ||
import BaseSelectionListRadio from './BaseSelectionListRadio'; | ||
|
||
const SelectionListRadio = forwardRef((props, ref) => ( | ||
<BaseSelectionListRadio | ||
// eslint-disable-next-line react/jsx-props-no-spreading | ||
{...props} | ||
ref={ref} | ||
onScrollBeginDrag={() => Keyboard.dismiss()} | ||
/> | ||
)); | ||
|
||
SelectionListRadio.displayName = 'SelectionListRadio'; | ||
|
||
export default SelectionListRadio; |
Oops, something went wrong.