From 4b11894ead580896b430914be894c2a0df08a27f Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Thu, 28 Mar 2024 16:12:16 +0100 Subject: [PATCH 01/12] Sync keyboard navigation in SelectionList and PopoverMenu --- src/components/MenuItem.tsx | 5 + src/components/PopoverMenu.tsx | 14 +- src/components/PopoverMenuItem.tsx | 29 ++ src/components/SelectionList/BaseListItem.tsx | 25 +- .../SelectionList/BaseSelectionList.tsx | 248 +++++++++--------- .../SelectionList/RadioListItem.tsx | 2 + .../SelectionList/TableListItem.tsx | 2 + src/components/SelectionList/types.ts | 3 + 8 files changed, 198 insertions(+), 130 deletions(-) create mode 100644 src/components/PopoverMenuItem.tsx diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 110256ba166b..730d4d025d95 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -246,6 +246,9 @@ type MenuItemBaseProps = { /** Adds padding to the left of the text when there is no icon. */ shouldPutLeftPaddingWhenNoIcon?: boolean; + + /** Handles what to do when the item is focused */ + onFocus?: () => void; }; type MenuItemProps = (IconProps | AvatarProps | NoIcon) & MenuItemBaseProps; @@ -317,6 +320,7 @@ function MenuItem( contentFit = 'cover', isPaneMenu = false, shouldPutLeftPaddingWhenNoIcon = false, + onFocus, }: MenuItemProps, ref: ForwardedRef, ) { @@ -447,6 +451,7 @@ function MenuItem( role={CONST.ROLE.MENUITEM} accessibilityLabel={title ? title.toString() : ''} accessible + onFocus={onFocus} > {({pressed}) => ( <> diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 1fd1c8ef5a3b..c2c8685aa528 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -12,10 +12,11 @@ import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; import * as Expensicons from './Icon/Expensicons'; import type {MenuItemProps} from './MenuItem'; import MenuItem from './MenuItem'; +import PopoverMenuItem from './PopoverMenuItem'; import PopoverWithMeasuredContent from './PopoverWithMeasuredContent'; import Text from './Text'; -type PopoverMenuItem = MenuItemProps & { +type PopoverMenuListItem = MenuItemProps & { /** Text label */ text: string; @@ -23,7 +24,7 @@ type PopoverMenuItem = MenuItemProps & { onSelected?: () => void; /** Sub menu items to be rendered after a menu item is selected */ - subMenuItems?: PopoverMenuItem[]; + subMenuItems?: PopoverMenuListItem[]; /** Determines whether the menu item is disabled or not */ disabled?: boolean; @@ -39,10 +40,10 @@ type PopoverMenuProps = Partial & { isVisible: boolean; /** Callback to fire when a CreateMenu item is selected */ - onItemSelected: (selectedItem: PopoverMenuItem, index: number) => void; + onItemSelected: (selectedItem: PopoverMenuListItem, index: number) => void; /** Menu items to be rendered on the list */ - menuItems: PopoverMenuItem[]; + menuItems: PopoverMenuListItem[]; /** Optional non-interactive text to display as a header for any create menu */ headerText?: string; @@ -193,7 +194,7 @@ function PopoverMenu({ {!!headerText && {headerText}} {enteredSubMenuIndexes.length > 0 && renderBackButtonItem()} {currentMenuItems.map((item, menuIndex) => ( - setFocusedIndex(menuIndex)} /> ))} @@ -225,4 +227,4 @@ function PopoverMenu({ PopoverMenu.displayName = 'PopoverMenu'; export default React.memo(PopoverMenu); -export type {PopoverMenuItem, PopoverMenuProps}; +export type {PopoverMenuListItem, PopoverMenuProps}; diff --git a/src/components/PopoverMenuItem.tsx b/src/components/PopoverMenuItem.tsx new file mode 100644 index 000000000000..ff4e12a2f9b1 --- /dev/null +++ b/src/components/PopoverMenuItem.tsx @@ -0,0 +1,29 @@ +import React, {useLayoutEffect, useRef} from 'react'; +import type {View} from 'react-native'; +import MenuItem from './MenuItem'; +import type {MenuItemProps} from './MenuItem'; + +function PopoverMenuItem(props: MenuItemProps) { + const ref = useRef(null); + + // Sync focus on an item + useLayoutEffect(() => { + if (!props.focused) { + return; + } + + ref?.current?.focus(); + }, [props.focused]); + + return ( + + ); +} + +PopoverMenuItem.displayName = 'PopoverMenuItem'; + +export default PopoverMenuItem; diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx index 42fdc7dc575e..458f05aaef8c 100644 --- a/src/components/SelectionList/BaseListItem.tsx +++ b/src/components/SelectionList/BaseListItem.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useLayoutEffect, useRef} from 'react'; import {View} from 'react-native'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -31,12 +31,16 @@ function BaseListItem({ pendingAction, FooterComponent, children, + isFocused, + onFocus = () => {}, }: BaseListItemProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {hovered, bind} = useHover(); + const pressableRef = useRef(null); + const rightHandSideComponentRender = () => { if (canSelectMultiple || !rightHandSideComponent) { return null; @@ -57,6 +61,15 @@ function BaseListItem({ } }; + // Sync focus on an item + useLayoutEffect(() => { + if (!isFocused) { + return; + } + + pressableRef?.current?.focus(); + }, [isFocused]); + return ( onDismissError(item)} @@ -68,6 +81,7 @@ function BaseListItem({ onSelectRow(item)} disabled={isDisabled} accessibilityLabel={item.text ?? ''} @@ -78,6 +92,7 @@ function BaseListItem({ onMouseDown={shouldPreventDefaultFocusOnSelectRow ? (e) => e.preventDefault() : undefined} nativeID={keyForList ?? ''} style={pressableStyle} + onFocus={onFocus} > {canSelectMultiple && checkmarkPosition === CONST.DIRECTION.LEFT && ( @@ -132,6 +147,14 @@ function BaseListItem({ )} + {!item.isSelected && item.brickRoadIndicator && [CONST.BRICK_ROAD_INDICATOR_STATUS.INFO, CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR].includes(item.brickRoadIndicator) && ( + + + + )} {rightHandSideComponentRender()} {FooterComponent} diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 32cd89854cff..b8cd19fe4171 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -4,7 +4,6 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import type {LayoutChangeEvent, SectionList as RNSectionList, TextInput as RNTextInput, SectionListRenderItemInfo} from 'react-native'; import {View} from 'react-native'; -import ArrowKeyFocusManager from '@components/ArrowKeyFocusManager'; import Button from '@components/Button'; import Checkbox from '@components/Checkbox'; import FixedFooter from '@components/FixedFooter'; @@ -16,6 +15,7 @@ import ShowMoreButton from '@components/ShowMoreButton'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; import useActiveElementRole from '@hooks/useActiveElementRole'; +import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; @@ -161,9 +161,6 @@ function BaseSelectionList( }; }, [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)); - const [slicedSections, ShowMoreButtonInstance] = useMemo(() => { let remainingOptionsLimit = CONST.MAX_OPTIONS_SELECTOR_PAGE_LENGTH * currentPage; const processedSections = sections.map((section) => { @@ -218,6 +215,17 @@ function BaseSelectionList( [flattenedSections.allOptions], ); + // If `initiallyFocusedOptionKey` is not passed, we fall back to `-1`, to avoid showing the highlight on the first member + const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({ + initialFocusedIndex: flattenedSections.allOptions.findIndex((option) => option.keyForList === initiallyFocusedOptionKey), + maxIndex: flattenedSections.allOptions.length - 1, + isActive: true, + onFocusedIndexChange: (index: number) => { + setFocusedIndex(index); + scrollToIndex(index, true); + }, + }); + /** * Logic to run when a row is selected, either with click/press or keyboard hotkeys. * @@ -335,6 +343,7 @@ function BaseSelectionList( checkmarkPosition={checkmarkPosition} keyForList={item.keyForList ?? ''} isMultilineSupported={isRowMultilineSupported} + onFocus={() => setFocusedIndex(index)} /> ); }; @@ -473,128 +482,121 @@ function BaseSelectionList( ); return ( - section.data).length - 1} - onFocusedIndexChanged={updateAndScrollToFocusedIndex} - > - - {({safeAreaPaddingBottomStyle}) => ( - - {shouldShowTextInput && ( - - { - innerTextInputRef.current = element as RNTextInput; - - if (!textInputRef) { - return; - } - - // eslint-disable-next-line no-param-reassign - textInputRef.current = element as RNTextInput; - }} - label={textInputLabel} - accessibilityLabel={textInputLabel} - hint={textInputHint} - role={CONST.ROLE.PRESENTATION} - value={textInputValue} - placeholder={textInputPlaceholder} - maxLength={textInputMaxLength} - onChangeText={onChangeText} - inputMode={inputMode} - selectTextOnFocus - spellCheck={false} - onSubmitEditing={selectFocusedOption} - blurOnSubmit={!!flattenedSections.allOptions.length} - isLoading={isLoadingNewOptions} - testID="selection-list-text-input" - /> - - )} - {!!headerMessage && ( - - {headerMessage} - - )} - {!!headerContent && headerContent} - {flattenedSections.allOptions.length === 0 && showLoadingPlaceholder ? ( - - ) : ( - <> - {!headerMessage && canSelectMultiple && shouldShowSelectAll && ( - - - + {({safeAreaPaddingBottomStyle}) => ( + + {shouldShowTextInput && ( + + { + innerTextInputRef.current = element as RNTextInput; + + if (!textInputRef) { + return; + } + + // eslint-disable-next-line no-param-reassign + textInputRef.current = element as RNTextInput; + }} + label={textInputLabel} + accessibilityLabel={textInputLabel} + hint={textInputHint} + role={CONST.ROLE.PRESENTATION} + value={textInputValue} + placeholder={textInputPlaceholder} + maxLength={textInputMaxLength} + onChangeText={onChangeText} + inputMode={inputMode} + selectTextOnFocus + spellCheck={false} + onSubmitEditing={selectFocusedOption} + blurOnSubmit={!!flattenedSections.allOptions.length} + isLoading={isLoadingNewOptions} + testID="selection-list-text-input" + /> + + )} + {!!headerMessage && ( + + {headerMessage} + + )} + {!!headerContent && headerContent} + {flattenedSections.allOptions.length === 0 && showLoadingPlaceholder ? ( + + ) : ( + <> + {!headerMessage && canSelectMultiple && shouldShowSelectAll && ( + + + + {!customListHeader && ( + - {!customListHeader && ( - e.preventDefault() : undefined} - > - {translate('workspace.people.selectAll')} - - )} - - {customListHeader} + dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} + onMouseDown={shouldPreventDefaultFocusOnSelectRow ? (e) => e.preventDefault() : undefined} + > + {translate('workspace.people.selectAll')} + + )} - )} - {!headerMessage && !canSelectMultiple && customListHeader} - item.keyForList ?? `${index}`} - extraData={focusedIndex} - indicatorStyle="white" - keyboardShouldPersistTaps="always" - showsVerticalScrollIndicator={showScrollIndicator} - initialNumToRender={12} - maxToRenderPerBatch={maxToRenderPerBatch} - windowSize={5} - viewabilityConfig={{viewAreaCoveragePercentThreshold: 95}} - testID="selection-list" - onLayout={onSectionListLayout} - style={(!maxToRenderPerBatch || isInitialSectionListRender) && styles.opacity0} - ListFooterComponent={ShowMoreButtonInstance} - /> - {children} - - )} - {showConfirmButton && ( - -