Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: SelectionListRadio #21048

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
814ce89
chore: copy OptionsSelector and port to functional
thiagobrez Jun 7, 2023
48f7254
chore: wip
thiagobrez Jun 13, 2023
4dc7127
chore: wip
thiagobrez Jun 14, 2023
8aaddaa
chore: Timezone list fully working
thiagobrez Jun 15, 2023
cf0556d
chore: Timezone list fully working
thiagobrez Jun 16, 2023
59c262b
chore: Pronouns list working
thiagobrez Jun 16, 2023
d3eccd5
chore: Year Picker list working
thiagobrez Jun 16, 2023
e40f55f
chore: Priority Mode list working
thiagobrez Jun 16, 2023
037dae5
chore: Language list working
thiagobrez Jun 16, 2023
1b6f1c5
chore: add storybook stories
thiagobrez Jun 19, 2023
7496be6
chore: cleanup
thiagobrez Jun 19, 2023
385ba7f
chore: cleanup
thiagobrez Jun 19, 2023
8f328e2
chore: cleanup
thiagobrez Jun 19, 2023
776fce2
chore: replace customIcon and boldStyle
thiagobrez Jun 20, 2023
16532f8
chore: cleanup
thiagobrez Jun 20, 2023
05fd8a8
chore: remove flattenedSections useMemo
thiagobrez Jun 20, 2023
cb13c23
chore: cleanup BaseSelectionListRadio
thiagobrez Jun 20, 2023
fa4648a
chore: cleanup pages
thiagobrez Jun 20, 2023
58a5c7a
chore: restore lockfile
thiagobrez Jun 20, 2023
594100a
chore: fix storybook
thiagobrez Jun 20, 2023
f4f41b8
chore: improve performance test
thiagobrez Jun 20, 2023
3e7a9ef
chore: add useKeyboardShortcut
thiagobrez Jun 20, 2023
a147dfc
chore: add proptypes comments
thiagobrez Jun 21, 2023
667cb8e
chore: remove console error
thiagobrez Jun 21, 2023
d4735be
chore: fix conflicts
thiagobrez Jun 21, 2023
571259a
Merge branch 'main' into refactor/selection_list/radio_button_list
thiagobrez Jun 27, 2023
82ff960
chore: add safe area padding and fix stories
thiagobrez Jun 27, 2023
3f56600
chore: cleanup
thiagobrez Jun 27, 2023
b187511
chore: scroll list animation
thiagobrez Jun 29, 2023
10f2d43
chore: cleanup
thiagobrez Jun 29, 2023
6b7f56b
chore: fix space above kb and scrolling
thiagobrez Jul 4, 2023
ef4f2fd
chore: fix scrolling issues
thiagobrez Jul 4, 2023
af1c288
Merge branch 'main' into refactor/selection_list/radio_button_list
thiagobrez Jul 4, 2023
05da119
Merge branch 'main' into refactor/selection_list/radio_button_list
thiagobrez Jul 5, 2023
35602db
chore: fix safe area on keyboard
thiagobrez Jul 5, 2023
43c4443
chore: cleanup
thiagobrez Jul 5, 2023
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
268 changes: 268 additions & 0 deletions src/components/SelectionListRadio/BaseSelectionListRadio.js
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(() => {
thiagobrez marked this conversation as resolved.
Show resolved Hide resolved
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) => {
thiagobrez marked this conversation as resolved.
Show resolved Hide resolved
const targetItem = flattenedSections.itemLayouts[flatDataArrayIndex];

return {
length: targetItem.length,
offset: targetItem.offset,
index: flatDataArrayIndex,
};
};

const renderItem = ({item, index, section}) => {
thiagobrez marked this conversation as resolved.
Show resolved Hide resolved
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(() => {
thiagobrez marked this conversation as resolved.
Show resolved Hide resolved
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>
)}
Comment on lines +233 to +237
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block leads to this bug. We still intended to keep consistent spacing for headerMessage as it was in the other list.

<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)}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line caused a regression in #22689, On mweb, whenever the keyboard is shown, it triggers a layout change which in turn causes the list to scroll back to the focused index.

/>
</View>
)}
</SafeAreaConsumer>
</ArrowKeyFocusManager>
);
}

BaseSelectionListRadio.displayName = 'BaseSelectionListRadio';
BaseSelectionListRadio.propTypes = propTypes;
BaseSelectionListRadio.defaultProps = selectionListRadioDefaultProps;

export default withKeyboardState(BaseSelectionListRadio);
74 changes: 74 additions & 0 deletions src/components/SelectionListRadio/RadioListItem.js
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),
thiagobrez marked this conversation as resolved.
Show resolved Hide resolved

/** 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;
17 changes: 17 additions & 0 deletions src/components/SelectionListRadio/index.android.js
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;
16 changes: 16 additions & 0 deletions src/components/SelectionListRadio/index.ios.js
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;
Loading