diff --git a/src/components/ContextMenuItem.tsx b/src/components/ContextMenuItem.tsx index 453e72dc761f..f252cc5b734f 100644 --- a/src/components/ContextMenuItem.tsx +++ b/src/components/ContextMenuItem.tsx @@ -8,8 +8,8 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import getButtonState from '@libs/getButtonState'; import type IconAsset from '@src/types/utils/IconAsset'; import BaseMiniContextMenuItem from './BaseMiniContextMenuItem'; +import FocusableMenuItem from './FocusableMenuItem'; import Icon from './Icon'; -import MenuItem from './MenuItem'; type ContextMenuItemProps = { /** Icon Component */ @@ -49,6 +49,9 @@ type ContextMenuItemProps = { /** The ref of mini context menu item */ buttonRef?: React.RefObject; + + /** Handles what to do when the item is focused */ + onFocus?: () => void; }; type ContextMenuItemHandle = { @@ -70,6 +73,7 @@ function ContextMenuItem( wrapperStyle, shouldPreventDefaultFocusOnPress = true, buttonRef = {current: null}, + onFocus = () => {}, }: ContextMenuItemProps, ref: ForwardedRef, ) { @@ -113,7 +117,7 @@ function ContextMenuItem( )} ) : ( - ); } diff --git a/src/components/FocusableMenuItem.tsx b/src/components/FocusableMenuItem.tsx new file mode 100644 index 000000000000..e3ec8394dfa0 --- /dev/null +++ b/src/components/FocusableMenuItem.tsx @@ -0,0 +1,24 @@ +import React, {useRef} from 'react'; +import type {View} from 'react-native'; +import useSyncFocus from '@hooks/useSyncFocus'; +import type {MenuItemProps} from './MenuItem'; +import MenuItem from './MenuItem'; + +function FocusableMenuItem(props: MenuItemProps) { + const ref = useRef(null); + + // Sync focus on an item + useSyncFocus(ref, Boolean(props.focused)); + + return ( + + ); +} + +FocusableMenuItem.displayName = 'FocusableMenuItem'; + +export default FocusableMenuItem; diff --git a/src/components/Hoverable/index.tsx b/src/components/Hoverable/index.tsx index 3729ee380b34..e3357fd963c4 100644 --- a/src/components/Hoverable/index.tsx +++ b/src/components/Hoverable/index.tsx @@ -1,6 +1,7 @@ import type {Ref} from 'react'; import React, {cloneElement, forwardRef} from 'react'; import {hasHoverSupport} from '@libs/DeviceCapabilities'; +import mergeRefs from '@libs/mergeRefs'; import {getReturnValue} from '@libs/ValueUtils'; import ActiveHoverable from './ActiveHoverable'; import type HoverableProps from './types'; @@ -14,7 +15,8 @@ function Hoverable({isDisabled, ...props}: HoverableProps, ref: Ref // If Hoverable is disabled, just render the child without additional logic or event listeners. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (isDisabled || !hasHoverSupport()) { - return cloneElement(getReturnValue(props.children, false), {ref}); + const child = getReturnValue(props.children, false); + return cloneElement(child, {ref: mergeRefs(ref, child.ref)}); } return ( diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 12ddf04658f4..f23c8db97f47 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -1,6 +1,6 @@ import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import type {ImageContentFit} from 'expo-image'; -import type {ForwardedRef, ReactNode} from 'react'; +import type {ReactNode} from 'react'; import React, {forwardRef, useContext, useMemo} from 'react'; import type {GestureResponderEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; @@ -32,6 +32,7 @@ import * as Expensicons from './Icon/Expensicons'; import * as defaultWorkspaceAvatars from './Icon/WorkspaceDefaultAvatars'; import {MenuItemGroupContext} from './MenuItemGroup'; import MultipleAvatars from './MultipleAvatars'; +import type {PressableRef} from './Pressable/GenericPressable/types'; import PressableWithSecondaryInteraction from './PressableWithSecondaryInteraction'; import RenderHTML from './RenderHTML'; import SelectCircle from './SelectCircle'; @@ -249,6 +250,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; @@ -321,8 +325,9 @@ function MenuItem( contentFit = 'cover', isPaneMenu = false, shouldPutLeftPaddingWhenNoIcon = false, + onFocus, }: MenuItemProps, - ref: ForwardedRef, + ref: PressableRef, ) { const theme = useTheme(); const styles = useThemeStyles(); @@ -449,6 +454,7 @@ function MenuItem( role={CONST.ROLE.MENUITEM} accessibilityLabel={title ? title.toString() : ''} accessible + onFocus={onFocus} > {({pressed}) => ( <> @@ -678,5 +684,5 @@ function MenuItem( MenuItem.displayName = 'MenuItem'; -export type {IconProps, AvatarProps, NoIcon, MenuItemBaseProps, MenuItemProps}; +export type {AvatarProps, IconProps, MenuItemBaseProps, MenuItemProps, NoIcon}; export default forwardRef(MenuItem); diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index cf77ac7c4fd6..7baf8bb8830e 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -9,6 +9,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import CONST from '@src/CONST'; import type {AnchorPosition} from '@src/styles'; import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; +import FocusableMenuItem from './FocusableMenuItem'; import * as Expensicons from './Icon/Expensicons'; import type {MenuItemProps} from './MenuItem'; import MenuItem from './MenuItem'; @@ -193,7 +194,7 @@ function PopoverMenu({ {!!headerText && {headerText}} {enteredSubMenuIndexes.length > 0 && renderBackButtonItem()} {currentMenuItems.map((item, menuIndex) => ( - setFocusedIndex(menuIndex)} /> ))} diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx index 0343ffa9826b..5f4438f18f60 100644 --- a/src/components/SelectionList/BaseListItem.tsx +++ b/src/components/SelectionList/BaseListItem.tsx @@ -1,10 +1,11 @@ -import React from 'react'; +import React, {useRef} 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 useHover from '@hooks/useHover'; +import useSyncFocus from '@hooks/useSyncFocus'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; @@ -26,11 +27,18 @@ function BaseListItem({ pendingAction, FooterComponent, children, + isFocused, + onFocus = () => {}, }: BaseListItemProps) { const theme = useTheme(); const styles = useThemeStyles(); const {hovered, bind} = useHover(); + const pressableRef = useRef(null); + + // Sync focus on an item + useSyncFocus(pressableRef, Boolean(isFocused)); + const rightHandSideComponentRender = () => { if (canSelectMultiple || !rightHandSideComponent) { return null; @@ -54,6 +62,7 @@ function BaseListItem({ onSelectRow(item)} disabled={isDisabled} accessibilityLabel={item.text ?? ''} @@ -64,6 +73,7 @@ function BaseListItem({ onMouseDown={shouldPreventDefaultFocusOnSelectRow ? (e) => e.preventDefault() : undefined} nativeID={keyForList ?? ''} style={pressableStyle} + onFocus={onFocus} > {typeof children === 'function' ? children(hovered) : children} diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 4c6979f1a53e..62f098e76228 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 useKeyboardState from '@hooks/useKeyboardState'; import useLocalize from '@hooks/useLocalize'; @@ -167,9 +167,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 = getSectionsWithIndexOffset( @@ -226,6 +223,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) => { + scrollToIndex(index, true); + }, + isFocused, + }); + /** * Logic to run when a row is selected, either with click/press or keyboard hotkeys. * @@ -341,6 +349,7 @@ function BaseSelectionList( rightHandSideComponent={rightHandSideComponent} keyForList={item.keyForList ?? ''} isMultilineSupported={isRowMultilineSupported} + onFocus={() => setFocusedIndex(index)} /> ); }; @@ -375,7 +384,7 @@ function BaseSelectionList( setFocusedIndex(newFocusedIndex); scrollToIndex(newFocusedIndex, true); }, - [scrollToIndex], + [scrollToIndex, setFocusedIndex], ); /** Focuses the text input when the component comes into focus and after any navigation animations finish. */ @@ -461,7 +470,7 @@ function BaseSelectionList( setItemsToHighlight(null); }, timeout); }, - [flattenedSections.allOptions, updateAndScrollToFocusedIndex], + [flattenedSections.allOptions, setFocusedIndex, updateAndScrollToFocusedIndex], ); useImperativeHandle(ref, () => ({scrollAndHighlightItem}), [scrollAndHighlightItem]); @@ -493,136 +502,129 @@ function BaseSelectionList( ); return ( - section.data).length - 1} - onFocusedIndexChanged={updateAndScrollToFocusedIndex} - > - - {({safeAreaPaddingBottomStyle}) => ( - - {shouldShowTextInput && ( - - { - innerTextInputRef.current = element as RNTextInput; - - if (!textInputRef) { - return; - } - - if (typeof textInputRef === 'function') { - textInputRef(element as RNTextInput); - } else { - // 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} - iconLeft={textInputIconLeft} - onSubmitEditing={selectFocusedOption} - blurOnSubmit={!!flattenedSections.allOptions.length} - isLoading={isLoadingNewOptions} - testID="selection-list-text-input" - /> - - )} - {/* If we are loading new options we will avoid showing any header message. This is mostly because one of the header messages says there are no options. */} - {/* This is misleading because we might be in the process of loading fresh options from the server. */} - {!isLoadingNewOptions && !!headerMessage && ( - - {headerMessage} - - )} - {!!headerContent && headerContent} - {flattenedSections.allOptions.length === 0 && showLoadingPlaceholder ? ( - - ) : ( - <> - {!headerMessage && canSelectMultiple && shouldShowSelectAll && ( - - - + {({safeAreaPaddingBottomStyle}) => ( + + {shouldShowTextInput && ( + + { + innerTextInputRef.current = element as RNTextInput; + + if (!textInputRef) { + return; + } + + if (typeof textInputRef === 'function') { + textInputRef(element as RNTextInput); + } else { + // 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} + iconLeft={textInputIconLeft} + onSubmitEditing={selectFocusedOption} + blurOnSubmit={!!flattenedSections.allOptions.length} + isLoading={isLoadingNewOptions} + testID="selection-list-text-input" + /> + + )} + {/* If we are loading new options we will avoid showing any header message. This is mostly because one of the header messages says there are no options. */} + {/* This is misleading because we might be in the process of loading fresh options from the server. */} + {!isLoadingNewOptions && !!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} - // the only valid values on the new arch are "white", "black", and "default", other values will cause a crash - indicatorStyle="white" - keyboardShouldPersistTaps="always" - showsVerticalScrollIndicator={showScrollIndicator} - initialNumToRender={12} - maxToRenderPerBatch={maxToRenderPerBatch} - windowSize={5} - viewabilityConfig={{viewAreaCoveragePercentThreshold: 95}} - testID="selection-list" - onLayout={onSectionListLayout} - style={(!maxToRenderPerBatch || (shouldHideListOnInitialRender && isInitialSectionListRender)) && styles.opacity0} - ListFooterComponent={ShowMoreButtonInstance} - /> - {children} - - )} - {showConfirmButton && ( - -