From 5717e91439c6c4696bfbf7ee02b63e0eec890a53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Fri, 11 Sep 2026 12:10:52 +0200 Subject: [PATCH 1/8] Delete BaseListItem --- .../SearchList/ListItem/ChatListItem.tsx | 43 ++-- .../ListItem/ExpenseReportListItem.tsx | 81 +++---- .../ExpenseReportListItemAvatar.tsx | 21 +- .../ExpenseReportListItemRowWide.tsx | 20 +- .../ExpenseReportListItemRow/index.tsx | 4 - .../ExpenseReportListItemRow/types.ts | 5 - .../ListItem/SearchMergeReportsListItem.tsx | 65 +++--- .../SearchList/ListItem/TaskListItem.tsx | 26 ++- .../SelectionList/ListItem/BaseListItem.tsx | 219 ------------------ .../ListItem/BaseSelectListItem.tsx | 9 +- .../ListItem/ListItemRenderer.tsx | 12 +- .../ListItem/MultiSelectListItem.tsx | 4 +- .../ListItem/SelectableListItem.tsx | 2 - .../SpendCategorySelectorListItem.tsx | 19 +- .../ListItem/SpendRuleListItem.tsx | 88 ++++--- .../SelectionList/ListItem/SplitListItem.tsx | 31 ++- .../ListItem/TravelDomainListItem.tsx | 12 +- .../ListItem/UserSelectionListItem.tsx | 4 +- .../SelectionList/ListItem/types.ts | 110 +++------ .../ListItemComposed/ListItemPressable.tsx | 37 +-- src/components/SelectionList/types.ts | 26 ++- tests/ui/SplitListItemTest.tsx | 43 +++- .../ListItemPressableTest.tsx} | 64 +++-- .../SpendCategorySelectorListItemTest.tsx | 58 +++++ tests/unit/SpendRuleListItemTest.tsx | 71 ++++++ 25 files changed, 462 insertions(+), 612 deletions(-) delete mode 100644 src/components/SelectionList/ListItem/BaseListItem.tsx rename tests/{ui/BaseListItemTest.tsx => unit/ListItemPressableTest.tsx} (61%) create mode 100644 tests/unit/SpendCategorySelectorListItemTest.tsx create mode 100644 tests/unit/SpendRuleListItemTest.tsx diff --git a/src/components/Search/SearchList/ListItem/ChatListItem.tsx b/src/components/Search/SearchList/ListItem/ChatListItem.tsx index 397a0202231f..5abeb93c2f75 100644 --- a/src/components/Search/SearchList/ListItem/ChatListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ChatListItem.tsx @@ -1,6 +1,5 @@ import {useRowSelection} from '@components/Search/SearchSelectionProvider'; -import BaseListItem from '@components/SelectionList/ListItem/BaseListItem'; -import {useListItemHighlight} from '@components/SelectionList/ListItemComposed'; +import ListItemComposed, {useListItemHighlight} from '@components/SelectionList/ListItemComposed'; import type {ListItem} from '@components/SelectionList/types'; import useOnyx from '@hooks/useOnyx'; @@ -15,6 +14,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {getStableReportSelector} from '@src/selectors/Report'; import React from 'react'; +import {View} from 'react-native'; import type {ChatListItemProps, ReportActionListItemType} from './types'; @@ -49,14 +49,13 @@ function ChatListItem({ const handlePress = () => onSelectRow(item); return ( - ({ shouldSyncFocus={shouldSyncFocus} pressableWrapperStyle={pressableWrapperStyle} hoverStyle={isSelected && styles.activeComponentBG} - forwardedFSClass={fsClass} > - - + + + + ); } diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx index 1f295b804180..53639fcc5f61 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx @@ -8,7 +8,7 @@ import { useSearchSubmitPopoverGuard, } from '@components/ReportSubmitToPopoverAnchor'; import {useSearchQueryContext, useSearchResultsContext} from '@components/Search/SearchContext'; -import BaseListItem from '@components/SelectionList/ListItem/BaseListItem'; +import ListItemComposed from '@components/SelectionList/ListItemComposed'; import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; @@ -375,15 +375,6 @@ function ExpenseReportListItemInner({ [styles, isSelected, isLargeScreenWidth, isFirstItem, isLastItem, isPendingDelete, StyleUtils], ); - const listItemWrapperStyle = useMemo( - () => [ - styles.flex1, - styles.userSelectNone, - isLargeScreenWidth ? {...styles.flexRow, ...styles.justifyContentBetween, ...styles.alignItemsCenter} : {...styles.flexColumn, ...styles.alignItemsStretch}, - ], - [styles, isLargeScreenWidth], - ); - // The animated style is applied inline, so the `borderRadius: 0` it carries wins over the static // `tableTopRadius`/`tableBottomRadius` below and squares off the list's outer corners. Skip it for the first // and last rows only, so every other row keeps its existing (already square) behavior. @@ -474,7 +465,7 @@ function ExpenseReportListItemInner({ // Keep nested controls reachable: a group on web, and accessible={false} on iOS (which otherwise collapses children). return ( - ({ accessibilityLabel={rowAccessibilityLabel} shouldUseOptionRole={false} pressableStyle={listItemPressableStyle} - wrapperStyle={listItemWrapperStyle} isFocused={isFocused} - showTooltip={showTooltip} + shouldShowTooltip={showTooltip} canSelectMultiple={canSelectMultiple} onSelectRow={onSelectRow} onFocus={onFocus} @@ -503,41 +493,36 @@ function ExpenseReportListItemInner({ isDisabled={isPendingDelete} shouldDisableHoverStyle={isPendingDelete} > - {(hovered) => ( - - {!isLargeScreenWidth && ( - - )} - - - - {getDescription} - - )} - + + {!isLargeScreenWidth && ( + + )} + + + + {getDescription} + + ); } diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemAvatar.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemAvatar.tsx index a671891dbf83..1f4d5be284ef 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemAvatar.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemAvatar.tsx @@ -4,6 +4,7 @@ import SingleAvatar from '@components/Avatar/layouts/SingleAvatar'; import SubscriptAvatar from '@components/Avatar/layouts/SubscriptAvatar'; import type {ExpenseReportListItemType} from '@components/Search/SearchList/ListItem/types'; import {useRowSelection} from '@components/Search/SearchSelectionProvider'; +import {useListItemContext, useListItemHovered} from '@components/SelectionList/ListItemContext'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -16,19 +17,20 @@ import {View} from 'react-native'; type ExpenseReportListItemAvatarProps = { item: ExpenseReportListItemType; - isHovered?: boolean; - isFocused?: boolean; - isLargeScreenWidth?: boolean; }; -function ExpenseReportListItemAvatar({item, isHovered = false, isFocused = false, isLargeScreenWidth = false}: ExpenseReportListItemAvatarProps) { +/** The report avatar cell of the wide (table) expense report row. */ +function ExpenseReportListItemAvatar({item}: ExpenseReportListItemAvatarProps) { const StyleUtils = useStyleUtils(); const styles = useThemeStyles(); const theme = useTheme(); const {isSelected} = useRowSelection(item.keyForList); + const {isFocusVisible} = useListItemContext(); + const isHovered = useListItemHovered(); const finalAvatarBorderColor = - StyleUtils.getItemBackgroundColorStyle(isSelected, isFocused || isHovered, !!item.isDisabled, theme.activeComponentBG, theme.hoverComponentBG)?.backgroundColor ?? theme.highlightBG; + StyleUtils.getItemBackgroundColorStyle(isSelected, isFocusVisible || isHovered, !!item.isDisabled, theme.activeComponentBG, theme.hoverComponentBG)?.backgroundColor ?? + theme.highlightBG; // Without a primary avatar there is nothing to anchor the row on, and compacting the array would promote the secondary avatar into the primary slot. if (!item.primaryAvatar) { @@ -36,7 +38,6 @@ function ExpenseReportListItemAvatar({item, isHovered = false, isFocused = false } const icons = item.secondaryAvatar ? [item.primaryAvatar, item.secondaryAvatar] : [item.primaryAvatar]; - const avatarSize = isLargeScreenWidth ? CONST.AVATAR_SIZE.SMALL : CONST.AVATAR_SIZE.DEFAULT; const {layout, primaryIcon, secondaryIcon} = getAvatarLayout({icons, avatarType: item.avatarType}); let avatarContent; @@ -45,14 +46,14 @@ function ExpenseReportListItemAvatar({item, isHovered = false, isFocused = false ); } else if (layout === CONST.REPORT_ACTION_AVATARS.TYPE.MULTIPLE_DIAGONAL) { avatarContent = ( @@ -61,8 +62,8 @@ function ExpenseReportListItemAvatar({item, isHovered = false, isFocused = false avatarContent = ( ); } diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx index a130d8376e39..c9e8df5790d6 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx @@ -10,6 +10,7 @@ import TotalCell from '@components/Search/SearchList/ListItem/TotalCell'; import UserInfoCell from '@components/Search/SearchList/ListItem/UserInfoCell'; import WorkspaceCell from '@components/Search/SearchList/ListItem/WorkspaceCell'; import {useRowSelection} from '@components/Search/SearchSelectionProvider'; +import {useListItemContext, useListItemHovered} from '@components/SelectionList/ListItemContext'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -36,15 +37,11 @@ function ExpenseReportListItemRowWide({ onButtonPress = () => {}, isActionLoading, chatReport, - containerStyle, canSelectMultiple, isSelectAllChecked, isIndeterminate, isDisabledCheckbox, columns = [], - isHovered = false, - isFocused = false, - isPendingDelete = false, shouldDisableActionPointerEvents = false, shouldShowMarkAsDoneCopy, }: ExpenseReportListItemRowWideProps) { @@ -53,6 +50,8 @@ function ExpenseReportListItemRowWide({ const theme = useTheme(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['ArrowRight']); const {isSelected} = useRowSelection(item.keyForList); + const isHovered = useListItemHovered(); + const {isDisabled} = useListItemContext(); const currency = item.currency ?? CONST.CURRENCY.USD; const {totalDisplaySpend = 0, nonReimbursableSpend = 0, reimbursableSpend = 0, isAllScanning: isScanning = false} = item; @@ -62,14 +61,7 @@ function ExpenseReportListItemRowWide({ const {debitedAmount, debitedCurrency, creditedAmount, creditedCurrency} = item; const columnComponents = { - [CONST.SEARCH.TABLE_COLUMNS.AVATAR]: ( - - ), + [CONST.SEARCH.TABLE_COLUMNS.AVATAR]: , [CONST.SEARCH.TABLE_COLUMNS.DATE]: ( @@ -310,7 +302,7 @@ function ExpenseReportListItemRowWide({ }; return ( - + {!!canSelectMultiple && ( void; chatReport?: OnyxEntry; - containerStyle?: StyleProp; - isHovered?: boolean; - isFocused?: boolean; - isPendingDelete?: boolean; shouldDisableActionPointerEvents?: boolean; columns?: SearchColumnType[]; shouldShowMarkAsDoneCopy: boolean; diff --git a/src/components/Search/SearchList/ListItem/SearchMergeReportsListItem.tsx b/src/components/Search/SearchList/ListItem/SearchMergeReportsListItem.tsx index 12b3fe29f425..1216318d13f2 100644 --- a/src/components/Search/SearchList/ListItem/SearchMergeReportsListItem.tsx +++ b/src/components/Search/SearchList/ListItem/SearchMergeReportsListItem.tsx @@ -1,6 +1,6 @@ import RadioButton from '@components/RadioButton'; -import BaseListItem from '@components/SelectionList/ListItem/BaseListItem'; import type {ListItemProps} from '@components/SelectionList/ListItem/types'; +import ListItemComposed from '@components/SelectionList/ListItemComposed'; import type {ListItem} from '@components/SelectionList/types'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -29,19 +29,16 @@ function SearchMergeReportsListItem({item, isFocused, sh isLastItem && [styles.tableBottomRadius, styles.overflowHidden], ]; - const listItemWrapperStyle = [styles.flex1, styles.userSelectNone, styles.flexColumn, styles.alignItemsStretch]; - const selectRow = () => { onSelectRow(item); }; return ( - ({item, isFocused, sh accessible={false} shouldDisableHoverStyle={false} > - {() => ( - - - - - - + + + - )} - + + + ); } diff --git a/src/components/Search/SearchList/ListItem/TaskListItem.tsx b/src/components/Search/SearchList/ListItem/TaskListItem.tsx index f354fbb25283..90e38325d43f 100644 --- a/src/components/Search/SearchList/ListItem/TaskListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TaskListItem.tsx @@ -1,5 +1,5 @@ import {useRowSelection} from '@components/Search/SearchSelectionProvider'; -import BaseListItem from '@components/SelectionList/ListItem/BaseListItem'; +import ListItemComposed from '@components/SelectionList/ListItemComposed'; import type {ListItem} from '@components/SelectionList/types'; import useOnyx from '@hooks/useOnyx'; @@ -14,6 +14,7 @@ import type {ReportAttributesDerivedValue} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import React from 'react'; +import {View} from 'react-native'; import type {TaskListItemProps, TaskListItemType} from './types'; @@ -65,14 +66,13 @@ function TaskListItem({ const fsClass = FS.getChatFSClass(parentReport); return ( - ({ shouldSyncFocus={shouldSyncFocus} hoverStyle={isSelected && styles.activeComponentBG} pressableWrapperStyle={pressableWrapperStyle} - forwardedFSClass={fsClass} > - - + + + + ); } diff --git a/src/components/SelectionList/ListItem/BaseListItem.tsx b/src/components/SelectionList/ListItem/BaseListItem.tsx deleted file mode 100644 index 009ed3372323..000000000000 --- a/src/components/SelectionList/ListItem/BaseListItem.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import {getButtonRole} from '@components/Button/utils'; -import Icon from '@components/Icon'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import getListItemAccessibilityProps from '@components/SelectionList/utils/getListItemAccessibilityProps'; -import isListItemSelected from '@components/SelectionList/utils/isListItemSelected'; -import shouldShowRBRIndicator from '@components/SelectionList/utils/shouldShowRBRIndicator'; - -import useHover from '@hooks/useHover'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import {useMouseActions, useMouseState} from '@hooks/useMouseContext'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useSyncFocus from '@hooks/useSyncFocus'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import variables from '@styles/variables'; - -import CONST from '@src/CONST'; - -import React, {useRef} from 'react'; -import {View} from 'react-native'; - -import type {BaseListItemProps, ListItem} from './types'; - -/** - * The foundational pressable row that all list items build on. Handles press/hover/focus states, - * error indicators, and accessibility roles. Use SelectableListItem when a selection button - * (checkbox or radio) is needed. - */ -function BaseListItem({ - item, - pressableStyle, - wrapperStyle, - pressableWrapperStyle, - containerStyle, - isDisabled = false, - shouldPreventEnterKeySubmit = false, - canSelectMultiple = false, - onSelectRow, - onDismissError = () => {}, - rightHandSideComponent, - errorRowStyles, - FooterComponent, - children, - isFocused, - isFocusVisible = isFocused, - shouldSyncFocus = true, - shouldDisplayRBR = true, - onFocus = () => {}, - hoverStyle, - onLongPressRow, - shouldHighlightSelectedItem = false, - shouldDisableHoverStyle, - accessible, - accessibilityLabel, - accessibilityRole = getButtonRole(true), - shouldUseOptionRole, - isSelected, - forwardedFSClass, - testID, -}: BaseListItemProps) { - const theme = useTheme(); - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const {hovered, bind} = useHover(); - const {isMouseDownOnInput} = useMouseState(); - const {setMouseUp} = useMouseActions(); - const icons = useMemoizedLazyExpensifyIcons(['DotIndicator']); - const pressableRef = useRef(null); - - // Sync focus on an item - useSyncFocus(pressableRef, !!isFocused, shouldSyncFocus); - - // List items use role="option" which doesn't natively respond to Enter key presses. - // When the list-level keyboard shortcut is disabled (disableKeyboardShortcuts), we handle - // Enter activation here at the item level so each row can still be activated individually - // without interfering with other focusable controls (e.g. footer inputs) on the same screen. - const handleKeyDown = (event: React.KeyboardEvent) => { - if ( - shouldPreventEnterKeySubmit || - accessible === false || - event.key !== CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey || - event.shiftKey || - event.metaKey || - event.ctrlKey || - item.isInteractive === false - ) { - return; - } - - event.preventDefault(); - onSelectRow(item); - }; - - const handleMouseLeave = (e: React.MouseEvent) => { - bind.onMouseLeave(); - e.stopPropagation(); - setMouseUp(); - }; - - const rightHandSideComponentRender = () => { - if (!rightHandSideComponent) { - return null; - } - - if (typeof rightHandSideComponent === 'function') { - return rightHandSideComponent(item, isFocused); - } - - return rightHandSideComponent; - }; - - const isRowSelected = isListItemSelected(item, isSelected); - const shouldShowRBR = shouldDisplayRBR && shouldShowRBRIndicator(item, isSelected); - - const {role, tabIndex, accessibilityState, accessibleAndAccessibilityLabel, ariaCurrent} = getListItemAccessibilityProps({ - role: accessibilityRole, - accessible, - accessibilityLabel, - tabIndex: item.tabIndex, - item, - isFocused, - canSelectMultiple, - shouldUseOptionRole, - isSelected: isRowSelected, - }); - - return ( - onDismissError(item)} - pendingAction={item.pendingAction} - errors={item.errors} - errorRowStyles={[styles.mh5, errorRowStyles]} - contentContainerStyle={containerStyle} - > - { - onLongPressRow?.(item); - }} - onPress={(e) => { - if (isMouseDownOnInput) { - e?.stopPropagation(); // Preventing the click action - return; - } - if (shouldPreventEnterKeySubmit && e && 'key' in e && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) { - return; - } - onSelectRow(item, undefined, e); - }} - disabled={isDisabled && !isRowSelected} - interactive={item.isInteractive} - isNested - hoverDimmingValue={1} - pressDimmingValue={item.isInteractive === false ? 1 : variables.pressDimValue} - hoverStyle={!shouldDisableHoverStyle ? [(!item.isDisabled || isRowSelected) && item.isInteractive !== false && styles.hoveredComponentBG, hoverStyle] : undefined} - dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, [CONST.INNER_BOX_SHADOW_ELEMENT]: true}} - onMouseDown={(e) => { - if ((e?.target as HTMLElement)?.tagName === CONST.ELEMENT_NAME.INPUT) { - return; - } - e.preventDefault(); - }} - id={item.keyForList ?? ''} - testID={`${CONST.BASE_LIST_ITEM_TEST_ID}${item.keyForList}`} - style={[ - pressableStyle, - isFocusVisible && - StyleUtils.getItemBackgroundColorStyle( - shouldHighlightSelectedItem && !!isRowSelected, - !!isFocusVisible, - !!item.isDisabled, - theme.activeComponentBG, - theme.hoverComponentBG, - ), - ]} - onFocus={onFocus} - role={role} - tabIndex={tabIndex} - {...accessibleAndAccessibilityLabel} - accessibilityState={accessibilityState} - aria-current={ariaCurrent} - onMouseLeave={handleMouseLeave} - // When the list-level Enter shortcut is disabled (disableKeyboardShortcuts), items with role="option" - // won't natively fire click on Enter, so we handle it manually via onKeyDown. - onKeyDown={!shouldPreventEnterKeySubmit ? handleKeyDown : undefined} - wrapperStyle={pressableWrapperStyle} - > - - {typeof children === 'function' ? children(hovered) : children} - - {shouldShowRBR && ( - - - - )} - - {rightHandSideComponentRender()} - - {FooterComponent} - - - ); -} - -export default BaseListItem; diff --git a/src/components/SelectionList/ListItem/BaseSelectListItem.tsx b/src/components/SelectionList/ListItem/BaseSelectListItem.tsx index 02f5362311f4..def3b3f6783c 100644 --- a/src/components/SelectionList/ListItem/BaseSelectListItem.tsx +++ b/src/components/SelectionList/ListItem/BaseSelectListItem.tsx @@ -7,13 +7,20 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type {ReactNode} from 'react'; + import React from 'react'; import {View} from 'react-native'; -import type {BaseSelectListItemProps, ListItem} from './types'; +import type {ListItem, ListItemProps} from './types'; import SelectableListItem from './SelectableListItem'; +type BaseSelectListItemProps = ListItemProps & { + /** Element rendered before the text column. Falls back to `item.leftElement` when omitted. */ + leftElement?: ReactNode; +}; + /** * A text-only row with a title and optional subtitle. Serves as the base for SingleSelectListItem and MultiSelectListItem. */ diff --git a/src/components/SelectionList/ListItem/ListItemRenderer.tsx b/src/components/SelectionList/ListItem/ListItemRenderer.tsx index f1228a669fe5..c2ae4993fc73 100644 --- a/src/components/SelectionList/ListItem/ListItemRenderer.tsx +++ b/src/components/SelectionList/ListItem/ListItemRenderer.tsx @@ -6,25 +6,19 @@ import type useSingleExecution from '@hooks/useSingleExecution'; import {isMobileChrome} from '@libs/Browser'; import {isTransactionGroupListItemType} from '@libs/SearchUIUtils'; -import type {NativeSyntheticEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {NativeSyntheticEvent} from 'react-native'; import React from 'react'; -import type {ExtendedTargetedEvent, ListItem, SelectableListItemProps} from './types'; +import type {ExtendedTargetedEvent, ListItem, ListItemProps} from './types'; -type ListItemRendererProps = Omit, 'onSelectRow'> & +type ListItemRendererProps = Omit, 'onSelectRow'> & Pick, 'ListItem' | 'shouldIgnoreFocus' | 'shouldSingleExecuteRowSelect'> & { index: number; normalizedIndex?: number; selectRow: (item: TItem, indexToFocus?: number) => void; setFocusedIndex: ReturnType[1]; singleExecution: ReturnType['singleExecution']; - titleStyles?: StyleProp; - titleContainerStyles?: StyleProp; - isFirstItem?: boolean; - isLastItem?: boolean; - shouldHighlightSelectedItem?: boolean; - shouldPreventEnterKeySubmit?: boolean; }; function ListItemRenderer({ diff --git a/src/components/SelectionList/ListItem/MultiSelectListItem.tsx b/src/components/SelectionList/ListItem/MultiSelectListItem.tsx index 32a82659a750..595101671f49 100644 --- a/src/components/SelectionList/ListItem/MultiSelectListItem.tsx +++ b/src/components/SelectionList/ListItem/MultiSelectListItem.tsx @@ -6,7 +6,7 @@ import CONST from '@src/CONST'; import React from 'react'; -import type {ListItem, MultiSelectListItemProps} from './types'; +import type {ListItem, ListItemProps} from './types'; import BaseSelectListItem from './BaseSelectListItem'; @@ -32,7 +32,7 @@ function MultiSelectListItem({ titleStyles, shouldHighlightSelectedItem, titleNumberOfLines, -}: MultiSelectListItemProps) { +}: ListItemProps) { const styles = useThemeStyles(); const icon = item.icons?.at(0); diff --git a/src/components/SelectionList/ListItem/SelectableListItem.tsx b/src/components/SelectionList/ListItem/SelectableListItem.tsx index dbd3adc3c251..79436fd62a93 100644 --- a/src/components/SelectionList/ListItem/SelectableListItem.tsx +++ b/src/components/SelectionList/ListItem/SelectableListItem.tsx @@ -26,7 +26,6 @@ function SelectableListItem({ isSelected, showTooltip, wrapperStyle, - testID, forwardedFSClass, pressableStyle, pressableWrapperStyle, @@ -81,7 +80,6 @@ function SelectableListItem({ shouldUseOptionRole={shouldUseOptionRole} > diff --git a/src/components/SelectionList/ListItem/SpendCategorySelectorListItem.tsx b/src/components/SelectionList/ListItem/SpendCategorySelectorListItem.tsx index c24511db6a52..50a43a7dda1c 100644 --- a/src/components/SelectionList/ListItem/SpendCategorySelectorListItem.tsx +++ b/src/components/SelectionList/ListItem/SpendCategorySelectorListItem.tsx @@ -1,4 +1,5 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import ListItemComposed from '@components/SelectionList/ListItemComposed'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -6,15 +7,13 @@ import {getDecodedCategoryName} from '@libs/CategoryUtils'; import React from 'react'; -import type {ListItem, SpendCategorySelectorListItemProps} from './types'; - -import BaseListItem from './BaseListItem'; +import type {ListItem, ListItemProps} from './types'; /** * A menu-item row showing a category name under a spend group label. Used in workspace * categories settings to map categories to spend groups. */ -function SpendCategorySelectorListItem({item, onSelectRow, isFocused}: SpendCategorySelectorListItemProps) { +function SpendCategorySelectorListItem({item, onSelectRow, isFocused}: ListItemProps) { const styles = useThemeStyles(); const {groupID, categoryID: category} = item; @@ -23,23 +22,23 @@ function SpendCategorySelectorListItem({item, onSelectRo } return ( - onSelectRow(item)} focused={isFocused} /> - + ); } diff --git a/src/components/SelectionList/ListItem/SpendRuleListItem.tsx b/src/components/SelectionList/ListItem/SpendRuleListItem.tsx index 6fcf34ed3266..f9bff2e86df2 100644 --- a/src/components/SelectionList/ListItem/SpendRuleListItem.tsx +++ b/src/components/SelectionList/ListItem/SpendRuleListItem.tsx @@ -1,5 +1,6 @@ import Badge from '@components/Badge'; import Checkbox from '@components/Checkbox'; +import ListItemComposed from '@components/SelectionList/ListItemComposed'; import Text from '@components/Text'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -10,65 +11,60 @@ import CONST from '@src/CONST'; import React from 'react'; import {View} from 'react-native'; -import type {ListItem, SpendRuleListItemProps, SpendRuleListItemType} from './types'; +import type {ListItem, ListItemProps, SpendRuleListItemType} from './types'; -import BaseListItem from './BaseListItem'; - -function SpendRuleListItem({item, onSelectRow}: SpendRuleListItemProps) { +function SpendRuleListItem({item, onSelectRow}: ListItemProps) { const styles = useThemeStyles(); const {getMinimumWidth} = useStyleUtils(); const cardRule = item as unknown as SpendRuleListItemType; - const rightHandSideComponent = () => ( - onSelectRow(item)} - /> - ); - return ( - - - - {cardRule.summary} - - - {cardRule.summaryParts.map((part) => ( - + + - - + + {cardRule.summaryParts.map((part) => ( + - {part.text} - - - ))} + + + {part.text} + + + ))} + + onSelectRow(item)} + /> - + ); } diff --git a/src/components/SelectionList/ListItem/SplitListItem.tsx b/src/components/SelectionList/ListItem/SplitListItem.tsx index 2f659f6cef38..6a275c6aaf55 100644 --- a/src/components/SelectionList/ListItem/SplitListItem.tsx +++ b/src/components/SelectionList/ListItem/SplitListItem.tsx @@ -1,6 +1,6 @@ import Icon from '@components/Icon'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import type {ListItem} from '@components/SelectionList/types'; +import ListItemComposed from '@components/SelectionList/ListItemComposed'; import Text from '@components/Text'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; @@ -15,7 +15,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getDecodedFullCategoryName} from '@libs/CategoryUtils'; import {getCommaSeparatedTagNameWithSanitizedColons} from '@libs/PolicyUtils'; -import {fontScale} from '@styles/typography'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -23,9 +22,8 @@ import CONST from '@src/CONST'; import React, {useCallback, useState} from 'react'; import {View} from 'react-native'; -import type {SplitListItemProps, SplitListItemType} from './types'; +import type {ListItem, ListItemProps, SplitListItemType} from './types'; -import BaseListItem from './BaseListItem'; import SplitAmountDisplay from './SplitListItem/SplitAmountDisplay'; import SplitListItemInput from './SplitListItem/SplitListItemInput'; @@ -40,17 +38,16 @@ function SplitListItem({ isDisabled, onSelectRow, shouldPreventEnterKeySubmit, - rightHandSideComponent, onFocus, onInputFocus, onInputBlur, -}: SplitListItemProps) { +}: ListItemProps) { + const splitItem = item as unknown as SplitListItemType; const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'Folder', 'Tag']); const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); const {convertToDisplayStringWithoutCurrency} = useCurrencyListActions(); - const splitItem = item as unknown as SplitListItemType; const formattedOriginalAmount = convertToDisplayStringWithoutCurrency(splitItem.originalAmount, splitItem.currency); @@ -109,23 +106,22 @@ function SplitListItem({ .join(', '); return ( - ({ {splitItem.merchant} @@ -164,7 +159,7 @@ function SplitListItem({ {isBottomVisible && ( {!!splitItem.category && ( @@ -202,8 +197,8 @@ function SplitListItem({ )} - - + + ({ inputCallbackRef={inputCallbackRef} /> - + {!splitItem.isEditable ? null : ( onSelectRow(item)} @@ -235,7 +230,7 @@ function SplitListItem({ - + ); } diff --git a/src/components/SelectionList/ListItem/TravelDomainListItem.tsx b/src/components/SelectionList/ListItem/TravelDomainListItem.tsx index 208fa14fb976..63141a15adbf 100644 --- a/src/components/SelectionList/ListItem/TravelDomainListItem.tsx +++ b/src/components/SelectionList/ListItem/TravelDomainListItem.tsx @@ -8,10 +8,20 @@ import CONST from '@src/CONST'; import React from 'react'; -import type {ListItem, TravelDomainListItemProps} from './types'; +import type {ListItem, SelectableListItemProps} from './types'; import SelectableListItem from './SelectableListItem'; +type TravelDomainListItemProps = SelectableListItemProps< + TItem & { + /** Value of the domain */ + value?: string; + + /** Should display tag 'Recommended' */ + isRecommended?: boolean; + } +>; + /** * A text row with a left-side checkbox and an optional "Recommended" badge. Used in the * travel domain selector for choosing booking domains. diff --git a/src/components/SelectionList/ListItem/UserSelectionListItem.tsx b/src/components/SelectionList/ListItem/UserSelectionListItem.tsx index 8fba42ddf76a..9befca3a2df6 100644 --- a/src/components/SelectionList/ListItem/UserSelectionListItem.tsx +++ b/src/components/SelectionList/ListItem/UserSelectionListItem.tsx @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import React from 'react'; import {View} from 'react-native'; -import type {ListItem, UserSelectionListItemProps} from './types'; +import type {ListItem, ListItemProps} from './types'; import SelectableListItem from './SelectableListItem'; @@ -35,7 +35,7 @@ function UserSelectionListItem({ shouldSyncFocus, wrapperStyle, pressableStyle, -}: UserSelectionListItemProps) { +}: ListItemProps) { const styles = useThemeStyles(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {formatPhoneNumber, translate} = useLocalize(); diff --git a/src/components/SelectionList/ListItem/types.ts b/src/components/SelectionList/ListItem/types.ts index be8c1bd36d4a..324294bf6484 100644 --- a/src/components/SelectionList/ListItem/types.ts +++ b/src/components/SelectionList/ListItem/types.ts @@ -1,5 +1,4 @@ import type {HoldMenuCallback} from '@components/Search'; -import type {SearchRouterItem} from '@components/Search/SearchAutocompleteList'; import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; import type {TransactionPreviewData} from '@libs/actions/Search'; @@ -15,23 +14,11 @@ import type CONST from '@src/CONST'; import type {SplitExpense} from '@src/types/onyx/IOU'; import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; -import type {ReactElement, ReactNode} from 'react'; +import type {PropsWithChildren, ReactNode} from 'react'; import type {BlurEvent, NativeSyntheticEvent, Role, StyleProp, TargetedEvent, TextStyle, ViewStyle} from 'react-native'; import type {AnimatedStyle} from 'react-native-reanimated'; import type {ValueOf} from 'type-fest'; -import type BareUserListItem from './BareUserListItem'; -import type BaseListItem from './BaseListItem'; -import type InviteMemberListItem from './InviteMemberListItem'; -import type MultiSelectListItem from './MultiSelectListItem'; -import type SingleSelectListItem from './SingleSelectListItem'; -import type SingleSelectWithAvatarListItem from './SingleSelectWithAvatarListItem'; -import type SpendCategorySelectorListItem from './SpendCategorySelectorListItem'; -import type SplitListItem from './SplitListItem'; -import type TravelDomainListItem from './TravelDomainListItem'; -import type UserListItem from './UserListItem'; -import type UserSelectionListItem from './UserSelectionListItem'; - type ListItem = { text?: string; alternateText?: string | null; @@ -148,6 +135,8 @@ type CommonListItemProps = { pressableStyle?: StyleProp; pressableWrapperStyle?: StyleProp>; wrapperStyle?: StyleProp; + + /** Style of the offline-feedback content container that wraps the pressable and its error row */ containerStyle?: StyleProp; errorRowStyles?: StyleProp; @@ -218,8 +207,6 @@ type ListItemProps = CommonListItemProps & { */ shouldSyncFocus?: boolean; - shouldDisplayRBR?: boolean; - titleStyles?: StyleProp; titleContainerStyles?: StyleProp; shouldHighlightSelectedItem?: boolean; @@ -239,34 +226,26 @@ type ListItemProps = CommonListItemProps & { isFirstItem?: boolean; }; -type ValidListItem = - | typeof BaseListItem - | typeof InviteMemberListItem - | typeof MultiSelectListItem - | typeof SearchRouterItem - | typeof SingleSelectListItem - | typeof SingleSelectWithAvatarListItem - | typeof SpendCategorySelectorListItem - | typeof SplitListItem - | typeof TravelDomainListItem - | typeof BareUserListItem - | typeof UserListItem - | typeof UserSelectionListItem; - -type BaseListItemProps = CommonListItemProps & - ForwardedFSClassProps & { +/** + * Props of the composed ListItem pressable root. Row content is passed as plain children; hover/focus/tooltip state reaches it through ListItemContext. + * Content-level props (wrapper style, right-hand component, multiline options) are omitted: the pressable never reads them, so the variant that renders them declares them. + */ +type ListItemPressableProps = PropsWithChildren< + Omit< + CommonListItemProps, + 'showTooltip' | 'wrapperStyle' | 'rightHandSideComponent' | 'isMultilineSupported' | 'isAlternateTextMultilineSupported' | 'alternateTextNumberOfLines' | 'titleNumberOfLines' + > & { item: TItem; + + /** Whether content inside the row should show tooltips (provided to children via ListItemContext) */ + shouldShowTooltip: boolean; + /** Overrides the row's screen-reader name. Defaults to the item's derived label when omitted. */ accessibilityLabel?: string; shouldPreventEnterKeySubmit?: boolean; errorRowStyles?: StyleProp; - FooterComponent?: ReactElement; - children?: ReactElement> | ((hovered: boolean) => ReactElement>); shouldSyncFocus?: boolean; hoverStyle?: StyleProp; - shouldDisplayRBR?: boolean; - /** Test ID of the component. Used to locate this view in end-to-end tests. */ - testID?: string; shouldHighlightSelectedItem?: boolean; shouldDisableHoverStyle?: boolean; @@ -275,7 +254,8 @@ type BaseListItemProps = CommonListItemProps & * When false, allows child elements (like TextInput) to be independently focusable by screen readers. */ accessible?: boolean; - }; + } +>; type SpendRuleListItemType = ListItem & { /** The action for this rule */ @@ -291,15 +271,20 @@ type SpendRuleListItemType = ListItem & { }; /** Props for SelectableListItem, which extends the composed ListItem pressable with selection button support. */ -type SelectableListItemProps = Omit, 'containerStyle' | 'children' | 'FooterComponent' | 'shouldDisplayRBR'> & { - /** Row content. Hover/focus/tooltip state is provided through ListItemContext instead of a render prop. */ - children?: ReactNode; +type SelectableListItemProps = Omit, 'containerStyle' | 'shouldShowTooltip'> & + ForwardedFSClassProps & + TRightHandSideComponent & { + /** Whether text in the row should show tooltips on overflow (forwarded to the pressable as shouldShowTooltip) */ + showTooltip: boolean; - /** Callback to fire when the selection button is pressed */ - onSelectionButtonPress?: (item: TItem, itemTransactions?: TransactionListItemType[]) => void; + /** Style of the row View that lays out the selection button, children, and right-hand component */ + wrapperStyle?: StyleProp; - selectionButtonPosition?: ValueOf; -}; + /** Callback to fire when the selection button is pressed */ + onSelectionButtonPress?: (item: TItem, itemTransactions?: TransactionListItemType[]) => void; + + selectionButtonPosition?: ValueOf; + }; type SplitListItemType = ListItem & SplitExpense & { @@ -336,21 +321,8 @@ type SplitListItemType = ListItem & onInputFocus?: (item: SplitListItemType) => void; }; -type SplitListItemProps = ListItemProps; - -type SpendRuleListItemProps = ListItemProps; - -type BaseSelectListItemProps = ListItemProps & { - /** Element rendered before the text column. Falls back to `item.leftElement` when omitted. */ - leftElement?: ReactNode; -}; - type SingleSelectListItemProps = ListItemProps; -type MultiSelectListItemProps = ListItemProps; - -type SpendCategorySelectorListItemProps = ListItemProps; - type UserListItemProps = ListItemProps & ForwardedFSClassProps; type InviteMemberListItemProps = UserListItemProps; @@ -363,37 +335,17 @@ type WorkspaceListItemType = { brickRoadIndicator?: BrickRoad; } & ListItem; -type TravelDomainListItemProps = SelectableListItemProps< - TItem & { - /** Value of the domain */ - value?: string; - - /** Should display tag 'Recommended' */ - isRecommended?: boolean; - } ->; - -type UserSelectionListItemProps = ListItemProps; - export type { SpendRuleListItemType, - SpendRuleListItemProps, - BaseListItemProps, + ListItemPressableProps, ExtendedTargetedEvent, ListItem, ListItemProps, ListItemFocusEventHandler, - BaseSelectListItemProps, - ValidListItem, SelectableListItemProps, SingleSelectListItemProps, - MultiSelectListItemProps, - TravelDomainListItemProps, - SpendCategorySelectorListItemProps, UserListItemProps, InviteMemberListItemProps, SplitListItemType, - SplitListItemProps, WorkspaceListItemType, - UserSelectionListItemProps, }; diff --git a/src/components/SelectionList/ListItemComposed/ListItemPressable.tsx b/src/components/SelectionList/ListItemComposed/ListItemPressable.tsx index c26c33e153df..f49684bb25f3 100644 --- a/src/components/SelectionList/ListItemComposed/ListItemPressable.tsx +++ b/src/components/SelectionList/ListItemComposed/ListItemPressable.tsx @@ -1,7 +1,7 @@ import {getButtonRole} from '@components/Button/utils'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import type {BaseListItemProps, ListItem} from '@components/SelectionList/ListItem/types'; +import type {ListItem, ListItemPressableProps} from '@components/SelectionList/ListItem/types'; import {ListItemContext, ListItemHoverContext} from '@components/SelectionList/ListItemContext'; import getListItemAccessibilityProps from '@components/SelectionList/utils/getListItemAccessibilityProps'; import isListItemSelected from '@components/SelectionList/utils/isListItemSelected'; @@ -17,43 +17,10 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; -import type {ReactNode} from 'react'; import type {View} from 'react-native'; import React, {useRef} from 'react'; -type ListItemPressableProps = Pick< - BaseListItemProps, - | 'item' - | 'pressableStyle' - | 'pressableWrapperStyle' - | 'isDisabled' - | 'shouldPreventEnterKeySubmit' - | 'canSelectMultiple' - | 'onSelectRow' - | 'onDismissError' - | 'errorRowStyles' - | 'isFocused' - | 'isFocusVisible' - | 'shouldSyncFocus' - | 'onFocus' - | 'hoverStyle' - | 'onLongPressRow' - | 'shouldHighlightSelectedItem' - | 'shouldDisableHoverStyle' - | 'accessible' - | 'accessibilityLabel' - | 'accessibilityRole' - | 'shouldUseOptionRole' - | 'isSelected' -> & { - /** Whether content inside the row should show tooltips (provided to children via ListItemContext) */ - shouldShowTooltip: boolean; - - /** Row content */ - children?: ReactNode; -}; - /** * The interaction core every list item row builds on: offline/error feedback, press/hover/focus states, * keyboard activation, and accessibility roles. Carries zero layout opinions - callers own the row @@ -63,6 +30,7 @@ function ListItemPressable({ item, pressableStyle, pressableWrapperStyle, + containerStyle, isDisabled = false, shouldPreventEnterKeySubmit = false, canSelectMultiple = false, @@ -146,6 +114,7 @@ function ListItemPressable({ pendingAction={item.pendingAction} errors={item.errors} errorRowStyles={[styles.mh5, errorRowStyles]} + contentContainerStyle={containerStyle} > ({ useCurrencyListActions: () => ({convertToDisplayStringWithoutCurrency: (amount: number) => String(amount)}), })); -const createSplitItem = (category: string): SplitListItemType => ({ +const createSplitItem = (category: string, isEditable = true): SplitListItemType => ({ keyForList: 'split-1', transactionID: 'split-1', amount: 1000, @@ -30,7 +30,7 @@ const createSplitItem = (category: string): SplitListItemType => ({ currency: CONST.CURRENCY.USD, currencySymbol: '$', originalAmount: 1000, - isEditable: true, + isEditable, mode: CONST.TAB.SPLIT.AMOUNT, percentage: 100, onSplitExpenseValueChange: jest.fn(), @@ -66,4 +66,41 @@ describe('SplitListItem', () => { expect(screen.getByText('Parent: Child', {includeHiddenElements: true})).toBeOnTheScreen(); expect(screen.getByLabelText('Aug 13, Coffee shop, Parent: Child')).toBeOnTheScreen(); }); + + it.each([ + [true, 1], + [false, 0], + ])('with isEditable=%s renders %i edit button(s)', async (isEditable, expectedCount) => { + render( + + + , + ); + await waitForBatchedUpdates(); + + expect(screen.queryAllByLabelText('Edit')).toHaveLength(expectedCount); + }); + + it('selects the row when the edit button is pressed', async () => { + const onSelectRow = jest.fn(); + const item = createSplitItem('Travel'); + render( + + + , + ); + await waitForBatchedUpdates(); + + fireEvent.press(screen.getByLabelText('Edit')); + + expect(onSelectRow).toHaveBeenCalledWith(item); + }); }); diff --git a/tests/ui/BaseListItemTest.tsx b/tests/unit/ListItemPressableTest.tsx similarity index 61% rename from tests/ui/BaseListItemTest.tsx rename to tests/unit/ListItemPressableTest.tsx index 8dc3fbaf0815..763ffec7eb50 100644 --- a/tests/ui/BaseListItemTest.tsx +++ b/tests/unit/ListItemPressableTest.tsx @@ -1,6 +1,6 @@ import {fireEvent, render, screen} from '@testing-library/react-native'; -import BaseListItem from '@components/SelectionList/ListItem/BaseListItem'; +import ListItemComposed from '@components/SelectionList/ListItemComposed'; import useHover from '@hooks/useHover'; @@ -10,7 +10,11 @@ jest.mock('@hooks/useHover', () => jest.fn()); const mockedUseHover = jest.mocked(useHover); -describe('BaseListItem', () => { +describe('ListItemPressable', () => { + beforeEach(() => { + mockedUseHover.mockReturnValue({hovered: false, deviceHasHoverSupport: true, bind: {onMouseEnter: jest.fn(), onMouseLeave: jest.fn()}}); + }); + it('hover should work correctly', () => { const mouseEnterMock = jest.fn(); const mouseLeaveMock = jest.fn(); @@ -23,10 +27,10 @@ describe('BaseListItem', () => { }, }); render( - {}} - showTooltip={false} + shouldShowTooltip={false} isFocused={false} />, ); @@ -38,13 +42,12 @@ describe('BaseListItem', () => { }); it('should use the accessibilityLabel prop as the row name when provided', () => { - mockedUseHover.mockReturnValue({hovered: false, deviceHasHoverSupport: true, bind: {onMouseEnter: jest.fn(), onMouseLeave: jest.fn()}}); render( - {}} - showTooltip={false} + shouldShowTooltip={false} isFocused={false} />, ); @@ -53,12 +56,11 @@ describe('BaseListItem', () => { }); it('should fall back to the item-derived label when accessibilityLabel is omitted', () => { - mockedUseHover.mockReturnValue({hovered: false, deviceHasHoverSupport: true, bind: {onMouseEnter: jest.fn(), onMouseLeave: jest.fn()}}); render( - {}} - showTooltip={false} + shouldShowTooltip={false} isFocused={false} />, ); @@ -66,13 +68,12 @@ describe('BaseListItem', () => { }); it('should keep the button role for a navigational row when shouldUseOptionRole is false', () => { - mockedUseHover.mockReturnValue({hovered: false, deviceHasHoverSupport: true, bind: {onMouseEnter: jest.fn(), onMouseLeave: jest.fn()}}); render( - {}} - showTooltip={false} + shouldShowTooltip={false} isFocused={false} />, ); @@ -80,12 +81,11 @@ describe('BaseListItem', () => { }); it('should resolve a single-select row to the option role by default', () => { - mockedUseHover.mockReturnValue({hovered: false, deviceHasHoverSupport: true, bind: {onMouseEnter: jest.fn(), onMouseLeave: jest.fn()}}); render( - {}} - showTooltip={false} + shouldShowTooltip={false} isFocused={false} />, ); @@ -93,40 +93,28 @@ describe('BaseListItem', () => { }); it('should be presentational (not a button) when accessible is false, so nested controls stay reachable', () => { - mockedUseHover.mockReturnValue({hovered: false, deviceHasHoverSupport: true, bind: {onMouseEnter: jest.fn(), onMouseLeave: jest.fn()}}); render( - {}} - showTooltip={false} + shouldShowTooltip={false} isFocused={false} />, ); expect(screen.queryByRole(CONST.ROLE.BUTTON)).toBeNull(); }); - it('should drive the row selected state from the isSelected prop when selection is not on the item', () => { - mockedUseHover.mockReturnValue({hovered: false, deviceHasHoverSupport: true, bind: {onMouseEnter: jest.fn(), onMouseLeave: jest.fn()}}); - render( - {}} - showTooltip={false} - isFocused={false} - />, - ); - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}1`).props.accessibilityState).toEqual(expect.objectContaining({selected: true})); - }); - - it('should fall back to item.isSelected for the row selected state when the isSelected prop is omitted', () => { - mockedUseHover.mockReturnValue({hovered: false, deviceHasHoverSupport: true, bind: {onMouseEnter: jest.fn(), onMouseLeave: jest.fn()}}); + it.each([ + ['the isSelected prop', {keyForList: '1', text: 'Item text'}, true], + ['item.isSelected', {keyForList: '1', text: 'Item text', isSelected: true}, undefined], + ])('should drive the row selected state from %s', (_source, item, isSelected) => { render( - {}} - showTooltip={false} + shouldShowTooltip={false} isFocused={false} />, ); diff --git a/tests/unit/SpendCategorySelectorListItemTest.tsx b/tests/unit/SpendCategorySelectorListItemTest.tsx new file mode 100644 index 000000000000..3e9f74b60345 --- /dev/null +++ b/tests/unit/SpendCategorySelectorListItemTest.tsx @@ -0,0 +1,58 @@ +import {fireEvent, render, screen, waitFor} from '@testing-library/react-native'; + +import SpendCategorySelectorListItem from '@components/SelectionList/ListItem/SpendCategorySelectorListItem'; +import type {ListItem} from '@components/SelectionList/ListItem/types'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +const buildItem = (extra: Partial = {}): ListItem => ({ + keyForList: 'meals', + groupID: 'meals', + categoryID: 'Food & Drink', + ...extra, +}); + +describe('SpendCategorySelectorListItem', () => { + it('renders nothing without a groupID', () => { + render( + , + ); + + expect(screen.queryByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}meals`)).toBeNull(); + }); + + it('renders the decoded category name under the capitalized group label', () => { + render( + , + ); + + expect(screen.getByText('Food & Drink')).toBeOnTheScreen(); + expect(screen.getByText('Meals')).toBeOnTheScreen(); + }); + + it('selects the row when it is pressed', async () => { + const onSelectRow = jest.fn(); + const item = buildItem(); + render( + , + ); + + fireEvent.press(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}meals`)); + + await waitFor(() => expect(onSelectRow).toHaveBeenCalledWith(item, undefined, undefined)); + }); +}); diff --git a/tests/unit/SpendRuleListItemTest.tsx b/tests/unit/SpendRuleListItemTest.tsx new file mode 100644 index 000000000000..e0f873bf84a3 --- /dev/null +++ b/tests/unit/SpendRuleListItemTest.tsx @@ -0,0 +1,71 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import SpendRuleListItem from '@components/SelectionList/ListItem/SpendRuleListItem'; +import type {SpendRuleListItemType} from '@components/SelectionList/ListItem/types'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +const buildItem = (isSelected: boolean): SpendRuleListItemType => ({ + keyForList: 'rule-1', + text: 'Travel rule', + isSelected, + action: CONST.SPEND_RULES.ACTION.ALLOW, + summary: 'Applies to all travel cards', + summaryParts: [ + {badgeLabel: 'Approve', text: 'Flights under $500', variant: CONST.SPEND_RULES.BADGE_VARIANTS.SUCCESS}, + {badgeLabel: 'Decline', text: 'Hotels over $300', variant: CONST.SPEND_RULES.BADGE_VARIANTS.ERROR}, + ], + searchTokens: ['travel'], +}); + +describe('SpendRuleListItem', () => { + it('renders the summary and every summary part', () => { + render( + , + ); + + expect(screen.getByText('Applies to all travel cards')).toBeOnTheScreen(); + expect(screen.getByText('Approve')).toBeOnTheScreen(); + expect(screen.getByText('Flights under $500')).toBeOnTheScreen(); + expect(screen.getByText('Decline')).toBeOnTheScreen(); + expect(screen.getByText('Hotels over $300')).toBeOnTheScreen(); + }); + + it.each([ + [true, true, false], + [false, false, true], + ])('with isSelected=%s renders the checkbox checked=%s and disabled=%s', (isSelected, expectedChecked, expectedDisabled) => { + render( + , + ); + + const checkbox = screen.getByRole(CONST.ROLE.CHECKBOX); + expect(checkbox.props.accessibilityState).toEqual(expect.objectContaining({checked: expectedChecked, disabled: expectedDisabled})); + }); + + it('selects the row when the checkbox is pressed', () => { + const onSelectRow = jest.fn(); + const item = buildItem(true); + render( + , + ); + + fireEvent.press(screen.getByRole(CONST.ROLE.CHECKBOX)); + + expect(onSelectRow).toHaveBeenCalledWith(item); + }); +}); From d89dbd5ec08f072c0de103bfe9347e0908a2874f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Fri, 11 Sep 2026 13:45:54 +0200 Subject: [PATCH 2/8] remove rightHandSideComponent as a prop --- .../Search/SearchAutocompleteList.tsx | 4 +- .../SelectionList/BaseSelectionList.tsx | 2 - .../ListItem/BareUserListItem.tsx | 6 +-- .../ListItem/BaseSelectListItem.tsx | 2 - .../SelectionList/ListItem/CardListItem.tsx | 2 - .../ListItem/InviteMemberListItem.tsx | 3 +- .../ListItem/ListItemRenderer.tsx | 2 - .../ListItem/SelectableListItem.tsx | 3 +- .../ListItem/SingleSelectListItem.tsx | 8 +--- .../ListItem/TravelDomainListItem.tsx | 12 ++--- .../SelectionList/ListItem/UserListItem.tsx | 6 +-- .../ListItem/UserSelectionListItem.tsx | 1 - .../SelectionList/ListItem/types.ts | 25 +++++------ .../ListItemComposed/ListItemPressable.tsx | 1 + .../SelectionList/ListItemContext.tsx | 4 ++ .../BaseSelectionListWithSections.tsx | 2 - src/components/SelectionList/types.ts | 3 +- src/pages/NewChatPage/AddToGroupButton.tsx | 44 +++++++++++++++++++ src/pages/NewChatPage/index.tsx | 30 ++++++------- .../UnshareBankAccount/UnshareBankAccount.tsx | 39 +++++++--------- .../DynamicWorkspaceOverviewPlanTypePage.tsx | 29 ++++++------ tests/unit/ListItemAvatarPrimitivesTest.tsx | 2 +- tests/unit/ListItemRightCaretTest.tsx | 2 +- tests/unit/SelectableListItemTest.tsx | 9 ++-- tests/unit/UserListItemTest.tsx | 4 +- tests/unit/useListItemAvatarColorsTest.tsx | 4 +- 26 files changed, 134 insertions(+), 115 deletions(-) create mode 100644 src/pages/NewChatPage/AddToGroupButton.tsx diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index ba9cf79480fa..f28fef76c7e6 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -141,7 +141,7 @@ function SearchRouterItem(props: UserListItemProps | Searc return ; } - const {item, isFocused, showTooltip, isDisabled, onSelectRow, onDismissError, shouldPreventEnterKeySubmit, rightHandSideComponent, onFocus, shouldSyncFocus, wrapperStyle} = props; + const {item, isFocused, showTooltip, isDisabled, onSelectRow, onDismissError, shouldPreventEnterKeySubmit, onFocus, shouldSyncFocus, wrapperStyle} = props; const fsClass = FS.getChatFSClass((item as SearchOption | undefined)?.item); return ( @@ -153,7 +153,6 @@ function SearchRouterItem(props: UserListItemProps | Searc onSelectRow={onSelectRow} onDismissError={onDismissError} shouldPreventEnterKeySubmit={shouldPreventEnterKeySubmit} - rightHandSideComponent={rightHandSideComponent} onFocus={onFocus} shouldSyncFocus={shouldSyncFocus} wrapperStyle={wrapperStyle} @@ -527,7 +526,6 @@ function SearchAutocompleteList({ keyForList, pressableStyle: styles.br2, text: StringUtils.lineBreaksToSpaces(shouldParserToHTML ? Parser.htmlToText(option.text ?? '') : (option.text ?? '')), - wrapperStyle: [styles.pr3, styles.pl3], } as AutocompleteListItem; }); diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 877357ee8dfa..1807fb4cb482 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -65,7 +65,6 @@ function BaseSelectionListImpl({ footerContent, listEmptyContent, listFooterContent, - rightHandSideComponent, alternateNumberOfSupportedLines, selectedItems = getEmptyArray(), style, @@ -308,7 +307,6 @@ function BaseSelectionListImpl({ onLongPressRow={onLongPressRow} onSelectionButtonPress={onSelectionButtonPress} shouldSingleExecuteRowSelect={shouldSingleExecuteRowSelect} - rightHandSideComponent={rightHandSideComponent} isMultilineSupported={isRowMultilineSupported} isAlternateTextMultilineSupported={(alternateNumberOfSupportedLines ?? 0) > 1} alternateTextNumberOfLines={alternateNumberOfSupportedLines} diff --git a/src/components/SelectionList/ListItem/BareUserListItem.tsx b/src/components/SelectionList/ListItem/BareUserListItem.tsx index d1f695abaf85..ac80d10c159f 100644 --- a/src/components/SelectionList/ListItem/BareUserListItem.tsx +++ b/src/components/SelectionList/ListItem/BareUserListItem.tsx @@ -23,7 +23,6 @@ function BareUserListItem({ onSelectRow, onDismissError, shouldPreventEnterKeySubmit, - rightHandSideComponent, onFocus, shouldSyncFocus, wrapperStyle, @@ -32,9 +31,8 @@ function BareUserListItem({ shouldDisableHoverStyle, shouldHighlightSelectedItem, }: UserListItemProps) { - const renderedRightComponent = typeof rightHandSideComponent === 'function' ? rightHandSideComponent(item, isFocused) : rightHandSideComponent; // Disable accessible grouping when a right-side button is visible, so VoiceOver can focus it independently. - const shouldDisableAccessibleGrouping = !!renderedRightComponent; + const shouldDisableAccessibleGrouping = !!item.actionElement; return ( ({ forwardedFSClass={forwardedFSClass} /> {shouldShowRBRIndicator(item) && } - {renderedRightComponent} + {item.actionElement} {!!item.invitedSecondaryLogin && } diff --git a/src/components/SelectionList/ListItem/BaseSelectListItem.tsx b/src/components/SelectionList/ListItem/BaseSelectListItem.tsx index def3b3f6783c..50acab0586ee 100644 --- a/src/components/SelectionList/ListItem/BaseSelectListItem.tsx +++ b/src/components/SelectionList/ListItem/BaseSelectListItem.tsx @@ -32,7 +32,6 @@ function BaseSelectListItem({ onSelectRow, onDismissError, shouldPreventEnterKeySubmit, - rightHandSideComponent, isMultilineSupported = false, isAlternateTextMultilineSupported = false, alternateTextNumberOfLines = 2, @@ -82,7 +81,6 @@ function BaseSelectListItem({ onSelectRow={onSelectRow} onDismissError={onDismissError} shouldPreventEnterKeySubmit={shouldPreventEnterKeySubmit} - rightHandSideComponent={rightHandSideComponent} canSelectMultiple={canSelectMultiple} onFocus={onFocus} shouldSyncFocus={shouldSyncFocus} diff --git a/src/components/SelectionList/ListItem/CardListItem.tsx b/src/components/SelectionList/ListItem/CardListItem.tsx index c70d74a7013b..c83086e99942 100644 --- a/src/components/SelectionList/ListItem/CardListItem.tsx +++ b/src/components/SelectionList/ListItem/CardListItem.tsx @@ -49,7 +49,6 @@ function CardListItem({ onSelectRow, onSelectionButtonPress, onDismissError, - rightHandSideComponent, onFocus, shouldSyncFocus, }: CardListItemProps) { @@ -87,7 +86,6 @@ function CardListItem({ onSelectRow={onSelectRow} onSelectionButtonPress={onSelectionButtonPress} onDismissError={onDismissError} - rightHandSideComponent={rightHandSideComponent} onFocus={onFocus} shouldSyncFocus={shouldSyncFocus} > diff --git a/src/components/SelectionList/ListItem/InviteMemberListItem.tsx b/src/components/SelectionList/ListItem/InviteMemberListItem.tsx index 30acb67f2168..2b00b3847f8b 100644 --- a/src/components/SelectionList/ListItem/InviteMemberListItem.tsx +++ b/src/components/SelectionList/ListItem/InviteMemberListItem.tsx @@ -27,7 +27,6 @@ function InviteMemberListItem({ onSelectRow, onSelectionButtonPress, onDismissError, - rightHandSideComponent, onFocus, shouldSyncFocus, wrapperStyle, @@ -102,7 +101,7 @@ function InviteMemberListItem({ canSelectMultiple={canSelectMultiple} /> )} - {typeof rightHandSideComponent === 'function' ? rightHandSideComponent(item, isFocused) : rightHandSideComponent} + {item.actionElement} {!!item.invitedSecondaryLogin && } diff --git a/src/components/SelectionList/ListItem/ListItemRenderer.tsx b/src/components/SelectionList/ListItem/ListItemRenderer.tsx index c2ae4993fc73..b5bf99322719 100644 --- a/src/components/SelectionList/ListItem/ListItemRenderer.tsx +++ b/src/components/SelectionList/ListItem/ListItemRenderer.tsx @@ -35,7 +35,6 @@ function ListItemRenderer({ selectRow, onSelectionButtonPress, onDismissError, - rightHandSideComponent, isMultilineSupported, isAlternateTextMultilineSupported, alternateTextNumberOfLines, @@ -83,7 +82,6 @@ function ListItemRenderer({ onSelectionButtonPress={handleOnSelectionButtonPress()} onDismissError={() => onDismissError?.(item)} shouldPreventEnterKeySubmit={shouldPreventEnterKeySubmit} - rightHandSideComponent={rightHandSideComponent} isMultilineSupported={isMultilineSupported} isAlternateTextMultilineSupported={isAlternateTextMultilineSupported} alternateTextNumberOfLines={alternateTextNumberOfLines} diff --git a/src/components/SelectionList/ListItem/SelectableListItem.tsx b/src/components/SelectionList/ListItem/SelectableListItem.tsx index 79436fd62a93..bd4b5029acb4 100644 --- a/src/components/SelectionList/ListItem/SelectableListItem.tsx +++ b/src/components/SelectionList/ListItem/SelectableListItem.tsx @@ -21,7 +21,6 @@ function SelectableListItem({ onSelectRow, isDisabled = false, children, - rightHandSideComponent, isFocused, isSelected, showTooltip, @@ -87,7 +86,7 @@ function SelectableListItem({ {children} {shouldShowRBRIndicator(item, isSelected) && } {selectionButtonPosition === CONST.SELECTION_BUTTON_POSITION.RIGHT && selectionButton} - {typeof rightHandSideComponent === 'function' ? rightHandSideComponent(item, isFocused) : rightHandSideComponent} + {item.actionElement} ); diff --git a/src/components/SelectionList/ListItem/SingleSelectListItem.tsx b/src/components/SelectionList/ListItem/SingleSelectListItem.tsx index f76fc8810c25..2cd01511b1dc 100644 --- a/src/components/SelectionList/ListItem/SingleSelectListItem.tsx +++ b/src/components/SelectionList/ListItem/SingleSelectListItem.tsx @@ -1,5 +1,3 @@ -import useThemeStyles from '@hooks/useThemeStyles'; - import React from 'react'; import type {ListItem, SingleSelectListItemProps} from './types'; @@ -28,19 +26,15 @@ function SingleSelectListItem({ titleStyles, shouldHighlightSelectedItem, isFocusVisible, - rightHandSideComponent, selectionButtonPosition, titleNumberOfLines, }: SingleSelectListItemProps) { - const styles = useThemeStyles(); - return ( ({ alternateTextNumberOfLines={alternateTextNumberOfLines} onFocus={onFocus} shouldSyncFocus={shouldSyncFocus} - wrapperStyle={[styles.optionRow, wrapperStyle]} + wrapperStyle={wrapperStyle} titleStyles={titleStyles} shouldHighlightSelectedItem={shouldHighlightSelectedItem} isFocusVisible={isFocusVisible} diff --git a/src/components/SelectionList/ListItem/TravelDomainListItem.tsx b/src/components/SelectionList/ListItem/TravelDomainListItem.tsx index 63141a15adbf..0c318fbb5ae6 100644 --- a/src/components/SelectionList/ListItem/TravelDomainListItem.tsx +++ b/src/components/SelectionList/ListItem/TravelDomainListItem.tsx @@ -58,13 +58,15 @@ function TravelDomainListItem({ onSelectionButtonPress={onSelectionButtonPress} onFocus={onFocus} shouldSyncFocus={shouldSyncFocus} - rightHandSideComponent={showRecommendedTag ? : undefined} selectionButtonPosition={selectionButtonPosition} > - + <> + + {showRecommendedTag && } + ); } diff --git a/src/components/SelectionList/ListItem/UserListItem.tsx b/src/components/SelectionList/ListItem/UserListItem.tsx index f57f168ffa1b..fdf8c45d2a20 100644 --- a/src/components/SelectionList/ListItem/UserListItem.tsx +++ b/src/components/SelectionList/ListItem/UserListItem.tsx @@ -24,7 +24,6 @@ function UserListItem({ onSelectionButtonPress, onDismissError, shouldPreventEnterKeySubmit, - rightHandSideComponent, onFocus, shouldSyncFocus, wrapperStyle, @@ -34,9 +33,8 @@ function UserListItem({ shouldHighlightSelectedItem, selectionButtonPosition = CONST.SELECTION_BUTTON_POSITION.RIGHT, }: UserListItemProps) { - const renderedRightComponent = typeof rightHandSideComponent === 'function' ? rightHandSideComponent(item, isFocused) : rightHandSideComponent; // Disable accessible grouping when a right-side button is visible, so VoiceOver can focus it independently. - const shouldDisableAccessibleGrouping = !!renderedRightComponent && !canSelectMultiple; + const shouldDisableAccessibleGrouping = !!item.actionElement && !canSelectMultiple; const selectionButton = !item.shouldHideSelectionButton && ( ({ /> {shouldShowRBRIndicator(item) && } {selectionButtonPosition === CONST.SELECTION_BUTTON_POSITION.RIGHT && selectionButton} - {renderedRightComponent} + {item.actionElement} {!!item.invitedSecondaryLogin && } diff --git a/src/components/SelectionList/ListItem/UserSelectionListItem.tsx b/src/components/SelectionList/ListItem/UserSelectionListItem.tsx index 9befca3a2df6..bf9f22caab17 100644 --- a/src/components/SelectionList/ListItem/UserSelectionListItem.tsx +++ b/src/components/SelectionList/ListItem/UserSelectionListItem.tsx @@ -66,7 +66,6 @@ function UserSelectionListItem({ onSelectionButtonPress={onSelectionButtonPress} onDismissError={onDismissError} shouldPreventEnterKeySubmit={shouldPreventEnterKeySubmit} - rightHandSideComponent={item.rightElement} pressableStyle={pressableStyle} onFocus={onFocus} shouldSyncFocus={shouldSyncFocus} diff --git a/src/components/SelectionList/ListItem/types.ts b/src/components/SelectionList/ListItem/types.ts index 324294bf6484..7b85af4ce360 100644 --- a/src/components/SelectionList/ListItem/types.ts +++ b/src/components/SelectionList/ListItem/types.ts @@ -58,6 +58,13 @@ type ListItem = { leftElement?: ReactNode; rightElement?: ReactNode; + /** + * Standalone control rendered at the very end of the row: after the selection button and outside the row's + * accessible content group, so screen readers can focus it independently. Use `rightElement` instead for + * content that belongs beside the text (badges, inline icons). + */ + actionElement?: ReactNode; + /** Icons for the user (can be multiple if it's a Workspace) */ icons?: Icon[]; @@ -170,7 +177,7 @@ type CommonListItemProps = { /** Overrides the row's selected state (aria-selected, highlight). Defaults to `item.isSelected`; pass it when selection isn't stored on the item itself. */ isSelected?: boolean; -} & TRightHandSideComponent; +}; type ListItemFocusEventHandler = (event: NativeSyntheticEvent) => void; @@ -182,10 +189,6 @@ type ExtendedTargetedEvent = TargetedEvent & { }; }; -type TRightHandSideComponent = { - rightHandSideComponent?: ((item: TItem, isFocused?: boolean) => ReactNode | null | undefined) | ReactNode | null; -}; - type ListItemProps = CommonListItemProps & { /** The section list item */ item: TItem; @@ -228,13 +231,10 @@ type ListItemProps = CommonListItemProps & { /** * Props of the composed ListItem pressable root. Row content is passed as plain children; hover/focus/tooltip state reaches it through ListItemContext. - * Content-level props (wrapper style, right-hand component, multiline options) are omitted: the pressable never reads them, so the variant that renders them declares them. + * Content-level props (wrapper style, multiline options) are omitted: the pressable never reads them, so the variant that renders them declares them. */ type ListItemPressableProps = PropsWithChildren< - Omit< - CommonListItemProps, - 'showTooltip' | 'wrapperStyle' | 'rightHandSideComponent' | 'isMultilineSupported' | 'isAlternateTextMultilineSupported' | 'alternateTextNumberOfLines' | 'titleNumberOfLines' - > & { + Omit, 'showTooltip' | 'wrapperStyle' | 'isMultilineSupported' | 'isAlternateTextMultilineSupported' | 'alternateTextNumberOfLines' | 'titleNumberOfLines'> & { item: TItem; /** Whether content inside the row should show tooltips (provided to children via ListItemContext) */ @@ -272,12 +272,11 @@ type SpendRuleListItemType = ListItem & { /** Props for SelectableListItem, which extends the composed ListItem pressable with selection button support. */ type SelectableListItemProps = Omit, 'containerStyle' | 'shouldShowTooltip'> & - ForwardedFSClassProps & - TRightHandSideComponent & { + ForwardedFSClassProps & { /** Whether text in the row should show tooltips on overflow (forwarded to the pressable as shouldShowTooltip) */ showTooltip: boolean; - /** Style of the row View that lays out the selection button, children, and right-hand component */ + /** Style of the row View that lays out the selection button, children, and the item's action element */ wrapperStyle?: StyleProp; /** Callback to fire when the selection button is pressed */ diff --git a/src/components/SelectionList/ListItemComposed/ListItemPressable.tsx b/src/components/SelectionList/ListItemComposed/ListItemPressable.tsx index f49684bb25f3..c7680879aa3f 100644 --- a/src/components/SelectionList/ListItemComposed/ListItemPressable.tsx +++ b/src/components/SelectionList/ListItemComposed/ListItemPressable.tsx @@ -176,6 +176,7 @@ function ListItemPressable({ > ({ + isFocused: false, isFocusVisible: false, shouldShowTooltip: false, isDisabled: false, diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index 501e3e25be01..8f53240e0ff4 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -58,7 +58,6 @@ function BaseSelectionListWithSectionsImpl({ onEndReachedThreshold, customListHeaderContent, customHeaderContent, - rightHandSideComponent, listEmptyContent, footerContent, listFooterContent, @@ -290,7 +289,6 @@ function BaseSelectionListWithSectionsImpl({ canSelectMultiple={canSelectMultiple} shouldSingleExecuteRowSelect={shouldSingleExecuteRowSelect} onDismissError={onDismissError} - rightHandSideComponent={rightHandSideComponent} setFocusedIndex={setFocusedIndex} singleExecution={singleExecution} shouldSyncFocus={!isTextInputFocusedRef.current && isKeyboardNavigating} diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 17208208758a..845124185e7e 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -5,7 +5,7 @@ import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import type CONST from '@src/CONST'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; -import type {ReactElement, Ref} from 'react'; +import type {Ref} from 'react'; import type {GestureResponderEvent, InputModeOptions, StyleProp, TextStyle, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -47,7 +47,6 @@ type BaseSelectionListProps = { footerContent?: React.ReactNode; listFooterContent?: React.JSX.Element | null | undefined; shouldShowLoadingPlaceholder?: boolean; - rightHandSideComponent?: ((item: TItem, isFocused?: boolean) => ReactElement | null | undefined) | ReactElement | null; shouldShowTooltips?: boolean; customListHeaderContent?: React.JSX.Element | null; onSelectionButtonPress?: (item: TItem) => void; diff --git a/src/pages/NewChatPage/AddToGroupButton.tsx b/src/pages/NewChatPage/AddToGroupButton.tsx new file mode 100644 index 000000000000..17a92715f8ae --- /dev/null +++ b/src/pages/NewChatPage/AddToGroupButton.tsx @@ -0,0 +1,44 @@ +import Button from '@components/ButtonComposed'; +import {useListItemContext} from '@components/SelectionList/ListItemContext'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {OptionWithKey} from '@libs/OptionsListUtils/types'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +type AddToGroupButtonProps = { + /** The row this button belongs to */ + item: OptionWithKey; + + /** Adds the row's user to the group draft */ + onPress: (item: OptionWithKey) => void; +}; + +/** + * The "Add to group" action rendered at the end of an eligible NewChatPage row. + * Takes the row's focus state from ListItemContext so the button picks up the hovered + * appearance while its row is the list's focused one. + */ +function AddToGroupButton({item, onPress}: AddToGroupButtonProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {isFocused} = useListItemContext(); + + return ( + + ); +} + +export default AddToGroupButton; diff --git a/src/pages/NewChatPage/index.tsx b/src/pages/NewChatPage/index.tsx index fd865f1cf66a..dcd13cb90f4f 100755 --- a/src/pages/NewChatPage/index.tsx +++ b/src/pages/NewChatPage/index.tsx @@ -45,6 +45,7 @@ import reject from 'lodash/reject'; import React, {startTransition, useEffect, useImperativeHandle, useRef, useState} from 'react'; import {Keyboard} from 'react-native'; +import AddToGroupButton from './AddToGroupButton'; import useGroupChatDraftParticipantSync from './useGroupChatDraftParticipantSync'; const excludedGroupEmails = new Set(CONST.EXPENSIFY_EMAILS.filter((value) => value !== CONST.EMAIL.CONCIERGE)); @@ -314,9 +315,9 @@ function NewChatPage({ref}: NewChatPageProps) { }); }; - const itemRightSideComponent = (item: OptionWithKey, isFocused?: boolean) => { + const getRowActionElement = (item: OptionWithKey) => { if (item.isSelfDM) { - return null; + return undefined; } if (item.isSelected) { @@ -335,23 +336,23 @@ function NewChatPage({ref}: NewChatPageProps) { // "Add to group" only makes sense for eligible (login-bearing, non-excluded) users if (!item.login || excludedGroupEmails.has(item.login)) { - return null; + return undefined; } - const buttonInnerStyles = isFocused ? styles.buttonDefaultHovered : {}; return ( - + ); }; + // Every row carries its own trailing control, so the rows stay plain data and the button reads its focus state from the row context. + const sectionsWithRowActions = sections.map((section) => ({ + ...section, + data: section.data.map((option) => ({...option, actionElement: getRowActionElement(option)})), + })); + const createGroup = () => { const latestSelectedOptions = latestSelectedOptionsRef.current; if (latestSelectedOptions.length === 0) { @@ -412,7 +413,7 @@ function NewChatPage({ref}: NewChatPageProps) { ref={selectionListRef} ListItem={BareUserListItem} - sections={areOptionsInitialized ? sections : getEmptyArray>()} + sections={areOptionsInitialized ? sectionsWithRowActions : getEmptyArray>()} onSelectRow={selectOption} shouldShowTextInput textInputOptions={textInputOptions} @@ -424,7 +425,6 @@ function NewChatPage({ref}: NewChatPageProps) { confirmButtonOptions={{ onConfirm: (e, option) => (latestSelectedOptionsRef.current.length > 0 ? createGroup() : selectOption(option)), }} - rightHandSideComponent={itemRightSideComponent} footerContent={footerContent} shouldShowLoadingPlaceholder={!areOptionsInitialized} shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} diff --git a/src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx b/src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx index d4cff80430c8..724e4589ec2d 100644 --- a/src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx +++ b/src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx @@ -6,7 +6,6 @@ import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import BareUserListItem from '@components/SelectionList/ListItem/BareUserListItem'; -import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -51,6 +50,7 @@ function UnshareBankAccount({route}: ShareBankAccountProps) { const totalAdmins = bankAccountList?.[bankAccountID]?.accountData?.sharees?.length; const adminEmails = admins?.filter((admin) => admin !== currentUserPersonalDetails?.email) ?? []; const adminPersonalDetails = usePersonalDetailsByLogins(adminEmails); + const isLoading = unsharedBankAccountData?.isLoading ?? false; const adminsWithInfo = adminEmails.map((admin) => { const personalDetails = adminPersonalDetails[admin]; const formattedAdmin = formatMemberForList({ @@ -62,7 +62,22 @@ function UnshareBankAccount({route}: ShareBankAccountProps) { pendingAction: personalDetails?.pendingAction, reportID: '', }); - return {...formattedAdmin, isInteractive: false}; + return { + ...formattedAdmin, + isInteractive: false, + actionElement: ( + + ), + }; }); let adminsList = adminsWithInfo; @@ -76,7 +91,6 @@ function UnshareBankAccount({route}: ShareBankAccountProps) { const isExpensifyCardSettlementAccount = bankAccountList?.[bankAccountID]?.isExpensifyCardSettlementAccount ?? false; const shouldShowTextInput = Number(totalAdmins) >= CONST.STANDARD_LIST_ITEM_LIMIT; const textInputLabel = shouldShowTextInput ? translate('common.search') : undefined; - const isLoading = unsharedBankAccountData?.isLoading ?? false; const shouldShowSuccess = unsharedBankAccountData?.shouldShowSuccess ?? false; useEffect(() => { @@ -116,24 +130,6 @@ function UnshareBankAccount({route}: ShareBankAccountProps) { setShowExpensifyCardErrorModal(false); }; - const itemRightSideComponent = (item: ListItem) => { - const promptUnshare = () => setUnshareUser({login: item?.login, text: item?.text}); - const isUnshareButtonLoading = isLoading && unsharedBankAccountData?.email === item?.login; - - return ( - - ); - }; - const onButtonPress = () => Navigation.goBack(ROUTES.SETTINGS_WALLET); const getHeaderSearchMessage = () => { @@ -160,7 +156,6 @@ function UnshareBankAccount({route}: ShareBankAccountProps) { }} data={adminsList} shouldShowListEmptyContent={false} - rightHandSideComponent={itemRightSideComponent} footerContent={ ; isSelected: boolean; + actionElement?: ReactNode; }; function DynamicWorkspaceOverviewPlanTypePage({policy}: WithPolicyProps) { const [currentPlan, setCurrentPlan] = useState(policy?.type); @@ -65,6 +67,12 @@ function DynamicWorkspaceOverviewPlanTypePage({policy}: WithPolicyProps) { setCurrentPlan(policy?.type); }, [policy?.type]); + const isControl = policy?.type === CONST.POLICY.TYPE.CORPORATE; + const isAnnual = privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.ANNUAL; + + /** If user has the annual Control plan and their first billing cycle is completed, they cannot downgrade the Workspace plan to Collect. */ + const isPlanTypeLocked = isControl && isAnnual && !policy.canDowngrade; + const isCurrentPolicySubmit = isSubmitPolicy(policy); const workspacePlanTypes = Object.values(CONST.POLICY.TYPE) .filter((type) => { @@ -85,26 +93,20 @@ function DynamicWorkspaceOverviewPlanTypePage({policy}: WithPolicyProps) { alternateText: translate(`workspace.planTypePage.planTypes.${policyType as PersonalPolicyTypeExcludedProps}.description`), keyForList: policyType, isSelected: policyType === currentPlan, + actionElement: + isPlanTypeLocked && policyType === policy?.type ? ( + + ) : undefined, })) .reverse(); - const isControl = policy?.type === CONST.POLICY.TYPE.CORPORATE; - const isAnnual = privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.ANNUAL; const autoRenewalDate = privateSubscription?.endDate ? format(privateSubscription.endDate, CONST.DATE.MONTH_DAY_YEAR_ORDINAL_FORMAT, {locale: dateFnsLocale}) : CardSectionUtils.getNextBillingDate(dateFnsLocale); - /** If user has the annual Control plan and their first billing cycle is completed, they cannot downgrade the Workspace plan to Collect. */ - const isPlanTypeLocked = isControl && isAnnual && !policy.canDowngrade; - - const lockedIcon = (option: WorkspacePlanTypeItem) => - option.value === policy?.type ? ( - - ) : null; - const handleUpdatePlan = () => { // Submit policies don't expose SUBMIT in the option list, but the editor can // still pick Team/Corporate. Route any selection from a Submit policy to the @@ -174,7 +176,6 @@ function DynamicWorkspaceOverviewPlanTypePage({policy}: WithPolicyProps) { onSelectRow={(option) => { setCurrentPlan(option.value); }} - rightHandSideComponent={isPlanTypeLocked ? lockedIcon : null} shouldUpdateFocusedIndex shouldSingleExecuteRowSelect shouldIgnoreFocus diff --git a/tests/unit/ListItemAvatarPrimitivesTest.tsx b/tests/unit/ListItemAvatarPrimitivesTest.tsx index 28677f251413..0c343f78b08b 100644 --- a/tests/unit/ListItemAvatarPrimitivesTest.tsx +++ b/tests/unit/ListItemAvatarPrimitivesTest.tsx @@ -29,7 +29,7 @@ const mockAvatarTooltipsProvider = jest.mocked(AvatarTooltipsProvider); const renderWithContext = (children: ReactNode, shouldShowTooltip = true) => render( - + {children} , ); diff --git a/tests/unit/ListItemRightCaretTest.tsx b/tests/unit/ListItemRightCaretTest.tsx index 6bd9a38c4e9d..42c30665e065 100644 --- a/tests/unit/ListItemRightCaretTest.tsx +++ b/tests/unit/ListItemRightCaretTest.tsx @@ -30,7 +30,7 @@ type RowContextState = {isDisabled?: boolean; isInteractive?: boolean}; const renderCaret = (isHovered: boolean, {isDisabled = false, isInteractive = true}: RowContextState = {}) => render( - + diff --git a/tests/unit/SelectableListItemTest.tsx b/tests/unit/SelectableListItemTest.tsx index 311aa526f80d..890baecc47f8 100644 --- a/tests/unit/SelectableListItemTest.tsx +++ b/tests/unit/SelectableListItemTest.tsx @@ -201,12 +201,9 @@ describe('SelectableListItem', () => { expect(screen.getByTestId('hovered-false')).toBeVisible(); }); - it('resolves a function-form rightHandSideComponent with the item and focus state', () => { - const item = buildItem(); - const rightHandSideComponent = jest.fn(() => ); - renderItem({item, isFocused: true, rightHandSideComponent}); + it('renders the action element the item carries', () => { + renderItem({item: buildItem({actionElement: })}); - expect(rightHandSideComponent).toHaveBeenCalledWith(item, true); - expect(screen.getByTestId('rhs')).toBeVisible(); + expect(screen.getByTestId('action')).toBeVisible(); }); }); diff --git a/tests/unit/UserListItemTest.tsx b/tests/unit/UserListItemTest.tsx index 833228ba5f2e..76e59f6cba85 100644 --- a/tests/unit/UserListItemTest.tsx +++ b/tests/unit/UserListItemTest.tsx @@ -124,10 +124,10 @@ describe('UserListItem', () => { }); it.each([ - ['disables the row accessibility grouping when a right-side component renders without multi-select', false, false], + ['disables the row accessibility grouping when an action element renders without multi-select', false, false], ['keeps the row accessibility grouping with multi-select', true, true], ])('%s', (_label, canSelectMultiple, isRowAccessible) => { - renderItem(buildItem(), {canSelectMultiple, rightHandSideComponent: }); + renderItem(buildItem({actionElement: }), {canSelectMultiple}); const rowAccessible: unknown = screen.getByTestId(ROW_TEST_ID).props.accessible; if (isRowAccessible) { diff --git a/tests/unit/useListItemAvatarColorsTest.tsx b/tests/unit/useListItemAvatarColorsTest.tsx index a9bd28b1d1b7..e80e9d20da88 100644 --- a/tests/unit/useListItemAvatarColorsTest.tsx +++ b/tests/unit/useListItemAvatarColorsTest.tsx @@ -34,7 +34,9 @@ const backgroundAndBorder = (color: string) => ({backgroundColor: color, borderC const renderColors = ({isFocusVisible, isHovered}: {isFocusVisible: boolean; isHovered: boolean}) => renderHook(() => ({subscriptAvatarBorderColor: useListItemSubscriptAvatarBorderColor(), secondaryAvatarContainerStyle: useListItemSecondaryAvatarContainerStyle()}), { wrapper: ({children}) => ( - + {children} ), From 02dd45e2dcb4a20ac0108b8f1baa40d7fec714ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Wed, 16 Sep 2026 11:43:08 +0200 Subject: [PATCH 3/8] readd the right item in UserSelectionListItem --- src/components/SelectionList/ListItem/UserSelectionListItem.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/SelectionList/ListItem/UserSelectionListItem.tsx b/src/components/SelectionList/ListItem/UserSelectionListItem.tsx index bf9f22caab17..86f5187d5728 100644 --- a/src/components/SelectionList/ListItem/UserSelectionListItem.tsx +++ b/src/components/SelectionList/ListItem/UserSelectionListItem.tsx @@ -85,6 +85,7 @@ function UserSelectionListItem({ /> )} + {item.rightElement} ); From b7ad2f42a4544f3a404e2bb7039c5eb3333717fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Wed, 16 Sep 2026 13:01:31 +0200 Subject: [PATCH 4/8] Code cleanup --- .../ListItem/SearchMergeReportsListItem.tsx | 2 +- .../ListItem/BaseSelectListItem.tsx | 13 ++---------- .../ListItem/MultiSelectListItem.tsx | 20 +++++++++++-------- .../SelectionList/ListItem/types.ts | 17 +++++++--------- src/pages/NewChatPage/AddToGroupButton.tsx | 6 +----- src/pages/NewChatPage/index.tsx | 1 - .../DynamicWorkspaceOverviewPlanTypePage.tsx | 1 - tests/unit/MultiSelectListItemTest.tsx | 5 ++--- 8 files changed, 25 insertions(+), 40 deletions(-) diff --git a/src/components/Search/SearchList/ListItem/SearchMergeReportsListItem.tsx b/src/components/Search/SearchList/ListItem/SearchMergeReportsListItem.tsx index 1216318d13f2..823f752bfc6c 100644 --- a/src/components/Search/SearchList/ListItem/SearchMergeReportsListItem.tsx +++ b/src/components/Search/SearchList/ListItem/SearchMergeReportsListItem.tsx @@ -53,7 +53,7 @@ function SearchMergeReportsListItem({item, isFocused, sh shouldDisableHoverStyle={false} > - + = ListItemProps & { - /** Element rendered before the text column. Falls back to `item.leftElement` when omitted. */ - leftElement?: ReactNode; -}; - /** * A text-only row with a title and optional subtitle. Serves as the base for SingleSelectListItem and MultiSelectListItem. */ @@ -45,11 +38,9 @@ function BaseSelectListItem({ isFocusVisible, accessibilityRole, selectionButtonPosition, - leftElement, -}: BaseSelectListItemProps) { +}: ListItemProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const rowLeftElement = leftElement ?? item.leftElement; const fullTitle = isMultilineSupported ? item.text?.trimStart() : item.text; const indentsLength = (item.text?.length ?? 0) - (fullTitle?.length ?? 0); const paddingLeft = Math.floor(indentsLength / CONST.INDENTS.length) * styles.ml3.marginLeft; @@ -89,7 +80,7 @@ function BaseSelectListItem({ selectionButtonPosition={selectionButtonPosition} > <> - {rowLeftElement} + {item.leftElement} ({ return ( - ) : undefined + item={ + icon + ? { + ...item, + leftElement: ( + + ), + } + : item } isFocused={isFocused} isFocusVisible={isFocusVisible} diff --git a/src/components/SelectionList/ListItem/types.ts b/src/components/SelectionList/ListItem/types.ts index 7b85af4ce360..31b064d9276e 100644 --- a/src/components/SelectionList/ListItem/types.ts +++ b/src/components/SelectionList/ListItem/types.ts @@ -55,14 +55,14 @@ type ListItem = { accountID?: number | null; login?: string | null; + + /** Content rendered before the text column (e.g. an avatar or icon) */ leftElement?: ReactNode; + + /** Content rendered beside the text (e.g. a badge or inline icon) */ rightElement?: ReactNode; - /** - * Standalone control rendered at the very end of the row: after the selection button and outside the row's - * accessible content group, so screen readers can focus it independently. Use `rightElement` instead for - * content that belongs beside the text (badges, inline icons). - */ + /** Standalone control (e.g. a button) rendered after the selection button at the row's end */ actionElement?: ReactNode; /** Icons for the user (can be multiple if it's a Workspace) */ @@ -229,15 +229,12 @@ type ListItemProps = CommonListItemProps & { isFirstItem?: boolean; }; -/** - * Props of the composed ListItem pressable root. Row content is passed as plain children; hover/focus/tooltip state reaches it through ListItemContext. - * Content-level props (wrapper style, multiline options) are omitted: the pressable never reads them, so the variant that renders them declares them. - */ +/** Props of the ListItem pressable root. Row content comes as children and reads hover/focus/tooltip state from ListItemContext */ type ListItemPressableProps = PropsWithChildren< Omit, 'showTooltip' | 'wrapperStyle' | 'isMultilineSupported' | 'isAlternateTextMultilineSupported' | 'alternateTextNumberOfLines' | 'titleNumberOfLines'> & { item: TItem; - /** Whether content inside the row should show tooltips (provided to children via ListItemContext) */ + /** Whether content inside the row should show tooltips */ shouldShowTooltip: boolean; /** Overrides the row's screen-reader name. Defaults to the item's derived label when omitted. */ diff --git a/src/pages/NewChatPage/AddToGroupButton.tsx b/src/pages/NewChatPage/AddToGroupButton.tsx index 46c3ce5cb984..b43273e4523c 100644 --- a/src/pages/NewChatPage/AddToGroupButton.tsx +++ b/src/pages/NewChatPage/AddToGroupButton.tsx @@ -18,11 +18,7 @@ type AddToGroupButtonProps = { onPress: (item: OptionWithKey) => void; }; -/** - * The "Add to group" action rendered at the end of an eligible NewChatPage row. - * Takes the row's focus state from ListItemContext so the button picks up the hovered - * appearance while its row is the list's focused one. - */ +/** The "Add to group" action rendered at the end of an eligible NewChatPage row. */ function AddToGroupButton({item, onPress}: AddToGroupButtonProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); diff --git a/src/pages/NewChatPage/index.tsx b/src/pages/NewChatPage/index.tsx index 1d44100203af..b65aaf81ea04 100755 --- a/src/pages/NewChatPage/index.tsx +++ b/src/pages/NewChatPage/index.tsx @@ -350,7 +350,6 @@ function NewChatPage({ref}: NewChatPageProps) { ); }; - // Every row carries its own trailing control, so the rows stay plain data and the button reads its focus state from the row context. const sectionsWithRowActions = sections.map((section) => ({ ...section, data: section.data.map((option) => ({...option, actionElement: getRowActionElement(option)})), diff --git a/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx b/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx index 3527e683f6fb..9828c3c174ba 100644 --- a/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx +++ b/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx @@ -70,7 +70,6 @@ function DynamicWorkspaceOverviewPlanTypePage({policy}: WithPolicyProps) { const isControl = policy?.type === CONST.POLICY.TYPE.CORPORATE; const isAnnual = privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.ANNUAL; - /** If user has the annual Control plan and their first billing cycle is completed, they cannot downgrade the Workspace plan to Collect. */ const isPlanTypeLocked = isControl && isAnnual && !policy.canDowngrade; const isCurrentPolicySubmit = isSubmitPolicy(policy); diff --git a/tests/unit/MultiSelectListItemTest.tsx b/tests/unit/MultiSelectListItemTest.tsx index 7d26a479c3a9..bd2ef9bcb42c 100644 --- a/tests/unit/MultiSelectListItemTest.tsx +++ b/tests/unit/MultiSelectListItemTest.tsx @@ -11,7 +11,7 @@ import React from 'react'; import {View} from 'react-native'; // The base item is stubbed to render just the left element, so the avatar wiring is exercised without the full row chrome. -jest.mock('@components/SelectionList/ListItem/BaseSelectListItem', () => jest.fn(({leftElement}: {leftElement?: React.ReactNode}) => leftElement ?? null)); +jest.mock('@components/SelectionList/ListItem/BaseSelectListItem', () => jest.fn(({item}: {item: ListItem}) => item.leftElement ?? null)); jest.mock('@components/Avatar/AvatarFromIcon', () => jest.fn(() => null)); @@ -61,11 +61,10 @@ describe('MultiSelectListItem', () => { expect(mockAvatarFromIcon.mock.calls.at(0)?.at(0)).toEqual(expect.objectContaining({icon: ICON})); }); - it('leaves leftElement undefined when there are no icons, so the item value is used', () => { + it('keeps the item leftElement when there are no icons', () => { const customLeftElement = ; const props = renderItem({keyForList: 'row', text: 'Row', leftElement: customLeftElement}); - expect(props?.leftElement).toBeUndefined(); expect(props?.item.leftElement).toBe(customLeftElement); }); }); From a063ad77e1325652a80145c5ff0db50adc91afd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Wed, 16 Sep 2026 16:10:01 +0200 Subject: [PATCH 5/8] fix some of Melvins findings --- .../ListItem/BaseSelectListItem.tsx | 11 ++++++++++- .../ListItem/MultiSelectListItem.tsx | 16 +--------------- .../ListItem/UserSelectionListItem.tsx | 1 - 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/components/SelectionList/ListItem/BaseSelectListItem.tsx b/src/components/SelectionList/ListItem/BaseSelectListItem.tsx index 56d30f3b42dd..109600a4e847 100644 --- a/src/components/SelectionList/ListItem/BaseSelectListItem.tsx +++ b/src/components/SelectionList/ListItem/BaseSelectListItem.tsx @@ -16,6 +16,7 @@ import SelectableListItem from './SelectableListItem'; /** * A text-only row with a title and optional subtitle. Serves as the base for SingleSelectListItem and MultiSelectListItem. + * The text column is preceded by a compact avatar of the item's first icon, or by `item.leftElement` when there is none. */ function BaseSelectListItem({ item, @@ -41,6 +42,7 @@ function BaseSelectListItem({ }: ListItemProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const icon = item.icons?.at(0); const fullTitle = isMultilineSupported ? item.text?.trimStart() : item.text; const indentsLength = (item.text?.length ?? 0) - (fullTitle?.length ?? 0); const paddingLeft = Math.floor(indentsLength / CONST.INDENTS.length) * styles.ml3.marginLeft; @@ -80,7 +82,14 @@ function BaseSelectListItem({ selectionButtonPosition={selectionButtonPosition} > <> - {item.leftElement} + {icon ? ( + + ) : ( + item.leftElement + )} ({ return ( - ), - } - : item - } + item={item} isFocused={isFocused} isFocusVisible={isFocusVisible} showTooltip={showTooltip} diff --git a/src/components/SelectionList/ListItem/UserSelectionListItem.tsx b/src/components/SelectionList/ListItem/UserSelectionListItem.tsx index 86f5187d5728..bf9f22caab17 100644 --- a/src/components/SelectionList/ListItem/UserSelectionListItem.tsx +++ b/src/components/SelectionList/ListItem/UserSelectionListItem.tsx @@ -85,7 +85,6 @@ function UserSelectionListItem({ /> )} - {item.rightElement} ); From 68ff08a34c4c34216a49e42dfb7bdd60e6098cce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Wed, 16 Sep 2026 16:15:48 +0200 Subject: [PATCH 6/8] update test --- tests/unit/MultiSelectListItemTest.tsx | 47 +++++++++++--------------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/tests/unit/MultiSelectListItemTest.tsx b/tests/unit/MultiSelectListItemTest.tsx index bd2ef9bcb42c..09b88e618727 100644 --- a/tests/unit/MultiSelectListItemTest.tsx +++ b/tests/unit/MultiSelectListItemTest.tsx @@ -1,7 +1,6 @@ -import {render} from '@testing-library/react-native'; +import {render, screen} from '@testing-library/react-native'; import AvatarFromIcon from '@components/Avatar/AvatarFromIcon'; -import BaseSelectListItem from '@components/SelectionList/ListItem/BaseSelectListItem'; import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem'; import type {ListItem} from '@components/SelectionList/ListItem/types'; @@ -10,12 +9,8 @@ import CONST from '@src/CONST'; import React from 'react'; import {View} from 'react-native'; -// The base item is stubbed to render just the left element, so the avatar wiring is exercised without the full row chrome. -jest.mock('@components/SelectionList/ListItem/BaseSelectListItem', () => jest.fn(({item}: {item: ListItem}) => item.leftElement ?? null)); - jest.mock('@components/Avatar/AvatarFromIcon', () => jest.fn(() => null)); -const mockBaseSelectListItem = jest.mocked(BaseSelectListItem); const mockAvatarFromIcon = jest.mocked(AvatarFromIcon); const ICON = { @@ -25,6 +20,8 @@ const ICON = { id: 7, }; +const CUSTOM_LEFT_ELEMENT_TEST_ID = 'custom-left-element'; + function renderItem(item: ListItem) { render( , ); - return mockBaseSelectListItem.mock.calls.at(0)?.at(0); } describe('MultiSelectListItem', () => { beforeEach(() => { - mockBaseSelectListItem.mockClear(); mockAvatarFromIcon.mockClear(); }); - it('delegates to BaseSelectListItem as a checkbox row', () => { - const props = renderItem({keyForList: 'row', text: 'Row'}); - - expect(props).toEqual( - expect.objectContaining({ - canSelectMultiple: true, - accessibilityRole: CONST.ROLE.CHECKBOX, - }), - ); - }); - - it('renders the item avatar as the left element when the item has icons', () => { - renderItem({keyForList: 'row', text: 'Row', icons: [ICON]}); + it('renders as a checkbox row', () => { + renderItem({keyForList: 'row', text: 'Row'}); - expect(mockAvatarFromIcon).toHaveBeenCalledTimes(1); - expect(mockAvatarFromIcon.mock.calls.at(0)?.at(0)).toEqual(expect.objectContaining({icon: ICON})); + expect(screen.getByRole(CONST.ROLE.CHECKBOX)).toBeOnTheScreen(); }); - it('keeps the item leftElement when there are no icons', () => { - const customLeftElement = ; - const props = renderItem({keyForList: 'row', text: 'Row', leftElement: customLeftElement}); - - expect(props?.item.leftElement).toBe(customLeftElement); + it.each([ + ['icons only', {icons: [ICON]}, true, false], + ['leftElement only', {leftElement: }, false, true], + ['both icons and leftElement', {icons: [ICON], leftElement: }, true, false], + ['neither', {}, false, false], + ])('with %s renders avatar=%s and custom left element=%s', (_label, itemFields, expectsAvatar, expectsLeftElement) => { + renderItem({keyForList: 'row', text: 'Row', ...itemFields}); + + expect(mockAvatarFromIcon).toHaveBeenCalledTimes(expectsAvatar ? 1 : 0); + if (expectsAvatar) { + expect(mockAvatarFromIcon.mock.calls.at(0)?.at(0)).toEqual(expect.objectContaining({icon: ICON})); + } + expect(screen.queryByTestId(CUSTOM_LEFT_ELEMENT_TEST_ID) !== null).toBe(expectsLeftElement); }); }); From ae1d9396a9b5471d2f574ca9c5cf8268cf065283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Wed, 16 Sep 2026 16:26:05 +0200 Subject: [PATCH 7/8] fix regression --- .../ListItem/BaseSelectListItem.tsx | 17 +++---- tests/unit/MultiSelectListItemTest.tsx | 2 +- .../SingleSelectWithAvatarListItemTest.tsx | 49 +++++++++++++++++++ 3 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 tests/unit/SingleSelectWithAvatarListItemTest.tsx diff --git a/src/components/SelectionList/ListItem/BaseSelectListItem.tsx b/src/components/SelectionList/ListItem/BaseSelectListItem.tsx index 109600a4e847..ebe712fda57e 100644 --- a/src/components/SelectionList/ListItem/BaseSelectListItem.tsx +++ b/src/components/SelectionList/ListItem/BaseSelectListItem.tsx @@ -16,7 +16,7 @@ import SelectableListItem from './SelectableListItem'; /** * A text-only row with a title and optional subtitle. Serves as the base for SingleSelectListItem and MultiSelectListItem. - * The text column is preceded by a compact avatar of the item's first icon, or by `item.leftElement` when there is none. + * The text column is preceded by `item.leftElement`, or by a compact avatar of the item's first icon when there is none. */ function BaseSelectListItem({ item, @@ -82,14 +82,13 @@ function BaseSelectListItem({ selectionButtonPosition={selectionButtonPosition} > <> - {icon ? ( - - ) : ( - item.leftElement - )} + {item.leftElement ?? + (icon ? ( + + ) : undefined)} { it.each([ ['icons only', {icons: [ICON]}, true, false], ['leftElement only', {leftElement: }, false, true], - ['both icons and leftElement', {icons: [ICON], leftElement: }, true, false], + ['both icons and leftElement', {icons: [ICON], leftElement: }, false, true], ['neither', {}, false, false], ])('with %s renders avatar=%s and custom left element=%s', (_label, itemFields, expectsAvatar, expectsLeftElement) => { renderItem({keyForList: 'row', text: 'Row', ...itemFields}); diff --git a/tests/unit/SingleSelectWithAvatarListItemTest.tsx b/tests/unit/SingleSelectWithAvatarListItemTest.tsx new file mode 100644 index 000000000000..30aaedfbfc3f --- /dev/null +++ b/tests/unit/SingleSelectWithAvatarListItemTest.tsx @@ -0,0 +1,49 @@ +import {render} from '@testing-library/react-native'; + +import AvatarFromIcon from '@components/Avatar/AvatarFromIcon'; +import SingleSelectWithAvatarListItem from '@components/SelectionList/ListItem/SingleSelectWithAvatarListItem'; +import type {ListItem} from '@components/SelectionList/ListItem/types'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +jest.mock('@components/Avatar/AvatarFromIcon', () => jest.fn(() => null)); + +const mockAvatarFromIcon = jest.mocked(AvatarFromIcon); + +const ICON = { + source: 'avatar.png', + type: CONST.ICON_TYPE_AVATAR, + name: 'Test User', + id: 7, +}; + +function renderItem(item: ListItem) { + render( + , + ); +} + +describe('SingleSelectWithAvatarListItem', () => { + beforeEach(() => { + mockAvatarFromIcon.mockClear(); + }); + + it.each([ + ['icons', {icons: [ICON]}, 1], + ['no icons', {}, 0], + ])('with %s renders %s default-size avatar(s)', (_label, itemFields, expectedCalls) => { + renderItem({keyForList: 'row', text: 'Row', ...itemFields}); + + expect(mockAvatarFromIcon).toHaveBeenCalledTimes(expectedCalls); + if (expectedCalls > 0) { + expect(mockAvatarFromIcon.mock.calls.at(0)?.at(0)).toEqual(expect.objectContaining({icon: ICON, size: CONST.AVATAR_SIZE.DEFAULT})); + } + }); +}); From 281eb11dd7a289cc48c826a828e65301c8ce8205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Thu, 17 Sep 2026 13:40:27 +0200 Subject: [PATCH 8/8] Search-row highlight conversions --- .../MoneyRequestReportTransactionItem.tsx | 12 +- .../SearchList/ListItem/ChatListItem.tsx | 3 +- .../ListItem/ExpenseReportListItem.tsx | 9 +- .../ListItem/GroupChildrenContainer.tsx | 7 +- .../SearchList/ListItem/GroupHeader.tsx | 7 +- .../ListItem/TransactionGroupListItem.tsx | 7 +- .../TransactionListItemNarrow.tsx | 9 +- .../TransactionListItemWide.tsx | 8 +- .../hooks/useSearchTableItemHighlight.ts | 15 +-- .../hooks/useListItemHighlight.ts | 19 +-- .../SelectionList/ListItemComposed/index.tsx | 1 - src/hooks/useRowHighlightAnimation.ts | 37 ++++++ .../TransactionMerge/MergeTransactionItem.tsx | 9 +- src/pages/UnreportedExpenseListItem.tsx | 11 +- tests/unit/useListItemHighlightTest.ts | 7 +- tests/unit/useRowHighlightAnimationTest.ts | 109 ++++++++++++++++++ 16 files changed, 188 insertions(+), 82 deletions(-) create mode 100644 src/hooks/useRowHighlightAnimation.ts create mode 100644 tests/unit/useRowHighlightAnimationTest.ts diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx index 81164692a4fa..235c0734ca2f 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx @@ -5,12 +5,11 @@ import type {SearchColumnType, TableColumnSize} from '@components/Search/types'; import TransactionItemRow from '@components/TransactionItemRow'; import {useEditingCellState} from '@components/TransactionItemRow/EditableCell'; -import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; +import useRowHighlightAnimation from '@hooks/useRowHighlightAnimation'; import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionInlineEdit from '@hooks/useTransactionInlineEdit'; @@ -95,7 +94,7 @@ type MoneyRequestReportTransactionItemBodyProps = Omit; + animatedHighlightStyle: ReturnType; shouldSkipDeferRBR?: boolean; }; @@ -318,17 +317,14 @@ function MoneyRequestReportTransactionItem(props: MoneyRequestReportTransactionI const {shouldBeHighlighted} = props; const {isMediumScreenWidth} = useResponsiveLayout(); const {shouldUseNarrowLayout} = useResponsiveLayoutOnWideRHP(); - const theme = useTheme(); // Mirrors the layout check inside TransactionItemRow so the narrow body never pays for useTransactionInlineEdit. const isNarrowLayout = shouldUseNarrowLayout || (isMediumScreenWidth && !props.shouldScrollHorizontally); // Hoisted out of the body so the highlight animation timeline survives the narrow↔wide // component-type swap caused by browser resize. - const animatedHighlightStyle = useAnimatedHighlightStyle({ - borderRadius: shouldUseNarrowLayout ? variables.componentBorderRadius : 0, + const animatedHighlightStyle = useRowHighlightAnimation({ shouldHighlight: shouldBeHighlighted, - highlightColor: theme.messageHighlightBG, - backgroundColor: theme.highlightBG, + borderRadius: shouldUseNarrowLayout ? variables.componentBorderRadius : 0, shouldApplyOtherStyles: !shouldUseNarrowLayout, }); diff --git a/src/components/Search/SearchList/ListItem/ChatListItem.tsx b/src/components/Search/SearchList/ListItem/ChatListItem.tsx index 5abeb93c2f75..fe67d2535a80 100644 --- a/src/components/Search/SearchList/ListItem/ChatListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ChatListItem.tsx @@ -1,5 +1,6 @@ import {useRowSelection} from '@components/Search/SearchSelectionProvider'; -import ListItemComposed, {useListItemHighlight} from '@components/SelectionList/ListItemComposed'; +import ListItemComposed from '@components/SelectionList/ListItemComposed'; +import useListItemHighlight from '@components/SelectionList/ListItemComposed/hooks/useListItemHighlight'; import type {ListItem} from '@components/SelectionList/types'; import useOnyx from '@hooks/useOnyx'; diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx index 7be04a1c8786..44ace41bf457 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx @@ -12,7 +12,6 @@ import ListItemComposed from '@components/SelectionList/ListItemComposed'; import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; -import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useConfirmModal from '@hooks/useConfirmModal'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -22,6 +21,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {useReportPaymentContext} from '@hooks/usePaymentContext'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useRowHighlightAnimation from '@hooks/useRowHighlightAnimation'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -383,11 +383,10 @@ function ExpenseReportListItemInner({ // `tableTopRadius`/`tableBottomRadius` below and squares off the list's outer corners. Skip it for the first // and last rows only, so every other row keeps its existing (already square) behavior. const shouldApplyAnimatedBorderRadius = !isLargeScreenWidth && !isFirstItem && !isLastItem; - const animatedHighlightStyle = useAnimatedHighlightStyle({ - borderRadius: 0, + const animatedHighlightStyle = useRowHighlightAnimation({ shouldHighlight: item?.shouldAnimateInHighlight ?? false, - highlightColor: theme.messageHighlightBG, - backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, + isSelected, + borderRadius: 0, shouldApplyOtherStyles: shouldApplyAnimatedBorderRadius, }); diff --git a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx index 1b46ff6317e8..4c8a1ee31d95 100644 --- a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx +++ b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx @@ -1,5 +1,5 @@ -import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useExpandCollapseAnimation from '@hooks/useExpandCollapseAnimation'; +import useRowHighlightAnimation from '@hooks/useRowHighlightAnimation'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -43,10 +43,9 @@ function GroupChildrenContainer({ // Only the rows this container holds decide its background, so a group still waiting for its first page is not painted as selected. const isSelected = !!item.isSelected || (item.transactions.length > 0 && isSelectAllChecked); - const animatedHighlightStyle = useAnimatedHighlightStyle({ + const animatedHighlightStyle = useRowHighlightAnimation({ shouldHighlight: item?.shouldAnimateInHighlight ?? false, - highlightColor: theme.messageHighlightBG, - backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, + isSelected, shouldApplyOtherStyles: false, }); diff --git a/src/components/Search/SearchList/ListItem/GroupHeader.tsx b/src/components/Search/SearchList/ListItem/GroupHeader.tsx index df582264a537..277e41bd2ac7 100644 --- a/src/components/Search/SearchList/ListItem/GroupHeader.tsx +++ b/src/components/Search/SearchList/ListItem/GroupHeader.tsx @@ -6,13 +6,13 @@ import SearchTableHeader from '@components/Search/SearchTableHeader'; import type {SearchColumnType, SearchCustomColumnIds, SearchGroupBy} from '@components/Search/types'; import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; -import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useExpandCollapseAnimation from '@hooks/useExpandCollapseAnimation'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useOnyx from '@hooks/useOnyx'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useRowHighlightAnimation from '@hooks/useRowHighlightAnimation'; import useStyleUtils from '@hooks/useStyleUtils'; import useSyncFocus from '@hooks/useSyncFocus'; import useTheme from '@hooks/useTheme'; @@ -184,10 +184,9 @@ function GroupHeader({ keyForList: item.groupKeyForList, }); - const animatedHighlightStyle = useAnimatedHighlightStyle({ + const animatedHighlightStyle = useRowHighlightAnimation({ shouldHighlight: item?.shouldAnimateInHighlight ?? false, - highlightColor: theme.messageHighlightBG, - backgroundColor: isItemSelected ? theme.activeComponentBG : theme.highlightBG, + isSelected: isItemSelected, shouldApplyOtherStyles: false, }); diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index 26f8021dab43..a1af75949f36 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -6,11 +6,11 @@ import {useRowSelection} from '@components/Search/SearchSelectionProvider'; import type {SearchGroupBy} from '@components/Search/types'; import type {ListItem} from '@components/SelectionList/types'; -import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useRowHighlightAnimation from '@hooks/useRowHighlightAnimation'; import useStyleUtils from '@hooks/useStyleUtils'; import useSyncFocus from '@hooks/useSyncFocus'; import useTheme from '@hooks/useTheme'; @@ -199,10 +199,9 @@ function TransactionGroupListItemImpl({ const {isSelected: liveRowSelected} = useRowSelection(item?.keyForList); const isItemSelected = isSelectAllChecked || (liveRowSelected && (isExpenseReportType || transactionsWithoutPendingDelete.length === 0)); - const animatedHighlightStyle = useAnimatedHighlightStyle({ + const animatedHighlightStyle = useRowHighlightAnimation({ shouldHighlight: item?.shouldAnimateInHighlight ?? false, - highlightColor: theme.messageHighlightBG, - backgroundColor: isItemSelected ? theme.activeComponentBG : theme.highlightBG, + isSelected: isItemSelected, shouldApplyOtherStyles: false, }); diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx index 697eed254b32..e6a57f5a7a58 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx @@ -6,7 +6,7 @@ import {useRowSelection} from '@components/Search/SearchSelectionProvider'; import type {ListItem} from '@components/SelectionList/types'; import TransactionItemRow from '@components/TransactionItemRow'; -import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; +import useRowHighlightAnimation from '@hooks/useRowHighlightAnimation'; import useStyleUtils from '@hooks/useStyleUtils'; import useSyncFocus from '@hooks/useSyncFocus'; import useTheme from '@hooks/useTheme'; @@ -82,11 +82,10 @@ function TransactionListItemNarrow({ // The animated style is applied inline, so the `borderRadius: 0` it carries wins over the static // `tableTopRadius`/`tableBottomRadius` on the wrapper below. Skip it for the first and last rows only, // so every other row keeps its existing (already square) behavior. - const animatedHighlightStyle = useAnimatedHighlightStyle({ - borderRadius: 0, + const animatedHighlightStyle = useRowHighlightAnimation({ shouldHighlight: item?.shouldAnimateInHighlight ?? false, - highlightColor: theme.messageHighlightBG, - backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, + isSelected, + borderRadius: 0, shouldApplyOtherStyles: !isFirstItem && !isLastItem, }); diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx index d94d0c3d3cf4..700e2dec8c67 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx @@ -6,7 +6,7 @@ import type {ListItem} from '@components/SelectionList/types'; import TransactionItemRow from '@components/TransactionItemRow'; import {useEditingCellState} from '@components/TransactionItemRow/EditableCell'; -import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; +import useRowHighlightAnimation from '@hooks/useRowHighlightAnimation'; import useStyleUtils from '@hooks/useStyleUtils'; import useSyncFocus from '@hooks/useSyncFocus'; import useTheme from '@hooks/useTheme'; @@ -143,11 +143,9 @@ function TransactionListItemWide({ }, ]; - const animatedHighlightStyle = useAnimatedHighlightStyle({ - borderRadius: 0, + const animatedHighlightStyle = useRowHighlightAnimation({ shouldHighlight: item?.shouldAnimateInHighlight ?? false, - highlightColor: theme.messageHighlightBG, - backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, + isSelected, shouldApplyOtherStyles: false, }); diff --git a/src/components/Search/SearchList/ListItem/hooks/useSearchTableItemHighlight.ts b/src/components/Search/SearchList/ListItem/hooks/useSearchTableItemHighlight.ts index 9aa24248e8d4..795ad27641b2 100644 --- a/src/components/Search/SearchList/ListItem/hooks/useSearchTableItemHighlight.ts +++ b/src/components/Search/SearchList/ListItem/hooks/useSearchTableItemHighlight.ts @@ -1,7 +1,6 @@ -import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useRowHighlightAnimation from '@hooks/useRowHighlightAnimation'; import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; @@ -17,21 +16,15 @@ type UseSearchTableItemHighlightParams = { isLastItem?: boolean; }; -/** - * Search-table flavor of `useListItemHighlight`: bundles the highlight animation with the pressable - * styles a table row needs (table row paddings, bottom radius on the last wide-screen row). - */ +/** Highlight animation plus the pressable styles a search-table row needs: row paddings, bottom radius on the last wide row. */ function useSearchTableItemHighlight({shouldHighlight = false, isSelected = false, isLastItem = false}: UseSearchTableItemHighlightParams = {}) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const theme = useTheme(); const {isLargeScreenWidth} = useResponsiveLayout(); - const animatedHighlightStyle = useAnimatedHighlightStyle({ - borderRadius: StyleUtils.getSearchTableHighlightBorderRadius(isLargeScreenWidth), + const animatedHighlightStyle = useRowHighlightAnimation({ shouldHighlight, - highlightColor: theme.messageHighlightBG, - backgroundColor: theme.highlightBG, + borderRadius: StyleUtils.getSearchTableHighlightBorderRadius(isLargeScreenWidth), shouldApplyOtherStyles: !isLargeScreenWidth, }); diff --git a/src/components/SelectionList/ListItemComposed/hooks/useListItemHighlight.ts b/src/components/SelectionList/ListItemComposed/hooks/useListItemHighlight.ts index f81e07d3625a..eba9cd9c1aab 100644 --- a/src/components/SelectionList/ListItemComposed/hooks/useListItemHighlight.ts +++ b/src/components/SelectionList/ListItemComposed/hooks/useListItemHighlight.ts @@ -1,5 +1,4 @@ -import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; -import useTheme from '@hooks/useTheme'; +import useRowHighlightAnimation from '@hooks/useRowHighlightAnimation'; import useThemeStyles from '@hooks/useThemeStyles'; type UseListItemHighlightParams = { @@ -10,23 +9,11 @@ type UseListItemHighlightParams = { isSelected?: boolean; }; -/** - * Bundles the highlight animation with the pressable styles a row needs so the highlight rests on - * the right background. Search-table rows use `useSearchTableItemHighlight` instead; rows owning - * their own pressable layout (grouped and wide/narrow search rows) take `animatedHighlightStyle` - * alone and ignore the pressable bundle. - */ +/** Highlight animation plus the pressable styles that let it show through. */ function useListItemHighlight({shouldHighlight = false, isSelected = false}: UseListItemHighlightParams = {}) { const styles = useThemeStyles(); - const theme = useTheme(); - const animatedHighlightStyle = useAnimatedHighlightStyle({ - borderRadius: styles.selectionListPressableItemWrapper.borderRadius, - shouldHighlight, - highlightColor: theme.messageHighlightBG, - backgroundColor: theme.highlightBG, - shouldApplyOtherStyles: true, - }); + const animatedHighlightStyle = useRowHighlightAnimation({shouldHighlight, borderRadius: styles.selectionListPressableItemWrapper.borderRadius}); return { animatedHighlightStyle, diff --git a/src/components/SelectionList/ListItemComposed/index.tsx b/src/components/SelectionList/ListItemComposed/index.tsx index a6ed837e290a..011c4d6e7310 100644 --- a/src/components/SelectionList/ListItemComposed/index.tsx +++ b/src/components/SelectionList/ListItemComposed/index.tsx @@ -48,4 +48,3 @@ const ListItem = Object.assign(ListItemPressable, { }); export default ListItem; -export {default as useListItemHighlight} from './hooks/useListItemHighlight'; diff --git a/src/hooks/useRowHighlightAnimation.ts b/src/hooks/useRowHighlightAnimation.ts new file mode 100644 index 000000000000..c7963785eb1d --- /dev/null +++ b/src/hooks/useRowHighlightAnimation.ts @@ -0,0 +1,37 @@ +import variables from '@styles/variables'; + +import useAnimatedHighlightStyle from './useAnimatedHighlightStyle'; +import useTheme from './useTheme'; + +type UseRowHighlightAnimationParams = { + /** Whether the row should play the highlight animation */ + shouldHighlight?: boolean; + + /** Selected rows rest on activeComponentBG instead of highlightBG */ + isSelected?: boolean; + + borderRadius?: number; + + /** Carry height and border radius in the animated style. False for rows that round their own corners */ + shouldApplyOtherStyles?: boolean; +}; + +/** Highlight flash for a list row in theme colors, returned as the style for the row's pressable wrapper. */ +function useRowHighlightAnimation({ + shouldHighlight = false, + isSelected = false, + borderRadius = variables.componentBorderRadius, + shouldApplyOtherStyles = true, +}: UseRowHighlightAnimationParams = {}) { + const theme = useTheme(); + + return useAnimatedHighlightStyle({ + borderRadius, + shouldHighlight, + highlightColor: theme.messageHighlightBG, + backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, + shouldApplyOtherStyles, + }); +} + +export default useRowHighlightAnimation; diff --git a/src/pages/TransactionMerge/MergeTransactionItem.tsx b/src/pages/TransactionMerge/MergeTransactionItem.tsx index 7edbaf669152..94777cbc64f0 100644 --- a/src/pages/TransactionMerge/MergeTransactionItem.tsx +++ b/src/pages/TransactionMerge/MergeTransactionItem.tsx @@ -4,9 +4,9 @@ import type {TransactionListItemType} from '@components/Search/SearchList/ListIt import type {ListItem, ListItemProps} from '@components/SelectionList/ListItem/types'; import TransactionItemRow from '@components/TransactionItemRow'; -import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; +import useRowHighlightAnimation from '@hooks/useRowHighlightAnimation'; import useStyleUtils from '@hooks/useStyleUtils'; import useSyncFocus from '@hooks/useSyncFocus'; import useTheme from '@hooks/useTheme'; @@ -26,12 +26,7 @@ function MergeTransactionItem({item, isFocused, showTool const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.reportID}`); const policy = usePolicy(report?.policyID); - const animatedHighlightStyle = useAnimatedHighlightStyle({ - borderRadius: 0, - shouldHighlight: item?.shouldAnimateInHighlight ?? false, - highlightColor: theme.messageHighlightBG, - backgroundColor: theme.highlightBG, - }); + const animatedHighlightStyle = useRowHighlightAnimation({shouldHighlight: item?.shouldAnimateInHighlight ?? false, borderRadius: 0}); const StyleUtils = useStyleUtils(); const pressableRef = useRef(null); diff --git a/src/pages/UnreportedExpenseListItem.tsx b/src/pages/UnreportedExpenseListItem.tsx index ed3cf4b9bace..28aa76b53a5d 100644 --- a/src/pages/UnreportedExpenseListItem.tsx +++ b/src/pages/UnreportedExpenseListItem.tsx @@ -5,9 +5,9 @@ import type {ListItemProps} from '@components/SelectionList/ListItem/types'; import type {ListItem} from '@components/SelectionList/types'; import TransactionItemRow from '@components/TransactionItemRow'; -import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; +import useRowHighlightAnimation from '@hooks/useRowHighlightAnimation'; import useStyleUtils from '@hooks/useStyleUtils'; import useSyncFocus from '@hooks/useSyncFocus'; import useTheme from '@hooks/useTheme'; @@ -15,8 +15,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import variables from '@styles/variables'; - import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {TransactionViolation} from '@src/types/onyx'; @@ -48,12 +46,7 @@ function UnreportedExpenseListItem({ const pressableStyle = [styles.transactionListItemStyle, isSelected && styles.activeComponentBG]; - const animatedHighlightStyle = useAnimatedHighlightStyle({ - borderRadius: variables.componentBorderRadius, - shouldHighlight: item?.shouldAnimateInHighlight ?? false, - highlightColor: theme.messageHighlightBG, - backgroundColor: theme.highlightBG, - }); + const animatedHighlightStyle = useRowHighlightAnimation({shouldHighlight: item?.shouldAnimateInHighlight ?? false}); const StyleUtils = useStyleUtils(); const pressableRef = useRef(null); diff --git a/tests/unit/useListItemHighlightTest.ts b/tests/unit/useListItemHighlightTest.ts index 8b8ddd735dd0..5854b6965791 100644 --- a/tests/unit/useListItemHighlightTest.ts +++ b/tests/unit/useListItemHighlightTest.ts @@ -27,8 +27,11 @@ describe('useListItemHighlight', () => { mockUseAnimatedHighlightStyle.mockClear(); }); - it('configures the animation with the selection list border radius and full style application', () => { - const {styles, theme} = renderHighlightHook({shouldHighlight: true}); + it.each<[string, HookParams]>([ + ['unselected', {shouldHighlight: true}], + ['selected', {shouldHighlight: true, isSelected: true}], + ])('configures the animation with the selection list border radius, the resting background and full style application when %s', (_name, params) => { + const {styles, theme} = renderHighlightHook(params); expect(mockUseAnimatedHighlightStyle).toHaveBeenCalledWith({ borderRadius: styles.selectionListPressableItemWrapper.borderRadius, diff --git a/tests/unit/useRowHighlightAnimationTest.ts b/tests/unit/useRowHighlightAnimationTest.ts new file mode 100644 index 000000000000..e124cccdff3b --- /dev/null +++ b/tests/unit/useRowHighlightAnimationTest.ts @@ -0,0 +1,109 @@ +import {renderHook} from '@testing-library/react-native'; + +import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; +import useRowHighlightAnimation from '@hooks/useRowHighlightAnimation'; +import useTheme from '@hooks/useTheme'; + +import variables from '@styles/variables'; + +const animatedHighlightStyleMock = {backgroundColor: 'animated-highlight'}; +jest.mock('@hooks/useAnimatedHighlightStyle', () => jest.fn(() => animatedHighlightStyleMock)); + +const mockUseAnimatedHighlightStyle = jest.mocked(useAnimatedHighlightStyle); + +type HookParams = Parameters[0]; +type AnimationParams = Parameters[0]; +type ExpectedAnimation = (theme: ReturnType) => AnimationParams; + +function renderRowHighlightAnimation(params?: HookParams) { + const {result} = renderHook(() => ({ + theme: useTheme(), + highlightStyle: useRowHighlightAnimation(params), + })); + return result.current; +} + +describe('useRowHighlightAnimation', () => { + beforeEach(() => { + mockUseAnimatedHighlightStyle.mockClear(); + }); + + it.each<[string, HookParams, ExpectedAnimation]>([ + [ + 'defaults to the component radius, the resting background and full style application', + {shouldHighlight: true}, + (theme) => ({ + borderRadius: variables.componentBorderRadius, + shouldHighlight: true, + highlightColor: theme.messageHighlightBG, + backgroundColor: theme.highlightBG, + shouldApplyOtherStyles: true, + }), + ], + [ + 'rests a selected row on the selected background', + {isSelected: true}, + (theme) => ({ + borderRadius: variables.componentBorderRadius, + shouldHighlight: false, + highlightColor: theme.messageHighlightBG, + backgroundColor: theme.activeComponentBG, + shouldApplyOtherStyles: true, + }), + ], + [ + 'keeps the resting background for an unselected row', + {isSelected: false}, + (theme) => ({ + borderRadius: variables.componentBorderRadius, + shouldHighlight: false, + highlightColor: theme.messageHighlightBG, + backgroundColor: theme.highlightBG, + shouldApplyOtherStyles: true, + }), + ], + [ + 'skips the layout styles for rows that round their own corners', + {shouldApplyOtherStyles: false}, + (theme) => ({ + borderRadius: variables.componentBorderRadius, + shouldHighlight: false, + highlightColor: theme.messageHighlightBG, + backgroundColor: theme.highlightBG, + shouldApplyOtherStyles: false, + }), + ], + [ + 'passes a custom radius for rows that square their edges', + {borderRadius: 0}, + (theme) => ({ + borderRadius: 0, + shouldHighlight: false, + highlightColor: theme.messageHighlightBG, + backgroundColor: theme.highlightBG, + shouldApplyOtherStyles: true, + }), + ], + [ + 'combines every search-row flag: squared corners, no layout styles, selected background', + {shouldHighlight: true, isSelected: true, borderRadius: 0, shouldApplyOtherStyles: false}, + (theme) => ({ + borderRadius: 0, + shouldHighlight: true, + highlightColor: theme.messageHighlightBG, + backgroundColor: theme.activeComponentBG, + shouldApplyOtherStyles: false, + }), + ], + ])('%s', (_name, params, expected) => { + const {theme} = renderRowHighlightAnimation(params); + + expect(mockUseAnimatedHighlightStyle).toHaveBeenCalledWith(expected(theme)); + }); + + it('returns the animated highlight style', () => { + const {highlightStyle} = renderRowHighlightAnimation(); + + expect(highlightStyle).toBe(animatedHighlightStyleMock); + }); +});