diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportGroupHeader.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportGroupHeader.tsx index 6f092d9df62d..55a0525823df 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportGroupHeader.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportGroupHeader.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type {GestureResponderEvent} from 'react-native'; import {View} from 'react-native'; import Checkbox from '@components/Checkbox'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -10,6 +11,8 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {getDecodedLeafCategoryName} from '@libs/CategoryUtils'; import {getCommaSeparatedTagNameWithSanitizedColons} from '@libs/PolicyUtils'; +import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {GroupedTransactions} from '@src/types/onyx'; @@ -41,7 +44,7 @@ type MoneyRequestReportGroupHeaderProps = { isDisabled?: boolean; /** Callback when group checkbox is toggled - receives groupKey */ - onToggleSelection?: (groupKey: string) => void; + onToggleSelection?: (groupKey: string, options?: Partial) => void; /** Pending action for offline feedback styling (Pattern B - Optimistic WITH Feedback) */ pendingAction?: PendingAction; @@ -77,8 +80,8 @@ function MoneyRequestReportGroupHeader({ const textStyle = shouldUseNarrowLayout ? {fontSize: variables.fontSizeLabel, lineHeight: 16} : [styles.labelStrong]; - const handleToggleSelection = () => { - onToggleSelection?.(groupKey); + const handleToggleSelection = (event?: GestureResponderEvent | KeyboardEvent) => { + onToggleSelection?.(groupKey, getModifierKeysFromEvent(event)); }; const groupHeaderStyle = !shouldUseNarrowLayout diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx index 025137635e5d..64b39705050f 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx @@ -18,6 +18,7 @@ import useTransactionInlineEdit from '@hooks/useTransactionInlineEdit'; import ControlSelection from '@libs/ControlSelection'; import canUseTouchScreen from '@libs/DeviceCapabilities/canUseTouchScreen'; import {hasFlexColumn} from '@libs/SearchUIUtils'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import {getTransactionPendingAction, isTransactionPendingDelete} from '@libs/TransactionUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -41,7 +42,7 @@ type MoneyRequestReportTransactionItemProps = { isSelectionModeEnabled: boolean; /** Callback function triggered upon pressing a transaction checkbox. */ - toggleTransaction: (transactionID: string) => void; + toggleTransaction: (transactionID: string, options?: Partial) => void; /** Callback function triggered upon pressing a transaction. */ handleOnPress: (transactionID: string) => void; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index e60b5f5bd5a4..0a735216398c 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -33,6 +33,7 @@ import useOnyx from '@hooks/useOnyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; +import useShiftRangeSelection from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -62,6 +63,8 @@ import { } from '@libs/ReportUtils'; import type {SortableColumnName} from '@libs/ReportUtils'; import {compareValues, getColumnsToShow, getTableMinWidth, hasFlexColumn, isTransactionAmountTooLong, isTransactionTaxAmountTooLong} from '@libs/SearchUIUtils'; +import {applyShiftRangeBatchToKeySet, farthestEndFromAnchor} from '@libs/shiftRangeSelection'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import {getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; import {transactionHasRBR} from '@libs/TransactionPreviewUtils'; import {getTransactionPendingAction, getVisibleTransactionViolations, isTransactionPendingDelete, shouldShowExpenseBreakdown} from '@libs/TransactionUtils'; @@ -251,19 +254,6 @@ function MoneyRequestReportTransactionList({ useHandleSelectionMode(selectedTransactionIDs); const isMobileSelectionModeEnabled = useMobileSelectionMode(); - const toggleTransaction = useCallback( - (transactionID: string) => { - let newSelectedTransactionIDs = selectedTransactionIDs; - if (selectedTransactionIDs.includes(transactionID)) { - newSelectedTransactionIDs = selectedTransactionIDs.filter((t) => t !== transactionID); - } else { - newSelectedTransactionIDs = [...selectedTransactionIDs, transactionID]; - } - setSelectedTransactions(newSelectedTransactionIDs); - }, - [setSelectedTransactions, selectedTransactionIDs], - ); - const isTransactionSelected = useCallback((transactionID: string) => selectedTransactionIDs.includes(transactionID), [selectedTransactionIDs]); useFocusEffect( @@ -451,12 +441,46 @@ function MoneyRequestReportTransactionList({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [resolvedTransactions, currentGroupBy, report?.reportID, report?.currency, localeCompare, shouldShowGroupedTransactions]); - const visualOrderTransactionIDs = useMemo(() => { - if (!shouldShowGroupedTransactions || groupedTransactions.length === 0) { - return sortedTransactions.filter((transaction) => !isTransactionPendingDelete(transaction)).map((transaction) => transaction.transactionID); - } - return groupedTransactions.flatMap((group) => group.transactions.filter((transaction) => !isTransactionPendingDelete(transaction)).map((transaction) => transaction.transactionID)); - }, [groupedTransactions, sortedTransactions, shouldShowGroupedTransactions]); + // Visual order, not the prop's DB-insertion order. + const visualOrderTransactions = useMemo( + () => (shouldShowGroupedTransactions && groupedTransactions.length > 0 ? groupedTransactions.flatMap((group) => group.transactions) : resolvedTransactions), + [groupedTransactions, resolvedTransactions, shouldShowGroupedTransactions], + ); + + const visualOrderTransactionIDs = useMemo( + () => visualOrderTransactions.filter((transaction) => !isTransactionPendingDelete(transaction)).map((transaction) => transaction.transactionID), + [visualOrderTransactions], + ); + + const rangeApi = useShiftRangeSelection({ + items: visualOrderTransactions, + getItemKey: (t) => t.transactionID ?? null, + getSelectedKeys: () => selectedTransactionIDs, + isDisabledItem: (t) => isTransactionPendingDelete(t), + onApplyRange: (batch) => + setSelectedTransactions( + applyShiftRangeBatchToKeySet( + batch, + selectedTransactionIDs, + (t) => t.transactionID, + (t) => !isTransactionPendingDelete(t), + ), + ), + }); + + const toggleTransaction = useCallback( + (transactionID: string, options?: Partial) => { + const item = visualOrderTransactions.find((t) => t.transactionID === transactionID); + if (item && rangeApi.applyShiftClick(item, options)) { + return; + } + setSelectedTransactions(selectedTransactionIDs.includes(transactionID) ? selectedTransactionIDs.filter((t) => t !== transactionID) : [...selectedTransactionIDs, transactionID]); + if (item) { + rangeApi.notifyAnchor(item); + } + }, + [setSelectedTransactions, selectedTransactionIDs, visualOrderTransactions, rangeApi], + ); // Primitive proxy for visualOrderTransactionIDs used as the effect dependency below. // Other callers (e.g. TransactionDuplicateReview.onPreviewPressed) can write to the same @@ -502,23 +526,37 @@ function MoneyRequestReportTransactionList({ }, [groupedTransactions, selectedTransactionIDs]); const toggleGroupSelection = useCallback( - (groupKey: string) => { + (groupKey: string, options?: Partial) => { const group = groupedTransactions.find((g) => g.groupKey === groupKey); if (!group) { return; } - const groupTransactionIDs = group.transactions.filter((t) => !isTransactionPendingDelete(t)).map((t) => t.transactionID); - const anySelected = groupTransactionIDs.some((id) => selectedTransactionIDs.includes(id)); + const selectableChildren = group.transactions.filter((t) => !isTransactionPendingDelete(t)); + + if (options?.shiftKey && selectableChildren.length > 0) { + const firstChild = selectableChildren.at(0); + const lastChild = selectableChildren.at(-1); + if (firstChild && lastChild) { + const anchorKey = rangeApi.getAnchorKey(); + const anchorIdx = anchorKey ? visualOrderTransactions.findIndex((t) => t.transactionID === anchorKey) : -1; + const firstIdx = visualOrderTransactions.findIndex((t) => t.transactionID === firstChild.transactionID); + const lastIdx = visualOrderTransactions.findIndex((t) => t.transactionID === lastChild.transactionID); + const target = farthestEndFromAnchor(firstIdx, lastIdx, anchorIdx) === 'first' ? firstChild : lastChild; + if (rangeApi.applyShiftClick(target, options)) { + return; + } + } + } - let newSelectedTransactionIDs = selectedTransactionIDs; - if (anySelected) { - newSelectedTransactionIDs = selectedTransactionIDs.filter((id) => !groupTransactionIDs.includes(id)); - } else { - newSelectedTransactionIDs = [...selectedTransactionIDs, ...groupTransactionIDs]; + const groupTransactionIDs = selectableChildren.map((t) => t.transactionID); + const anySelected = groupTransactionIDs.some((id) => selectedTransactionIDs.includes(id)); + setSelectedTransactions(anySelected ? selectedTransactionIDs.filter((id) => !groupTransactionIDs.includes(id)) : [...selectedTransactionIDs, ...groupTransactionIDs]); + const firstChild = selectableChildren.at(0); + if (firstChild) { + rangeApi.notifyAnchor(firstChild); } - setSelectedTransactions(newSelectedTransactionIDs); }, - [groupedTransactions, selectedTransactionIDs, setSelectedTransactions], + [groupedTransactions, selectedTransactionIDs, setSelectedTransactions, rangeApi, visualOrderTransactions], ); /** @@ -794,6 +832,7 @@ function MoneyRequestReportTransactionList({ } else { setSelectedTransactions(transactionsWithoutPendingDelete.map((t) => t.transactionID)); } + rangeApi.clearAnchor(); }} accessibilityLabel={translate('accessibilityHints.selectAllTransactions')} isIndeterminate={selectedTransactionIDs.length > 0 && selectedTransactionIDs.length !== transactionsWithoutPendingDelete.length} diff --git a/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx b/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx index c1c0c44dbede..016192b9b1d2 100644 --- a/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx @@ -2,12 +2,13 @@ import React from 'react'; import {View} from 'react-native'; import Checkbox from '@components/Checkbox'; import type {SearchColumnType} from '@components/Search/types'; -import type {ListItem} from '@components/SelectionList/types'; import TextWithTooltip from '@components/TextWithTooltip'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import CONST from '@src/CONST'; import ExpandCollapseArrowButton from './ExpandCollapseArrowButton'; import TextCell from './TextCell'; @@ -46,7 +47,7 @@ type ColumnStyleKey = | typeof CONST.SEARCH.TABLE_COLUMNS.GROUP_YEAR | typeof CONST.SEARCH.TABLE_COLUMNS.GROUP_QUARTER; -type BaseListItemHeaderProps = { +type BaseListItemHeaderProps = { /** The group item being rendered */ item: BaseGroupListItemType; @@ -60,7 +61,7 @@ type BaseListItemHeaderProps = { columnStyleKey: ColumnStyleKey; /** Callback to fire when a checkbox is pressed */ - onCheckboxPress?: (item: TItem) => void; + onCheckboxPress?: (options?: Partial) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -84,7 +85,7 @@ type BaseListItemHeaderProps = { columns?: SearchColumnType[]; }; -function BaseListItemHeader({ +function BaseListItemHeader({ item, displayName, groupColumnKey, @@ -97,7 +98,7 @@ function BaseListItemHeader({ isExpanded, onDownArrowClick, columns, -}: BaseListItemHeaderProps) { +}: BaseListItemHeaderProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {isLargeScreenWidth} = useResponsiveLayout(); @@ -144,7 +145,7 @@ function BaseListItemHeader({ {!!canSelectMultiple && ( onCheckboxPress?.(item as unknown as TItem)} + onPress={(event) => onCheckboxPress?.(getModifierKeysFromEvent(event))} isChecked={isSelectAllChecked} isIndeterminate={isIndeterminate} disabled={!!isDisabled || item.isDisabledCheckbox} diff --git a/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx b/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx index 14024d29b9c9..b4e8a3323507 100644 --- a/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx @@ -3,7 +3,6 @@ import {View} from 'react-native'; import Checkbox from '@components/Checkbox'; import ReportActionAvatars from '@components/ReportActionAvatars'; import type {SearchColumnType} from '@components/Search/types'; -import type {ListItem} from '@components/SelectionList/types'; import TextWithTooltip from '@components/TextWithTooltip'; import UserDetailsTooltip from '@components/UserDetailsTooltip'; import useLocalize from '@hooks/useLocalize'; @@ -12,6 +11,8 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; +import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import CONST from '@src/CONST'; import type {CompanyCardFeed} from '@src/types/onyx/CardFeeds'; import ExpandCollapseArrowButton from './ExpandCollapseArrowButton'; @@ -19,12 +20,12 @@ import TextCell from './TextCell'; import TotalCell from './TotalCell'; import type {TransactionCardGroupListItemType} from './types'; -type CardListItemHeaderProps = { +type CardListItemHeaderProps = { /** The card currently being looked at */ card: TransactionCardGroupListItemType; /** Callback to fire when a checkbox is pressed */ - onCheckboxPress?: (item: TItem) => void; + onCheckboxPress?: (options?: Partial) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -51,7 +52,7 @@ type CardListItemHeaderProps = { columns?: SearchColumnType[]; }; -function CardListItemHeader({ +function CardListItemHeader({ card: cardItem, onCheckboxPress, isDisabled, @@ -62,7 +63,7 @@ function CardListItemHeader({ onDownArrowClick, columns, isExpanded, -}: CardListItemHeaderProps) { +}: CardListItemHeaderProps) { const theme = useTheme(); const styles = useThemeStyles(); const {isLargeScreenWidth} = useResponsiveLayout(); @@ -144,7 +145,7 @@ function CardListItemHeader({ {!!canSelectMultiple && ( onCheckboxPress?.(cardItem as unknown as TItem)} + onPress={(event) => onCheckboxPress?.(getModifierKeysFromEvent(event))} isChecked={isSelectAllChecked} isIndeterminate={isIndeterminate} disabled={!!isDisabled || cardItem.isDisabledCheckbox} diff --git a/src/components/Search/SearchList/ListItem/CategoryListItemHeader.tsx b/src/components/Search/SearchList/ListItem/CategoryListItemHeader.tsx index ffa4c3ecc235..439d5cd41e75 100644 --- a/src/components/Search/SearchList/ListItem/CategoryListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/CategoryListItemHeader.tsx @@ -1,27 +1,16 @@ import React from 'react'; -import type {ListItem} from '@components/SelectionList/types'; import useLocalize from '@hooks/useLocalize'; import CONST from '@src/CONST'; import type {BaseListItemHeaderProps} from './BaseListItemHeader'; import BaseListItemHeader from './BaseListItemHeader'; import type {TransactionCategoryGroupListItemType} from './types'; -type CategoryListItemHeaderProps = Omit, 'item' | 'displayName' | 'groupColumnKey' | 'columnStyleKey'> & { +type CategoryListItemHeaderProps = Omit & { /** The category currently being looked at */ category: TransactionCategoryGroupListItemType; }; -function CategoryListItemHeader({ - category: categoryItem, - onCheckboxPress, - isDisabled, - canSelectMultiple, - isSelectAllChecked, - isIndeterminate, - isExpanded, - onDownArrowClick, - columns, -}: CategoryListItemHeaderProps) { +function CategoryListItemHeader({category: categoryItem, ...baseProps}: CategoryListItemHeaderProps) { const {translate} = useLocalize(); // formattedCategory is pre-decoded in SearchUIUtils, just translate empty values @@ -30,18 +19,11 @@ function CategoryListItemHeader({ return ( ); } diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx index 182cb0ef0401..8764905cd702 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx @@ -28,6 +28,7 @@ import {syncMissingAttendeesViolation} from '@libs/AttendeeUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {isAttendeeTrackingEnabled} from '@libs/PolicyUtils'; import {getNonHeldAndFullAmount, isInvoiceReport, isOpenExpenseReport, isProcessingReport, isReportPendingDelete} from '@libs/ReportUtils'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import {isOnHold, isViolationDismissed, shouldShowViolation, showPendingCardTransactionsBlockModal} from '@libs/TransactionUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -153,7 +154,7 @@ function ExpenseReportListItem({ handleActionButtonPress({ hash: currentSearchHash, item: reportItem, - goToItem: () => onSelectRow(reportItem as unknown as TItem), + goToItem: () => onSelectRow(item), snapshotReport, snapshotPolicy, policy: parentPolicy, @@ -195,6 +196,7 @@ function ExpenseReportListItem({ }, [ currentSearchHash, reportItem, + item, onSelectRow, snapshotReport, snapshotChatReport, @@ -216,9 +218,12 @@ function ExpenseReportListItem({ translate, ]); - const handleSelectionButtonPress = useCallback(() => { - onSelectionButtonPress?.(reportItem as unknown as TItem); - }, [onSelectionButtonPress, reportItem]); + const handleSelectionButtonPress = useCallback( + (_passedItem?: unknown, options?: Partial) => { + onSelectionButtonPress?.(item, undefined, options); + }, + [onSelectionButtonPress, item], + ); const listItemPressableStyle = useMemo( () => [ diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx index d0b5d19cefbf..96598a494606 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx @@ -6,6 +6,7 @@ import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; +import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; import CONST from '@src/CONST'; import type {ExpenseReportListItemRowNarrowProps} from './types'; @@ -37,7 +38,7 @@ function ExpenseReportListItemRowNarrow({item, onCheckboxPress = () => {}, canSe > {!!canSelectMultiple && ( onCheckboxPress(undefined, getModifierKeysFromEvent(event))} isChecked={isSelectAllChecked} isIndeterminate={isIndeterminate} containerStyle={styles.m0} diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx index 7151ebab9782..09f9c95a9862 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx @@ -15,6 +15,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import getBase62ReportID from '@libs/getBase62ReportID'; +import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ExpenseReportListItemAvatar from './ExpenseReportListItemAvatar'; @@ -216,7 +217,7 @@ function ExpenseReportListItemRowWide({ {!!canSelectMultiple && ( onCheckboxPress(undefined, getModifierKeysFromEvent(event))} isChecked={isSelectAllChecked} isIndeterminate={isIndeterminate} containerStyle={styles.m0} diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/types.ts b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/types.ts index 6a405152939a..1c65ba9c8ec1 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/types.ts +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/types.ts @@ -1,12 +1,13 @@ import type {StyleProp, ViewStyle} from 'react-native'; import type {ExpenseReportListItemType} from '@components/Search/SearchList/ListItem/types'; import type {SearchColumnType} from '@components/Search/types'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import type {ReportAction} from '@src/types/onyx'; type ExpenseReportListItemRowNarrowProps = { item: ExpenseReportListItemType; canSelectMultiple?: boolean; - onCheckboxPress?: () => void; + onCheckboxPress?: (_unused?: unknown, options?: Partial) => void; isSelectAllChecked?: boolean; isIndeterminate?: boolean; isDisabledCheckbox?: boolean; diff --git a/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx b/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx index d69bce9e9b44..6ec78c5bcee9 100644 --- a/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx @@ -3,25 +3,26 @@ import {View} from 'react-native'; import Avatar from '@components/Avatar'; import Checkbox from '@components/Checkbox'; import type {SearchColumnType} from '@components/Search/types'; -import type {ListItem} from '@components/SelectionList/types'; import TextWithTooltip from '@components/TextWithTooltip'; import UserDetailsTooltip from '@components/UserDetailsTooltip'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; +import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import CONST from '@src/CONST'; import ExpandCollapseArrowButton from './ExpandCollapseArrowButton'; import TextCell from './TextCell'; import TotalCell from './TotalCell'; import type {TransactionMemberGroupListItemType} from './types'; -type MemberListItemHeaderProps = { +type MemberListItemHeaderProps = { /** The member currently being looked at */ member: TransactionMemberGroupListItemType; /** Callback to fire when a checkbox is pressed */ - onCheckboxPress?: (item: TItem) => void; + onCheckboxPress?: (options?: Partial) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -48,7 +49,7 @@ type MemberListItemHeaderProps = { isLargeScreenWidth?: boolean; }; -function MemberListItemHeader({ +function MemberListItemHeader({ member: memberItem, onCheckboxPress, isDisabled, @@ -59,7 +60,7 @@ function MemberListItemHeader({ onDownArrowClick, columns, isLargeScreenWidth, -}: MemberListItemHeaderProps) { +}: MemberListItemHeaderProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate, formatPhoneNumber} = useLocalize(); @@ -129,7 +130,7 @@ function MemberListItemHeader({ {!!canSelectMultiple && ( onCheckboxPress?.(memberItem as unknown as TItem)} + onPress={(event) => onCheckboxPress?.(getModifierKeysFromEvent(event))} isChecked={isSelectAllChecked} isIndeterminate={isIndeterminate} disabled={!!isDisabled || memberItem.isDisabledCheckbox} diff --git a/src/components/Search/SearchList/ListItem/MerchantListItemHeader.tsx b/src/components/Search/SearchList/ListItem/MerchantListItemHeader.tsx index 2900f736442e..07b4f17043bf 100644 --- a/src/components/Search/SearchList/ListItem/MerchantListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/MerchantListItemHeader.tsx @@ -1,43 +1,25 @@ import React from 'react'; -import type {ListItem} from '@components/SelectionList/types'; import CONST from '@src/CONST'; import type {BaseListItemHeaderProps} from './BaseListItemHeader'; import BaseListItemHeader from './BaseListItemHeader'; import type {TransactionMerchantGroupListItemType} from './types'; -type MerchantListItemHeaderProps = Omit, 'item' | 'displayName' | 'groupColumnKey' | 'columnStyleKey'> & { +type MerchantListItemHeaderProps = Omit & { /** The merchant currently being looked at */ merchant: TransactionMerchantGroupListItemType; }; -function MerchantListItemHeader({ - merchant: merchantItem, - onCheckboxPress, - isDisabled, - canSelectMultiple, - isSelectAllChecked, - isIndeterminate, - isExpanded, - onDownArrowClick, - columns, -}: MerchantListItemHeaderProps) { +function MerchantListItemHeader({merchant: merchantItem, ...baseProps}: MerchantListItemHeaderProps) { // formattedMerchant is already translated to "No merchant" for empty values in SearchUIUtils const merchantName = merchantItem.formattedMerchant ?? merchantItem.merchant ?? ''; return ( ); } diff --git a/src/components/Search/SearchList/ListItem/MonthListItemHeader.tsx b/src/components/Search/SearchList/ListItem/MonthListItemHeader.tsx index 8ce5aab7c353..e9da87c3a83e 100644 --- a/src/components/Search/SearchList/ListItem/MonthListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/MonthListItemHeader.tsx @@ -1,42 +1,22 @@ import React from 'react'; -import type {ListItem} from '@components/SelectionList/types'; import CONST from '@src/CONST'; import type {BaseListItemHeaderProps} from './BaseListItemHeader'; import BaseListItemHeader from './BaseListItemHeader'; import type {TransactionMonthGroupListItemType} from './types'; -type MonthListItemHeaderProps = Omit, 'item' | 'displayName' | 'groupColumnKey' | 'columnStyleKey'> & { +type MonthListItemHeaderProps = Omit & { /** The month group currently being looked at */ month: TransactionMonthGroupListItemType; }; -function MonthListItemHeader({ - month: monthItem, - onCheckboxPress, - isDisabled, - canSelectMultiple, - isSelectAllChecked, - isIndeterminate, - isExpanded, - onDownArrowClick, - columns, -}: MonthListItemHeaderProps) { - const monthName = monthItem.formattedMonth; - +function MonthListItemHeader({month: monthItem, ...baseProps}: MonthListItemHeaderProps) { return ( ); } diff --git a/src/components/Search/SearchList/ListItem/QuarterListItemHeader.tsx b/src/components/Search/SearchList/ListItem/QuarterListItemHeader.tsx index 123abc4470b4..f9cc73b824c4 100644 --- a/src/components/Search/SearchList/ListItem/QuarterListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/QuarterListItemHeader.tsx @@ -1,42 +1,22 @@ import React from 'react'; -import type {ListItem} from '@components/SelectionList/types'; import CONST from '@src/CONST'; import type {BaseListItemHeaderProps} from './BaseListItemHeader'; import BaseListItemHeader from './BaseListItemHeader'; import type {TransactionQuarterGroupListItemType} from './types'; -type QuarterListItemHeaderProps = Omit, 'item' | 'displayName' | 'groupColumnKey' | 'columnStyleKey'> & { +type QuarterListItemHeaderProps = Omit & { /** The quarter group currently being looked at */ quarter: TransactionQuarterGroupListItemType; }; -function QuarterListItemHeader({ - quarter: quarterItem, - onCheckboxPress, - isDisabled, - canSelectMultiple, - isSelectAllChecked, - isIndeterminate, - isExpanded, - onDownArrowClick, - columns, -}: QuarterListItemHeaderProps) { - const quarterName = quarterItem.formattedQuarter; - +function QuarterListItemHeader({quarter: quarterItem, ...baseProps}: QuarterListItemHeaderProps) { return ( ); } diff --git a/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx b/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx index ee2ae2cd6640..4a1c37dcac00 100644 --- a/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx @@ -7,7 +7,6 @@ import Icon from '@components/Icon'; import {PressableWithFeedback} from '@components/Pressable'; import ReportSearchHeader from '@components/ReportSearchHeader'; import {useSearchQueryContext, useSearchResultsContext} from '@components/Search/SearchContext'; -import type {ListItem} from '@components/SelectionList/types'; import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -18,6 +17,8 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; +import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import {showPendingCardTransactionsBlockModal} from '@libs/TransactionUtils'; import {handleActionButtonPress} from '@userActions/Search'; import CONST from '@src/CONST'; @@ -29,15 +30,15 @@ import TotalCell from './TotalCell'; import type {SearchListActionProps, TransactionReportGroupListItemType} from './types'; import UserInfoAndActionButtonRow from './UserInfoAndActionButtonRow'; -type ReportListItemHeaderProps = SearchListActionProps & { +type ReportListItemHeaderProps = SearchListActionProps & { /** The report currently being looked at */ report: TransactionReportGroupListItemType; /** Callback to fire when the item is pressed */ - onSelectRow: (item: TItem, event?: ModifiedMouseEvent) => void; + onSelectRow: (event?: ModifiedMouseEvent) => void; /** Callback to fire when a checkbox is pressed */ - onCheckboxPress?: (item: TItem) => void; + onCheckboxPress?: (options?: Partial) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -64,12 +65,12 @@ type ReportListItemHeaderProps = SearchListActionProps & isHovered?: boolean; }; -type FirstRowReportHeaderProps = { +type FirstRowReportHeaderProps = { /** The report currently being looked at */ report: TransactionReportGroupListItemType; /** Callback to fire when a checkbox is pressed */ - onCheckboxPress?: (item: TItem) => void; + onCheckboxPress?: (options?: Partial) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -96,7 +97,7 @@ type FirstRowReportHeaderProps = { isExpanded?: boolean; }; -function HeaderFirstRow({ +function HeaderFirstRow({ report: reportItem, onCheckboxPress, isDisabled, @@ -107,7 +108,7 @@ function HeaderFirstRow({ isIndeterminate, onDownArrowClick, isExpanded, -}: FirstRowReportHeaderProps) { +}: FirstRowReportHeaderProps) { const icons = useMemoizedLazyExpensifyIcons(['DownArrow', 'UpArrow']); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -130,7 +131,7 @@ function HeaderFirstRow({ {!!canSelectMultiple && ( onCheckboxPress?.(reportItem as unknown as TItem)} + onPress={(event) => onCheckboxPress?.(getModifierKeysFromEvent(event))} isChecked={isSelectAllChecked} isIndeterminate={isIndeterminate} containerStyle={styles.m0} @@ -194,7 +195,7 @@ function HeaderFirstRow({ ); } -function ReportListItemHeader({ +function ReportListItemHeader({ report: reportItem, onSelectRow, onCheckboxPress, @@ -210,7 +211,7 @@ function ReportListItemHeader({ personalPolicyID, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, -}: ReportListItemHeaderProps) { +}: ReportListItemHeaderProps) { const StyleUtils = useStyleUtils(); const styles = useThemeStyles(); const theme = useTheme(); @@ -239,7 +240,7 @@ function ReportListItemHeader({ handleActionButtonPress({ hash: currentSearchHash, item: reportItem, - goToItem: () => onSelectRow(reportItem as unknown as TItem, event), + goToItem: () => onSelectRow(event), snapshotReport, snapshotPolicy, policy: parentPolicy, diff --git a/src/components/Search/SearchList/ListItem/TagListItemHeader.tsx b/src/components/Search/SearchList/ListItem/TagListItemHeader.tsx index 5e11d4d4558f..a0e2b37ef2cc 100644 --- a/src/components/Search/SearchList/ListItem/TagListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/TagListItemHeader.tsx @@ -1,43 +1,25 @@ import React from 'react'; -import type {ListItem} from '@components/SelectionList/types'; import CONST from '@src/CONST'; import type {BaseListItemHeaderProps} from './BaseListItemHeader'; import BaseListItemHeader from './BaseListItemHeader'; import type {TransactionTagGroupListItemType} from './types'; -type TagListItemHeaderProps = Omit, 'item' | 'displayName' | 'groupColumnKey' | 'columnStyleKey'> & { +type TagListItemHeaderProps = Omit & { /** The tag currently being looked at */ tag: TransactionTagGroupListItemType; }; -function TagListItemHeader({ - tag: tagItem, - onCheckboxPress, - isDisabled, - canSelectMultiple, - isSelectAllChecked, - isIndeterminate, - isExpanded, - onDownArrowClick, - columns, -}: TagListItemHeaderProps) { +function TagListItemHeader({tag: tagItem, ...baseProps}: TagListItemHeaderProps) { // formattedTag is already translated to "No tag" for empty values in SearchUIUtils const tagName = tagItem.formattedTag ?? tagItem.tag ?? ''; return ( ); } diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx index 44be71152603..b954a1f9a758 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx @@ -340,7 +340,7 @@ function TransactionGroupListExpanded({ shouldUseNarrowLayout={!isLargeScreenWidth} shouldShowCheckbox={!!canSelectMultiple} checkboxSentryLabel={CONST.SENTRY_LABEL.SEARCH.EXPANDED_TRANSACTION_ROW_CHECKBOX} - onCheckboxPress={() => onSelectionButtonPress?.(transaction as unknown as TItem)} + onCheckboxPress={(_transactionID, options) => onSelectionButtonPress?.(transaction as unknown as TItem, undefined, options)} columns={currentColumns} onButtonPress={(event) => handleButtonPress(transaction, event)} style={[styles.noBorderRadius, isLargeScreenWidth ? [styles.p3, styles.pv2, styles.tableRowHeight] : styles.p4, styles.flex1]} diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index f8fdcde1b50d..ef7d00e9b833 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -29,6 +29,7 @@ import type {TransactionPreviewData} from '@libs/actions/Search'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; import {getSections} from '@libs/SearchUIUtils'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import {mergeProhibitedViolations, shouldShowViolation} from '@libs/TransactionUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -292,9 +293,10 @@ function TransactionGroupListItem({ onLongPressRow?.(transaction as unknown as TItem); }; - const handleSelectionButtonPress = (val: TItem) => { - onSelectionButtonPress?.(val, isExpenseReportType ? undefined : transactions); + const handleSelectionButtonPress = (val: TItem, options?: Partial) => { + onSelectionButtonPress?.(val, isExpenseReportType ? undefined : transactions, options); }; + const handleSelectionButtonPressForExpanded = (val: TItem, _itemTransactions?: TransactionListItemType[], options?: Partial) => handleSelectionButtonPress(val, options); const onExpandIconPress = () => { if (isEmpty && !shouldDisplayEmptyView) { @@ -310,7 +312,7 @@ function TransactionGroupListItem({ [CONST.SEARCH.GROUP_BY.FROM]: ( handleSelectionButtonPress(item, options)} isDisabled={isDisabled} columns={columns} canSelectMultiple={canSelectMultiple} @@ -324,7 +326,7 @@ function TransactionGroupListItem({ [CONST.SEARCH.GROUP_BY.CARD]: ( handleSelectionButtonPress(item, options)} isDisabled={isDisabled} columns={columns} isFocused={isFocused} @@ -338,7 +340,7 @@ function TransactionGroupListItem({ [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: ( handleSelectionButtonPress(item, options)} isDisabled={isDisabled} columns={columns} canSelectMultiple={canSelectMultiple} @@ -351,7 +353,7 @@ function TransactionGroupListItem({ [CONST.SEARCH.GROUP_BY.CATEGORY]: ( handleSelectionButtonPress(item, options)} isDisabled={isDisabled} columns={columns} canSelectMultiple={canSelectMultiple} @@ -364,7 +366,7 @@ function TransactionGroupListItem({ [CONST.SEARCH.GROUP_BY.MERCHANT]: ( handleSelectionButtonPress(item, options)} isDisabled={isDisabled} columns={columns} canSelectMultiple={canSelectMultiple} @@ -377,7 +379,7 @@ function TransactionGroupListItem({ [CONST.SEARCH.GROUP_BY.TAG]: ( handleSelectionButtonPress(item, options)} isDisabled={isDisabled} columns={columns} canSelectMultiple={canSelectMultiple} @@ -390,7 +392,7 @@ function TransactionGroupListItem({ [CONST.SEARCH.GROUP_BY.MONTH]: ( handleSelectionButtonPress(item, options)} isDisabled={isDisabled} columns={columns} canSelectMultiple={canSelectMultiple} @@ -403,7 +405,7 @@ function TransactionGroupListItem({ [CONST.SEARCH.GROUP_BY.WEEK]: ( handleSelectionButtonPress(item, options)} isDisabled={isDisabled} columns={columns} canSelectMultiple={canSelectMultiple} @@ -416,7 +418,7 @@ function TransactionGroupListItem({ [CONST.SEARCH.GROUP_BY.YEAR]: ( handleSelectionButtonPress(item, options)} isDisabled={isDisabled} columns={columns} canSelectMultiple={canSelectMultiple} @@ -429,7 +431,7 @@ function TransactionGroupListItem({ [CONST.SEARCH.GROUP_BY.QUARTER]: ( handleSelectionButtonPress(item, options)} isDisabled={isDisabled} columns={columns} canSelectMultiple={canSelectMultiple} @@ -445,8 +447,8 @@ function TransactionGroupListItem({ return ( onSelectRow(listItem, transactionPreviewData, event)} - onCheckboxPress={handleSelectionButtonPress} + onSelectRow={(event) => onSelectRow(item, transactionPreviewData, event)} + onCheckboxPress={(options) => handleSelectionButtonPress(item, options)} isDisabled={isDisabled} isFocused={isFocused} canSelectMultiple={canSelectMultiple} @@ -563,7 +565,7 @@ function TransactionGroupListItem({ ({ policy={transactionItem.policy} shouldShowTooltip={showTooltip} onButtonPress={handleActionButtonPress} - onCheckboxPress={() => onCheckboxPress?.(item)} + onCheckboxPress={(_transactionID, options) => onCheckboxPress?.(item, undefined, options)} shouldUseNarrowLayout isLargeScreenWidth={false} columns={columns} diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx index dc5fc4232336..3aa6f41a9dc4 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx @@ -167,7 +167,7 @@ function TransactionListItemWide({ policy={transactionItem.policy} shouldShowTooltip={showTooltip} onButtonPress={handleActionButtonPress} - onCheckboxPress={() => onCheckboxPress?.(item)} + onCheckboxPress={(_transactionID, options) => onCheckboxPress?.(item, undefined, options)} shouldUseNarrowLayout={false} isLargeScreenWidth columns={columns} diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts b/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts index 9b9f8f0ba7c7..1a573dc0e035 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts @@ -1,8 +1,10 @@ +import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; import type {SearchColumnType} from '@components/Search/types'; import type {ListItemFocusEventHandler} from '@components/SelectionList/ListItem/types'; import type {ListItem} from '@components/SelectionList/types'; import type {TransactionPreviewData} from '@libs/actions/Search'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import type {CardList, ReportAction, TransactionViolation} from '@src/types/onyx'; type TransactionListItemSharedProps = { @@ -13,7 +15,7 @@ type TransactionListItemSharedProps = { isDisabled?: boolean | null; canSelectMultiple?: boolean; onSelectRow: (item: TItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => void; - onCheckboxPress?: (item: TItem) => void; + onCheckboxPress?: (item: TItem, itemTransactions?: TransactionListItemType[], options?: Partial) => void; onFocus?: ListItemFocusEventHandler; onLongPressRow?: (item: TItem) => void; shouldSyncFocus?: boolean; diff --git a/src/components/Search/SearchList/ListItem/WeekListItemHeader.tsx b/src/components/Search/SearchList/ListItem/WeekListItemHeader.tsx index e7a12be901bf..0210f877e8f8 100644 --- a/src/components/Search/SearchList/ListItem/WeekListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/WeekListItemHeader.tsx @@ -1,42 +1,22 @@ import React from 'react'; -import type {ListItem} from '@components/SelectionList/types'; import CONST from '@src/CONST'; import type {BaseListItemHeaderProps} from './BaseListItemHeader'; import BaseListItemHeader from './BaseListItemHeader'; import type {TransactionWeekGroupListItemType} from './types'; -type WeekListItemHeaderProps = Omit, 'item' | 'displayName' | 'groupColumnKey' | 'columnStyleKey'> & { +type WeekListItemHeaderProps = Omit & { /** The week group currently being looked at */ week: TransactionWeekGroupListItemType; }; -function WeekListItemHeader({ - week: weekItem, - onCheckboxPress, - isDisabled, - canSelectMultiple, - isSelectAllChecked, - isIndeterminate, - isExpanded, - onDownArrowClick, - columns, -}: WeekListItemHeaderProps) { - const weekName = weekItem.formattedWeek; - +function WeekListItemHeader({week: weekItem, ...baseProps}: WeekListItemHeaderProps) { return ( ); } diff --git a/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx b/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx index 9e89bb899541..4f5435dc07ac 100644 --- a/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx @@ -5,7 +5,6 @@ import Icon from '@components/Icon'; import getBankIcon from '@components/Icon/BankIcons'; import RenderHTML from '@components/RenderHTML'; import type {SearchColumnType} from '@components/Search/types'; -import type {ListItem} from '@components/SelectionList/types'; import StatusBadge from '@components/StatusBadge'; import TextWithTooltip from '@components/TextWithTooltip'; import useEnvironment from '@hooks/useEnvironment'; @@ -17,6 +16,8 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; import {getSettlementStatus, getSettlementStatusBadgeProps} from '@libs/SearchUIUtils'; +import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -25,12 +26,12 @@ import TextCell from './TextCell'; import TotalCell from './TotalCell'; import type {TransactionWithdrawalIDGroupListItemType} from './types'; -type WithdrawalIDListItemHeaderProps = { +type WithdrawalIDListItemHeaderProps = { /** The withdrawal ID currently being looked at */ withdrawalID: TransactionWithdrawalIDGroupListItemType; /** Callback to fire when a checkbox is pressed */ - onCheckboxPress?: (item: TItem) => void; + onCheckboxPress?: (options?: Partial) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -54,7 +55,7 @@ type WithdrawalIDListItemHeaderProps = { columns?: SearchColumnType[]; }; -function WithdrawalIDListItemHeader({ +function WithdrawalIDListItemHeader({ withdrawalID: withdrawalIDItem, onCheckboxPress, isDisabled, @@ -64,7 +65,7 @@ function WithdrawalIDListItemHeader({ onDownArrowClick, isExpanded, columns, -}: WithdrawalIDListItemHeaderProps) { +}: WithdrawalIDListItemHeaderProps) { const {isLargeScreenWidth} = useResponsiveLayout(); const theme = useTheme(); const styles = useThemeStyles(); @@ -185,7 +186,7 @@ function WithdrawalIDListItemHeader({ {!!canSelectMultiple && ( onCheckboxPress?.(withdrawalIDItem as unknown as TItem)} + onPress={(event) => onCheckboxPress?.(getModifierKeysFromEvent(event))} isChecked={isSelectAllChecked} disabled={!!isDisabled || withdrawalIDItem.isDisabledCheckbox} accessibilityLabel={translate('common.select')} diff --git a/src/components/Search/SearchList/ListItem/YearListItemHeader.tsx b/src/components/Search/SearchList/ListItem/YearListItemHeader.tsx index e221c264d00d..b947b1b08159 100644 --- a/src/components/Search/SearchList/ListItem/YearListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/YearListItemHeader.tsx @@ -1,42 +1,22 @@ import React from 'react'; -import type {ListItem} from '@components/SelectionList/types'; import CONST from '@src/CONST'; import type {BaseListItemHeaderProps} from './BaseListItemHeader'; import BaseListItemHeader from './BaseListItemHeader'; import type {TransactionYearGroupListItemType} from './types'; -type YearListItemHeaderProps = Omit, 'item' | 'displayName' | 'groupColumnKey' | 'columnStyleKey'> & { +type YearListItemHeaderProps = Omit & { /** The year group currently being looked at */ year: TransactionYearGroupListItemType; }; -function YearListItemHeader({ - year: yearItem, - onCheckboxPress, - isDisabled, - canSelectMultiple, - isSelectAllChecked, - isIndeterminate, - isExpanded, - onDownArrowClick, - columns, -}: YearListItemHeaderProps) { - const yearName = yearItem.formattedYear; - +function YearListItemHeader({year: yearItem, ...baseProps}: YearListItemHeaderProps) { return ( ); } diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index cf5ad95e775e..b5223cb1d54a 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -29,6 +29,7 @@ import DateUtils from '@libs/DateUtils'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; import navigationRef from '@libs/Navigation/navigationRef'; import {applySelectionToItem, getTableMinWidth} from '@libs/SearchUIUtils'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import variables from '@styles/variables'; import type {TransactionPreviewData} from '@userActions/Search'; import CONST from '@src/CONST'; @@ -82,7 +83,7 @@ type SearchListProps = Pick, 'onScroll' | 'conten canSelectMultiple: boolean; /** Callback to fire when a checkbox is pressed */ - onCheckboxPress: (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => void; + onCheckboxPress: (item: SearchListItem, itemTransactions?: TransactionListItemType[], options?: Partial) => void; /** Callback to fire when "Select All" checkbox is pressed. Only use along with `canSelectMultiple` */ onAllCheckboxPress: () => void; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 483a43c9177d..96a3f9fd2aea 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -29,6 +29,7 @@ import useSaveSortedReportIDs from '@hooks/useSaveSortedReportIDs'; import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useSelfDMReport from '@hooks/useSelfDMReport'; +import useShiftRangeSelection from '@hooks/useShiftRangeSelection'; import useStableArrayReference from '@hooks/useStableArrayReference'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; @@ -67,6 +68,8 @@ import { shouldShowEmptyState, shouldShowYear as shouldShowYearUtil, } from '@libs/SearchUIUtils'; +import {farthestEndFromAnchor} from '@libs/shiftRangeSelection'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import {cancelSpan, endSpanWithAttributes, getSpan, startSpan} from '@libs/telemetry/activeSpans'; import {cancelSubmitFollowUpActionSpan, getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; @@ -1041,14 +1044,185 @@ function Search({ [totalSelectableItemsCount, selectAllMatchingItems], ); + const onApplyShiftRange = useCallback( + ({toSelect, toDeselect}: {toSelect: SearchListItem[]; toDeselect: SearchListItem[]}) => { + const updated: SelectedTransactions = {...selectedTransactions}; + const parentGroupKeyByTransactionKey = new Map(); + if (areItemsGrouped) { + for (const group of filteredData as TransactionGroupListItemType[]) { + const groupKey = group.keyForList; + if (!groupKey) { + continue; + } + for (const child of group.transactions ?? []) { + if (child.keyForList) { + parentGroupKeyByTransactionKey.set(child.keyForList, groupKey); + } + } + } + } + const addTransaction = (tx: TransactionListItemType) => { + if (!tx.keyForList || isTransactionPendingDelete(tx)) { + return; + } + const txRef = searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${tx.transactionID}`] ?? transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${tx.transactionID}`]; + const originalRef = + searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${txRef?.comment?.originalTransactionID}`] ?? + transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${txRef?.comment?.originalTransactionID}`]; + const parentReport = searchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${tx.report?.parentReportID}`] as OnyxEntry; + const [k, info] = mapTransactionItemToSelectedEntry( + tx, + txRef as OnyxEntry, + originalRef, + email ?? '', + accountID, + outstandingReportsByPolicyID, + true, + parentReport, + selfDMReport, + isProduction, + ); + const parentGroupKey = parentGroupKeyByTransactionKey.get(tx.keyForList); + updated[k] = parentGroupKey ? {...info, groupKey: parentGroupKey} : info; + }; + const removeRow = (row: SearchListItem) => { + if (isTransactionListItemType(row) || (isTransactionReportGroupListItemType(row) && row.transactions.length === 0)) { + if (row.keyForList) { + delete updated[row.keyForList]; + } + } else if (isTransactionGroupListItemType(row)) { + for (const child of row.transactions ?? []) { + if (child.keyForList) { + delete updated[child.keyForList]; + } + } + } + }; + const addRow = (row: SearchListItem) => { + if (isTransactionListItemType(row)) { + addTransaction(row); + } else if (isTransactionReportGroupListItemType(row) && row.transactions.length === 0) { + if (row.keyForList && row.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + const [k, info] = mapEmptyReportToSelectedEntry(row); + updated[k] = info; + } + } else if (isTransactionGroupListItemType(row)) { + for (const child of row.transactions ?? []) { + addTransaction(child); + } + } + }; + for (const row of toDeselect) { + removeRow(row); + } + for (const row of toSelect) { + addRow(row); + } + setSelectedTransactions(updated, filteredData); + updateSelectAllMatchingItemsState(updated); + }, + [ + selectedTransactions, + setSelectedTransactions, + filteredData, + updateSelectAllMatchingItemsState, + transactions, + email, + accountID, + outstandingReportsByPolicyID, + searchResults?.data, + selfDMReport, + isProduction, + areItemsGrouped, + ], + ); + + const isChat = type === CONST.SEARCH.DATA_TYPES.CHAT; + const isTask = type === CONST.SEARCH.DATA_TYPES.TASK; + const canSelectMultiple = !isChat && !isTask && (!isSmallScreenWidth || isMobileSelectionModeEnabled); + const ListItem = getListItem(type, status, validGroupBy); + + const sortedData = useMemo( + () => + getSortedSections(type, status, filteredData, localeCompare, translate, sortBy, sortOrder, validGroupBy).map((item) => { + const baseKey = isChat + ? `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${(item as ReportActionListItemType).reportActionID}` + : `${ONYXKEYS.COLLECTION.TRANSACTION}${(item as TransactionListItemType).transactionID}`; + + const isBaseKeyMatch = !!newSearchResultKeys?.has(baseKey); + + const isAnyTransactionMatch = + !isChat && + (item as TransactionGroupListItemType)?.transactions?.some((transaction) => { + const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`; + return !!newSearchResultKeys?.has(transactionKey); + }); + + const shouldAnimateInHighlight = isBaseKeyMatch || isAnyTransactionMatch; + + if (item.shouldAnimateInHighlight === shouldAnimateInHighlight && item.hash === hash) { + return item; + } + + return {...item, shouldAnimateInHighlight, hash}; + }), + [type, status, filteredData, localeCompare, translate, sortBy, sortOrder, validGroupBy, isChat, newSearchResultKeys, hash], + ); + + useSaveSortedReportIDs(type, sortedData); + + const {stableSortedData, hasCachedOptimisticItem} = useStableOptimisticSortedData(sortedData, searchResults, optimisticTrackingState); + + // Children must be in the range items; header exclusion is gated on validGroupBy separately. + const flattenedShiftRangeItems = useMemo(() => { + if (!areItemsGrouped) { + return stableSortedData; + } + const isGroupArray = (items: SearchListItem[]): items is TransactionGroupListItemType[] => items.every((g) => isTransactionGroupListItemType(g) && Array.isArray(g.transactions)); + if (!isGroupArray(stableSortedData)) { + return stableSortedData; + } + return stableSortedData.flatMap((g) => [g, ...(g.transactions ?? [])]); + }, [stableSortedData, areItemsGrouped]); + + const selectedTransactionKeySet = useMemo(() => new Set(Object.keys(selectedTransactions ?? {})), [selectedTransactions]); + + const rangeApi = useShiftRangeSelection({ + items: flattenedShiftRangeItems, + onApplyRange: onApplyShiftRange, + isHeaderItem: (item) => isTransactionGroupListItemType(item) && (!!validGroupBy || (Array.isArray(item.transactions) && item.transactions.length > 0)), + getSelectedKeys: () => selectedTransactionKeySet, + }); + const toggleTransaction = useCallback( - (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => { - if (isReportActionListItemType(item)) { + (item: SearchListItem, itemTransactions?: TransactionListItemType[], options?: Partial) => { + if (isReportActionListItemType(item) || isTaskListItemType(item)) { return; } - if (isTaskListItemType(item)) { + if (options?.shiftKey && isTransactionGroupListItemType(item) && item.transactions && item.transactions.length > 0) { + const firstChild = item.transactions.at(0); + const lastChild = item.transactions.at(-1); + if (firstChild && lastChild) { + const anchorKey = rangeApi.getAnchorKey(); + const anchorIdx = anchorKey ? flattenedShiftRangeItems.findIndex((r) => r.keyForList === anchorKey) : -1; + const firstIdx = flattenedShiftRangeItems.findIndex((r) => r.keyForList === firstChild.keyForList); + const lastIdx = flattenedShiftRangeItems.findIndex((r) => r.keyForList === lastChild.keyForList); + const target = farthestEndFromAnchor(firstIdx, lastIdx, anchorIdx) === 'first' ? firstChild : lastChild; + if (rangeApi.applyShiftClick(target, options)) { + return; + } + } + } + if (rangeApi.applyShiftClick(item, options)) { return; } + + // The hook rejects group headers; pick a selectable child as the anchor. + const anchorSource: SearchListItem = + validGroupBy && isTransactionGroupListItemType(item) && item.transactions && item.transactions.length > 0 + ? (item.transactions.find((t) => !isTransactionPendingDelete(t)) ?? item) + : item; + if (isTransactionListItemType(item)) { if (!item.keyForList) { return; @@ -1084,6 +1258,7 @@ function Search({ setSelectedTransactions(updatedTransactions); updateSelectAllMatchingItemsState(updatedTransactions); + rangeApi.notifyAnchor(anchorSource); return; } @@ -1103,6 +1278,7 @@ function Search({ delete reducedSelectedTransactions[reportKey]; setSelectedTransactions(reducedSelectedTransactions); updateSelectAllMatchingItemsState(reducedSelectedTransactions); + rangeApi.notifyAnchor(anchorSource); return; } @@ -1113,6 +1289,7 @@ function Search({ }; setSelectedTransactions(updatedTransactions); updateSelectAllMatchingItemsState(updatedTransactions); + rangeApi.notifyAnchor(anchorSource); return; } @@ -1127,6 +1304,7 @@ function Search({ setSelectedTransactions(reducedSelectedTransactions); updateSelectAllMatchingItemsState(reducedSelectedTransactions); + rangeApi.notifyAnchor(anchorSource); return; } @@ -1160,6 +1338,7 @@ function Search({ }; setSelectedTransactions(updatedTransactions); updateSelectAllMatchingItemsState(updatedTransactions); + rangeApi.notifyAnchor(anchorSource); }, [ selectedTransactions, @@ -1172,6 +1351,9 @@ function Search({ outstandingReportsByPolicyID, selfDMReport, isProduction, + rangeApi, + flattenedShiftRangeItems, + validGroupBy, areItemsGrouped, filteredData, ], @@ -1393,42 +1575,6 @@ function Search({ ); }, [previousColumns, currentColumns, setColumnsToShow, opacity, offset, isSmallScreenWidth]); - const isChat = type === CONST.SEARCH.DATA_TYPES.CHAT; - const isTask = type === CONST.SEARCH.DATA_TYPES.TASK; - const canSelectMultiple = !isChat && !isTask && (!isSmallScreenWidth || isMobileSelectionModeEnabled); - const ListItem = getListItem(type, status, validGroupBy); - - const sortedData = useMemo( - () => - getSortedSections(type, status, filteredData, localeCompare, translate, sortBy, sortOrder, validGroupBy).map((item) => { - const baseKey = isChat - ? `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${(item as ReportActionListItemType).reportActionID}` - : `${ONYXKEYS.COLLECTION.TRANSACTION}${(item as TransactionListItemType).transactionID}`; - - const isBaseKeyMatch = !!newSearchResultKeys?.has(baseKey); - - const isAnyTransactionMatch = - !isChat && - (item as TransactionGroupListItemType)?.transactions?.some((transaction) => { - const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`; - return !!newSearchResultKeys?.has(transactionKey); - }); - - const shouldAnimateInHighlight = isBaseKeyMatch || isAnyTransactionMatch; - - if (item.shouldAnimateInHighlight === shouldAnimateInHighlight && item.hash === hash) { - return item; - } - - return {...item, shouldAnimateInHighlight, hash}; - }), - [type, status, filteredData, localeCompare, translate, sortBy, sortOrder, validGroupBy, isChat, newSearchResultKeys, hash], - ); - - useSaveSortedReportIDs(type, sortedData); - - const {stableSortedData, hasCachedOptimisticItem} = useStableOptimisticSortedData(sortedData, searchResults, optimisticTrackingState); - useEffect(() => { const currentRoute = Navigation.getActiveRouteWithoutParams(); if (hasErrors && (currentRoute === '/' || (shouldResetSearchQuery && currentRoute === '/search'))) { @@ -1460,6 +1606,7 @@ function Search({ if (totalSelected > 0) { clearSelectedTransactions(); updateSelectAllMatchingItemsState({}); + rangeApi.clearAnchor(); return; } @@ -1520,6 +1667,7 @@ function Search({ } setSelectedTransactions(updatedTransactions, filteredData); updateSelectAllMatchingItemsState(updatedTransactions); + rangeApi.clearAnchor(); }, [ areItemsGrouped, selectedTransactions, @@ -1534,6 +1682,7 @@ function Search({ searchResults?.data, selfDMReport, isProduction, + rangeApi, ]); const onLayoutBase = useCallback(() => { diff --git a/src/components/SelectionButton.tsx b/src/components/SelectionButton.tsx index 6925138d46ab..92c77a0ca098 100644 --- a/src/components/SelectionButton.tsx +++ b/src/components/SelectionButton.tsx @@ -158,6 +158,10 @@ function SelectionButton({ if (shouldStopMouseDownPropagation) { e.stopPropagation(); } + // Shift+mousedown otherwise extends browser text selection, swallowing the click on web. + if (e.shiftKey) { + e.preventDefault(); + } onMouseDown?.(e); }} ref={ref as PressableRef} diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 38f899f71f65..8e47f15e608c 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -14,11 +14,13 @@ import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useKeyboardState from '@hooks/useKeyboardState'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useScrollEnabled from '@hooks/useScrollEnabled'; +import useShiftRangeSelection from '@hooks/useShiftRangeSelection'; import useSingleExecution from '@hooks/useSingleExecution'; import {focusedItemRef} from '@hooks/useSyncFocus/useSyncFocusImplementation'; import useThemeStyles from '@hooks/useThemeStyles'; import {addKeyDownPressListener, removeKeyDownPressListener} from '@libs/KeyboardShortcut/KeyDownPressListener'; import {isFocusRestoreInProgress} from '@libs/NavigationFocusReturn'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import CONST from '@src/CONST'; import getEmptyArray from '@src/types/utils/getEmptyArray'; @@ -50,6 +52,7 @@ function BaseSelectionList({ onSelectAll, onLongPressRow, onSelectionButtonPress, + onShiftRangeApply, onScrollBeginDrag, onDismissError, onEndReached, @@ -214,12 +217,33 @@ function BaseSelectionList({ const debouncedScrollToIndex = useDebounce(scrollToIndex, CONST.TIMING.LIST_SCROLLING_DEBOUNCE_TIME, {leading: true, trailing: true}); + const rangeApi = useShiftRangeSelection({ + items: data, + getItemKey: (item) => item.keyForList ?? null, + getSelectedKeys: () => { + // Mirror isItemSelected so custom isSelected callers still get an anchor. + const keys = new Set(); + for (const item of data) { + if (item.keyForList && isItemSelected(item)) { + keys.add(item.keyForList); + } + } + return keys; + }, + isDisabledItem: (item) => !!item.isDisabled || !!item.isDisabledCheckbox, + onApplyRange: onShiftRangeApply, + }); + const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({ initialFocusedIndex, maxIndex: data.length - 1, disabledIndexes: dataDetails.disabledArrowKeyIndexes, isActive: isFocused, onFocusedIndexChange: (index: number) => { + const focusedItem = data.at(index); + if (focusedItem) { + rangeApi.notifyAnchor(focusedItem); + } if (suppressNextFocusScrollRef.current) { suppressNextFocusScrollRef.current = false; return; @@ -255,6 +279,21 @@ function BaseSelectionList({ const extraData = useMemo(() => [data.length], [data.length]); const syncedSearchValue = searchValueForFocusSync ?? textInputOptions?.value; + const handleSelectionButtonPress = useCallback( + (item: TItem, itemTransactions?: unknown, options?: Partial) => { + if (onShiftRangeApply && rangeApi.applyShiftClick(item, options)) { + return; + } + if (onSelectionButtonPress) { + onSelectionButtonPress(item, itemTransactions, options); + } else { + onSelectRow(item); + } + rangeApi.notifyAnchor(item); + }, + [onShiftRangeApply, rangeApi, onSelectionButtonPress, onSelectRow], + ); + const selectRow = useCallback( (item: TItem, indexToFocus?: number) => { if (!isFocused) { @@ -265,7 +304,7 @@ function BaseSelectionList({ textInputOptions?.onChangeText?.(''); } else if (isSmallScreenWidth) { if (!item.isDisabledCheckbox) { - onSelectionButtonPress?.(item); + handleSelectionButtonPress(item); } return; } @@ -277,6 +316,7 @@ function BaseSelectionList({ setFocusedIndex(indexToFocus); } onSelectRow(item); + rangeApi.notifyAnchor(item); if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow && innerTextInputRef.current) { innerTextInputRef.current.focus(); @@ -293,8 +333,9 @@ function BaseSelectionList({ shouldPreventDefaultFocusOnSelectRow, isSmallScreenWidth, textInputOptions, - onSelectionButtonPress, + handleSelectionButtonPress, setFocusedIndex, + rangeApi, ], ); @@ -404,7 +445,7 @@ function BaseSelectionList({ canSelectMultiple={canSelectMultiple} onDismissError={onDismissError} onLongPressRow={onLongPressRow} - onSelectionButtonPress={onSelectionButtonPress} + onSelectionButtonPress={handleSelectionButtonPress} shouldSingleExecuteRowSelect={shouldSingleExecuteRowSelect} rightHandSideComponent={rightHandSideComponent} isMultilineSupported={isRowMultilineSupported} @@ -583,10 +624,11 @@ function BaseSelectionList({ const handleSelectAll = useCallback(() => { onSelectAll?.(); + rangeApi.clearAnchor(); if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow && innerTextInputRef.current) { innerTextInputRef.current.focus(); } - }, [onSelectAll, shouldShowTextInput, shouldPreventDefaultFocusOnSelectRow]); + }, [onSelectAll, rangeApi, shouldShowTextInput, shouldPreventDefaultFocusOnSelectRow]); useImperativeHandle(ref, () => ({scrollAndHighlightItem, scrollToIndex, updateFocusedIndex, scrollToFocusedInput, focusTextInput}), [ focusTextInput, diff --git a/src/components/SelectionList/ListItem/ListItemRenderer.tsx b/src/components/SelectionList/ListItem/ListItemRenderer.tsx index 7449ba212602..d4e5817f495b 100644 --- a/src/components/SelectionList/ListItem/ListItemRenderer.tsx +++ b/src/components/SelectionList/ListItem/ListItemRenderer.tsx @@ -1,10 +1,12 @@ import React from 'react'; import type {NativeSyntheticEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; import type {SelectionListProps} from '@components/SelectionList/types'; import type useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import type useSingleExecution from '@hooks/useSingleExecution'; import {isMobileChrome} from '@libs/Browser'; import {isTransactionGroupListItemType} from '@libs/SearchUIUtils'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import type {ExtendedTargetedEvent, ListItem, SelectableListItemProps} from './types'; type ListItemRendererProps = Omit, 'onSelectRow' | 'keyForList'> & @@ -60,7 +62,9 @@ function ListItemRenderer({ if (isTransactionGroupListItemType(item)) { return onSelectionButtonPress; } - return onSelectionButtonPress ? () => onSelectionButtonPress(item) : undefined; + return onSelectionButtonPress + ? (_passedItem: TItem, itemTransactions?: TransactionListItemType[], options?: Partial) => onSelectionButtonPress(item, itemTransactions, options) + : undefined; }; return ( diff --git a/src/components/SelectionList/ListItem/SelectableListItem.tsx b/src/components/SelectionList/ListItem/SelectableListItem.tsx index 60b1e02e4a8c..f784e829a2d6 100644 --- a/src/components/SelectionList/ListItem/SelectableListItem.tsx +++ b/src/components/SelectionList/ListItem/SelectableListItem.tsx @@ -39,7 +39,13 @@ function SelectableListItem({ <> { + if (onSelectionButtonPress) { + onSelectionButtonPress(it, undefined, opts); + return; + } + onSelectRow(it); + }} disabled={!!isDisabled || !!item.isDisabledCheckbox} style={styles.ml3} /> @@ -55,7 +61,13 @@ function SelectableListItem({ <> { + if (onSelectionButtonPress) { + onSelectionButtonPress(it, undefined, opts); + return; + } + onSelectRow(it); + }} disabled={!!isDisabled || item.isDisabledCheckbox} style={styles.mr3} /> diff --git a/src/components/SelectionList/ListItem/types.ts b/src/components/SelectionList/ListItem/types.ts index b0af063de83f..35fca956e200 100644 --- a/src/components/SelectionList/ListItem/types.ts +++ b/src/components/SelectionList/ListItem/types.ts @@ -8,6 +8,7 @@ import type {TransactionListItemType} from '@components/Search/SearchList/ListIt import type {TransactionPreviewData} from '@libs/actions/Search'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import type {SpendRuleSummaryPart} from '@libs/SpendRulesUtils'; import type {BrickRoad} from '@libs/WorkspacesSettingsUtils'; // eslint-disable-next-line no-restricted-imports @@ -238,7 +239,7 @@ type ListItemProps = CommonListItemProps & { item: TItem; /** Callback to fire when the selection button is pressed */ - onSelectionButtonPress?: (item: TItem, itemTransactions?: TransactionListItemType[]) => void; + onSelectionButtonPress?: (item: TItem, itemTransactions?: TransactionListItemType[], options?: Partial) => void; /** Which side of the row to render the selection button on */ selectionButtonPosition?: ValueOf; @@ -368,7 +369,7 @@ type SpendRuleListItemType = ListItem & { */ type SelectableListItemProps = BaseListItemProps & { /** Callback to fire when the selection button is pressed */ - onSelectionButtonPress?: (item: TItem, itemTransactions?: TransactionListItemType[]) => void; + onSelectionButtonPress?: (item: TItem, itemTransactions?: TransactionListItemType[], options?: Partial) => void; /** Which side of the row to render the selection button on */ selectionButtonPosition?: ValueOf; diff --git a/src/components/SelectionList/components/ListSelectionButton.tsx b/src/components/SelectionList/components/ListSelectionButton.tsx index 3bb1ab015fc9..cf1696883416 100644 --- a/src/components/SelectionList/components/ListSelectionButton.tsx +++ b/src/components/SelectionList/components/ListSelectionButton.tsx @@ -2,6 +2,8 @@ import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import SelectionButton from '@components/SelectionButton'; import type {ListItem} from '@components/SelectionList/ListItem/types'; +import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import CONST from '@src/CONST'; type ListSelectionButtonProps = { @@ -9,7 +11,7 @@ type ListSelectionButtonProps = { item: TItem; /** Callback to fire when the item is pressed */ - onSelectRow: (item: TItem) => void; + onSelectRow: (item: TItem, options?: Partial) => void; /** Custom accessibility label */ accessibilityLabel?: string; @@ -53,7 +55,7 @@ function ListSelectionButton({ role={role} accessibilityLabel={label} isChecked={item.isSelected ?? false} - onPress={() => onSelectRow(item)} + onPress={(event) => onSelectRow(item, getModifierKeysFromEvent(event))} disabled={disabled} style={style} containerStyle={containerStyle} diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 757c347af81f..4c981128c5dd 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -3,6 +3,7 @@ import type {GestureResponderEvent, InputModeOptions, StyleProp, TextStyle, View import type {ValueOf} from 'type-fest'; import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; +import type {Modifiers, ShiftRangeBatch} from '@libs/shiftRangeSelection'; import type CONST from '@src/CONST'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type {ListItem, ValidListItem} from './ListItem/types'; @@ -44,7 +45,10 @@ type BaseSelectionListProps = { customListHeaderContent?: React.JSX.Element | null; /** Called when a selection button is pressed */ - onSelectionButtonPress?: (item: TItem) => void; + onSelectionButtonPress?: (item: TItem, itemTransactions?: unknown, options?: Partial) => void; + + /** Apply a shift+click range batch atomically. Without this, shift+click falls through to per-item toggle. */ + onShiftRangeApply?: (batch: ShiftRangeBatch) => void; /** Callback to fire when an error is dismissed */ onDismissError?: (item: TItem) => void; diff --git a/src/components/SpendRules/configuration/SpendRuleCategoryBase.tsx b/src/components/SpendRules/configuration/SpendRuleCategoryBase.tsx index 5604f8e51685..773549d3fe13 100644 --- a/src/components/SpendRules/configuration/SpendRuleCategoryBase.tsx +++ b/src/components/SpendRules/configuration/SpendRuleCategoryBase.tsx @@ -13,6 +13,7 @@ import useSearchResults from '@hooks/useSearchResults'; import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import variables from '@styles/variables'; import type {SpendRuleCategory} from '@src/types/form/SpendRuleForm'; import {SPEND_RULE_CATEGORIES} from '@src/types/form/SpendRuleForm'; @@ -103,6 +104,7 @@ export default function SpendRuleCategoryBase({categories, onCategoriesChange}: shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} onSelectRow={toggleCategory} onSelectionButtonPress={toggleCategory} + onShiftRangeApply={(batch) => setSelectedCategories((prev) => applyShiftRangeBatchToKeySet(batch, prev, (c) => c.value))} onSelectAll={filteredCategoryItems.length > 0 ? toggleSelectAll : undefined} textInputOptions={{ value: inputValue, diff --git a/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx b/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx index efa148e5815b..5a2f552f8b0c 100644 --- a/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx @@ -8,6 +8,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import DeferredChatBubbleCell from './DataCells/DeferredChatBubbleCell'; @@ -84,8 +85,8 @@ function TransactionItemRowNarrow({ {shouldShowCheckbox && ( { - onCheckboxPress(transactionItem.transactionID); + onPress={(event) => { + onCheckboxPress(transactionItem.transactionID, getModifierKeysFromEvent(event)); }} accessibilityLabel={CONST.ROLE.CHECKBOX} isChecked={isSelected} diff --git a/src/components/TransactionItemRow/TransactionItemRowWide.tsx b/src/components/TransactionItemRow/TransactionItemRowWide.tsx index 4d1052dccf27..6dc5c387a4dd 100644 --- a/src/components/TransactionItemRow/TransactionItemRowWide.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowWide.tsx @@ -24,6 +24,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import getBase62ReportID from '@libs/getBase62ReportID'; import {getReportName} from '@libs/ReportNameUtils'; import {isExpenseReport} from '@libs/ReportUtils'; +import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; import { getAmount, getConvertedAmount, @@ -580,8 +581,8 @@ function TransactionItemRowWide({ {!shouldShowRadioButton && ( { - onCheckboxPress(transactionItem.transactionID); + onPress={(event) => { + onCheckboxPress(transactionItem.transactionID, getModifierKeysFromEvent(event)); }} accessibilityLabel={CONST.ROLE.CHECKBOX} isChecked={isSelected} diff --git a/src/components/TransactionItemRow/types.ts b/src/components/TransactionItemRow/types.ts index 5fdf8e76477f..d28bff65390c 100644 --- a/src/components/TransactionItemRow/types.ts +++ b/src/components/TransactionItemRow/types.ts @@ -2,6 +2,7 @@ import type {StyleProp, ViewStyle} from 'react-native'; import type {TransactionWithOptionalHighlight} from '@components/MoneyRequestReportView/MoneyRequestReportTransactionList'; import type {SearchColumnType, TableColumnSize} from '@components/Search/types'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import type {CardList, PersonalDetails, Policy, Report, ReportAction, TransactionViolation} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; import type {SearchTransactionAction} from '@src/types/onyx/SearchResults'; @@ -64,7 +65,7 @@ type TransactionItemRowProps = { exportedColumnSize?: TableColumnSize; amountColumnSize: TableColumnSize; taxAmountColumnSize: TableColumnSize; - onCheckboxPress?: (transactionID: string) => void; + onCheckboxPress?: (transactionID: string, options?: Partial) => void; shouldShowCheckbox?: boolean; columns?: SearchColumnType[]; onButtonPress?: (event?: ModifiedMouseEvent) => void; diff --git a/src/hooks/useShiftRangeSelection.ts b/src/hooks/useShiftRangeSelection.ts new file mode 100644 index 000000000000..f6b9f06b5f29 --- /dev/null +++ b/src/hooks/useShiftRangeSelection.ts @@ -0,0 +1,210 @@ +import {useRef} from 'react'; +import type {Modifiers, ShiftRangeBatch} from '@libs/shiftRangeSelection'; + +type ItemWithKey = {keyForList?: string | null}; + +type Params = { + items: TItem[]; + getItemKey?: (item: TItem) => string | null | undefined; + getSelectedKeys?: () => ReadonlySet | readonly string[]; + isHeaderItem?: (item: TItem) => boolean; + isDisabledItem?: (item: TItem) => boolean; + onApplyRange?: (batch: ShiftRangeBatch) => void; +}; + +type Api = { + applyShiftClick: (item: TItem, options?: Partial) => boolean; + notifyAnchor: (item: TItem) => void; + clearAnchor: () => void; + getAnchorKey: () => string | null; +}; + +type SessionState = {kind: 'idle'} | {kind: 'anchored'; anchor: string} | {kind: 'ranging'; anchor: string; prevEnd: string}; + +type SessionEvent = {type: 'notify'; key: string} | {type: 'clear'} | {type: 'range'; anchor: string; prevEnd: string}; + +const IDLE: SessionState = {kind: 'idle'}; + +function sessionReducer(state: SessionState, event: SessionEvent): SessionState { + switch (event.type) { + case 'notify': + return {kind: 'anchored', anchor: event.key}; + case 'clear': + return IDLE; + case 'range': + return {kind: 'ranging', anchor: event.anchor, prevEnd: event.prevEnd}; + default: { + const exhaustive: never = event; + return exhaustive; + } + } +} + +/** Shift+click range selection. Consumers notify on plain clicks / select-all so the hook can resolve an anchor for the next shift+click. */ +function useShiftRangeSelection(params: Params): Api { + // Session lives entirely in event handlers, never in render output — useRef avoids re-renders that useReducer/useState would trigger. + const sessionRef = useRef(IDLE); + + return { + applyShiftClick: (target, options) => { + if (!options?.shiftKey) { + return false; + } + const result = computeShiftRange(params, sessionRef.current, target, !!options.additive); + if (!result) { + return false; + } + if (result.batch.toSelect.length || result.batch.toDeselect.length) { + params.onApplyRange?.(result.batch); + } + sessionRef.current = sessionReducer(sessionRef.current, {type: 'range', anchor: result.anchor, prevEnd: result.prevEnd}); + return true; + }, + notifyAnchor: (item) => { + const key = keyOf(params, item); + if (key) { + sessionRef.current = sessionReducer(sessionRef.current, {type: 'notify', key}); + } + }, + clearAnchor: () => { + sessionRef.current = sessionReducer(sessionRef.current, {type: 'clear'}); + }, + getAnchorKey: () => (sessionRef.current.kind === 'idle' ? resolveAnchor(params, null) : sessionRef.current.anchor), + }; +} + +type ShiftRangeResult = { + batch: ShiftRangeBatch; + anchor: string; + prevEnd: string; +}; + +function computeShiftRange(params: Params, state: SessionState, target: TItem, additive: boolean): ShiftRangeResult | null { + const targetKey = keyOf(params, target); + if (!targetKey || isExcluded(params, target)) { + return null; + } + + let anchor: string; + let prevEnd: string | null; + if (state.kind === 'ranging') { + const resolved = resolveAnchor(params, state.anchor); + if (!resolved) { + return null; + } + anchor = resolved; + prevEnd = resolved === state.anchor ? state.prevEnd : null; + } else { + const seed = state.kind === 'anchored' ? state.anchor : null; + const resolved = resolveAnchor(params, seed); + if (!resolved) { + return null; + } + anchor = resolved; + prevEnd = null; + } + + const anchorIdx = indexOfKey(params, anchor); + const targetIdx = indexOfKey(params, targetKey); + if (anchorIdx < 0 || targetIdx < 0) { + return null; + } + + const newRange = orderedRange(anchorIdx, targetIdx); + // Additive preserves the prior range; otherwise prevEndIdx is -1 when prevEnd has been removed. + const prevEndIdx = !additive && prevEnd != null ? indexOfKey(params, prevEnd) : -1; + const prevRange = prevEndIdx >= 0 ? orderedRange(anchorIdx, prevEndIdx) : null; + + const toSelect: TItem[] = []; + for (let i = newRange[0]; i <= newRange[1]; i++) { + const row = params.items.at(i); + if (row && !isExcluded(params, row)) { + toSelect.push(row); + } + } + const toDeselect: TItem[] = []; + if (prevRange) { + for (let i = prevRange[0]; i <= prevRange[1]; i++) { + if (i >= newRange[0] && i <= newRange[1]) { + continue; + } + const row = params.items.at(i); + if (row && !isExcluded(params, row)) { + toDeselect.push(row); + } + } + } + + return {batch: {toSelect, toDeselect}, anchor, prevEnd: targetKey}; +} + +function hasKeyForList(item: unknown): item is ItemWithKey { + return typeof item === 'object' && item !== null && 'keyForList' in item; +} + +function keyOf(params: Params, item: TItem | null | undefined): string | null { + if (item == null) { + return null; + } + if (params.getItemKey) { + return params.getItemKey(item) ?? null; + } + return hasKeyForList(item) ? (item.keyForList ?? null) : null; +} + +function isExcluded(params: Params, item: TItem | null | undefined): boolean { + if (item == null) { + return true; + } + if (params.isHeaderItem?.(item)) { + return true; + } + if (params.isDisabledItem?.(item)) { + return true; + } + return false; +} + +function indexOfKey(params: Params, key: string): number { + return params.items.findIndex((row) => keyOf(params, row) === key); +} + +function orderedRange(a: number, b: number): readonly [number, number] { + return a <= b ? [a, b] : [b, a]; +} + +function resolveAnchor(params: Params, source: string | null): string | null { + if (source) { + const idx = indexOfKey(params, source); + if (idx >= 0 && !isExcluded(params, params.items.at(idx))) { + return source; + } + } + if (params.getSelectedKeys) { + const selected = params.getSelectedKeys(); + const set: ReadonlySet = selected instanceof Set ? selected : new Set(selected); + if (set.size) { + for (const row of params.items) { + if (isExcluded(params, row)) { + continue; + } + const key = keyOf(params, row); + if (key && set.has(key)) { + return key; + } + } + } + } + for (const row of params.items) { + if (isExcluded(params, row)) { + continue; + } + const key = keyOf(params, row); + if (key) { + return key; + } + } + return null; +} + +export default useShiftRangeSelection; diff --git a/src/libs/shiftRangeSelection/applyShiftRangeBatchToKeySet.ts b/src/libs/shiftRangeSelection/applyShiftRangeBatchToKeySet.ts new file mode 100644 index 000000000000..3f595a22702f --- /dev/null +++ b/src/libs/shiftRangeSelection/applyShiftRangeBatchToKeySet.ts @@ -0,0 +1,58 @@ +import type {ShiftRangeBatch} from './types'; + +/** Apply a shift-range batch to a list of primitive keys, preserving prev order and deduping. */ +function applyShiftRangeBatchToKeySet( + batch: ShiftRangeBatch, + prevKeys: readonly TKey[], + getKey: (item: TItem) => TKey | null | undefined, + isSelectable?: (item: TItem) => boolean, +): TKey[] { + if (!batch.toSelect.length && !batch.toDeselect.length) { + return [...prevKeys]; + } + const removeSet = new Set(); + for (const item of batch.toDeselect) { + const key = getKey(item); + if (key != null) { + removeSet.add(key); + } + } + const addOrdered: TKey[] = []; + const addSet = new Set(); + for (const item of batch.toSelect) { + const key = getKey(item); + if (key == null || addSet.has(key)) { + continue; + } + if (isSelectable ? !isSelectable(item) : isBlocked(item)) { + continue; + } + addSet.add(key); + addOrdered.push(key); + } + const next: TKey[] = []; + const seen = new Set(); + for (const key of prevKeys) { + if (removeSet.has(key) || seen.has(key)) { + continue; + } + seen.add(key); + next.push(key); + } + for (const key of addOrdered) { + if (!seen.has(key)) { + seen.add(key); + next.push(key); + } + } + return next; +} + +function isBlocked(item: unknown): boolean { + if (typeof item !== 'object' || item === null) { + return false; + } + return ('isDisabled' in item && !!item.isDisabled) || ('isDisabledCheckbox' in item && !!item.isDisabledCheckbox); +} + +export default applyShiftRangeBatchToKeySet; diff --git a/src/libs/shiftRangeSelection/farthestEndFromAnchor.ts b/src/libs/shiftRangeSelection/farthestEndFromAnchor.ts new file mode 100644 index 000000000000..3f453df14346 --- /dev/null +++ b/src/libs/shiftRangeSelection/farthestEndFromAnchor.ts @@ -0,0 +1,9 @@ +/** Pick the group end farther from the anchor so a header-clicked range covers the whole group. */ +function farthestEndFromAnchor(firstIdx: number, lastIdx: number, anchorIdx: number): 'first' | 'last' { + if (anchorIdx < 0) { + return 'last'; + } + return Math.abs(firstIdx - anchorIdx) > Math.abs(lastIdx - anchorIdx) ? 'first' : 'last'; +} + +export default farthestEndFromAnchor; diff --git a/src/libs/shiftRangeSelection/getModifierKeysFromEvent.ts b/src/libs/shiftRangeSelection/getModifierKeysFromEvent.ts new file mode 100644 index 000000000000..e5fc11e2faff --- /dev/null +++ b/src/libs/shiftRangeSelection/getModifierKeysFromEvent.ts @@ -0,0 +1,11 @@ +import type {ModifierEvent, Modifiers} from './types'; + +/** Reads shift + the additive modifier (Cmd on Mac, Ctrl elsewhere — metaKey||ctrlKey works on every OS since each OS only delivers its own modifier). */ +function getModifierKeysFromEvent(event?: ModifierEvent | null): Modifiers { + const shiftKey = !!(event?.shiftKey ?? event?.nativeEvent?.shiftKey); + const meta = !!(event?.metaKey ?? event?.nativeEvent?.metaKey); + const ctrl = !!(event?.ctrlKey ?? event?.nativeEvent?.ctrlKey); + return {shiftKey, additive: meta || ctrl}; +} + +export default getModifierKeysFromEvent; diff --git a/src/libs/shiftRangeSelection/index.ts b/src/libs/shiftRangeSelection/index.ts new file mode 100644 index 000000000000..9467eacc91fa --- /dev/null +++ b/src/libs/shiftRangeSelection/index.ts @@ -0,0 +1,4 @@ +export {default as getModifierKeysFromEvent} from './getModifierKeysFromEvent'; +export {default as applyShiftRangeBatchToKeySet} from './applyShiftRangeBatchToKeySet'; +export {default as farthestEndFromAnchor} from './farthestEndFromAnchor'; +export type {Modifiers, ShiftRangeBatch} from './types'; diff --git a/src/libs/shiftRangeSelection/types.ts b/src/libs/shiftRangeSelection/types.ts new file mode 100644 index 000000000000..4ab7236fdd94 --- /dev/null +++ b/src/libs/shiftRangeSelection/types.ts @@ -0,0 +1,21 @@ +import type {KeyboardEvent as ReactKeyboardEvent} from 'react'; +import type {GestureResponderEvent} from 'react-native'; + +/** Keys pressed during a click — shift extends the range, additive stacks ranges (Cmd on Mac, Ctrl elsewhere). */ +type Modifiers = {shiftKey: boolean; additive: boolean}; + +/** Atomic batch the hook emits when shift+click changes the selection. */ +type ShiftRangeBatch = { + toSelect: TItem[]; + toDeselect: TItem[]; +}; + +/** Intersection with the event union lets native / RN-Web / DOM call sites pass their event without a cast. */ +type ModifierEvent = (GestureResponderEvent | KeyboardEvent | ReactKeyboardEvent | MouseEvent) & { + shiftKey?: boolean; + metaKey?: boolean; + ctrlKey?: boolean; + nativeEvent?: {shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean}; +}; + +export type {Modifiers, ShiftRangeBatch, ModifierEvent}; diff --git a/src/pages/ReportParticipantsPage.tsx b/src/pages/ReportParticipantsPage.tsx index 16cd7844cdc5..7352b2b2c7f7 100755 --- a/src/pages/ReportParticipantsPage.tsx +++ b/src/pages/ReportParticipantsPage.tsx @@ -52,6 +52,7 @@ import { isSelfDM, isTaskReport, } from '@libs/ReportUtils'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -393,6 +394,7 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) { onTurnOnSelectionMode={(item) => item && toggleUser(item)} onSelectAll={() => toggleAllUsers(participants)} onSelectionButtonPress={toggleUser} + onShiftRangeApply={(batch) => setSelectedMembers((prev) => applyShiftRangeBatchToKeySet(batch, prev, (m) => m.accountID))} shouldShowTextInput={shouldShowTextInput} customListHeader={customListHeader} showScrollIndicator diff --git a/src/pages/RoomMembersPage.tsx b/src/pages/RoomMembersPage.tsx index 2e53caa4b120..6f21d11cf9fc 100644 --- a/src/pages/RoomMembersPage.tsx +++ b/src/pages/RoomMembersPage.tsx @@ -47,6 +47,7 @@ import { isPolicyExpenseChat as isPolicyExpenseChatUtils, isUserCreatedPolicyRoom, } from '@libs/ReportUtils'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import StringUtils from '@libs/StringUtils'; import {clearAddRoomMemberError, openRoomMembersPage, removeFromRoom} from '@userActions/Report'; import CONST from '@src/CONST'; @@ -465,6 +466,7 @@ function RoomMembersPage({report, policy}: RoomMembersPageProps) { ListItem={TableListItem} onSelectRow={openRoomMemberDetails} onSelectionButtonPress={toggleUser} + onShiftRangeApply={(batch) => setSelectedMembers((prev) => applyShiftRangeBatchToKeySet(batch, prev, (m) => m.accountID))} textInputOptions={textInputOptions} shouldShowTextInput={shouldShowTextInput} shouldShowLoadingPlaceholder={!isPersonalDetailsReady(personalDetails) || !didLoadRoomMembers} diff --git a/src/pages/UnreportedExpenseListItem.tsx b/src/pages/UnreportedExpenseListItem.tsx index b4a6ffa7ae18..aa5fb26a8d0d 100644 --- a/src/pages/UnreportedExpenseListItem.tsx +++ b/src/pages/UnreportedExpenseListItem.tsx @@ -31,6 +31,7 @@ function UnreportedExpenseListItem({ readOnly, shouldSyncFocus, onSelectRow, + onSelectionButtonPress, violations, }: UnreportedExpenseListItemProps) { const styles = useThemeStyles(); @@ -83,7 +84,11 @@ function UnreportedExpenseListItem({ dateColumnSize={CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL} amountColumnSize={CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL} taxAmountColumnSize={CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL} - onCheckboxPress={() => { + onCheckboxPress={(_id, options) => { + if (onSelectionButtonPress) { + onSelectionButtonPress(item, undefined, options); + return; + } onSelectRow(item); }} isDisabled={isItemDisabled} diff --git a/src/pages/settings/Rules/ExpenseRulesPage.tsx b/src/pages/settings/Rules/ExpenseRulesPage.tsx index f6117dfcdf07..f113c0447816 100644 --- a/src/pages/settings/Rules/ExpenseRulesPage.tsx +++ b/src/pages/settings/Rules/ExpenseRulesPage.tsx @@ -33,6 +33,7 @@ import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import {formatExpenseRuleChanges, getKeyForRule} from '@libs/ExpenseRuleUtils'; import Navigation from '@libs/Navigation/Navigation'; import Parser from '@libs/Parser'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import tokenizedSearch from '@libs/tokenizedSearch'; import CONST from '@src/CONST'; @@ -302,6 +303,7 @@ function ExpenseRulesPage() { data={filteredRuleList} ListItem={TableListItem} onSelectionButtonPress={toggleRule} + onShiftRangeApply={(batch) => setSelectedRules((prev) => applyShiftRangeBatchToKeySet(batch, prev, (r) => r.keyForList))} onSelectAll={filteredRuleList.length > 0 ? toggleAllRules : undefined} onSelectRow={onSelectRow} onTurnOnSelectionMode={(item) => item && toggleRule(item)} diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 3a504c2c3d87..acfc0eb4937e 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -77,6 +77,7 @@ import { isPolicyApprover, } from '@libs/PolicyUtils'; import {getDisplayNameForParticipant} from '@libs/ReportUtils'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import tokenizedSearch from '@libs/tokenizedSearch'; import {convertPolicyEmployeesToApprovalWorkflows, updateWorkflowDataOnApproverRemoval} from '@libs/WorkflowUtils'; import variables from '@styles/variables'; @@ -1075,6 +1076,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers onTurnOnSelectionMode={(item) => item && toggleUser(item.login)} shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} onSelectionButtonPress={(item) => toggleUser(item.login)} + onShiftRangeApply={(batch) => setSelectedEmployees((prev) => applyShiftRangeBatchToKeySet(batch, prev, (m) => m.login))} shouldSingleExecuteRowSelect={!isPolicyAdmin} customListHeader={getCustomListHeader()} customListHeaderContent={headerContent} diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 9ae95d557c73..f0a82b44a45f 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -52,6 +52,7 @@ import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import {isDisablingOrDeletingLastEnabledCategory} from '@libs/OptionsListUtils'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {getConnectedIntegration, getCurrentConnectionName, hasAccountingConnections, hasTags, isControlPolicy, shouldShowSyncError} from '@libs/PolicyUtils'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import tokenizedSearch from '@libs/tokenizedSearch'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; @@ -763,6 +764,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { data={filteredCategoryList} ListItem={TableListItem} onSelectionButtonPress={toggleCategory} + onShiftRangeApply={(batch) => setSelectedCategories((prev) => applyShiftRangeBatchToKeySet(batch, prev, (c) => c.keyForList))} selectedItems={selectedCategories} onSelectRow={navigateToCategorySettings} onTurnOnSelectionMode={(item) => item && toggleCategory(item)} diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index fb7c2edf4d94..42c3b3ffd8fe 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -44,6 +44,7 @@ import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {getDistanceRateCustomUnit} from '@libs/PolicyUtils'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import tokenizedSearch from '@libs/tokenizedSearch'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; @@ -529,6 +530,7 @@ function PolicyDistanceRatesPage({ ListItem={TableListItem} onSelectRow={openRateDetails} onSelectionButtonPress={toggleRate} + onShiftRangeApply={(batch) => setSelectedDistanceRates((prev) => applyShiftRangeBatchToKeySet(batch, prev, (r) => r.value))} selectedItems={selectedDistanceRates} customListHeader={getCustomListHeader()} onTurnOnSelectionMode={(item) => item && toggleRate(item)} diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx index 9c86c0b15902..6e0bf96ea301 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx @@ -14,6 +14,8 @@ import {getTranslationKeyForLimitType} from '@libs/CardUtils'; import {convertToShortDisplayString} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; +import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import CONST from '@src/CONST'; import type {PersonalDetails} from '@src/types/onyx'; import type {CardLimitType} from '@src/types/onyx/Card'; @@ -55,7 +57,7 @@ type WorkspacesListRowProps = { /** When set, shows a row checkbox for bulk selection */ bulkSelection?: { isSelected: boolean; - onToggle: () => void; + onToggle: (options?: Partial) => void; }; }; @@ -103,7 +105,7 @@ function WorkspaceCardListRow({ bulkSelection.onToggle(getModifierKeysFromEvent(event))} /> )} diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index f4752a19018d..a3af8ff8d73f 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -28,6 +28,7 @@ import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchResults from '@hooks/useSearchResults'; +import useShiftRangeSelection from '@hooks/useShiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -39,6 +40,8 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import Navigation from '@navigation/Navigation'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; import variables from '@styles/variables'; @@ -114,8 +117,21 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp if (prunedSelectedCardIDs.length !== selectedCardIDs.length) { setSelectedCardIDs(prunedSelectedCardIDs); } - const toggleCardSelection = (cardID: number) => { + const rangeApi = useShiftRangeSelection({ + items: filteredSortedCards, + getItemKey: (c) => String(c.cardID), + getSelectedKeys: () => selectedCardIDs.map(String), + onApplyRange: (batch) => setSelectedCardIDs((prev) => applyShiftRangeBatchToKeySet(batch, prev, (c) => c.cardID)), + }); + const toggleCardSelection = (cardID: number, options?: Partial) => { + const card = filteredSortedCards.find((c) => c.cardID === cardID); + if (card && rangeApi.applyShiftClick(card, options)) { + return; + } setSelectedCardIDs((prev) => (prev.includes(cardID) ? prev.filter((id) => id !== cardID) : [...prev, cardID])); + if (card) { + rangeApi.notifyAnchor(card); + } }; const toggleSelectAll = () => { if (selectableCardIDs.length === 0) { @@ -127,6 +143,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp } return [...selectableCardIDs]; }); + rangeApi.clearAnchor(); }; const isSelectAllChecked = selectedCardIDs.length > 0 && selectedCardIDs.length === selectableCardIDs.length; const isSelectAllIndeterminate = selectedCardIDs.length > 0 && selectedCardIDs.length < selectableCardIDs.length; @@ -263,7 +280,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp shouldShowBulkSelection ? { isSelected: isCardSelected, - onToggle: () => toggleCardSelection(item.cardID), + onToggle: (options) => toggleCardSelection(item.cardID, options), } : undefined } diff --git a/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx index a9e21cb185f4..b77970b12b57 100644 --- a/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx +++ b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx @@ -486,6 +486,34 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { canSelectMultiple={canSelectMultiple} selectAllAccessibilityLabel={translate('accessibilityHints.selectAllPerDiemRates')} onSelectionButtonPress={toggleSubRate} + onShiftRangeApply={(batch) => + setSelectedPerDiem((prev) => { + if (!batch.toSelect.length && !batch.toDeselect.length) { + return prev; + } + const removeSet = new Set(batch.toDeselect.map((r) => r.subRateID)); + const seen = new Set(); + const next: SubRateData[] = []; + for (const value of prev) { + if (removeSet.has(value.subRateID) || seen.has(value.subRateID)) { + continue; + } + seen.add(value.subRateID); + next.push(value); + } + for (const row of batch.toSelect) { + if (row.isDisabled || row.isDisabledCheckbox || seen.has(row.subRateID)) { + continue; + } + const built = generateSingleSubRateData(allRatesArray, row.rateID, row.subRateID); + if (built) { + seen.add(row.subRateID); + next.push(built); + } + } + return next; + }) + } customListHeader={getCustomListHeader()} selectedItems={selectedPerDiem.map((item) => item.subRateID)} onSelectAll={filteredSubRatesList.length > 0 ? toggleAllSubRates : undefined} diff --git a/src/pages/workspace/reports/ReportFieldsListValuesPage.tsx b/src/pages/workspace/reports/ReportFieldsListValuesPage.tsx index 721c6c7502ba..c700e19edfd9 100644 --- a/src/pages/workspace/reports/ReportFieldsListValuesPage.tsx +++ b/src/pages/workspace/reports/ReportFieldsListValuesPage.tsx @@ -410,6 +410,18 @@ function ReportFieldsListValuesPage({ canSelectMultiple={canSelectMultiple} selectAllAccessibilityLabel={translate('accessibilityHints.selectAllValues')} onSelectionButtonPress={toggleValue} + onShiftRangeApply={(batch) => + setSelectedValues((prev) => { + const next = {...prev}; + for (const item of batch.toDeselect) { + next[item.value] = false; + } + for (const item of batch.toSelect) { + next[item.value] = true; + } + return next; + }) + } shouldShowListEmptyContent={false} showScrollIndicator={false} turnOnSelectionModeOnLongPress diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx index 0094a49c59da..aaf99eeeac5e 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx @@ -27,6 +27,7 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {getHeaderMessage} from '@libs/OptionsListUtils'; import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import {getSpendRuleFormValuesFromCardRule} from '@libs/SpendRulesUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import variables from '@styles/variables'; @@ -234,6 +235,7 @@ function SpendRuleCardPage({route}: SpendRuleCardPageProps) { }} onSelectAll={listData.length > 0 ? toggleSelectAll : undefined} onSelectionButtonPress={toggleCard} + onShiftRangeApply={(batch) => setSelectedCardIDs((prev) => applyShiftRangeBatchToKeySet(batch, prev, (item) => item.keyForList))} onSelectRow={toggleCard} selectedItems={selectedCardIDs} ListItem={CardListItem} diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index a0e3d9de6593..8598c21eb4ef 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -71,6 +71,7 @@ import { isMultiLevelTags as isMultiLevelTagsPolicyUtils, shouldShowSyncError, } from '@libs/PolicyUtils'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import tokenizedSearch from '@libs/tokenizedSearch'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; @@ -922,6 +923,7 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { shouldShowListEmptyContent={false} showScrollIndicator={false} onSelectionButtonPress={toggleTag} + onShiftRangeApply={(batch) => setSelectedTags((prev) => applyShiftRangeBatchToKeySet(batch, prev, (t) => t.value))} isSelected={isTagSelected} shouldHeaderBeInsideList shouldShowRightCaret diff --git a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx index 231f3e039260..b04a5590585c 100644 --- a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx @@ -44,6 +44,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {isDisablingOrDeletingLastEnabledTag, isMakingLastRequiredTagListOptional} from '@libs/OptionsListUtils'; import {getCleanedTagName, getTagListName, hasDependentTags as hasDependentTagsPolicyUtils, isMultiLevelTags as isMultiLevelTagsPolicyUtils} from '@libs/PolicyUtils'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import StringUtils from '@libs/StringUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import type {SettingsNavigatorParamList} from '@navigation/types'; @@ -445,6 +446,7 @@ function WorkspaceViewTagsPage({route}: WorkspaceViewTagsProps) { onSelectRow={navigateToTagSettings} shouldShowListEmptyContent={false} onSelectionButtonPress={toggleTag} + onShiftRangeApply={(batch) => setSelectedTags((prev) => applyShiftRangeBatchToKeySet(batch, prev, (t) => t.value))} shouldHeaderBeInsideList shouldShowRightCaret showScrollIndicator diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx index 7c8316821549..2b4f11274ed7 100644 --- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx @@ -42,6 +42,7 @@ import { hasAccountingConnections as hasAccountingConnectionsPolicyUtils, shouldShowSyncError, } from '@libs/PolicyUtils'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import tokenizedSearch from '@libs/tokenizedSearch'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; @@ -468,6 +469,7 @@ function WorkspaceTaxesPage({ customListHeaderContent={headerContent} shouldShowListEmptyContent={false} onSelectionButtonPress={toggleTax} + onShiftRangeApply={(batch) => setSelectedTaxesIDs((prev) => applyShiftRangeBatchToKeySet(batch, prev, (t) => t.keyForList))} showScrollIndicator={false} turnOnSelectionModeOnLongPress shouldHeaderBeInsideList diff --git a/tests/ui/CategoryListItemHeaderTest.tsx b/tests/ui/CategoryListItemHeaderTest.tsx index c7c60a834b33..bd28dc453f19 100644 --- a/tests/ui/CategoryListItemHeaderTest.tsx +++ b/tests/ui/CategoryListItemHeaderTest.tsx @@ -202,7 +202,7 @@ describe('CategoryListItemHeader', () => { const checkbox = screen.getByRole('checkbox'); fireEvent.press(checkbox); - expect(onCheckboxPress).toHaveBeenCalledWith(categoryItem); + expect(onCheckboxPress).toHaveBeenCalledWith({shiftKey: false, additive: false}); }); it('should show checkbox as checked when isSelectAllChecked is true', async () => { diff --git a/tests/ui/MerchantListItemHeaderTest.tsx b/tests/ui/MerchantListItemHeaderTest.tsx index 9c04024ded0f..8ea42cbed44f 100644 --- a/tests/ui/MerchantListItemHeaderTest.tsx +++ b/tests/ui/MerchantListItemHeaderTest.tsx @@ -211,7 +211,7 @@ describe('MerchantListItemHeader', () => { const checkbox = screen.getByRole('checkbox'); fireEvent.press(checkbox); - expect(onCheckboxPress).toHaveBeenCalledWith(merchantItem); + expect(onCheckboxPress).toHaveBeenCalledWith({shiftKey: false, additive: false}); }); it('should show checkbox as checked when isSelectAllChecked is true', async () => { diff --git a/tests/ui/MonthListItemHeaderTest.tsx b/tests/ui/MonthListItemHeaderTest.tsx index 5bef65155abd..55f2b5da15af 100644 --- a/tests/ui/MonthListItemHeaderTest.tsx +++ b/tests/ui/MonthListItemHeaderTest.tsx @@ -191,7 +191,7 @@ describe('MonthListItemHeader', () => { const checkbox = screen.getByRole('checkbox'); fireEvent.press(checkbox); - expect(onCheckboxPress).toHaveBeenCalledWith(monthItem); + expect(onCheckboxPress).toHaveBeenCalledWith({shiftKey: false, additive: false}); }); it('should show checkbox as checked when isSelectAllChecked is true', async () => { diff --git a/tests/ui/WeekListItemHeaderTest.tsx b/tests/ui/WeekListItemHeaderTest.tsx index 74e205090391..456c6986e0ff 100644 --- a/tests/ui/WeekListItemHeaderTest.tsx +++ b/tests/ui/WeekListItemHeaderTest.tsx @@ -188,7 +188,7 @@ describe('WeekListItemHeader', () => { const checkbox = screen.getByRole('checkbox'); fireEvent.press(checkbox); - expect(onCheckboxPress).toHaveBeenCalledWith(weekItem); + expect(onCheckboxPress).toHaveBeenCalledWith({shiftKey: false, additive: false}); }); it('should show checkbox as checked when isSelectAllChecked is true', async () => { diff --git a/tests/ui/YearListItemHeaderTest.tsx b/tests/ui/YearListItemHeaderTest.tsx index 7779681ee43d..abb6bcb846cc 100644 --- a/tests/ui/YearListItemHeaderTest.tsx +++ b/tests/ui/YearListItemHeaderTest.tsx @@ -190,7 +190,7 @@ describe('YearListItemHeader', () => { const checkbox = screen.getByRole('checkbox'); fireEvent.press(checkbox); - expect(onCheckboxPress).toHaveBeenCalledWith(yearItem); + expect(onCheckboxPress).toHaveBeenCalledWith({shiftKey: false, additive: false}); }); it('should show checkbox as checked when isSelectAllChecked is true', async () => { diff --git a/tests/unit/TransactionGroupListItemTest.tsx b/tests/unit/TransactionGroupListItemTest.tsx index 3cc1c51f20a8..74984abb2f4f 100644 --- a/tests/unit/TransactionGroupListItemTest.tsx +++ b/tests/unit/TransactionGroupListItemTest.tsx @@ -465,7 +465,7 @@ describe('Empty Report Selection', () => { // Then onCheckboxPress should be called with the empty report and undefined (for groupBy reports) expect(mockOnCheckboxPress).toHaveBeenCalledTimes(1); - expect(mockOnCheckboxPress).toHaveBeenCalledWith(mockEmptyReport, undefined); + expect(mockOnCheckboxPress).toHaveBeenCalledWith(mockEmptyReport, undefined, {shiftKey: false, additive: false}); }); it('should call onCheckboxPress multiple times when checkbox is clicked multiple times', async () => { @@ -511,7 +511,7 @@ describe('Empty Report Selection', () => { await waitForBatchedUpdatesWithAct(); expect(mockOnCheckboxPress).toHaveBeenCalledTimes(1); - expect(mockOnCheckboxPress).toHaveBeenCalledWith(mockEmptyReport, undefined); + expect(mockOnCheckboxPress).toHaveBeenCalledWith(mockEmptyReport, undefined, {shiftKey: false, additive: false}); unmountEmpty(); mockOnCheckboxPress.mockClear(); @@ -532,7 +532,7 @@ describe('Empty Report Selection', () => { await waitForBatchedUpdatesWithAct(); expect(mockOnCheckboxPress).toHaveBeenCalledTimes(1); - expect(mockOnCheckboxPress).toHaveBeenCalledWith(mockNonEmptyReport, undefined); + expect(mockOnCheckboxPress).toHaveBeenCalledWith(mockNonEmptyReport, undefined, {shiftKey: false, additive: false}); unmountNonEmpty(); }); @@ -549,9 +549,9 @@ describe('Empty Report Selection', () => { expect(mockOnCheckboxPress).toHaveBeenCalledTimes(i); } - expect(mockOnCheckboxPress).toHaveBeenNthCalledWith(1, mockEmptyReport, undefined); - expect(mockOnCheckboxPress).toHaveBeenNthCalledWith(2, mockEmptyReport, undefined); - expect(mockOnCheckboxPress).toHaveBeenNthCalledWith(3, mockEmptyReport, undefined); + expect(mockOnCheckboxPress).toHaveBeenNthCalledWith(1, mockEmptyReport, undefined, {shiftKey: false, additive: false}); + expect(mockOnCheckboxPress).toHaveBeenNthCalledWith(2, mockEmptyReport, undefined, {shiftKey: false, additive: false}); + expect(mockOnCheckboxPress).toHaveBeenNthCalledWith(3, mockEmptyReport, undefined, {shiftKey: false, additive: false}); }); it('should show expandable content for non-empty reports', async () => { diff --git a/tests/unit/hooks/useShiftRangeSelection.test.ts b/tests/unit/hooks/useShiftRangeSelection.test.ts new file mode 100644 index 000000000000..e2210a54a6e9 --- /dev/null +++ b/tests/unit/hooks/useShiftRangeSelection.test.ts @@ -0,0 +1,735 @@ +import {act, renderHook} from '@testing-library/react-native'; +import useShiftRangeSelection from '@hooks/useShiftRangeSelection'; +import {applyShiftRangeBatchToKeySet, farthestEndFromAnchor, getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; +import type {ShiftRangeBatch} from '@libs/shiftRangeSelection'; + +type Row = {keyForList: string; isHeader?: boolean; isDisabled?: boolean}; + +// Fixed-length tuple types let the test access indices without triggering the `prefer-at` lint rule. +type Tuple2 = [T, T]; +type Tuple5 = [T, T, T, T, T]; +type Tuple6 = [T, T, T, T, T, T]; +type Tuple7 = [T, T, T, T, T, T, T]; + +const ROWS: Tuple5 = [{keyForList: 'a'}, {keyForList: 'b'}, {keyForList: 'c'}, {keyForList: 'd'}, {keyForList: 'e'}]; + +const GROUPED: Tuple7 = [ + {keyForList: 'h1', isHeader: true}, + {keyForList: 'a'}, + {keyForList: 'b'}, + {keyForList: 'h2', isHeader: true}, + {keyForList: 'c'}, + {keyForList: 'd'}, + {keyForList: 'e'}, +]; + +const MIXED: Tuple5 = [{keyForList: 'a'}, {keyForList: 'b', isDisabled: true}, {keyForList: 'c'}, {keyForList: 'd', isDisabled: true}, {keyForList: 'e'}]; + +function makeParams(overrides: Partial>[0]> = {}): Parameters>[0] { + return { + items: ROWS, + onApplyRange: jest.fn(), + ...overrides, + }; +} + +function makeApplyMock() { + return jest.fn]>(); +} + +function nthBatchKeys(mockFn: ReturnType, n: number): {toSelect: string[]; toDeselect: string[]} { + const batch = mockFn.mock.calls.at(n)?.at(0) ?? {toSelect: [], toDeselect: []}; + return { + toSelect: batch.toSelect.map((r) => r.keyForList), + toDeselect: batch.toDeselect.map((r) => r.keyForList), + }; +} + +describe('useShiftRangeSelection', () => { + describe('getAnchorKey', () => { + it('falls back to the first selectable row when no session and no prior anchor', () => { + const {result} = renderHook(() => useShiftRangeSelection(makeParams())); + expect(result.current.getAnchorKey()).toBe('a'); + }); + + it('falls back to the first selected row when no session', () => { + const {result} = renderHook(() => useShiftRangeSelection(makeParams({getSelectedKeys: () => new Set(['c'])}))); + expect(result.current.getAnchorKey()).toBe('c'); + }); + + it('returns null when items is empty and no selection', () => { + const {result} = renderHook(() => useShiftRangeSelection(makeParams({items: []}))); + expect(result.current.getAnchorKey()).toBeNull(); + }); + + it('returns the last notifyAnchor key', () => { + const {result} = renderHook(() => useShiftRangeSelection(makeParams())); + act(() => result.current.notifyAnchor(ROWS[2])); + expect(result.current.getAnchorKey()).toBe('c'); + }); + + it('returns the active anchor across a shift+click sequence', () => { + const {result} = renderHook(() => useShiftRangeSelection(makeParams())); + act(() => result.current.notifyAnchor(ROWS[1])); + act(() => { + result.current.applyShiftClick(ROWS[3], {shiftKey: true}); + }); + expect(result.current.getAnchorKey()).toBe('b'); + }); + + it('clearAnchor resets the session so the next anchor query falls back to the first selectable row', () => { + const {result} = renderHook(() => useShiftRangeSelection(makeParams())); + act(() => result.current.notifyAnchor(ROWS[0])); + act(() => { + result.current.applyShiftClick(ROWS[2], {shiftKey: true}); + }); + act(() => result.current.clearAnchor()); + expect(result.current.getAnchorKey()).toBe('a'); + }); + }); + + describe('applyShiftClick — gating', () => { + it('returns false when shiftKey is missing or false', () => { + const {result} = renderHook(() => useShiftRangeSelection(makeParams())); + act(() => result.current.notifyAnchor(ROWS[0])); + let applied = true; + act(() => { + applied = result.current.applyShiftClick(ROWS[2]); + }); + expect(applied).toBe(false); + act(() => { + applied = result.current.applyShiftClick(ROWS[2], {shiftKey: false}); + }); + expect(applied).toBe(false); + }); + + it('returns false when additive is true but shiftKey is false', () => { + const {result} = renderHook(() => useShiftRangeSelection(makeParams())); + act(() => result.current.notifyAnchor(ROWS[0])); + let applied = true; + act(() => { + applied = result.current.applyShiftClick(ROWS[2], {shiftKey: false, additive: true}); + }); + expect(applied).toBe(false); + }); + + it('returns true when a shift+click successfully extends a range', () => { + const {result} = renderHook(() => useShiftRangeSelection(makeParams())); + act(() => result.current.notifyAnchor(ROWS[0])); + let applied = false; + act(() => { + applied = result.current.applyShiftClick(ROWS[2], {shiftKey: true}); + }); + expect(applied).toBe(true); + }); + + it('returns false when the target row is a header', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => + useShiftRangeSelection( + makeParams({ + items: GROUPED, + isHeaderItem: (r) => !!r.isHeader, + onApplyRange, + }), + ), + ); + act(() => result.current.notifyAnchor(GROUPED[1])); + let applied = true; + act(() => { + applied = result.current.applyShiftClick(GROUPED[3], {shiftKey: true}); + }); + expect(applied).toBe(false); + expect(onApplyRange).not.toHaveBeenCalled(); + }); + + it('returns false when the target row is disabled', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => + useShiftRangeSelection( + makeParams({ + items: MIXED, + isDisabledItem: (r) => !!r.isDisabled, + onApplyRange, + }), + ), + ); + act(() => result.current.notifyAnchor(MIXED[0])); + let applied = true; + act(() => { + applied = result.current.applyShiftClick(MIXED[1], {shiftKey: true}); + }); + expect(applied).toBe(false); + expect(onApplyRange).not.toHaveBeenCalled(); + }); + + it("returns false when the target row's key is empty", () => { + const onApplyRange = makeApplyMock(); + const missingKeyRow: Row = {keyForList: ''}; + const itemsWithMissingKey: Tuple6 = [...ROWS, missingKeyRow]; + const {result} = renderHook(() => useShiftRangeSelection(makeParams({items: itemsWithMissingKey, onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[0])); + let applied = true; + act(() => { + applied = result.current.applyShiftClick(missingKeyRow, {shiftKey: true}); + }); + expect(applied).toBe(false); + }); + + it('returns false when the target row cannot be keyed', () => { + const onApplyRange = makeApplyMock(); + const items: Tuple2 = [{keyForList: 'a'}, {keyForList: 'b'}]; + const {result} = renderHook(() => + useShiftRangeSelection( + makeParams({ + items, + getItemKey: () => null, + onApplyRange, + }), + ), + ); + let applied = true; + act(() => { + applied = result.current.applyShiftClick(items[1], {shiftKey: true}); + }); + expect(applied).toBe(false); + }); + }); + + describe('applyShiftClick — anchor resolution', () => { + it('uses notifyAnchor key as anchor for the first shift+click', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[1])); + act(() => { + result.current.applyShiftClick(ROWS[3], {shiftKey: true}); + }); + expect(onApplyRange).toHaveBeenCalledTimes(1); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['b', 'c', 'd'], toDeselect: []}); + }); + + it('uses the first selected row as anchor when no plain click came first', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => + useShiftRangeSelection( + makeParams({ + getSelectedKeys: () => new Set(['c']), + onApplyRange, + }), + ), + ); + act(() => { + result.current.applyShiftClick(ROWS[4], {shiftKey: true}); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['c', 'd', 'e'], toDeselect: []}); + }); + + it('accepts an array from getSelectedKeys in addition to a Set', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => + useShiftRangeSelection( + makeParams({ + getSelectedKeys: () => ['c'], + onApplyRange, + }), + ), + ); + act(() => { + result.current.applyShiftClick(ROWS[4], {shiftKey: true}); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['c', 'd', 'e'], toDeselect: []}); + }); + + it('uses the first selectable row as anchor when nothing is selected', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => { + result.current.applyShiftClick(ROWS[2], {shiftKey: true}); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: []}); + }); + + it('the row-fallback anchor skips header rows', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => + useShiftRangeSelection( + makeParams({ + items: GROUPED, + isHeaderItem: (r) => !!r.isHeader, + onApplyRange, + }), + ), + ); + act(() => { + result.current.applyShiftClick(GROUPED[2], {shiftKey: true}); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'b'], toDeselect: []}); + }); + + it('ignores a header passed to notifyAnchor and falls back to the selected row', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => + useShiftRangeSelection( + makeParams({ + items: GROUPED, + isHeaderItem: (r) => !!r.isHeader, + getSelectedKeys: () => new Set(['c']), + onApplyRange, + }), + ), + ); + act(() => result.current.notifyAnchor(GROUPED[0])); + act(() => { + result.current.applyShiftClick(GROUPED[5], {shiftKey: true}); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['c', 'd'], toDeselect: []}); + }); + + it('ignores a disabled row passed to notifyAnchor and falls back to the first selectable row', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => + useShiftRangeSelection( + makeParams({ + items: MIXED, + isDisabledItem: (r) => !!r.isDisabled, + onApplyRange, + }), + ), + ); + act(() => result.current.notifyAnchor(MIXED[1])); + act(() => { + result.current.applyShiftClick(MIXED[4], {shiftKey: true}); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'c', 'e'], toDeselect: []}); + }); + }); + + describe('range computation', () => { + it('selects from anchor down through the target when the target is below', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[0])); + act(() => { + result.current.applyShiftClick(ROWS[3], {shiftKey: true}); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'b', 'c', 'd'], toDeselect: []}); + }); + + it('selects from the target up through anchor when the target is above', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[4])); + act(() => { + result.current.applyShiftClick(ROWS[1], {shiftKey: true}); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['b', 'c', 'd', 'e'], toDeselect: []}); + }); + + it('headers between anchor and target are excluded from the batch', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => + useShiftRangeSelection( + makeParams({ + items: GROUPED, + isHeaderItem: (r) => !!r.isHeader, + onApplyRange, + }), + ), + ); + act(() => result.current.notifyAnchor(GROUPED[1])); + act(() => { + result.current.applyShiftClick(GROUPED[5], {shiftKey: true}); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'b', 'c', 'd'], toDeselect: []}); + }); + + it('disabled rows between anchor and target are excluded from the batch', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => + useShiftRangeSelection( + makeParams({ + items: MIXED, + isDisabledItem: (r) => !!r.isDisabled, + onApplyRange, + }), + ), + ); + act(() => result.current.notifyAnchor(MIXED[0])); + act(() => { + result.current.applyShiftClick(MIXED[4], {shiftKey: true}); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'c', 'e'], toDeselect: []}); + }); + + it('emits a single-row batch when the target equals the anchor', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[2])); + act(() => { + result.current.applyShiftClick(ROWS[2], {shiftKey: true}); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['c'], toDeselect: []}); + }); + + it('always includes the anchor row in the emitted toSelect batch', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[2])); + act(() => { + result.current.applyShiftClick(ROWS[4], {shiftKey: true}); + }); + expect(nthBatchKeys(onApplyRange, 0).toSelect).toContain('c'); + }); + }); + + describe('session continuity', () => { + it('deselects the tail when a second shift+click lands inside the existing range', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[0])); + act(() => { + result.current.applyShiftClick(ROWS[4], {shiftKey: true}); + }); + act(() => { + result.current.applyShiftClick(ROWS[2], {shiftKey: true}); + }); + expect(nthBatchKeys(onApplyRange, 1)).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: ['d', 'e']}); + }); + + it('keeps the anchor stable when a second shift+click extends past the previous end', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[0])); + act(() => { + result.current.applyShiftClick(ROWS[2], {shiftKey: true}); + }); + act(() => { + result.current.applyShiftClick(ROWS[4], {shiftKey: true}); + }); + expect(nthBatchKeys(onApplyRange, 1)).toEqual({toSelect: ['a', 'b', 'c', 'd', 'e'], toDeselect: []}); + }); + + it('deselects the prior side when a shift+click reverses direction across the anchor', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[2])); + act(() => { + result.current.applyShiftClick(ROWS[4], {shiftKey: true}); + }); + act(() => { + result.current.applyShiftClick(ROWS[0], {shiftKey: true}); + }); + expect(nthBatchKeys(onApplyRange, 1)).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: ['d', 'e']}); + }); + + it('notifyAnchor mid-session ends the session', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[0])); + act(() => { + result.current.applyShiftClick(ROWS[4], {shiftKey: true}); + }); + act(() => result.current.notifyAnchor(ROWS[2])); + act(() => { + result.current.applyShiftClick(ROWS[3], {shiftKey: true}); + }); + expect(nthBatchKeys(onApplyRange, 1)).toEqual({toSelect: ['c', 'd'], toDeselect: []}); + }); + + it('re-emits the full range when a shift+click lands on the same target as the previous one', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[0])); + act(() => { + result.current.applyShiftClick(ROWS[3], {shiftKey: true}); + }); + onApplyRange.mockClear(); + act(() => { + result.current.applyShiftClick(ROWS[3], {shiftKey: true}); + }); + expect(onApplyRange).toHaveBeenCalledTimes(1); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'b', 'c', 'd'], toDeselect: []}); + }); + + it('clearAnchor mid-session lets the next shift+click start fresh', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[0])); + act(() => { + result.current.applyShiftClick(ROWS[4], {shiftKey: true}); + }); + act(() => result.current.clearAnchor()); + onApplyRange.mockClear(); + act(() => { + result.current.notifyAnchor(ROWS[2]); + }); + act(() => { + result.current.applyShiftClick(ROWS[3], {shiftKey: true}); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['c', 'd'], toDeselect: []}); + }); + }); + + describe('additive shift+click', () => { + it('extends without emitting toDeselect when the additive modifier is set', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[0])); + act(() => { + result.current.applyShiftClick(ROWS[2], {shiftKey: true, additive: true}); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: []}); + }); + + it('preserves the previous range when a second additive shift+click lands inside it', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[0])); + act(() => { + result.current.applyShiftClick(ROWS[4], {shiftKey: true, additive: true}); + }); + act(() => { + result.current.applyShiftClick(ROWS[2], {shiftKey: true, additive: true}); + }); + expect(nthBatchKeys(onApplyRange, 1)).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: []}); + }); + + it('lets a subsequent non-additive shift+click replace the additive range', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[0])); + act(() => { + result.current.applyShiftClick(ROWS[4], {shiftKey: true, additive: true}); + }); + act(() => { + result.current.applyShiftClick(ROWS[2], {shiftKey: true}); + }); + expect(nthBatchKeys(onApplyRange, 1)).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: ['d', 'e']}); + }); + + it('uses the selected-row fallback when no prior notify happened', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange, getSelectedKeys: () => ['b']}))); + act(() => { + result.current.applyShiftClick(ROWS[3], {shiftKey: true, additive: true}); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['b', 'c', 'd'], toDeselect: []}); + }); + + it('uses the first selectable row when additive shift+click has no anchor and no selection', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => { + result.current.applyShiftClick(ROWS[2], {shiftKey: true, additive: true}); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: []}); + }); + + it('extends additively after a non-additive range was emitted', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[0])); + act(() => { + result.current.applyShiftClick(ROWS[2], {shiftKey: true}); + }); + act(() => { + result.current.applyShiftClick(ROWS[4], {shiftKey: true, additive: true}); + }); + expect(nthBatchKeys(onApplyRange, 1)).toEqual({toSelect: ['a', 'b', 'c', 'd', 'e'], toDeselect: []}); + }); + }); + + describe('items change mid-session', () => { + it('extends from the anchor when the previous endpoint disappears mid-session', () => { + const onApplyRange = makeApplyMock(); + const {result, rerender} = renderHook(({items}: {items: Row[]}) => useShiftRangeSelection(makeParams({items, onApplyRange})), { + initialProps: {items: [...ROWS]}, + }); + act(() => result.current.notifyAnchor(ROWS[0])); + act(() => { + result.current.applyShiftClick(ROWS[4], {shiftKey: true}); + }); + rerender({items: ROWS.slice(0, 3)}); + onApplyRange.mockClear(); + act(() => { + result.current.applyShiftClick(ROWS[1], {shiftKey: true}); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'b'], toDeselect: []}); + }); + + it('keeps the anchor active when items shrink mid-session', () => { + const onApplyRange = makeApplyMock(); + const {result, rerender} = renderHook(({items}: {items: Row[]}) => useShiftRangeSelection(makeParams({items, onApplyRange})), { + initialProps: {items: [...ROWS]}, + }); + act(() => result.current.notifyAnchor(ROWS[0])); + rerender({items: ROWS.slice(0, 3)}); + act(() => { + result.current.applyShiftClick(ROWS[2], {shiftKey: true}); + }); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: []}); + }); + + it('falls back through resolveAnchor when the ranging anchor disappears between shift+clicks', () => { + const onApplyRange = makeApplyMock(); + const {result, rerender} = renderHook(({items}: {items: Row[]}) => useShiftRangeSelection(makeParams({items, onApplyRange})), { + initialProps: {items: [...ROWS]}, + }); + act(() => result.current.notifyAnchor(ROWS[0])); + act(() => { + result.current.applyShiftClick(ROWS[2], {shiftKey: true}); + }); + // Anchor 'a' is removed from the items list while the session is ranging. + rerender({items: ROWS.slice(1)}); + onApplyRange.mockClear(); + act(() => { + result.current.applyShiftClick(ROWS[4], {shiftKey: true}); + }); + // Falls back to first selectable row ('b') as the new anchor; emits a fresh range from there to the target. + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['b', 'c', 'd', 'e'], toDeselect: []}); + }); + }); + + describe('defensive bails', () => { + it('returns false when every item is excluded and no anchor can be resolved', () => { + const onApplyRange = makeApplyMock(); + const allHeaders: Tuple2 = [ + {keyForList: 'h1', isHeader: true}, + {keyForList: 'h2', isHeader: true}, + ]; + const {result} = renderHook(() => + useShiftRangeSelection( + makeParams({ + items: allHeaders, + isHeaderItem: (r) => !!r.isHeader, + onApplyRange, + }), + ), + ); + let applied = true; + act(() => { + applied = result.current.applyShiftClick(allHeaders[0], {shiftKey: true}); + }); + expect(applied).toBe(false); + expect(onApplyRange).not.toHaveBeenCalled(); + }); + + it('ignores notifyAnchor when the item key is empty', () => { + const {result} = renderHook(() => useShiftRangeSelection(makeParams({items: []}))); + act(() => result.current.notifyAnchor({keyForList: ''})); + expect(result.current.getAnchorKey()).toBeNull(); + }); + + it('clearAnchor on an idle hook does not emit a batch', () => { + const onApplyRange = makeApplyMock(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({items: [], onApplyRange}))); + act(() => result.current.clearAnchor()); + expect(onApplyRange).not.toHaveBeenCalled(); + expect(result.current.getAnchorKey()).toBeNull(); + }); + }); +}); + +describe('getModifierKeysFromEvent', () => { + type EventArg = Parameters[0]; + + it('returns shiftKey false and additive false for nullish input', () => { + expect(getModifierKeysFromEvent(undefined)).toEqual({shiftKey: false, additive: false}); + expect(getModifierKeysFromEvent(null)).toEqual({shiftKey: false, additive: false}); + }); + + it('reads shiftKey from the outer event when present', () => { + expect(getModifierKeysFromEvent({shiftKey: true} as EventArg).shiftKey).toBe(true); + expect(getModifierKeysFromEvent({shiftKey: false} as EventArg).shiftKey).toBe(false); + }); + + it('falls back to nativeEvent.shiftKey when outer shiftKey is absent', () => { + expect(getModifierKeysFromEvent({nativeEvent: {shiftKey: true}} as EventArg).shiftKey).toBe(true); + expect(getModifierKeysFromEvent({nativeEvent: {shiftKey: false}} as EventArg).shiftKey).toBe(false); + }); + + it('outer shiftKey false takes precedence over nativeEvent shiftKey true', () => { + expect(getModifierKeysFromEvent({shiftKey: false, nativeEvent: {shiftKey: true}} as EventArg).shiftKey).toBe(false); + }); + + it('marks additive when either modifier key is set', () => { + expect(getModifierKeysFromEvent({metaKey: true} as EventArg).additive).toBe(true); + expect(getModifierKeysFromEvent({ctrlKey: true} as EventArg).additive).toBe(true); + }); + + it('reads metaKey and ctrlKey from nativeEvent as a fallback', () => { + expect(getModifierKeysFromEvent({nativeEvent: {metaKey: true}} as EventArg).additive).toBe(true); + expect(getModifierKeysFromEvent({nativeEvent: {ctrlKey: true}} as EventArg).additive).toBe(true); + }); + + it('reports additive false when neither modifier key is set', () => { + expect(getModifierKeysFromEvent({shiftKey: true} as EventArg).additive).toBe(false); + }); +}); + +describe('applyShiftRangeBatchToKeySet', () => { + type Item = {keyForList: string; isDisabled?: boolean}; + const getKey = (i: Item) => i.keyForList; + + it('returns a copy of prevKeys for empty batches', () => { + const prev = ['a', 'b']; + const out = applyShiftRangeBatchToKeySet({toSelect: [], toDeselect: []}, prev, getKey); + expect(out).toEqual(['a', 'b']); + expect(out).not.toBe(prev); + }); + + it('adds toSelect keys when the previous list is empty', () => { + const out = applyShiftRangeBatchToKeySet({toSelect: [{keyForList: 'a'}, {keyForList: 'b'}], toDeselect: []}, [] as string[], getKey); + expect(out).toEqual(['a', 'b']); + }); + + it('adds toSelect keys, preserving prevKeys order then new keys', () => { + const out = applyShiftRangeBatchToKeySet({toSelect: [{keyForList: 'c'}, {keyForList: 'd'}], toDeselect: []}, ['a', 'b'], getKey); + expect(out).toEqual(['a', 'b', 'c', 'd']); + }); + + it('removes toDeselect keys', () => { + const out = applyShiftRangeBatchToKeySet({toSelect: [], toDeselect: [{keyForList: 'b'}]}, ['a', 'b', 'c'], getKey); + expect(out).toEqual(['a', 'c']); + }); + + it('dedupes new keys against prevKeys', () => { + const out = applyShiftRangeBatchToKeySet({toSelect: [{keyForList: 'a'}, {keyForList: 'b'}, {keyForList: 'c'}], toDeselect: []}, ['a'], getKey); + expect(out).toEqual(['a', 'b', 'c']); + }); + + it('re-adds a key at the end when it appears in both toDeselect and toSelect', () => { + const out = applyShiftRangeBatchToKeySet({toSelect: [{keyForList: 'b'}], toDeselect: [{keyForList: 'b'}]}, ['a', 'b', 'c'], getKey); + expect(out).toEqual(['a', 'c', 'b']); + }); + + it('skips items where isSelectable returns false', () => { + const out = applyShiftRangeBatchToKeySet({toSelect: [{keyForList: 'b'}, {keyForList: 'c'}], toDeselect: []}, ['a'], getKey, (i) => i.keyForList !== 'b'); + expect(out).toEqual(['a', 'c']); + }); + + it('skips items with isDisabled or isDisabledCheckbox by default', () => { + const out = applyShiftRangeBatchToKeySet({toSelect: [{keyForList: 'b', isDisabled: true}, {keyForList: 'c'}], toDeselect: []}, ['a'], getKey); + expect(out).toEqual(['a', 'c']); + }); + + it('skips items whose key is null/undefined', () => { + type WithNullKey = {keyForList: string | null}; + const out = applyShiftRangeBatchToKeySet({toSelect: [{keyForList: null}, {keyForList: 'c'}], toDeselect: []}, ['a'], (i) => i.keyForList); + expect(out).toEqual(['a', 'c']); + }); +}); + +describe('farthestEndFromAnchor', () => { + it('returns last when the anchor is above the group', () => { + expect(farthestEndFromAnchor(3, 6, 0)).toBe('last'); + }); + + it('returns first when the anchor is below the group', () => { + expect(farthestEndFromAnchor(3, 6, 10)).toBe('first'); + }); + + it('returns last when there is no anchor', () => { + expect(farthestEndFromAnchor(3, 6, -1)).toBe('last'); + }); +});