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/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 78036e9d9532..712ed46bb40e 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} @@ -535,7 +534,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/Search/SearchList/ListItem/ChatListItem.tsx b/src/components/Search/SearchList/ListItem/ChatListItem.tsx index 397a0202231f..fe67d2535a80 100644 --- a/src/components/Search/SearchList/ListItem/ChatListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ChatListItem.tsx @@ -1,6 +1,6 @@ import {useRowSelection} from '@components/Search/SearchSelectionProvider'; -import BaseListItem from '@components/SelectionList/ListItem/BaseListItem'; -import {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'; @@ -15,6 +15,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 +50,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 b6e3a2f5ee27..44ace41bf457 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx @@ -8,11 +8,10 @@ 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'; -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'; @@ -379,24 +379,14 @@ 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. 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, }); @@ -478,7 +468,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} @@ -507,41 +496,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 cf216fb0c3d6..b4fb3ddb3998 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 fad263da4057..8ba52850ace0 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx @@ -11,6 +11,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 useTheme from '@hooks/useTheme'; @@ -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]: ( @@ -306,7 +298,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/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/SearchMergeReportsListItem.tsx b/src/components/Search/SearchList/ListItem/SearchMergeReportsListItem.tsx index 12b3fe29f425..823f752bfc6c 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/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/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index a7d13cb0fcd9..f8d5b1672829 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, @@ -323,7 +322,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/BaseListItem.tsx b/src/components/SelectionList/ListItem/BaseListItem.tsx deleted file mode 100644 index 07921df350a6..000000000000 --- a/src/components/SelectionList/ListItem/BaseListItem.tsx +++ /dev/null @@ -1,218 +0,0 @@ -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 = CONST.ROLE.BUTTON, - 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..ebe712fda57e 100644 --- a/src/components/SelectionList/ListItem/BaseSelectListItem.tsx +++ b/src/components/SelectionList/ListItem/BaseSelectListItem.tsx @@ -10,12 +10,13 @@ import CONST from '@src/CONST'; 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'; /** * A text-only row with a title and optional subtitle. Serves as the base for SingleSelectListItem and MultiSelectListItem. + * 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, @@ -25,7 +26,6 @@ function BaseSelectListItem({ onSelectRow, onDismissError, shouldPreventEnterKeySubmit, - rightHandSideComponent, isMultilineSupported = false, isAlternateTextMultilineSupported = false, alternateTextNumberOfLines = 2, @@ -39,11 +39,10 @@ function BaseSelectListItem({ isFocusVisible, accessibilityRole, selectionButtonPosition, - leftElement, -}: BaseSelectListItemProps) { +}: ListItemProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const rowLeftElement = leftElement ?? item.leftElement; + 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; @@ -75,7 +74,6 @@ function BaseSelectListItem({ onSelectRow={onSelectRow} onDismissError={onDismissError} shouldPreventEnterKeySubmit={shouldPreventEnterKeySubmit} - rightHandSideComponent={rightHandSideComponent} canSelectMultiple={canSelectMultiple} onFocus={onFocus} shouldSyncFocus={shouldSyncFocus} @@ -84,7 +82,13 @@ function BaseSelectListItem({ selectionButtonPosition={selectionButtonPosition} > <> - {rowLeftElement} + {item.leftElement ?? + (icon ? ( + + ) : undefined)} ({ 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 6e491c3e0e20..f7b63bb24d4b 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, @@ -103,7 +102,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 f1228a669fe5..b5bf99322719 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({ @@ -41,7 +35,6 @@ function ListItemRenderer({ selectRow, onSelectionButtonPress, onDismissError, - rightHandSideComponent, isMultilineSupported, isAlternateTextMultilineSupported, alternateTextNumberOfLines, @@ -89,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/MultiSelectListItem.tsx b/src/components/SelectionList/ListItem/MultiSelectListItem.tsx index 32a82659a750..3aa59d6efeb8 100644 --- a/src/components/SelectionList/ListItem/MultiSelectListItem.tsx +++ b/src/components/SelectionList/ListItem/MultiSelectListItem.tsx @@ -1,12 +1,10 @@ -import ListItemComposed from '@components/SelectionList/ListItemComposed'; - import useThemeStyles from '@hooks/useThemeStyles'; 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 +30,7 @@ function MultiSelectListItem({ titleStyles, shouldHighlightSelectedItem, titleNumberOfLines, -}: MultiSelectListItemProps) { +}: ListItemProps) { const styles = useThemeStyles(); const icon = item.icons?.at(0); @@ -41,14 +39,6 @@ function MultiSelectListItem({ return ( - ) : undefined - } isFocused={isFocused} isFocusVisible={isFocusVisible} showTooltip={showTooltip} diff --git a/src/components/SelectionList/ListItem/SelectableListItem.tsx b/src/components/SelectionList/ListItem/SelectableListItem.tsx index dbd3adc3c251..bd4b5029acb4 100644 --- a/src/components/SelectionList/ListItem/SelectableListItem.tsx +++ b/src/components/SelectionList/ListItem/SelectableListItem.tsx @@ -21,12 +21,10 @@ function SelectableListItem({ onSelectRow, isDisabled = false, children, - rightHandSideComponent, isFocused, isSelected, showTooltip, wrapperStyle, - testID, forwardedFSClass, pressableStyle, pressableWrapperStyle, @@ -81,7 +79,6 @@ function SelectableListItem({ shouldUseOptionRole={shouldUseOptionRole} > @@ -89,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/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..0c318fbb5ae6 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. @@ -48,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 8fba42ddf76a..bf9f22caab17 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(); @@ -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 be8c1bd36d4a..31b064d9276e 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; @@ -68,9 +55,16 @@ 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 (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) */ icons?: Icon[]; @@ -148,6 +142,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; @@ -181,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; @@ -193,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; @@ -218,8 +210,6 @@ type ListItemProps = CommonListItemProps & { */ shouldSyncFocus?: boolean; - shouldDisplayRBR?: boolean; - titleStyles?: StyleProp; titleContainerStyles?: StyleProp; shouldHighlightSelectedItem?: boolean; @@ -239,34 +229,20 @@ 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 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 */ + 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 +251,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 +268,19 @@ 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 & { + /** 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 the item's action element */ + 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 +317,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 +331,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 6a91c0eafb81..54439eabcf00 100644 --- a/src/components/SelectionList/ListItemComposed/ListItemPressable.tsx +++ b/src/components/SelectionList/ListItemComposed/ListItemPressable.tsx @@ -1,6 +1,6 @@ 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'; @@ -16,43 +16,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 @@ -62,6 +29,7 @@ function ListItemPressable({ item, pressableStyle, pressableWrapperStyle, + containerStyle, isDisabled = false, shouldPreventEnterKeySubmit = false, canSelectMultiple = false, @@ -145,6 +113,7 @@ function ListItemPressable({ pendingAction={item.pendingAction} errors={item.errors} errorRowStyles={[styles.mh5, errorRowStyles]} + contentContainerStyle={containerStyle} > ({ > ({ + 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 c341c3cafb9a..237dd3689e26 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -64,7 +64,6 @@ function BaseSelectionListWithSectionsImpl({ onEndReachedThreshold, customListHeaderContent, customHeaderContent, - rightHandSideComponent, listEmptyContent, footerContent, listFooterContent, @@ -314,7 +313,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 6e59ce000ad4..97d2d6c69737 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -1,16 +1,40 @@ +import type {SearchRouterItem} from '@components/Search/SearchAutocompleteList'; import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; 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'; -import type {ListItem, ValidListItem} from './ListItem/types'; +import type BareUserListItem from './ListItem/BareUserListItem'; +import type InviteMemberListItem from './ListItem/InviteMemberListItem'; +import type MultiSelectListItem from './ListItem/MultiSelectListItem'; +import type SingleSelectListItem from './ListItem/SingleSelectListItem'; +import type SingleSelectWithAvatarListItem from './ListItem/SingleSelectWithAvatarListItem'; +import type SpendCategorySelectorListItem from './ListItem/SpendCategorySelectorListItem'; +import type SplitListItem from './ListItem/SplitListItem'; +import type TravelDomainListItem from './ListItem/TravelDomainListItem'; +import type {ListItem} from './ListItem/types'; +import type UserListItem from './ListItem/UserListItem'; +import type UserSelectionListItem from './ListItem/UserSelectionListItem'; import type {SelectionListWithSectionsHandle, SelectionListWithSectionsProps} from './SelectionListWithSections/types'; +type ValidListItem = + | typeof InviteMemberListItem + | typeof MultiSelectListItem + | typeof SearchRouterItem + | typeof SingleSelectListItem + | typeof SingleSelectWithAvatarListItem + | typeof SpendCategorySelectorListItem + | typeof SplitListItem + | typeof TravelDomainListItem + | typeof BareUserListItem + | typeof UserListItem + | typeof UserSelectionListItem; + /** * Base props shared between SelectionList and SelectionListWithSections. * Contains common configuration for list behavior, styling, and callbacks. @@ -23,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/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/NewChatPage/AddToGroupButton.tsx b/src/pages/NewChatPage/AddToGroupButton.tsx new file mode 100644 index 000000000000..b43273e4523c --- /dev/null +++ b/src/pages/NewChatPage/AddToGroupButton.tsx @@ -0,0 +1,40 @@ +import Button from '@components/Button'; +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. */ +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 4dc9b4ecbd1f..975f93e89d80 100755 --- a/src/pages/NewChatPage/index.tsx +++ b/src/pages/NewChatPage/index.tsx @@ -47,6 +47,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)); @@ -319,9 +320,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) { @@ -340,23 +341,22 @@ 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 ( - + ); }; + 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) { @@ -417,7 +417,7 @@ function NewChatPage({ref}: NewChatPageProps) { ref={selectionListRef} ListItem={BareUserListItem} - sections={areOptionsInitialized ? sections : getEmptyArray>()} + sections={areOptionsInitialized ? sectionsWithRowActions : getEmptyArray>()} onSelectRow={selectOption} shouldShowTextInput textInputOptions={textInputOptions} @@ -430,7 +430,6 @@ function NewChatPage({ref}: NewChatPageProps) { onConfirm: (e, option) => (latestSelectedOptionsRef.current.length > 0 ? createGroup() : selectOption(option)), isFooterConfirmEnabled: selectedOptions.length > 0, }} - rightHandSideComponent={itemRightSideComponent} footerContent={footerContent} shouldShowLoadingPlaceholder={!areOptionsInitialized} shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} 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/src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx b/src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx index fb9e6635095c..90dbeab4e3cc 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 useConfirmModal from '@hooks/useConfirmModal'; @@ -56,32 +55,12 @@ function UnshareBankAccount({route}: ShareBankAccountProps) { const totalAdmins = bankAccountList?.[bankAccountID]?.accountData?.sharees?.length; const adminEmails = admins?.filter((admin) => admin !== currentUserPersonalDetails?.email) ?? []; const adminPersonalDetails = usePersonalDetailsByLogins(adminEmails); - const adminsWithInfo = adminEmails.map((admin) => { - const personalDetails = adminPersonalDetails[admin]; - const formattedAdmin = formatMemberForList({ - text: personalDetails?.displayName, - alternateText: personalDetails?.login, - keyForList: personalDetails?.login ?? '', - accountID: personalDetails?.accountID, - login: personalDetails?.login, - pendingAction: personalDetails?.pendingAction, - reportID: '', - }); - return {...formattedAdmin, isInteractive: false}; - }); - - let adminsList = adminsWithInfo; - if (debouncedSearchTerm) { - const searchValue = getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode).toLowerCase(); - adminsList = tokenizedSearch(adminsWithInfo, searchValue, (option) => [option.text ?? '', option.alternateText ?? '']); - } - + const isLoading = unsharedBankAccountData?.isLoading ?? false; const error = getLatestErrorMessage(bankAccountList?.[bankAccountID] ?? {}); const isExpensifyCardError = error?.includes(CONST.EXPENSIFY_CARD.BANK); 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; const isExpensifyCardSettlementAccountRef = useRef(isExpensifyCardSettlementAccount); @@ -151,39 +130,58 @@ function UnshareBankAccount({route}: ShareBankAccountProps) { unshareBankAccount(Number(bankAccountID), unshareUser.login); }; - const itemRightSideComponent = (item: ListItem) => { - const promptUnshare = () => { - showConfirmModal({ - title: translate('common.areYouSure'), - prompt: translate('walletPage.unshareBankAccountWarning', {admin: item?.text}), - confirmText: translate('common.unshare'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }).then((result) => { - if (result.action !== ModalActions.CONFIRM) { - return; - } - - // Chained here so this modal is off the stack before the error modal can be pushed on top of it. - handleUnshare({login: item?.login, text: item?.text}); - }); - }; - const isUnshareButtonLoading = isLoading && unsharedBankAccountData?.email === item?.login; - - return ( - - ); + const promptUnshare = (unshareUser: {login?: string | null; text?: string | null}) => { + showConfirmModal({ + title: translate('common.areYouSure'), + prompt: translate('walletPage.unshareBankAccountWarning', {admin: unshareUser.text}), + confirmText: translate('common.unshare'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + + // Chained here so this modal is off the stack before the error modal can be pushed on top of it. + handleUnshare(unshareUser); + }); }; + const adminsWithInfo = adminEmails.map((admin) => { + const personalDetails = adminPersonalDetails[admin]; + const formattedAdmin = formatMemberForList({ + text: personalDetails?.displayName, + alternateText: personalDetails?.login, + keyForList: personalDetails?.login ?? '', + accountID: personalDetails?.accountID, + login: personalDetails?.login, + pendingAction: personalDetails?.pendingAction, + reportID: '', + }); + return { + ...formattedAdmin, + isInteractive: false, + actionElement: ( + + ), + }; + }); + + let adminsList = adminsWithInfo; + if (debouncedSearchTerm) { + const searchValue = getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode).toLowerCase(); + adminsList = tokenizedSearch(adminsWithInfo, searchValue, (option) => [option.text ?? '', option.alternateText ?? '']); + } + const onButtonPress = () => Navigation.goBack(ROUTES.SETTINGS_WALLET); const getHeaderSearchMessage = () => { @@ -210,7 +208,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,11 @@ 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; + + const isPlanTypeLocked = isControl && isAnnual && !policy.canDowngrade; + const isCurrentPolicySubmit = isSubmitPolicy(policy); const workspacePlanTypes = Object.values(CONST.POLICY.TYPE) .filter((type) => { @@ -85,26 +92,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 +175,6 @@ function DynamicWorkspaceOverviewPlanTypePage({policy}: WithPolicyProps) { onSelectRow={(option) => { setCurrentPlan(option.value); }} - rightHandSideComponent={isPlanTypeLocked ? lockedIcon : null} shouldUpdateFocusedIndex shouldSingleExecuteRowSelect shouldIgnoreFocus diff --git a/tests/ui/SplitListItemTest.tsx b/tests/ui/SplitListItemTest.tsx index 597ea0f1abf7..e5061a968ec0 100644 --- a/tests/ui/SplitListItemTest.tsx +++ b/tests/ui/SplitListItemTest.tsx @@ -1,4 +1,4 @@ -import {render, screen} from '@testing-library/react-native'; +import {fireEvent, render, screen} from '@testing-library/react-native'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import SplitListItem from '@components/SelectionList/ListItem/SplitListItem'; @@ -19,7 +19,7 @@ jest.mock('@hooks/useCurrencyList', () => ({ 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/unit/ListItemAvatarPrimitivesTest.tsx b/tests/unit/ListItemAvatarPrimitivesTest.tsx index 3f86338a5a1e..16995b4319bb 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/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/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/MultiSelectListItemTest.tsx b/tests/unit/MultiSelectListItemTest.tsx index 7d26a479c3a9..62f1b9173107 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(({leftElement}: {leftElement?: React.ReactNode}) => 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('leaves leftElement undefined when there are no icons, so the item value is used', () => { - const customLeftElement = ; - const props = renderItem({keyForList: 'row', text: 'Row', leftElement: customLeftElement}); - - expect(props?.leftElement).toBeUndefined(); - 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: }, 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}); + + 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); }); }); 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/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})); + } + }); +}); 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); + }); +}); 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/useListItemBackdropColorTest.tsx b/tests/unit/useListItemBackdropColorTest.tsx index 5f62232c3d57..3bc9ee8e4b11 100644 --- a/tests/unit/useListItemBackdropColorTest.tsx +++ b/tests/unit/useListItemBackdropColorTest.tsx @@ -25,7 +25,7 @@ jest.mock('@hooks/useThemeStyles', () => ({ const renderBackdropColor = ({isFocusVisible, isHovered}: {isFocusVisible: boolean; isHovered: boolean}) => renderHook(() => useListItemBackdropColor(), { wrapper: ({children}) => ( - + {children} ), 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); + }); +});