-
Notifications
You must be signed in to change notification settings - Fork 2.9k
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
Selection List refactor phase 3: base #27767
Changes from 21 commits
beca6d0
478d731
37f66e4
0b12a94
9d3cb2e
5978ba7
a74b3bd
fc74d8d
b652bb6
0bc58a4
0b2a660
0b31679
e1ecc08
68be6e9
b629c22
ae0ce3d
02a8af9
300a092
c126fc3
424457d
4f74105
b387ed8
158e8a1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
import React from 'react'; | ||
import {View} from 'react-native'; | ||
import lodashGet from 'lodash/get'; | ||
import PressableWithFeedback from '../Pressable/PressableWithFeedback'; | ||
import styles from '../../styles/styles'; | ||
import Icon from '../Icon'; | ||
import * as Expensicons from '../Icon/Expensicons'; | ||
import themeColors from '../../styles/themes/default'; | ||
import {baseListItemPropTypes} from './selectionListPropTypes'; | ||
import * as StyleUtils from '../../styles/StyleUtils'; | ||
import UserListItem from './UserListItem'; | ||
import RadioListItem from './RadioListItem'; | ||
import OfflineWithFeedback from '../OfflineWithFeedback'; | ||
import CONST from '../../CONST'; | ||
|
||
function BaseListItem({item, isFocused = false, isDisabled = false, showTooltip, canSelectMultiple = false, onSelectRow, onDismissError = () => {}}) { | ||
const isUserItem = lodashGet(item, 'icons.length', 0) > 0; | ||
const ListItem = isUserItem ? UserListItem : RadioListItem; | ||
|
||
return ( | ||
<OfflineWithFeedback | ||
onClose={() => onDismissError(item)} | ||
pendingAction={item.pendingAction} | ||
errors={item.errors} | ||
errorRowStyles={styles.ph5} | ||
> | ||
<PressableWithFeedback | ||
onPress={() => onSelectRow(item)} | ||
disabled={isDisabled} | ||
accessibilityLabel={item.text} | ||
accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} | ||
hoverDimmingValue={1} | ||
hoverStyle={styles.hoveredComponentBG} | ||
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} | ||
> | ||
<View | ||
style={[ | ||
styles.flex1, | ||
styles.justifyContentBetween, | ||
styles.sidebarLinkInner, | ||
styles.userSelectNone, | ||
isUserItem ? styles.peopleRow : styles.optionRow, | ||
isFocused && styles.sidebarLinkActive, | ||
]} | ||
> | ||
{canSelectMultiple && ( | ||
<View style={styles.checkboxPressable}> | ||
<View | ||
style={[ | ||
StyleUtils.getCheckboxContainerStyle(20, 4), | ||
styles.mr3, | ||
item.isSelected && styles.checkedContainer, | ||
item.isSelected && styles.borderColorFocus, | ||
item.isDisabled && styles.cursorDisabled, | ||
item.isDisabled && styles.buttonOpacityDisabled, | ||
]} | ||
> | ||
{item.isSelected && ( | ||
<Icon | ||
src={Expensicons.Checkmark} | ||
fill={themeColors.textLight} | ||
height={14} | ||
width={14} | ||
/> | ||
)} | ||
</View> | ||
</View> | ||
)} | ||
|
||
<ListItem | ||
item={item} | ||
isFocused={isFocused} | ||
isDisabled={isDisabled} | ||
onSelectRow={onSelectRow} | ||
showTooltip={showTooltip} | ||
/> | ||
|
||
{!canSelectMultiple && item.isSelected && ( | ||
<View | ||
style={[styles.flexRow, styles.alignItemsCenter, styles.ml3]} | ||
accessible={false} | ||
> | ||
<View> | ||
<Icon | ||
src={Expensicons.Checkmark} | ||
fill={themeColors.success} | ||
/> | ||
</View> | ||
</View> | ||
)} | ||
</View> | ||
</PressableWithFeedback> | ||
</OfflineWithFeedback> | ||
); | ||
} | ||
|
||
BaseListItem.displayName = 'BaseListItem'; | ||
BaseListItem.propTypes = baseListItemPropTypes; | ||
|
||
export default BaseListItem; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,12 +7,9 @@ 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 selectionListPropTypes} from './selectionListPropTypes'; | ||
import RadioListItem from './RadioListItem'; | ||
import UserListItem from './UserListItem'; | ||
import useKeyboardShortcut from '../../hooks/useKeyboardShortcut'; | ||
import SafeAreaConsumer from '../SafeAreaConsumer'; | ||
import withKeyboardState, {keyboardStatePropTypes} from '../withKeyboardState'; | ||
|
@@ -24,6 +21,9 @@ import useLocalize from '../../hooks/useLocalize'; | |
import Log from '../../libs/Log'; | ||
import OptionsListSkeletonView from '../OptionsListSkeletonView'; | ||
import useActiveElement from '../../hooks/useActiveElement'; | ||
import BaseListItem from './BaseListItem'; | ||
import themeColors from '../../styles/themes/default'; | ||
import ArrowKeyFocusManager from '../ArrowKeyFocusManager'; | ||
|
||
const propTypes = { | ||
...keyboardStatePropTypes, | ||
|
@@ -48,10 +48,13 @@ function BaseSelectionList({ | |
headerMessage = '', | ||
confirmButtonText = '', | ||
onConfirm, | ||
footerContent, | ||
showScrollIndicator = false, | ||
showLoadingPlaceholder = false, | ||
showConfirmButton = false, | ||
isKeyboardShown = false, | ||
disableKeyboardShortcuts = false, | ||
children, | ||
}) { | ||
const {translate} = useLocalize(); | ||
const firstLayoutRef = useRef(true); | ||
|
@@ -136,19 +139,19 @@ function BaseSelectionList({ | |
}; | ||
}, [canSelectMultiple, sections]); | ||
|
||
// Disable `Enter` hotkey if the active element is a button or checkbox | ||
const shouldDisableHotkeys = activeElement && [CONST.ACCESSIBILITY_ROLE.BUTTON, CONST.ACCESSIBILITY_ROLE.CHECKBOX].includes(activeElement.role); | ||
|
||
// If `initiallyFocusedOptionKey` is not passed, we fall back to `-1`, to avoid showing the highlight on the first member | ||
const [focusedIndex, setFocusedIndex] = useState(() => _.findIndex(flattenedSections.allOptions, (option) => option.keyForList === initiallyFocusedOptionKey)); | ||
|
||
// Disable `Enter` shortcut if the active element is a button or checkbox | ||
const disableEnterShortcut = activeElement && [CONST.ACCESSIBILITY_ROLE.BUTTON, CONST.ACCESSIBILITY_ROLE.CHECKBOX].includes(activeElement.role); | ||
|
||
/** | ||
* 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 scrollToIndex = useCallback((index, animated = true) => { | ||
const item = flattenedSections.allOptions[index]; | ||
|
||
if (!listRef.current || !item) { | ||
|
@@ -169,7 +172,10 @@ function BaseSelectionList({ | |
} | ||
|
||
listRef.current.scrollToLocation({sectionIndex: adjustedSectionIndex, itemIndex, animated, viewOffset: variables.contentHeaderHeight}); | ||
}; | ||
|
||
// If we don't disable dependencies here, we would need to make sure that the `sections` prop is stable in every usage of this component. | ||
// eslint-disable-next-line react-hooks/exhaustive-deps | ||
}, []); | ||
|
||
/** | ||
* Logic to run when a row is selected, either with click/press or keyboard hotkeys. | ||
|
@@ -234,6 +240,14 @@ function BaseSelectionList({ | |
const getItemLayout = (data, flatDataArrayIndex) => { | ||
const targetItem = flattenedSections.itemLayouts[flatDataArrayIndex]; | ||
|
||
if (!targetItem) { | ||
return { | ||
length: 0, | ||
offset: 0, | ||
index: flatDataArrayIndex, | ||
}; | ||
} | ||
|
||
return { | ||
length: targetItem.length, | ||
offset: targetItem.offset, | ||
|
@@ -259,33 +273,40 @@ function BaseSelectionList({ | |
|
||
const renderItem = ({item, index, section}) => { | ||
const normalizedIndex = index + lodashGet(section, 'indexOffset', 0); | ||
const isDisabled = section.isDisabled; | ||
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; | ||
|
||
if (canSelectMultiple) { | ||
return ( | ||
<UserListItem | ||
item={item} | ||
isFocused={isItemFocused} | ||
onSelectRow={() => selectRow(item, true)} | ||
onDismissError={onDismissError} | ||
showTooltip={showTooltip} | ||
/> | ||
); | ||
} | ||
|
||
return ( | ||
<RadioListItem | ||
<BaseListItem | ||
item={item} | ||
isFocused={isItemFocused} | ||
isDisabled={isDisabled} | ||
showTooltip={showTooltip} | ||
canSelectMultiple={canSelectMultiple} | ||
onSelectRow={() => selectRow(item, true)} | ||
onDismissError={onDismissError} | ||
/> | ||
); | ||
}; | ||
|
||
const scrollToFocusedIndexOnFirstRender = useCallback(() => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
if (!firstLayoutRef.current) { | ||
return; | ||
} | ||
scrollToIndex(focusedIndex, false); | ||
firstLayoutRef.current = false; | ||
}, [focusedIndex, scrollToIndex]); | ||
|
||
const updateAndScrollToFocusedIndex = useCallback( | ||
(newFocusedIndex) => { | ||
setFocusedIndex(newFocusedIndex); | ||
scrollToIndex(newFocusedIndex, true); | ||
}, | ||
[scrollToIndex], | ||
); | ||
|
||
/** Focuses the text input when the component comes into focus and after any navigation animations finish. */ | ||
useFocusEffect( | ||
useCallback(() => { | ||
|
@@ -305,25 +326,22 @@ function BaseSelectionList({ | |
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, { | ||
captureOnInputs: true, | ||
shouldBubble: () => !flattenedSections.allOptions[focusedIndex], | ||
isActive: !shouldDisableHotkeys && isFocused, | ||
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: Boolean(onConfirm) && isFocused, | ||
isActive: !disableKeyboardShortcuts && Boolean(onConfirm) && isFocused, | ||
}); | ||
|
||
return ( | ||
<ArrowKeyFocusManager | ||
thiagobrez marked this conversation as resolved.
Show resolved
Hide resolved
|
||
disabledIndexes={flattenedSections.disabledOptionsIndexes} | ||
focusedIndex={focusedIndex} | ||
maxIndex={flattenedSections.allOptions.length - 1} | ||
onFocusedIndexChanged={(newFocusedIndex) => { | ||
setFocusedIndex(newFocusedIndex); | ||
scrollToIndex(newFocusedIndex, true); | ||
}} | ||
onFocusedIndexChanged={updateAndScrollToFocusedIndex} | ||
> | ||
<SafeAreaConsumer> | ||
{({safeAreaPaddingBottomStyle}) => ( | ||
|
@@ -360,7 +378,7 @@ function BaseSelectionList({ | |
style={[styles.peopleRow, styles.userSelectNone, styles.ph5, styles.pb3]} | ||
onPress={onSelectAll} | ||
accessibilityLabel={translate('workspace.people.selectAll')} | ||
accessibilityRole="button" | ||
accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} | ||
accessibilityState={{checked: flattenedSections.allSelected}} | ||
disabled={flattenedSections.allOptions.length === flattenedSections.disabledOptionsIndexes.length} | ||
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} | ||
|
@@ -387,26 +405,22 @@ function BaseSelectionList({ | |
onScrollBeginDrag={onScrollBeginDrag} | ||
keyExtractor={(item) => item.keyForList} | ||
extraData={focusedIndex} | ||
indicatorStyle="white" | ||
indicatorStyle={themeColors.selectionListIndicatorColor} | ||
keyboardShouldPersistTaps="always" | ||
showsVerticalScrollIndicator={showScrollIndicator} | ||
initialNumToRender={12} | ||
maxToRenderPerBatch={5} | ||
windowSize={5} | ||
viewabilityConfig={{viewAreaCoveragePercentThreshold: 95}} | ||
testID="selection-list" | ||
onLayout={() => { | ||
if (!firstLayoutRef.current) { | ||
return; | ||
} | ||
scrollToIndex(focusedIndex, false); | ||
firstLayoutRef.current = false; | ||
}} | ||
style={[styles.flexGrow0]} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This caused minor scrolling issue related to iOS bounce - #30428 |
||
onLayout={scrollToFocusedIndexOnFirstRender} | ||
/> | ||
{children} | ||
</> | ||
)} | ||
{showConfirmButton && ( | ||
<FixedFooter> | ||
<FixedFooter style={[styles.mtAuto]}> | ||
<Button | ||
success | ||
style={[styles.w100]} | ||
|
@@ -417,6 +431,7 @@ function BaseSelectionList({ | |
/> | ||
</FixedFooter> | ||
)} | ||
{Boolean(footerContent) && <FixedFooter style={[styles.mtAuto]}>{footerContent}</FixedFooter>} | ||
</View> | ||
)} | ||
</SafeAreaConsumer> | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,51 +1,18 @@ | ||
import React from 'react'; | ||
import {View} from 'react-native'; | ||
import CONST from '../../CONST'; | ||
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 './selectionListPropTypes'; | ||
|
||
function RadioListItem({item, isFocused = false, isDisabled = false, onSelectRow}) { | ||
function RadioListItem({item, isFocused = false}) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Removed the Checkmark part and moved to |
||
return ( | ||
<PressableWithFeedback | ||
onPress={() => onSelectRow(item)} | ||
disabled={isDisabled} | ||
accessibilityLabel={item.text} | ||
accessibilityRole="button" | ||
hoverDimmingValue={1} | ||
hoverStyle={styles.hoveredComponentBG} | ||
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} | ||
> | ||
<View style={[styles.flex1, styles.justifyContentBetween, styles.sidebarLinkInner, styles.optionRow, styles.userSelectNone, isFocused && styles.sidebarLinkActive]}> | ||
<View style={[styles.flex1, styles.alignItemsStart]}> | ||
<Text style={[styles.optionDisplayName, isFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText, item.isSelected && styles.sidebarLinkTextBold]}> | ||
{item.text} | ||
</Text> | ||
<View style={[styles.flex1, styles.alignItemsStart]}> | ||
<Text style={[styles.optionDisplayName, isFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText, item.isSelected && styles.sidebarLinkTextBold]}>{item.text}</Text> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We had to specify the |
||
|
||
{Boolean(item.alternateText) && ( | ||
<Text style={[isFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText, styles.optionAlternateText, styles.textLabelSupporting]}>{item.alternateText}</Text> | ||
)} | ||
</View> | ||
|
||
{item.isSelected && ( | ||
<View | ||
style={[styles.flexRow, styles.alignItemsCenter]} | ||
accessible={false} | ||
> | ||
<View> | ||
<Icon | ||
src={Expensicons.Checkmark} | ||
fill={themeColors.success} | ||
/> | ||
</View> | ||
</View> | ||
)} | ||
</View> | ||
</PressableWithFeedback> | ||
{Boolean(item.alternateText) && ( | ||
<Text style={[isFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText, styles.optionAlternateText, styles.textLabelSupporting]}>{item.alternateText}</Text> | ||
)} | ||
</View> | ||
); | ||
} | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This change caused a bug - WS - Members - List of users invited to the workspace does not scroll with Up and Down arrows
allOptions
is initially empty. But this callback runs only on mount, so it doesn't change when allOptions are populated.