From febd5a96347dd4cf917e01cf9be2e98d6ffc67f2 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 31 May 2026 20:11:04 +0300 Subject: [PATCH 01/19] feat: Shift+Click Range Selection Not Supported in Multi-Select Lists --- src/CONST/index.ts | 12 + .../MoneyRequestReportTransactionItem.tsx | 2 +- .../MoneyRequestReportTransactionList.tsx | 62 +++- .../ListItem/BaseListItemHeader.tsx | 5 +- .../ListItem/CardListItemHeader.tsx | 5 +- .../ListItem/ExpenseReportListItem.tsx | 9 +- .../ExpenseReportListItemRowNarrow.tsx | 3 +- .../ExpenseReportListItemRowWide.tsx | 3 +- .../ExpenseReportListItemRow/types.ts | 2 +- .../ListItem/MemberListItemHeader.tsx | 5 +- .../ListItem/ReportListItemHeader.tsx | 7 +- .../ListItem/TransactionGroupListExpanded.tsx | 2 +- .../ListItem/TransactionGroupListItem.tsx | 7 +- .../TransactionListItemNarrow.tsx | 2 +- .../TransactionListItemWide.tsx | 2 +- .../ListItem/TransactionListItem/types.ts | 3 +- .../ListItem/WithdrawalIDListItemHeader.tsx | 5 +- src/components/Search/SearchList/index.tsx | 2 +- src/components/Search/index.tsx | 188 ++++++++-- .../SelectionList/BaseSelectionList.tsx | 63 +++- .../ListItem/ListItemRenderer.tsx | 5 +- .../ListItem/SelectableListItem.tsx | 16 +- .../SelectionList/ListItem/types.ts | 4 +- .../components/ListSelectionButton.tsx | 5 +- src/components/SelectionList/types.ts | 6 +- .../configuration/SpendRuleCategoryBase.tsx | 2 + .../TransactionItemRowNarrow.tsx | 5 +- .../TransactionItemRowWide.tsx | 5 +- src/components/TransactionItemRow/types.ts | 2 +- src/hooks/useShiftRangeSelection.ts | 337 ++++++++++++++++++ src/pages/ReportParticipantsPage.tsx | 2 + src/pages/RoomMembersPage.tsx | 2 + src/pages/UnreportedExpenseListItem.tsx | 7 +- src/pages/settings/Rules/ExpenseRulesPage.tsx | 2 + src/pages/workspace/WorkspaceMembersPage.tsx | 2 + .../categories/WorkspaceCategoriesPage.tsx | 2 + .../distanceRates/PolicyDistanceRatesPage.tsx | 2 + .../expensifyCard/WorkspaceCardListRow.tsx | 5 +- .../WorkspaceExpensifyCardListPage.tsx | 28 +- .../perDiem/WorkspacePerDiemPage.tsx | 12 + .../reports/ReportFieldsListValuesPage.tsx | 12 + .../rules/SpendRules/SpendRuleCardPage.tsx | 2 + .../workspace/tags/WorkspaceTagsPage.tsx | 2 + .../workspace/tags/WorkspaceViewTagsPage.tsx | 2 + .../workspace/taxes/WorkspaceTaxesPage.tsx | 2 + 45 files changed, 748 insertions(+), 112 deletions(-) create mode 100644 src/hooks/useShiftRangeSelection.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 48cee22a2e53..7bf0e2ce4997 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1177,6 +1177,18 @@ const CONST = { [PLATFORM_IOS]: {input: keyInputRightArrow}, }, }, + SHIFT_ARROW_UP: { + descriptionKey: null, + shortcutKey: 'ArrowUp', + modifiers: ['SHIFT'], + trigger: {DEFAULT: {input: keyInputUpArrow}}, + }, + SHIFT_ARROW_DOWN: { + descriptionKey: null, + shortcutKey: 'ArrowDown', + modifiers: ['SHIFT'], + trigger: {DEFAULT: {input: keyInputDownArrow}}, + }, TAB: { descriptionKey: null, shortcutKey: 'Tab', diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx index c1fb7a6e92ee..92c24363c4a0 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx @@ -41,7 +41,7 @@ type MoneyRequestReportTransactionItemProps = { isSelectionModeEnabled: boolean; /** Callback function triggered upon pressing a transaction checkbox. */ - toggleTransaction: (transactionID: string) => void; + toggleTransaction: (transactionID: string, options?: {shiftKey?: boolean}) => 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 c33f74b54840..42883e2bf054 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, {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -237,19 +238,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( @@ -415,12 +403,47 @@ 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]); + // Visually-rendered order, not the prop-level `transactions` (which is 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 lastClickedTransactionIDRef = useRef(null); + + const rangeApi = useShiftRangeSelection({ + items: visualOrderTransactions, + getItemKey: (t) => t.transactionID ?? null, + getSelectedKeys: () => selectedTransactionIDs, + getFocusedKey: () => lastClickedTransactionIDRef.current, + isDisabledItem: (t) => isTransactionPendingDelete(t), + onApplyRange: (batch) => + setSelectedTransactions( + applyShiftRangeBatchToKeySet( + batch, + selectedTransactionIDs, + (t) => t.transactionID, + (t) => !isTransactionPendingDelete(t), + ), + ), + }); + + const toggleTransaction = useCallback( + (transactionID: string, options?: {shiftKey?: boolean}) => { + 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]); + lastClickedTransactionIDRef.current = transactionID; + }, + [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 @@ -757,6 +780,7 @@ function MoneyRequestReportTransactionList({ } else { setSelectedTransactions(transactionsWithoutPendingDelete.map((t) => t.transactionID)); } + lastClickedTransactionIDRef.current = null; }} 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..ad7d2e343008 100644 --- a/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx @@ -6,6 +6,7 @@ import type {ListItem} from '@components/SelectionList/types'; import TextWithTooltip from '@components/TextWithTooltip'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; @@ -60,7 +61,7 @@ type BaseListItemHeaderProps = { columnStyleKey: ColumnStyleKey; /** Callback to fire when a checkbox is pressed */ - onCheckboxPress?: (item: TItem) => void; + onCheckboxPress?: (item: TItem, options?: {shiftKey?: boolean}) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -144,7 +145,7 @@ function BaseListItemHeader({ {!!canSelectMultiple && ( onCheckboxPress?.(item as unknown as TItem)} + onPress={(event) => onCheckboxPress?.(item as unknown as TItem, {shiftKey: getShiftKeyFromEvent(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..7ea3f2a34ced 100644 --- a/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx @@ -8,6 +8,7 @@ import TextWithTooltip from '@components/TextWithTooltip'; import UserDetailsTooltip from '@components/UserDetailsTooltip'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -24,7 +25,7 @@ type CardListItemHeaderProps = { card: TransactionCardGroupListItemType; /** Callback to fire when a checkbox is pressed */ - onCheckboxPress?: (item: TItem) => void; + onCheckboxPress?: (item: TItem, options?: {shiftKey?: boolean}) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -144,7 +145,7 @@ function CardListItemHeader({ {!!canSelectMultiple && ( onCheckboxPress?.(cardItem as unknown as TItem)} + onPress={(event) => onCheckboxPress?.(cardItem as unknown as TItem, {shiftKey: getShiftKeyFromEvent(event)})} isChecked={isSelectAllChecked} isIndeterminate={isIndeterminate} disabled={!!isDisabled || cardItem.isDisabledCheckbox} diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx index 182cb0ef0401..6052235a991d 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx @@ -216,9 +216,12 @@ function ExpenseReportListItem({ translate, ]); - const handleSelectionButtonPress = useCallback(() => { - onSelectionButtonPress?.(reportItem as unknown as TItem); - }, [onSelectionButtonPress, reportItem]); + const handleSelectionButtonPress = useCallback( + (_passedItem?: unknown, options?: {shiftKey?: boolean}) => { + onSelectionButtonPress?.(reportItem as unknown as TItem, undefined, options); + }, + [onSelectionButtonPress, reportItem], + ); 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..dd3e6532621c 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx @@ -4,6 +4,7 @@ import Checkbox from '@components/Checkbox'; import Text from '@components/Text'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; +import {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; @@ -37,7 +38,7 @@ function ExpenseReportListItemRowNarrow({item, onCheckboxPress = () => {}, canSe > {!!canSelectMultiple && ( onCheckboxPress(undefined, {shiftKey: getShiftKeyFromEvent(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..b0f6e34f927d 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx @@ -11,6 +11,7 @@ import TotalCell from '@components/Search/SearchList/ListItem/TotalCell'; import UserInfoCell from '@components/Search/SearchList/ListItem/UserInfoCell'; import WorkspaceCell from '@components/Search/SearchList/ListItem/WorkspaceCell'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -216,7 +217,7 @@ function ExpenseReportListItemRowWide({ {!!canSelectMultiple && ( onCheckboxPress(undefined, {shiftKey: getShiftKeyFromEvent(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..020d868f15d0 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/types.ts +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/types.ts @@ -6,7 +6,7 @@ import type {ReportAction} from '@src/types/onyx'; type ExpenseReportListItemRowNarrowProps = { item: ExpenseReportListItemType; canSelectMultiple?: boolean; - onCheckboxPress?: () => void; + onCheckboxPress?: (_unused?: unknown, options?: {shiftKey?: boolean}) => 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..d94eaf920529 100644 --- a/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx @@ -7,6 +7,7 @@ import type {ListItem} from '@components/SelectionList/types'; import TextWithTooltip from '@components/TextWithTooltip'; import UserDetailsTooltip from '@components/UserDetailsTooltip'; import useLocalize from '@hooks/useLocalize'; +import {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; @@ -21,7 +22,7 @@ type MemberListItemHeaderProps = { member: TransactionMemberGroupListItemType; /** Callback to fire when a checkbox is pressed */ - onCheckboxPress?: (item: TItem) => void; + onCheckboxPress?: (item: TItem, options?: {shiftKey?: boolean}) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -129,7 +130,7 @@ function MemberListItemHeader({ {!!canSelectMultiple && ( onCheckboxPress?.(memberItem as unknown as TItem)} + onPress={(event) => onCheckboxPress?.(memberItem as unknown as TItem, {shiftKey: getShiftKeyFromEvent(event)})} isChecked={isSelectAllChecked} isIndeterminate={isIndeterminate} disabled={!!isDisabled || memberItem.isDisabledCheckbox} diff --git a/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx b/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx index ee2ae2cd6640..9da015acef45 100644 --- a/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx @@ -13,6 +13,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -37,7 +38,7 @@ type ReportListItemHeaderProps = SearchListActionProps & onSelectRow: (item: TItem, event?: ModifiedMouseEvent) => void; /** Callback to fire when a checkbox is pressed */ - onCheckboxPress?: (item: TItem) => void; + onCheckboxPress?: (item: TItem, options?: {shiftKey?: boolean}) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -69,7 +70,7 @@ type FirstRowReportHeaderProps = { report: TransactionReportGroupListItemType; /** Callback to fire when a checkbox is pressed */ - onCheckboxPress?: (item: TItem) => void; + onCheckboxPress?: (item: TItem, options?: {shiftKey?: boolean}) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -130,7 +131,7 @@ function HeaderFirstRow({ {!!canSelectMultiple && ( onCheckboxPress?.(reportItem as unknown as TItem)} + onPress={(event) => onCheckboxPress?.(reportItem as unknown as TItem, {shiftKey: getShiftKeyFromEvent(event)})} isChecked={isSelectAllChecked} isIndeterminate={isIndeterminate} containerStyle={styles.m0} 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 72d8fd1657da..c0f7d6292a7f 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -294,9 +294,10 @@ function TransactionGroupListItem({ onLongPressRow?.(transaction as unknown as TItem); }; - const handleSelectionButtonPress = (val: TItem) => { - onSelectionButtonPress?.(val, isExpenseReportType ? undefined : transactions); + const handleSelectionButtonPress = (val: TItem, options?: {shiftKey?: boolean}) => { + onSelectionButtonPress?.(val, isExpenseReportType ? undefined : transactions, options); }; + const handleSelectionButtonPressForExpanded = (val: TItem, _itemTransactions?: TransactionListItemType[], options?: {shiftKey?: boolean}) => handleSelectionButtonPress(val, options); const onExpandIconPress = () => { if (isEmpty && !shouldDisplayEmptyView) { @@ -565,7 +566,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..87ca8fa48bc3 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts @@ -1,3 +1,4 @@ +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'; @@ -13,7 +14,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?: {shiftKey?: boolean}) => void; onFocus?: ListItemFocusEventHandler; onLongPressRow?: (item: TItem) => void; shouldSyncFocus?: boolean; diff --git a/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx b/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx index 9e89bb899541..fa9fdfa25c6e 100644 --- a/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx @@ -12,6 +12,7 @@ import useEnvironment from '@hooks/useEnvironment'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -30,7 +31,7 @@ type WithdrawalIDListItemHeaderProps = { withdrawalID: TransactionWithdrawalIDGroupListItemType; /** Callback to fire when a checkbox is pressed */ - onCheckboxPress?: (item: TItem) => void; + onCheckboxPress?: (item: TItem, options?: {shiftKey?: boolean}) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -185,7 +186,7 @@ function WithdrawalIDListItemHeader({ {!!canSelectMultiple && ( onCheckboxPress?.(withdrawalIDItem as unknown as TItem)} + onPress={(event) => onCheckboxPress?.(withdrawalIDItem as unknown as TItem, {shiftKey: getShiftKeyFromEvent(event)})} isChecked={isSelectAllChecked} disabled={!!isDisabled || withdrawalIDItem.isDisabledCheckbox} accessibilityLabel={translate('common.select')} diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index 31bed1e153a4..64b45685cf4f 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -82,7 +82,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?: {shiftKey?: boolean}) => 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 7b98b998000a..1f253b6eb0cb 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'; @@ -1006,14 +1007,152 @@ function Search({ [totalSelectableItemsCount, selectAllMatchingItems], ); + const onApplyShiftRange = useCallback( + ({toSelect, toDeselect}: {toSelect: SearchListItem[]; toDeselect: SearchListItem[]}) => { + const updated: SelectedTransactions = {...selectedTransactions}; + 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, + ); + updated[k] = 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, + ], + ); + + 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); + + // Mirrors stableSortedData (what the renderer iterates) so ranges stay consistent with optimistic injections. + 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 lastClickedKeyRef = useRef(null); + + const rangeApi = useShiftRangeSelection({ + items: flattenedShiftRangeItems, + onApplyRange: onApplyShiftRange, + isHeaderItem: areItemsGrouped ? isTransactionGroupListItemType : undefined, + getSelectedKeys: () => selectedTransactionKeySet, + getFocusedKey: () => lastClickedKeyRef.current, + }); + const toggleTransaction = useCallback( - (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => { - if (isReportActionListItemType(item)) { + (item: SearchListItem, itemTransactions?: TransactionListItemType[], options?: {shiftKey?: boolean}) => { + if (isReportActionListItemType(item) || isTaskListItemType(item)) { return; } - if (isTaskListItemType(item)) { + if (rangeApi.applyShiftClick(item, options)) { return; } + if (isTransactionListItemType(item)) { if (!item.keyForList) { return; @@ -1038,6 +1177,7 @@ function Search({ ); setSelectedTransactions(updatedTransactions); updateSelectAllMatchingItemsState(updatedTransactions); + lastClickedKeyRef.current = item.keyForList ?? null; return; } @@ -1062,6 +1202,7 @@ function Search({ delete reducedSelectedTransactions[reportKey]; setSelectedTransactions(reducedSelectedTransactions); updateSelectAllMatchingItemsState(reducedSelectedTransactions); + lastClickedKeyRef.current = item.keyForList ?? null; return; } @@ -1072,6 +1213,7 @@ function Search({ }; setSelectedTransactions(updatedTransactions); updateSelectAllMatchingItemsState(updatedTransactions); + lastClickedKeyRef.current = item.keyForList ?? null; return; } @@ -1086,6 +1228,7 @@ function Search({ setSelectedTransactions(reducedSelectedTransactions); updateSelectAllMatchingItemsState(reducedSelectedTransactions); + lastClickedKeyRef.current = item.keyForList ?? null; return; } @@ -1118,6 +1261,7 @@ function Search({ }; setSelectedTransactions(updatedTransactions); updateSelectAllMatchingItemsState(updatedTransactions); + lastClickedKeyRef.current = item.keyForList ?? null; }, [ selectedTransactions, @@ -1130,6 +1274,7 @@ function Search({ outstandingReportsByPolicyID, selfDMReport, isProduction, + rangeApi, ], ); @@ -1349,42 +1494,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'))) { @@ -1416,6 +1525,7 @@ function Search({ if (totalSelected > 0) { clearSelectedTransactions(); updateSelectAllMatchingItemsState({}); + lastClickedKeyRef.current = null; return; } diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 38f899f71f65..28e772c16648 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -14,6 +14,7 @@ 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'; @@ -50,6 +51,7 @@ function BaseSelectionList({ onSelectAll, onLongPressRow, onSelectionButtonPress, + onShiftRangeApply, onScrollBeginDrag, onDismissError, onEndReached, @@ -255,6 +257,34 @@ function BaseSelectionList({ const extraData = useMemo(() => [data.length], [data.length]); const syncedSearchValue = searchValueForFocusSync ?? textInputOptions?.value; + const noopApply = useCallback(() => {}, []); + const rangeApi = useShiftRangeSelection({ + items: data, + getItemKey: (item) => item.keyForList ?? null, + getFocusedKey: () => (focusedIndex >= 0 ? (data.at(focusedIndex)?.keyForList ?? null) : null), + getSelectedKeys: () => { + const keys = new Set(); + for (const item of data) { + if (item.isSelected && item.keyForList) { + keys.add(item.keyForList); + } + } + return keys; + }, + isDisabledItem: (item) => !!item.isDisabled || !!item.isDisabledCheckbox, + onApplyRange: onShiftRangeApply ?? noopApply, + }); + + const handleSelectionButtonPress = useCallback( + (item: TItem, itemTransactions?: unknown, options?: {shiftKey?: boolean}) => { + if (onShiftRangeApply && rangeApi.applyShiftClick(item, options)) { + return; + } + onSelectionButtonPress?.(item, itemTransactions, options); + }, + [onShiftRangeApply, rangeApi, onSelectionButtonPress], + ); + const selectRow = useCallback( (item: TItem, indexToFocus?: number) => { if (!isFocused) { @@ -265,7 +295,7 @@ function BaseSelectionList({ textInputOptions?.onChangeText?.(''); } else if (isSmallScreenWidth) { if (!item.isDisabledCheckbox) { - onSelectionButtonPress?.(item); + handleSelectionButtonPress(item); } return; } @@ -293,7 +323,7 @@ function BaseSelectionList({ shouldPreventDefaultFocusOnSelectRow, isSmallScreenWidth, textInputOptions, - onSelectionButtonPress, + handleSelectionButtonPress, setFocusedIndex, ], ); @@ -341,6 +371,33 @@ function BaseSelectionList({ isActive: !disableKeyboardShortcuts && isFocused && !confirmButtonOptions?.isDisabled, }, ); + + const extendSelectionByKeyboard = useCallback( + (direction: 'up' | 'down') => { + if (!canSelectMultiple || !onShiftRangeApply) { + return; + } + const nextKey = rangeApi.extendByKeyboard(direction); + if (!nextKey) { + return; + } + const nextIdx = data.findIndex((row) => row.keyForList === nextKey); + if (nextIdx >= 0) { + setFocusedIndex(nextIdx); + } + }, + [canSelectMultiple, onShiftRangeApply, rangeApi, data, setFocusedIndex], + ); + const handleShiftArrowDown = useCallback(() => extendSelectionByKeyboard('down'), [extendSelectionByKeyboard]); + const handleShiftArrowUp = useCallback(() => extendSelectionByKeyboard('up'), [extendSelectionByKeyboard]); + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.SHIFT_ARROW_DOWN, handleShiftArrowDown, { + captureOnInputs: false, + isActive: !disableKeyboardShortcuts && isFocused && canSelectMultiple && !!onShiftRangeApply, + }); + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.SHIFT_ARROW_UP, handleShiftArrowUp, { + captureOnInputs: false, + isActive: !disableKeyboardShortcuts && isFocused && canSelectMultiple && !!onShiftRangeApply, + }); const textInputKeyPress = useCallback( (event: TextInputKeyPressEvent) => { if (event.nativeEvent.key !== CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) { @@ -404,7 +461,7 @@ function BaseSelectionList({ canSelectMultiple={canSelectMultiple} onDismissError={onDismissError} onLongPressRow={onLongPressRow} - onSelectionButtonPress={onSelectionButtonPress} + onSelectionButtonPress={handleSelectionButtonPress} shouldSingleExecuteRowSelect={shouldSingleExecuteRowSelect} rightHandSideComponent={rightHandSideComponent} isMultilineSupported={isRowMultilineSupported} diff --git a/src/components/SelectionList/ListItem/ListItemRenderer.tsx b/src/components/SelectionList/ListItem/ListItemRenderer.tsx index 7449ba212602..4ca2bdc96d6c 100644 --- a/src/components/SelectionList/ListItem/ListItemRenderer.tsx +++ b/src/components/SelectionList/ListItem/ListItemRenderer.tsx @@ -1,5 +1,6 @@ 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'; @@ -60,7 +61,9 @@ function ListItemRenderer({ if (isTransactionGroupListItemType(item)) { return onSelectionButtonPress; } - return onSelectionButtonPress ? () => onSelectionButtonPress(item) : undefined; + return onSelectionButtonPress + ? (_passedItem: TItem, itemTransactions?: TransactionListItemType[], options?: {shiftKey?: boolean}) => 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..991b8c831c9e 100644 --- a/src/components/SelectionList/ListItem/types.ts +++ b/src/components/SelectionList/ListItem/types.ts @@ -238,7 +238,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?: {shiftKey?: boolean}) => void; /** Which side of the row to render the selection button on */ selectionButtonPosition?: ValueOf; @@ -368,7 +368,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?: {shiftKey?: boolean}) => 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..edb0136ffcd9 100644 --- a/src/components/SelectionList/components/ListSelectionButton.tsx +++ b/src/components/SelectionList/components/ListSelectionButton.tsx @@ -2,6 +2,7 @@ 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 {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; import CONST from '@src/CONST'; type ListSelectionButtonProps = { @@ -9,7 +10,7 @@ type ListSelectionButtonProps = { item: TItem; /** Callback to fire when the item is pressed */ - onSelectRow: (item: TItem) => void; + onSelectRow: (item: TItem, options?: {shiftKey?: boolean}) => void; /** Custom accessibility label */ accessibilityLabel?: string; @@ -53,7 +54,7 @@ function ListSelectionButton({ role={role} accessibilityLabel={label} isChecked={item.isSelected ?? false} - onPress={() => onSelectRow(item)} + onPress={(event) => onSelectRow(item, {shiftKey: getShiftKeyFromEvent(event)})} disabled={disabled} style={style} containerStyle={containerStyle} diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 757c347af81f..d54cbfded5cd 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 {ShiftRangeBatch} from '@hooks/useShiftRangeSelection'; 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?: {shiftKey?: boolean}) => void; + + /** Apply a shift+click range batch atomically. Opt-in: without it, 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..547ef1d4884b 100644 --- a/src/components/SpendRules/configuration/SpendRuleCategoryBase.tsx +++ b/src/components/SpendRules/configuration/SpendRuleCategoryBase.tsx @@ -10,6 +10,7 @@ import type {ListItem} from '@components/SelectionList/types'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useSearchResults from '@hooks/useSearchResults'; +import {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; @@ -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 58e4c35036ea..1ba5a0ac034f 100644 --- a/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx @@ -5,6 +5,7 @@ import Icon from '@components/Icon'; import RadioButton from '@components/RadioButton'; import DateCell from '@components/Search/SearchList/ListItem/DateCell'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -82,8 +83,8 @@ function TransactionItemRowNarrow({ {shouldShowCheckbox && ( { - onCheckboxPress(transactionItem.transactionID); + onPress={(event) => { + onCheckboxPress(transactionItem.transactionID, {shiftKey: getShiftKeyFromEvent(event)}); }} accessibilityLabel={CONST.ROLE.CHECKBOX} isChecked={isSelected} diff --git a/src/components/TransactionItemRow/TransactionItemRowWide.tsx b/src/components/TransactionItemRow/TransactionItemRowWide.tsx index 4d1052dccf27..c25bf6e60a87 100644 --- a/src/components/TransactionItemRow/TransactionItemRowWide.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowWide.tsx @@ -18,6 +18,7 @@ import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -580,8 +581,8 @@ function TransactionItemRowWide({ {!shouldShowRadioButton && ( { - onCheckboxPress(transactionItem.transactionID); + onPress={(event) => { + onCheckboxPress(transactionItem.transactionID, {shiftKey: getShiftKeyFromEvent(event)}); }} accessibilityLabel={CONST.ROLE.CHECKBOX} isChecked={isSelected} diff --git a/src/components/TransactionItemRow/types.ts b/src/components/TransactionItemRow/types.ts index 77df4666b994..64146b196c14 100644 --- a/src/components/TransactionItemRow/types.ts +++ b/src/components/TransactionItemRow/types.ts @@ -64,7 +64,7 @@ type TransactionItemRowProps = { exportedColumnSize?: TableColumnSize; amountColumnSize: TableColumnSize; taxAmountColumnSize: TableColumnSize; - onCheckboxPress?: (transactionID: string) => void; + onCheckboxPress?: (transactionID: string, options?: {shiftKey?: boolean}) => 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..8f69eb37022e --- /dev/null +++ b/src/hooks/useShiftRangeSelection.ts @@ -0,0 +1,337 @@ +import type {KeyboardEvent as ReactKeyboardEvent} from 'react'; +import {useEffect, useMemo, useRef} from 'react'; +import type {GestureResponderEvent} from 'react-native'; + +/** + * Excel/AG Grid-style shift+click range selection. Anchor is derived from getFocusedKey() + * + getSelectedKeys() on each call; the only internal state is the active shift session. + * A focused-key change between clicks implicitly ends the session. + * Headers and disabled rows are excluded from ranges and keyboard step. + * Anchor's selection state is invariant under shift+click (synthetic anchors join the range). + */ + +type ItemWithKey = {keyForList?: string | null}; + +type ModifierEvent = (GestureResponderEvent | KeyboardEvent | ReactKeyboardEvent | MouseEvent) & { + shiftKey?: boolean; + nativeEvent?: {shiftKey?: boolean}; +}; + +type ShiftRangeBatch = { + toSelect: TItem[]; + toDeselect: TItem[]; +}; + +type KeyboardDirection = 'up' | 'down'; + +type Params = { + items: TItem[]; + getItemKey?: (item: TItem) => string | null | undefined; + getFocusedKey?: () => 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?: {shiftKey?: boolean}) => boolean; + extendByKeyboard: (direction: KeyboardDirection) => string | null; +}; + +type Session = {anchor: string; prevEnd: string; isSyntheticAnchor: boolean}; + +function useShiftRangeSelection(params: Params): Api { + const paramsRef = useRef(params); + useEffect(() => { + paramsRef.current = params; + }); + const sessionRef = useRef(null); + + return useMemo>(() => { + const runRange = (target: TItem): boolean => { + const p = paramsRef.current; + const targetKey = keyOf(p, target); + if (!targetKey || isExcluded(p, target)) { + return false; + } + + const currentFocused = p.getFocusedKey?.() ?? null; + const session = sessionRef.current; + const continues = !!session && !!currentFocused && currentFocused === session.prevEnd; + + let anchor: string; + let prevEnd: string | null; + let isSyntheticAnchor: boolean; + if (continues && session) { + anchor = session.anchor; + prevEnd = session.prevEnd; + isSyntheticAnchor = session.isSyntheticAnchor; + } else { + const resolved = resolveAnchor(p, currentFocused); + if (!resolved) { + return false; + } + anchor = resolved; + prevEnd = null; + isSyntheticAnchor = resolved !== currentFocused; + } + + const anchorIdx = indexOfKey(p, anchor); + const targetIdx = indexOfKey(p, targetKey); + if (anchorIdx < 0 || targetIdx < 0) { + return false; + } + + const newRange = orderedRange(anchorIdx, targetIdx); + const prevRange = prevEnd != null ? orderedRange(anchorIdx, indexOfKey(p, prevEnd)) : null; + + const isInvariantAnchor = !isSyntheticAnchor; + const isUsable = (i: number) => !isExcluded(p, p.items.at(i)) && !(isInvariantAnchor && i === anchorIdx); + + const toSelect: TItem[] = []; + for (let i = newRange[0]; i <= newRange[1]; i++) { + if (isUsable(i)) { + const row = p.items.at(i); + if (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; + } + if (isUsable(i)) { + const row = p.items.at(i); + if (row) { + toDeselect.push(row); + } + } + } + } + + if (!toSelect.length && !toDeselect.length) { + sessionRef.current = {anchor, prevEnd: targetKey, isSyntheticAnchor}; + return true; + } + + p.onApplyRange({toSelect, toDeselect}); + sessionRef.current = {anchor, prevEnd: targetKey, isSyntheticAnchor}; + return true; + }; + + return { + applyShiftClick: (item, options) => !!options?.shiftKey && runRange(item), + extendByKeyboard: (direction) => { + const p = paramsRef.current; + const focused = p.getFocusedKey?.(); + if (!focused) { + return null; + } + const fromIdx = indexOfKey(p, focused); + if (fromIdx < 0) { + return null; + } + const nextIdx = stepFocus(p, fromIdx, direction); + if (nextIdx < 0) { + return null; + } + const nextRow = p.items.at(nextIdx); + const nextKey = nextRow ? keyOf(p, nextRow) : null; + if (!nextRow || !nextKey || !runRange(nextRow)) { + return null; + } + return nextKey; + }, + }; + }, []); +} + +function hasKeyForList(item: unknown): item is ItemWithKey { + return typeof item === 'object' && item !== null && 'keyForList' in item; +} + +function keyOf(p: Params, item: TItem | null | undefined): string | null { + if (item == null) { + return null; + } + if (p.getItemKey) { + return p.getItemKey(item) ?? null; + } + return hasKeyForList(item) ? (item.keyForList ?? null) : null; +} + +function isExcluded(p: Params, item: TItem | null | undefined): boolean { + if (item == null) { + return true; + } + if (p.isHeaderItem?.(item)) { + return true; + } + if (p.isDisabledItem?.(item)) { + return true; + } + return false; +} + +function indexOfKey(p: Params, key: string): number { + return p.items.findIndex((row) => keyOf(p, row) === key); +} + +function orderedRange(a: number, b: number): readonly [number, number] { + return a <= b ? [a, b] : [b, a]; +} + +function resolveAnchor(p: Params, focused: string | null): string | null { + if (focused) { + const idx = indexOfKey(p, focused); + if (idx >= 0 && !isExcluded(p, p.items.at(idx))) { + return focused; + } + } + if (p.getSelectedKeys) { + const sel = p.getSelectedKeys(); + const set: ReadonlySet = sel instanceof Set ? sel : new Set(sel); + if (set.size) { + for (const row of p.items) { + if (isExcluded(p, row)) { + continue; + } + const k = keyOf(p, row); + if (k && set.has(k)) { + return k; + } + } + } + } + for (const row of p.items) { + if (isExcluded(p, row)) { + continue; + } + const k = keyOf(p, row); + if (k) { + return k; + } + } + return null; +} + +function stepFocus(p: Params, from: number, dir: KeyboardDirection): number { + const step = dir === 'up' ? -1 : 1; + for (let i = from + step; i >= 0 && i < p.items.length; i += step) { + if (!isExcluded(p, p.items.at(i))) { + return i; + } + } + return -1; +} + +function getShiftKeyFromEvent(e?: ModifierEvent | null): boolean { + return !!(e?.shiftKey ?? e?.nativeEvent?.shiftKey); +} + +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 it of batch.toDeselect) { + const k = getKey(it); + if (k != null) { + removeSet.add(k); + } + } + const addOrdered: TKey[] = []; + const addSet = new Set(); + for (const it of batch.toSelect) { + const k = getKey(it); + if (k == null || addSet.has(k)) { + continue; + } + if (isSelectable ? !isSelectable(it) : isBlocked(it)) { + continue; + } + addSet.add(k); + addOrdered.push(k); + } + const next: TKey[] = []; + const seen = new Set(); + for (const k of prevKeys) { + if (removeSet.has(k) || seen.has(k)) { + continue; + } + seen.add(k); + next.push(k); + } + for (const k of addOrdered) { + if (!seen.has(k)) { + seen.add(k); + next.push(k); + } + } + return next; +} + +function applyShiftRangeBatchToValueArray( + batch: ShiftRangeBatch, + prevValues: readonly TValue[], + getItemKey: (item: TItem) => TKey | null | undefined, + getValueKey: (value: TValue) => TKey, + buildValue: (item: TItem) => TValue | null | undefined, + isSelectable?: (item: TItem) => boolean, +): TValue[] { + if (!batch.toSelect.length && !batch.toDeselect.length) { + return [...prevValues]; + } + const removeSet = new Set(); + for (const it of batch.toDeselect) { + const k = getItemKey(it); + if (k != null) { + removeSet.add(k); + } + } + const next: TValue[] = []; + const seen = new Set(); + for (const v of prevValues) { + const k = getValueKey(v); + if (removeSet.has(k) || seen.has(k)) { + continue; + } + seen.add(k); + next.push(v); + } + for (const it of batch.toSelect) { + const k = getItemKey(it); + if (k == null || seen.has(k)) { + continue; + } + if (isSelectable ? !isSelectable(it) : isBlocked(it)) { + continue; + } + const v = buildValue(it); + if (v != null) { + seen.add(k); + next.push(v); + } + } + 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 useShiftRangeSelection; +export {getShiftKeyFromEvent, applyShiftRangeBatchToKeySet, applyShiftRangeBatchToValueArray}; +export type {ShiftRangeBatch}; diff --git a/src/pages/ReportParticipantsPage.tsx b/src/pages/ReportParticipantsPage.tsx index 16cd7844cdc5..ac054fb1bf57 100755 --- a/src/pages/ReportParticipantsPage.tsx +++ b/src/pages/ReportParticipantsPage.tsx @@ -27,6 +27,7 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; +import {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; @@ -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..b775400cefa5 100644 --- a/src/pages/RoomMembersPage.tsx +++ b/src/pages/RoomMembersPage.tsx @@ -25,6 +25,7 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; +import {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {clearUserSearchPhrase, updateUserSearchPhrase} from '@libs/actions/RoomMembersUserSearchPhrase'; @@ -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..6df87f14236c 100644 --- a/src/pages/settings/Rules/ExpenseRulesPage.tsx +++ b/src/pages/settings/Rules/ExpenseRulesPage.tsx @@ -25,6 +25,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchResults from '@hooks/useSearchResults'; +import {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; @@ -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..67f1f7f69d46 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -38,6 +38,7 @@ import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; +import {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -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..e2cc9cb67fe6 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -36,6 +36,7 @@ import usePolicyData from '@hooks/usePolicyData'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; +import {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -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..0a7bd594de3c 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -27,6 +27,7 @@ import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; +import {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionViolation from '@hooks/useTransactionViolation'; @@ -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..aa358687473b 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx @@ -8,6 +8,7 @@ import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {getTranslationKeyForLimitType} from '@libs/CardUtils'; @@ -55,7 +56,7 @@ type WorkspacesListRowProps = { /** When set, shows a row checkbox for bulk selection */ bulkSelection?: { isSelected: boolean; - onToggle: () => void; + onToggle: (options?: {shiftKey?: boolean}) => void; }; }; @@ -103,7 +104,7 @@ function WorkspaceCardListRow({ bulkSelection.onToggle({shiftKey: getShiftKeyFromEvent(event)})} /> )} diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index f4752a19018d..6d58fee49598 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useMemo, useRef, useState} from 'react'; import type {ListRenderItemInfo} from 'react-native'; import {FlatList, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -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, {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -114,19 +115,28 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp if (prunedSelectedCardIDs.length !== selectedCardIDs.length) { setSelectedCardIDs(prunedSelectedCardIDs); } - const toggleCardSelection = (cardID: number) => { + const lastClickedCardIDRef = useRef(null); + const rangeApi = useShiftRangeSelection({ + items: filteredSortedCards, + getItemKey: (c) => String(c.cardID), + getSelectedKeys: () => selectedCardIDs.map(String), + getFocusedKey: () => lastClickedCardIDRef.current, + onApplyRange: (batch) => setSelectedCardIDs((prev) => applyShiftRangeBatchToKeySet(batch, prev, (c) => c.cardID)), + }); + const toggleCardSelection = (cardID: number, options?: {shiftKey?: boolean}) => { + 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])); + lastClickedCardIDRef.current = String(cardID); }; const toggleSelectAll = () => { if (selectableCardIDs.length === 0) { return; } - setSelectedCardIDs((prev) => { - if (prev.length > 0) { - return []; - } - return [...selectableCardIDs]; - }); + setSelectedCardIDs((prev) => (prev.length > 0 ? [] : [...selectableCardIDs])); + lastClickedCardIDRef.current = null; }; const isSelectAllChecked = selectedCardIDs.length > 0 && selectedCardIDs.length === selectableCardIDs.length; const isSelectAllIndeterminate = selectedCardIDs.length > 0 && selectedCardIDs.length < selectableCardIDs.length; @@ -263,7 +273,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..06ab58976025 100644 --- a/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx +++ b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx @@ -28,6 +28,7 @@ import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; +import {applyShiftRangeBatchToValueArray} from '@hooks/useShiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; @@ -486,6 +487,17 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { canSelectMultiple={canSelectMultiple} selectAllAccessibilityLabel={translate('accessibilityHints.selectAllPerDiemRates')} onSelectionButtonPress={toggleSubRate} + onShiftRangeApply={(batch) => + setSelectedPerDiem((prev) => + applyShiftRangeBatchToValueArray( + batch, + prev, + (r) => r.subRateID, + (v) => v.subRateID, + (r) => generateSingleSubRateData(allRatesArray, r.rateID, r.subRateID) ?? null, + ), + ) + } 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..b69bca3af54b 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx @@ -18,6 +18,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useSearchResults from '@hooks/useSearchResults'; +import {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; import useThemeIllustrations from '@hooks/useThemeIllustrations'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateDraftSpendRule} from '@libs/actions/User'; @@ -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..d8997de09f20 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -34,6 +34,7 @@ import usePolicyData from '@hooks/usePolicyData'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; +import {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -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..494204a49083 100644 --- a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx @@ -26,6 +26,7 @@ import usePolicyData from '@hooks/usePolicyData'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; +import {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; @@ -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..fbf747ffed3b 100644 --- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx @@ -25,6 +25,7 @@ import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; +import {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; @@ -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 From e7eccfb8ef159a40eba840083fe3b7a3fcbbe020 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 31 May 2026 20:59:29 +0300 Subject: [PATCH 02/19] fix: shift-range session continuity (deselect-jump, shift-shrink-stuck) --- .../MoneyRequestReportTransactionList.tsx | 9 ++- src/components/Search/index.tsx | 16 +++-- .../SelectionList/BaseSelectionList.tsx | 28 ++++++++- src/hooks/useShiftRangeSelection.ts | 63 ++++++++++--------- .../WorkspaceExpensifyCardListPage.tsx | 10 +-- 5 files changed, 74 insertions(+), 52 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 42883e2bf054..a08d782a4aef 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -414,13 +414,10 @@ function MoneyRequestReportTransactionList({ [visualOrderTransactions], ); - const lastClickedTransactionIDRef = useRef(null); - const rangeApi = useShiftRangeSelection({ items: visualOrderTransactions, getItemKey: (t) => t.transactionID ?? null, getSelectedKeys: () => selectedTransactionIDs, - getFocusedKey: () => lastClickedTransactionIDRef.current, isDisabledItem: (t) => isTransactionPendingDelete(t), onApplyRange: (batch) => setSelectedTransactions( @@ -440,7 +437,9 @@ function MoneyRequestReportTransactionList({ return; } setSelectedTransactions(selectedTransactionIDs.includes(transactionID) ? selectedTransactionIDs.filter((t) => t !== transactionID) : [...selectedTransactionIDs, transactionID]); - lastClickedTransactionIDRef.current = transactionID; + if (item) { + rangeApi.notifyAnchor(item); + } }, [setSelectedTransactions, selectedTransactionIDs, visualOrderTransactions, rangeApi], ); @@ -780,7 +779,7 @@ function MoneyRequestReportTransactionList({ } else { setSelectedTransactions(transactionsWithoutPendingDelete.map((t) => t.transactionID)); } - lastClickedTransactionIDRef.current = null; + rangeApi.clearAnchor(); }} accessibilityLabel={translate('accessibilityHints.selectAllTransactions')} isIndeterminate={selectedTransactionIDs.length > 0 && selectedTransactionIDs.length !== transactionsWithoutPendingDelete.length} diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 1f253b6eb0cb..a1abe25de1e0 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1134,14 +1134,11 @@ function Search({ const selectedTransactionKeySet = useMemo(() => new Set(Object.keys(selectedTransactions ?? {})), [selectedTransactions]); - const lastClickedKeyRef = useRef(null); - const rangeApi = useShiftRangeSelection({ items: flattenedShiftRangeItems, onApplyRange: onApplyShiftRange, isHeaderItem: areItemsGrouped ? isTransactionGroupListItemType : undefined, getSelectedKeys: () => selectedTransactionKeySet, - getFocusedKey: () => lastClickedKeyRef.current, }); const toggleTransaction = useCallback( @@ -1177,7 +1174,7 @@ function Search({ ); setSelectedTransactions(updatedTransactions); updateSelectAllMatchingItemsState(updatedTransactions); - lastClickedKeyRef.current = item.keyForList ?? null; + rangeApi.notifyAnchor(item); return; } @@ -1202,7 +1199,7 @@ function Search({ delete reducedSelectedTransactions[reportKey]; setSelectedTransactions(reducedSelectedTransactions); updateSelectAllMatchingItemsState(reducedSelectedTransactions); - lastClickedKeyRef.current = item.keyForList ?? null; + rangeApi.notifyAnchor(item); return; } @@ -1213,7 +1210,7 @@ function Search({ }; setSelectedTransactions(updatedTransactions); updateSelectAllMatchingItemsState(updatedTransactions); - lastClickedKeyRef.current = item.keyForList ?? null; + rangeApi.notifyAnchor(item); return; } @@ -1228,7 +1225,7 @@ function Search({ setSelectedTransactions(reducedSelectedTransactions); updateSelectAllMatchingItemsState(reducedSelectedTransactions); - lastClickedKeyRef.current = item.keyForList ?? null; + rangeApi.notifyAnchor(item); return; } @@ -1261,7 +1258,7 @@ function Search({ }; setSelectedTransactions(updatedTransactions); updateSelectAllMatchingItemsState(updatedTransactions); - lastClickedKeyRef.current = item.keyForList ?? null; + rangeApi.notifyAnchor(item); }, [ selectedTransactions, @@ -1525,7 +1522,7 @@ function Search({ if (totalSelected > 0) { clearSelectedTransactions(); updateSelectAllMatchingItemsState({}); - lastClickedKeyRef.current = null; + rangeApi.clearAnchor(); return; } @@ -1599,6 +1596,7 @@ function Search({ searchResults?.data, selfDMReport, isProduction, + rangeApi, ]); const onLayoutBase = useCallback(() => { diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 28e772c16648..409ae6378d67 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -261,7 +261,6 @@ function BaseSelectionList({ const rangeApi = useShiftRangeSelection({ items: data, getItemKey: (item) => item.keyForList ?? null, - getFocusedKey: () => (focusedIndex >= 0 ? (data.at(focusedIndex)?.keyForList ?? null) : null), getSelectedKeys: () => { const keys = new Set(); for (const item of data) { @@ -281,6 +280,7 @@ function BaseSelectionList({ return; } onSelectionButtonPress?.(item, itemTransactions, options); + rangeApi.notifyAnchor(item); }, [onShiftRangeApply, rangeApi, onSelectionButtonPress], ); @@ -307,6 +307,7 @@ function BaseSelectionList({ setFocusedIndex(indexToFocus); } onSelectRow(item); + rangeApi.notifyAnchor(item); if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow && innerTextInputRef.current) { innerTextInputRef.current.focus(); @@ -325,6 +326,7 @@ function BaseSelectionList({ textInputOptions, handleSelectionButtonPress, setFocusedIndex, + rangeApi, ], ); @@ -372,6 +374,7 @@ function BaseSelectionList({ }, ); + const skipNextFocusAnchorRef = useRef(false); const extendSelectionByKeyboard = useCallback( (direction: 'up' | 'down') => { if (!canSelectMultiple || !onShiftRangeApply) { @@ -383,11 +386,31 @@ function BaseSelectionList({ } const nextIdx = data.findIndex((row) => row.keyForList === nextKey); if (nextIdx >= 0) { + // Paired with the focus effect below: skip the anchor-sync for shift-driven focus moves. + skipNextFocusAnchorRef.current = true; setFocusedIndex(nextIdx); } }, [canSelectMultiple, onShiftRangeApply, rangeApi, data, setFocusedIndex], ); + + // Bump the hook's anchor on plain arrow-key focus moves; the ref dedupes across data re-references. + const previousFocusAnchorKeyRef = useRef(null); + useEffect(() => { + if (skipNextFocusAnchorRef.current) { + skipNextFocusAnchorRef.current = false; + return; + } + if (focusedIndex < 0 || focusedIndex >= data.length) { + return; + } + const item = data.at(focusedIndex); + if (!item?.keyForList || previousFocusAnchorKeyRef.current === item.keyForList) { + return; + } + previousFocusAnchorKeyRef.current = item.keyForList; + rangeApi.notifyAnchor(item); + }, [focusedIndex, data, rangeApi]); const handleShiftArrowDown = useCallback(() => extendSelectionByKeyboard('down'), [extendSelectionByKeyboard]); const handleShiftArrowUp = useCallback(() => extendSelectionByKeyboard('up'), [extendSelectionByKeyboard]); useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.SHIFT_ARROW_DOWN, handleShiftArrowDown, { @@ -640,10 +663,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/hooks/useShiftRangeSelection.ts b/src/hooks/useShiftRangeSelection.ts index 8f69eb37022e..64080ff2f195 100644 --- a/src/hooks/useShiftRangeSelection.ts +++ b/src/hooks/useShiftRangeSelection.ts @@ -3,11 +3,9 @@ import {useEffect, useMemo, useRef} from 'react'; import type {GestureResponderEvent} from 'react-native'; /** - * Excel/AG Grid-style shift+click range selection. Anchor is derived from getFocusedKey() - * + getSelectedKeys() on each call; the only internal state is the active shift session. - * A focused-key change between clicks implicitly ends the session. - * Headers and disabled rows are excluded from ranges and keyboard step. - * Anchor's selection state is invariant under shift+click (synthetic anchors join the range). + * Excel/AG Grid-style shift+click range selection. Consumers call notifyAnchor on plain + * clicks / focus changes and clearAnchor on select-all / deselect-all; the session lives + * between shift+clicks and is ended by either notify. Headers and disabled rows are excluded. */ type ItemWithKey = {keyForList?: string | null}; @@ -27,7 +25,6 @@ type KeyboardDirection = 'up' | 'down'; type Params = { items: TItem[]; getItemKey?: (item: TItem) => string | null | undefined; - getFocusedKey?: () => string | null | undefined; getSelectedKeys?: () => ReadonlySet | readonly string[]; isHeaderItem?: (item: TItem) => boolean; isDisabledItem?: (item: TItem) => boolean; @@ -36,16 +33,19 @@ type Params = { type Api = { applyShiftClick: (item: TItem, options?: {shiftKey?: boolean}) => boolean; + notifyAnchor: (item: TItem) => void; + clearAnchor: () => void; extendByKeyboard: (direction: KeyboardDirection) => string | null; }; -type Session = {anchor: string; prevEnd: string; isSyntheticAnchor: boolean}; +type Session = {anchor: string; prevEnd: string}; function useShiftRangeSelection(params: Params): Api { const paramsRef = useRef(params); useEffect(() => { paramsRef.current = params; }); + const anchorRef = useRef(null); const sessionRef = useRef(null); return useMemo>(() => { @@ -56,25 +56,19 @@ function useShiftRangeSelection(params: Params): Api { return false; } - const currentFocused = p.getFocusedKey?.() ?? null; const session = sessionRef.current; - const continues = !!session && !!currentFocused && currentFocused === session.prevEnd; - let anchor: string; let prevEnd: string | null; - let isSyntheticAnchor: boolean; - if (continues && session) { + if (session) { anchor = session.anchor; prevEnd = session.prevEnd; - isSyntheticAnchor = session.isSyntheticAnchor; } else { - const resolved = resolveAnchor(p, currentFocused); + const resolved = resolveAnchor(p, anchorRef.current); if (!resolved) { return false; } anchor = resolved; prevEnd = null; - isSyntheticAnchor = resolved !== currentFocused; } const anchorIdx = indexOfKey(p, anchor); @@ -85,9 +79,7 @@ function useShiftRangeSelection(params: Params): Api { const newRange = orderedRange(anchorIdx, targetIdx); const prevRange = prevEnd != null ? orderedRange(anchorIdx, indexOfKey(p, prevEnd)) : null; - - const isInvariantAnchor = !isSyntheticAnchor; - const isUsable = (i: number) => !isExcluded(p, p.items.at(i)) && !(isInvariantAnchor && i === anchorIdx); + const isUsable = (i: number) => !isExcluded(p, p.items.at(i)); const toSelect: TItem[] = []; for (let i = newRange[0]; i <= newRange[1]; i++) { @@ -113,25 +105,34 @@ function useShiftRangeSelection(params: Params): Api { } } - if (!toSelect.length && !toDeselect.length) { - sessionRef.current = {anchor, prevEnd: targetKey, isSyntheticAnchor}; - return true; + if (toSelect.length || toDeselect.length) { + p.onApplyRange({toSelect, toDeselect}); } - - p.onApplyRange({toSelect, toDeselect}); - sessionRef.current = {anchor, prevEnd: targetKey, isSyntheticAnchor}; + sessionRef.current = {anchor, prevEnd: targetKey}; return true; }; return { applyShiftClick: (item, options) => !!options?.shiftKey && runRange(item), + notifyAnchor: (item) => { + const k = keyOf(paramsRef.current, item); + if (k) { + anchorRef.current = k; + } + sessionRef.current = null; + }, + clearAnchor: () => { + anchorRef.current = null; + sessionRef.current = null; + }, extendByKeyboard: (direction) => { const p = paramsRef.current; - const focused = p.getFocusedKey?.(); - if (!focused) { + const session = sessionRef.current; + const fromKey = session?.prevEnd ?? anchorRef.current; + if (!fromKey) { return null; } - const fromIdx = indexOfKey(p, focused); + const fromIdx = indexOfKey(p, fromKey); if (fromIdx < 0) { return null; } @@ -185,11 +186,11 @@ function orderedRange(a: number, b: number): readonly [number, number] { return a <= b ? [a, b] : [b, a]; } -function resolveAnchor(p: Params, focused: string | null): string | null { - if (focused) { - const idx = indexOfKey(p, focused); +function resolveAnchor(p: Params, source: string | null): string | null { + if (source) { + const idx = indexOfKey(p, source); if (idx >= 0 && !isExcluded(p, p.items.at(idx))) { - return focused; + return source; } } if (p.getSelectedKeys) { diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index 6d58fee49598..1d6976a237c0 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import type {ListRenderItemInfo} from 'react-native'; import {FlatList, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -115,12 +115,10 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp if (prunedSelectedCardIDs.length !== selectedCardIDs.length) { setSelectedCardIDs(prunedSelectedCardIDs); } - const lastClickedCardIDRef = useRef(null); const rangeApi = useShiftRangeSelection({ items: filteredSortedCards, getItemKey: (c) => String(c.cardID), getSelectedKeys: () => selectedCardIDs.map(String), - getFocusedKey: () => lastClickedCardIDRef.current, onApplyRange: (batch) => setSelectedCardIDs((prev) => applyShiftRangeBatchToKeySet(batch, prev, (c) => c.cardID)), }); const toggleCardSelection = (cardID: number, options?: {shiftKey?: boolean}) => { @@ -129,14 +127,16 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp return; } setSelectedCardIDs((prev) => (prev.includes(cardID) ? prev.filter((id) => id !== cardID) : [...prev, cardID])); - lastClickedCardIDRef.current = String(cardID); + if (card) { + rangeApi.notifyAnchor(card); + } }; const toggleSelectAll = () => { if (selectableCardIDs.length === 0) { return; } setSelectedCardIDs((prev) => (prev.length > 0 ? [] : [...selectableCardIDs])); - lastClickedCardIDRef.current = null; + rangeApi.clearAnchor(); }; const isSelectAllChecked = selectedCardIDs.length > 0 && selectedCardIDs.length === selectableCardIDs.length; const isSelectAllIndeterminate = selectedCardIDs.length > 0 && selectedCardIDs.length < selectableCardIDs.length; From 479ed37bc70c48ac8e5126b052a212f18e76c590 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 31 May 2026 21:06:20 +0300 Subject: [PATCH 03/19] fix: restore Reports view shift+click (gate flatten + isHeaderItem on validGroupBy) --- src/components/Search/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index a1abe25de1e0..ad917c24d955 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1120,9 +1120,9 @@ function Search({ const {stableSortedData, hasCachedOptimisticItem} = useStableOptimisticSortedData(sortedData, searchResults, optimisticTrackingState); - // Mirrors stableSortedData (what the renderer iterates) so ranges stay consistent with optimistic injections. + // Mirrors stableSortedData; only flattens for Spend grouped views (validGroupBy) where rows render as [header, ...children]. const flattenedShiftRangeItems = useMemo(() => { - if (!areItemsGrouped) { + if (!validGroupBy) { return stableSortedData; } const isGroupArray = (items: SearchListItem[]): items is TransactionGroupListItemType[] => items.every((g) => isTransactionGroupListItemType(g) && Array.isArray(g.transactions)); @@ -1130,14 +1130,14 @@ function Search({ return stableSortedData; } return stableSortedData.flatMap((g) => [g, ...(g.transactions ?? [])]); - }, [stableSortedData, areItemsGrouped]); + }, [stableSortedData, validGroupBy]); const selectedTransactionKeySet = useMemo(() => new Set(Object.keys(selectedTransactions ?? {})), [selectedTransactions]); const rangeApi = useShiftRangeSelection({ items: flattenedShiftRangeItems, onApplyRange: onApplyShiftRange, - isHeaderItem: areItemsGrouped ? isTransactionGroupListItemType : undefined, + isHeaderItem: validGroupBy ? isTransactionGroupListItemType : undefined, getSelectedKeys: () => selectedTransactionKeySet, }); From 3fd430ff0376dafe6ce605b0404a78bef5ea1966 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 31 May 2026 21:45:07 +0300 Subject: [PATCH 04/19] fix: shift+click on report group header + add hook unit tests --- .../MoneyRequestReportGroupHeader.tsx | 11 +- .../MoneyRequestReportTransactionList.tsx | 36 +- src/hooks/useShiftRangeSelection.ts | 2 + .../unit/hooks/useShiftRangeSelection.test.ts | 617 ++++++++++++++++++ 4 files changed, 651 insertions(+), 15 deletions(-) create mode 100644 tests/unit/hooks/useShiftRangeSelection.test.ts diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportGroupHeader.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportGroupHeader.tsx index 6f092d9df62d..4fad59d1424a 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'; @@ -6,6 +7,7 @@ import Text from '@components/Text'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; +import {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {getDecodedLeafCategoryName} from '@libs/CategoryUtils'; @@ -41,7 +43,7 @@ type MoneyRequestReportGroupHeaderProps = { isDisabled?: boolean; /** Callback when group checkbox is toggled - receives groupKey */ - onToggleSelection?: (groupKey: string) => void; + onToggleSelection?: (groupKey: string, options?: {shiftKey?: boolean}) => void; /** Pending action for offline feedback styling (Pattern B - Optimistic WITH Feedback) */ pendingAction?: PendingAction; @@ -77,8 +79,8 @@ function MoneyRequestReportGroupHeader({ const textStyle = shouldUseNarrowLayout ? {fontSize: variables.fontSizeLabel, lineHeight: 16} : [styles.labelStrong]; - const handleToggleSelection = () => { - onToggleSelection?.(groupKey); + const handleToggleSelection = (event?: GestureResponderEvent | KeyboardEvent) => { + onToggleSelection?.(groupKey, {shiftKey: getShiftKeyFromEvent(event)}); }; const groupHeaderStyle = !shouldUseNarrowLayout @@ -89,9 +91,10 @@ function MoneyRequestReportGroupHeader({ styles.pv2, styles.ph3, styles.borderBottom, + styles.userSelectNone, isSelected && {borderColor: theme.buttonHoveredBG}, ] - : [styles.ph4, styles.pv3, styles.borderBottom]; + : [styles.ph4, styles.pv3, styles.borderBottom, styles.userSelectNone]; return ( diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index a08d782a4aef..28f317e81529 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -488,23 +488,37 @@ function MoneyRequestReportTransactionList({ }, [groupedTransactions, selectedTransactionIDs]); const toggleGroupSelection = useCallback( - (groupKey: string) => { + (groupKey: string, options?: {shiftKey?: boolean}) => { 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)); - - let newSelectedTransactionIDs = selectedTransactionIDs; - if (anySelected) { - newSelectedTransactionIDs = selectedTransactionIDs.filter((id) => !groupTransactionIDs.includes(id)); - } else { - newSelectedTransactionIDs = [...selectedTransactionIDs, ...groupTransactionIDs]; + const selectableChildren = group.transactions.filter((t) => !isTransactionPendingDelete(t)); + + // Shift+click header extends through the group; target = farthest child from anchor so both group edges are covered. + if (options?.shiftKey && selectableChildren.length > 0) { + const anchorKey = rangeApi.getAnchorKey(); + const anchorIdx = anchorKey ? visualOrderTransactions.findIndex((t) => t.transactionID === anchorKey) : -1; + let target = selectableChildren.at(-1) ?? selectableChildren.at(0); + if (anchorIdx >= 0 && target) { + const firstChild = selectableChildren.at(0); + const lastChild = selectableChildren.at(-1); + if (firstChild && lastChild) { + const firstIdx = visualOrderTransactions.findIndex((t) => t.transactionID === firstChild.transactionID); + const lastIdx = visualOrderTransactions.findIndex((t) => t.transactionID === lastChild.transactionID); + target = Math.abs(firstIdx - anchorIdx) > Math.abs(lastIdx - anchorIdx) ? firstChild : lastChild; + } + } + if (target && rangeApi.applyShiftClick(target, {shiftKey: true})) { + return; + } } - setSelectedTransactions(newSelectedTransactionIDs); + + 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]); }, - [groupedTransactions, selectedTransactionIDs, setSelectedTransactions], + [groupedTransactions, selectedTransactionIDs, setSelectedTransactions, rangeApi, visualOrderTransactions], ); /** diff --git a/src/hooks/useShiftRangeSelection.ts b/src/hooks/useShiftRangeSelection.ts index 64080ff2f195..d6cf27212652 100644 --- a/src/hooks/useShiftRangeSelection.ts +++ b/src/hooks/useShiftRangeSelection.ts @@ -36,6 +36,7 @@ type Api = { notifyAnchor: (item: TItem) => void; clearAnchor: () => void; extendByKeyboard: (direction: KeyboardDirection) => string | null; + getAnchorKey: () => string | null; }; type Session = {anchor: string; prevEnd: string}; @@ -125,6 +126,7 @@ function useShiftRangeSelection(params: Params): Api { anchorRef.current = null; sessionRef.current = null; }, + getAnchorKey: () => sessionRef.current?.anchor ?? anchorRef.current, extendByKeyboard: (direction) => { const p = paramsRef.current; const session = sessionRef.current; diff --git a/tests/unit/hooks/useShiftRangeSelection.test.ts b/tests/unit/hooks/useShiftRangeSelection.test.ts new file mode 100644 index 000000000000..ef4a5b63788b --- /dev/null +++ b/tests/unit/hooks/useShiftRangeSelection.test.ts @@ -0,0 +1,617 @@ +import {act, renderHook} from '@testing-library/react-native'; +import useShiftRangeSelection, {applyShiftRangeBatchToKeySet, applyShiftRangeBatchToValueArray, getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; +import type {ShiftRangeBatch} from '@hooks/useShiftRangeSelection'; + +type Row = {keyForList: string; isHeader?: boolean; isDisabled?: boolean}; + +const ROWS: Row[] = [{keyForList: 'a'}, {keyForList: 'b'}, {keyForList: 'c'}, {keyForList: 'd'}, {keyForList: 'e'}]; + +const GROUPED: Row[] = [ + {keyForList: 'h1', isHeader: true}, + {keyForList: 'a'}, + {keyForList: 'b'}, + {keyForList: 'h2', isHeader: true}, + {keyForList: 'c'}, + {keyForList: 'd'}, + {keyForList: 'e'}, +]; + +const MIXED: Row[] = [{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 keys(batch: ShiftRangeBatch): {toSelect: string[]; toDeselect: string[]} { + return { + toSelect: batch.toSelect.map((r) => r.keyForList), + toDeselect: batch.toDeselect.map((r) => r.keyForList), + }; +} + +describe('useShiftRangeSelection', () => { + describe('getAnchorKey', () => { + it('returns null when no anchor and no session', () => { + const {result} = renderHook(() => useShiftRangeSelection(makeParams())); + 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 session anchor while a shift session is active', () => { + 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 wipes both anchor and session', () => { + 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()).toBeNull(); + }); + }); + + 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 the target row is a header', () => { + const onApplyRange = jest.fn(); + 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 = jest.fn(); + 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 target has no key', () => { + const onApplyRange = jest.fn(); + const itemsWithMissingKey = [...ROWS, {keyForList: ''} as Row]; + const {result} = renderHook(() => useShiftRangeSelection(makeParams({items: itemsWithMissingKey, onApplyRange}))); + act(() => result.current.notifyAnchor(itemsWithMissingKey[0])); + let applied = true; + act(() => { + applied = result.current.applyShiftClick(itemsWithMissingKey.at(-1) as Row, {shiftKey: true}); + }); + expect(applied).toBe(false); + }); + }); + + describe('applyShiftClick — anchor resolution', () => { + it('uses notifyAnchor key as anchor for the first shift+click', () => { + const onApplyRange = jest.fn(); + 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(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['b', 'c', 'd'], toDeselect: []}); + }); + + it('falls back to first-selected when no notifyAnchor', () => { + const onApplyRange = jest.fn(); + const {result} = renderHook(() => + useShiftRangeSelection( + makeParams({ + getSelectedKeys: () => new Set(['c']), + onApplyRange, + }), + ), + ); + act(() => { + result.current.applyShiftClick(ROWS[4], {shiftKey: true}); + }); + expect(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['c', 'd', 'e'], toDeselect: []}); + }); + + it('accepts an array from getSelectedKeys in addition to a Set', () => { + const onApplyRange = jest.fn(); + const {result} = renderHook(() => + useShiftRangeSelection( + makeParams({ + getSelectedKeys: () => ['c'], + onApplyRange, + }), + ), + ); + act(() => { + result.current.applyShiftClick(ROWS[4], {shiftKey: true}); + }); + expect(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['c', 'd', 'e'], toDeselect: []}); + }); + + it('falls back to first-visible when no anchor and no selection', () => { + const onApplyRange = jest.fn(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => { + result.current.applyShiftClick(ROWS[2], {shiftKey: true}); + }); + expect(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: []}); + }); + + it('first-visible fallback skips headers', () => { + const onApplyRange = jest.fn(); + const {result} = renderHook(() => + useShiftRangeSelection( + makeParams({ + items: GROUPED, + isHeaderItem: (r) => !!r.isHeader, + onApplyRange, + }), + ), + ); + act(() => { + result.current.applyShiftClick(GROUPED[2], {shiftKey: true}); + }); + expect(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['a', 'b'], toDeselect: []}); + }); + }); + + describe('range computation — direction', () => { + it('selects from anchor down through the target when the target is below', () => { + const onApplyRange = jest.fn(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[0])); + act(() => { + result.current.applyShiftClick(ROWS[3], {shiftKey: true}); + }); + expect(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['a', 'b', 'c', 'd'], toDeselect: []}); + }); + + it('selects from the target up through anchor when the target is above', () => { + const onApplyRange = jest.fn(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[4])); + act(() => { + result.current.applyShiftClick(ROWS[1], {shiftKey: true}); + }); + expect(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['b', 'c', 'd', 'e'], toDeselect: []}); + }); + + it('headers between anchor and target are excluded from the batch', () => { + const onApplyRange = jest.fn(); + 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(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['a', 'b', 'c', 'd'], toDeselect: []}); + }); + + it('disabled rows between anchor and target are excluded from the batch', () => { + const onApplyRange = jest.fn(); + 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(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['a', 'c', 'e'], toDeselect: []}); + }); + }); + + describe('session continuity', () => { + it('deselects the tail when a second shift+click lands inside the existing range', () => { + const onApplyRange = jest.fn(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[0])); // anchor='a' + act(() => { + result.current.applyShiftClick(ROWS[4], {shiftKey: true}); // extend to 'e' + }); + act(() => { + result.current.applyShiftClick(ROWS[2], {shiftKey: true}); // shrink to 'c' + }); + expect(keys(onApplyRange.mock.calls[1][0])).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 = jest.fn(); + 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(keys(onApplyRange.mock.calls[1][0])).toEqual({toSelect: ['a', 'b', 'c', 'd', 'e'], toDeselect: []}); + }); + + it('deselects the prior side when a shift+click reverses direction across the anchor', () => { + const onApplyRange = jest.fn(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[2])); // anchor='c' + act(() => { + result.current.applyShiftClick(ROWS[4], {shiftKey: true}); + }); + act(() => { + result.current.applyShiftClick(ROWS[0], {shiftKey: true}); + }); + expect(keys(onApplyRange.mock.calls[1][0])).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: ['d', 'e']}); + }); + + it('ends the session when notifyAnchor is called mid-session, so the next shift+click starts fresh', () => { + const onApplyRange = jest.fn(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[0])); + act(() => { + result.current.applyShiftClick(ROWS[4], {shiftKey: true}); // session: anchor='a', prevEnd='e' + }); + act(() => result.current.notifyAnchor(ROWS[2])); // plain click on 'c' → session ends + act(() => { + result.current.applyShiftClick(ROWS[3], {shiftKey: true}); // anchor='c', range ['c','d'], no deselect + }); + expect(keys(onApplyRange.mock.calls[1][0])).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 = jest.fn(); + 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(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['a', 'b', 'c', 'd'], toDeselect: []}); + }); + }); + + describe('extendByKeyboard', () => { + it('returns null when no anchor and no session', () => { + const {result} = renderHook(() => useShiftRangeSelection(makeParams())); + let key: string | null = 'sentinel'; + act(() => { + key = result.current.extendByKeyboard('down'); + }); + expect(key).toBeNull(); + }); + + it('steps down from anchor and applies the range', () => { + const onApplyRange = jest.fn(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[1])); + let key: string | null = null; + act(() => { + key = result.current.extendByKeyboard('down'); + }); + expect(key).toBe('c'); + expect(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['b', 'c'], toDeselect: []}); + }); + + it('steps up from session.prevEnd while continuing the session', () => { + const onApplyRange = jest.fn(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[1])); + act(() => { + result.current.applyShiftClick(ROWS[4], {shiftKey: true}); // session: anchor='b', prevEnd='e' + }); + let key: string | null = null; + act(() => { + key = result.current.extendByKeyboard('up'); + }); + expect(key).toBe('d'); + expect(keys(onApplyRange.mock.calls[1][0])).toEqual({toSelect: ['b', 'c', 'd'], toDeselect: ['e']}); + }); + + it('skips headers and disabled rows when stepping', () => { + const onApplyRange = jest.fn(); + const {result} = renderHook(() => + useShiftRangeSelection( + makeParams({ + items: GROUPED, + isHeaderItem: (r) => !!r.isHeader, + onApplyRange, + }), + ), + ); + act(() => result.current.notifyAnchor(GROUPED[2])); + let key: string | null = null; + act(() => { + key = result.current.extendByKeyboard('down'); + }); + expect(key).toBe('c'); // skips header h2 between b and c + }); + + it('returns null when stepping off the end of the list', () => { + const onApplyRange = jest.fn(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[4])); // last + let key: string | null = 'sentinel'; + act(() => { + key = result.current.extendByKeyboard('down'); + }); + expect(key).toBeNull(); + expect(onApplyRange).not.toHaveBeenCalled(); + }); + }); + + describe('defensive bails', () => { + it('returns false when every item is excluded and no anchor can be resolved', () => { + const onApplyRange = jest.fn(); + const allHeaders: Row[] = [ + {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('returns false when getItemKey returns null/undefined for the target', () => { + const onApplyRange = jest.fn(); + const items: Row[] = [{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); + }); + + it('extendByKeyboard returns null when the focused key no longer exists in items', () => { + const onApplyRange = jest.fn(); + const {result, rerender} = renderHook(({items}: {items: Row[]}) => useShiftRangeSelection(makeParams({items, onApplyRange})), { + initialProps: {items: ROWS}, + }); + act(() => result.current.notifyAnchor(ROWS[2])); + rerender({items: [{keyForList: 'x'}, {keyForList: 'y'}]}); + let key: string | null = 'sentinel'; + act(() => { + key = result.current.extendByKeyboard('down'); + }); + expect(key).toBeNull(); + expect(onApplyRange).not.toHaveBeenCalled(); + }); + }); + + describe('end-to-end interaction flows', () => { + it('includes the anchor row in the range when the user selected then deselected it before shift+clicking', () => { + const onApplyRange = jest.fn(); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[1])); + act(() => result.current.notifyAnchor(ROWS[1])); + act(() => { + result.current.applyShiftClick(ROWS[4], {shiftKey: true}); + }); + expect(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['b', 'c', 'd', 'e'], toDeselect: []}); + }); + + it('shrinks the existing range when a second shift+click lands before the previous endpoint with no plain click in between', () => { + const onApplyRange = jest.fn(); + 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(keys(onApplyRange.mock.calls[1][0])).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: ['d', 'e']}); + }); + }); +}); + +describe('getShiftKeyFromEvent', () => { + it('returns false for nullish input', () => { + expect(getShiftKeyFromEvent(undefined)).toBe(false); + expect(getShiftKeyFromEvent(null)).toBe(false); + }); + + it('reads shiftKey from the outer event when present', () => { + expect(getShiftKeyFromEvent({shiftKey: true} as Parameters[0])).toBe(true); + expect(getShiftKeyFromEvent({shiftKey: false} as Parameters[0])).toBe(false); + }); + + it('falls back to nativeEvent.shiftKey when outer shiftKey is absent', () => { + expect(getShiftKeyFromEvent({nativeEvent: {shiftKey: true}} as Parameters[0])).toBe(true); + expect(getShiftKeyFromEvent({nativeEvent: {shiftKey: false}} as Parameters[0])).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, 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('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('default isSelectable skips items with isDisabled or isDisabledCheckbox', () => { + 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('applyShiftRangeBatchToValueArray', () => { + type Item = {id: string; isDisabled?: boolean}; + type Value = {id: string; label: string}; + const getItemKey = (i: Item) => i.id; + const getValueKey = (v: Value) => v.id; + const buildValue = (i: Item): Value => ({id: i.id, label: i.id.toUpperCase()}); + + it('returns a copy of prevValues for empty batches', () => { + const prev: Value[] = [{id: 'a', label: 'A'}]; + const out = applyShiftRangeBatchToValueArray({toSelect: [], toDeselect: []}, prev, getItemKey, getValueKey, buildValue); + expect(out).toEqual(prev); + expect(out).not.toBe(prev); + }); + + it('builds and appends new values for toSelect items', () => { + const out = applyShiftRangeBatchToValueArray({toSelect: [{id: 'b'}, {id: 'c'}], toDeselect: []}, [{id: 'a', label: 'A'}], getItemKey, getValueKey, buildValue); + expect(out).toEqual([ + {id: 'a', label: 'A'}, + {id: 'b', label: 'B'}, + {id: 'c', label: 'C'}, + ]); + }); + + it('removes prev values whose key matches toDeselect', () => { + const out = applyShiftRangeBatchToValueArray( + {toSelect: [], toDeselect: [{id: 'b'}]}, + [ + {id: 'a', label: 'A'}, + {id: 'b', label: 'B'}, + {id: 'c', label: 'C'}, + ], + getItemKey, + getValueKey, + buildValue, + ); + expect(out).toEqual([ + {id: 'a', label: 'A'}, + {id: 'c', label: 'C'}, + ]); + }); + + it('skips toSelect items whose key is already present in prev', () => { + const out = applyShiftRangeBatchToValueArray({toSelect: [{id: 'a'}, {id: 'b'}], toDeselect: []}, [{id: 'a', label: 'A'}], getItemKey, getValueKey, buildValue); + expect(out).toEqual([ + {id: 'a', label: 'A'}, + {id: 'b', label: 'B'}, + ]); + }); + + it('skips items where buildValue returns null/undefined', () => { + const out = applyShiftRangeBatchToValueArray({toSelect: [{id: 'b'}, {id: 'c'}], toDeselect: []}, [{id: 'a', label: 'A'}], getItemKey, getValueKey, (i) => + i.id === 'b' ? null : buildValue(i), + ); + expect(out).toEqual([ + {id: 'a', label: 'A'}, + {id: 'c', label: 'C'}, + ]); + }); + + it('skips items where isSelectable returns false', () => { + const out = applyShiftRangeBatchToValueArray({toSelect: [{id: 'b'}, {id: 'c'}], toDeselect: []}, [{id: 'a', label: 'A'}], getItemKey, getValueKey, buildValue, (i) => i.id !== 'b'); + expect(out).toEqual([ + {id: 'a', label: 'A'}, + {id: 'c', label: 'C'}, + ]); + }); +}); From d7200e1f83cd2711517a7aa1e0f7e230ce82cb8c Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 31 May 2026 21:54:29 +0300 Subject: [PATCH 05/19] fix: guard stale prevEnd in shift-range session --- src/hooks/useShiftRangeSelection.ts | 4 +++- tests/unit/hooks/useShiftRangeSelection.test.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/hooks/useShiftRangeSelection.ts b/src/hooks/useShiftRangeSelection.ts index d6cf27212652..1f076db6cabd 100644 --- a/src/hooks/useShiftRangeSelection.ts +++ b/src/hooks/useShiftRangeSelection.ts @@ -79,7 +79,9 @@ function useShiftRangeSelection(params: Params): Api { } const newRange = orderedRange(anchorIdx, targetIdx); - const prevRange = prevEnd != null ? orderedRange(anchorIdx, indexOfKey(p, prevEnd)) : null; + // Guard against stale prevEnd: indexOfKey returns -1 → items.at(-1) would deselect the last row. + const prevEndIdx = prevEnd != null ? indexOfKey(p, prevEnd) : -1; + const prevRange = prevEndIdx >= 0 ? orderedRange(anchorIdx, prevEndIdx) : null; const isUsable = (i: number) => !isExcluded(p, p.items.at(i)); const toSelect: TItem[] = []; diff --git a/tests/unit/hooks/useShiftRangeSelection.test.ts b/tests/unit/hooks/useShiftRangeSelection.test.ts index ef4a5b63788b..06eb059a63b1 100644 --- a/tests/unit/hooks/useShiftRangeSelection.test.ts +++ b/tests/unit/hooks/useShiftRangeSelection.test.ts @@ -445,6 +445,23 @@ describe('useShiftRangeSelection', () => { expect(applied).toBe(false); }); + it('does not emit deselects from a vanished previous endpoint when items change mid-session', () => { + const onApplyRange = jest.fn(); + 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(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['a', 'b'], toDeselect: []}); + }); + it('extendByKeyboard returns null when the focused key no longer exists in items', () => { const onApplyRange = jest.fn(); const {result, rerender} = renderHook(({items}: {items: Row[]}) => useShiftRangeSelection(makeParams({items, onApplyRange})), { From 55f063e78448fc4ea3c202845c93e9af22edac2a Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 31 May 2026 22:17:59 +0300 Subject: [PATCH 06/19] fix: shift+click on Search group header + resolve anchor from real selection --- src/components/Search/index.tsx | 16 ++ .../SelectionList/BaseSelectionList.tsx | 3 +- .../unit/hooks/useShiftRangeSelection.test.ts | 146 ++++++++++-------- 3 files changed, 98 insertions(+), 67 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index ad917c24d955..aa33092f2281 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1146,6 +1146,21 @@ function Search({ if (isReportActionListItemType(item) || isTaskListItemType(item)) { return; } + // Shift+click on a group header extends through the group; target = farthest loaded child from anchor so both group edges are covered. + if (options?.shiftKey && isTransactionGroupListItemType(item) && item.transactions && item.transactions.length > 0) { + const anchorKey = rangeApi.getAnchorKey(); + const anchorIdx = anchorKey ? flattenedShiftRangeItems.findIndex((r) => r.keyForList === anchorKey) : -1; + const firstChild = item.transactions.at(0); + const lastChild = item.transactions.at(-1); + if (firstChild && lastChild) { + const firstIdx = flattenedShiftRangeItems.findIndex((r) => r.keyForList === firstChild.keyForList); + const lastIdx = flattenedShiftRangeItems.findIndex((r) => r.keyForList === lastChild.keyForList); + const target = anchorIdx >= 0 && Math.abs(firstIdx - anchorIdx) > Math.abs(lastIdx - anchorIdx) ? firstChild : lastChild; + if (rangeApi.applyShiftClick(target, {shiftKey: true})) { + return; + } + } + } if (rangeApi.applyShiftClick(item, options)) { return; } @@ -1272,6 +1287,7 @@ function Search({ selfDMReport, isProduction, rangeApi, + flattenedShiftRangeItems, ], ); diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 409ae6378d67..eb1f0dd65d92 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -262,9 +262,10 @@ function BaseSelectionList({ items: data, getItemKey: (item) => item.keyForList ?? null, getSelectedKeys: () => { + // Mirror isItemSelected so consumers using selectedItems / custom isSelected resolve an anchor too. const keys = new Set(); for (const item of data) { - if (item.isSelected && item.keyForList) { + if (item.keyForList && isItemSelected(item)) { keys.add(item.keyForList); } } diff --git a/tests/unit/hooks/useShiftRangeSelection.test.ts b/tests/unit/hooks/useShiftRangeSelection.test.ts index 06eb059a63b1..358003d2a56a 100644 --- a/tests/unit/hooks/useShiftRangeSelection.test.ts +++ b/tests/unit/hooks/useShiftRangeSelection.test.ts @@ -3,10 +3,12 @@ import useShiftRangeSelection, {applyShiftRangeBatchToKeySet, applyShiftRangeBat import type {ShiftRangeBatch} from '@hooks/useShiftRangeSelection'; type Row = {keyForList: string; isHeader?: boolean; isDisabled?: boolean}; +type Tuple5 = [T, T, T, T, T]; +type Tuple7 = [T, T, T, T, T, T, T]; -const ROWS: Row[] = [{keyForList: 'a'}, {keyForList: 'b'}, {keyForList: 'c'}, {keyForList: 'd'}, {keyForList: 'e'}]; +const ROWS: Tuple5 = [{keyForList: 'a'}, {keyForList: 'b'}, {keyForList: 'c'}, {keyForList: 'd'}, {keyForList: 'e'}]; -const GROUPED: Row[] = [ +const GROUPED: Tuple7 = [ {keyForList: 'h1', isHeader: true}, {keyForList: 'a'}, {keyForList: 'b'}, @@ -16,7 +18,7 @@ const GROUPED: Row[] = [ {keyForList: 'e'}, ]; -const MIXED: Row[] = [{keyForList: 'a'}, {keyForList: 'b', isDisabled: true}, {keyForList: 'c'}, {keyForList: 'd', isDisabled: true}, {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 { @@ -33,6 +35,17 @@ function keys(batch: ShiftRangeBatch): {toSelect: string[]; toDeselect: str }; } +type RowApplyMock = jest.MockedFunction<(batch: ShiftRangeBatch) => void>; + +function makeApplyMock(): RowApplyMock { + return jest.fn]>(); +} + +function nthBatchKeys(mockFn: RowApplyMock, n: number): {toSelect: string[]; toDeselect: string[]} { + const batch = mockFn.mock.calls.at(n)?.at(0); + return keys(batch ?? {toSelect: [], toDeselect: []}); +} + describe('useShiftRangeSelection', () => { describe('getAnchorKey', () => { it('returns null when no anchor and no session', () => { @@ -82,7 +95,7 @@ describe('useShiftRangeSelection', () => { }); it('returns false when the target row is a header', () => { - const onApplyRange = jest.fn(); + const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection( makeParams({ @@ -102,7 +115,7 @@ describe('useShiftRangeSelection', () => { }); it('returns false when the target row is disabled', () => { - const onApplyRange = jest.fn(); + const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection( makeParams({ @@ -122,13 +135,14 @@ describe('useShiftRangeSelection', () => { }); it('returns false when target has no key', () => { - const onApplyRange = jest.fn(); - const itemsWithMissingKey = [...ROWS, {keyForList: ''} as Row]; + const onApplyRange = makeApplyMock(); + const missingKeyRow: Row = {keyForList: ''}; + const itemsWithMissingKey: [Row, Row, Row, Row, Row, Row] = [...ROWS, missingKeyRow]; const {result} = renderHook(() => useShiftRangeSelection(makeParams({items: itemsWithMissingKey, onApplyRange}))); - act(() => result.current.notifyAnchor(itemsWithMissingKey[0])); + act(() => result.current.notifyAnchor(ROWS[0])); let applied = true; act(() => { - applied = result.current.applyShiftClick(itemsWithMissingKey.at(-1) as Row, {shiftKey: true}); + applied = result.current.applyShiftClick(missingKeyRow, {shiftKey: true}); }); expect(applied).toBe(false); }); @@ -136,18 +150,18 @@ describe('useShiftRangeSelection', () => { describe('applyShiftClick — anchor resolution', () => { it('uses notifyAnchor key as anchor for the first shift+click', () => { - const onApplyRange = jest.fn(); + 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(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['b', 'c', 'd'], toDeselect: []}); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['b', 'c', 'd'], toDeselect: []}); }); it('falls back to first-selected when no notifyAnchor', () => { - const onApplyRange = jest.fn(); + const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection( makeParams({ @@ -159,11 +173,11 @@ describe('useShiftRangeSelection', () => { act(() => { result.current.applyShiftClick(ROWS[4], {shiftKey: true}); }); - expect(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['c', 'd', 'e'], toDeselect: []}); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['c', 'd', 'e'], toDeselect: []}); }); it('accepts an array from getSelectedKeys in addition to a Set', () => { - const onApplyRange = jest.fn(); + const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection( makeParams({ @@ -175,20 +189,20 @@ describe('useShiftRangeSelection', () => { act(() => { result.current.applyShiftClick(ROWS[4], {shiftKey: true}); }); - expect(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['c', 'd', 'e'], toDeselect: []}); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['c', 'd', 'e'], toDeselect: []}); }); it('falls back to first-visible when no anchor and no selection', () => { - const onApplyRange = jest.fn(); + const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); act(() => { result.current.applyShiftClick(ROWS[2], {shiftKey: true}); }); - expect(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: []}); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: []}); }); it('first-visible fallback skips headers', () => { - const onApplyRange = jest.fn(); + const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection( makeParams({ @@ -201,33 +215,33 @@ describe('useShiftRangeSelection', () => { act(() => { result.current.applyShiftClick(GROUPED[2], {shiftKey: true}); }); - expect(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['a', 'b'], toDeselect: []}); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'b'], toDeselect: []}); }); }); describe('range computation — direction', () => { it('selects from anchor down through the target when the target is below', () => { - const onApplyRange = jest.fn(); + const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); act(() => result.current.notifyAnchor(ROWS[0])); act(() => { result.current.applyShiftClick(ROWS[3], {shiftKey: true}); }); - expect(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['a', 'b', 'c', 'd'], toDeselect: []}); + 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 = jest.fn(); + const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); act(() => result.current.notifyAnchor(ROWS[4])); act(() => { result.current.applyShiftClick(ROWS[1], {shiftKey: true}); }); - expect(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['b', 'c', 'd', 'e'], toDeselect: []}); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['b', 'c', 'd', 'e'], toDeselect: []}); }); it('headers between anchor and target are excluded from the batch', () => { - const onApplyRange = jest.fn(); + const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection( makeParams({ @@ -241,11 +255,11 @@ describe('useShiftRangeSelection', () => { act(() => { result.current.applyShiftClick(GROUPED[5], {shiftKey: true}); }); - expect(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['a', 'b', 'c', 'd'], toDeselect: []}); + 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 = jest.fn(); + const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection( makeParams({ @@ -259,26 +273,26 @@ describe('useShiftRangeSelection', () => { act(() => { result.current.applyShiftClick(MIXED[4], {shiftKey: true}); }); - expect(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['a', 'c', 'e'], toDeselect: []}); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'c', 'e'], toDeselect: []}); }); }); describe('session continuity', () => { it('deselects the tail when a second shift+click lands inside the existing range', () => { - const onApplyRange = jest.fn(); + const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); - act(() => result.current.notifyAnchor(ROWS[0])); // anchor='a' + act(() => result.current.notifyAnchor(ROWS[0])); act(() => { - result.current.applyShiftClick(ROWS[4], {shiftKey: true}); // extend to 'e' + result.current.applyShiftClick(ROWS[4], {shiftKey: true}); }); act(() => { - result.current.applyShiftClick(ROWS[2], {shiftKey: true}); // shrink to 'c' + result.current.applyShiftClick(ROWS[2], {shiftKey: true}); }); - expect(keys(onApplyRange.mock.calls[1][0])).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: ['d', 'e']}); + 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 = jest.fn(); + const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); act(() => result.current.notifyAnchor(ROWS[0])); act(() => { @@ -287,38 +301,38 @@ describe('useShiftRangeSelection', () => { act(() => { result.current.applyShiftClick(ROWS[4], {shiftKey: true}); }); - expect(keys(onApplyRange.mock.calls[1][0])).toEqual({toSelect: ['a', 'b', 'c', 'd', 'e'], toDeselect: []}); + 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 = jest.fn(); + const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); - act(() => result.current.notifyAnchor(ROWS[2])); // anchor='c' + act(() => result.current.notifyAnchor(ROWS[2])); act(() => { result.current.applyShiftClick(ROWS[4], {shiftKey: true}); }); act(() => { result.current.applyShiftClick(ROWS[0], {shiftKey: true}); }); - expect(keys(onApplyRange.mock.calls[1][0])).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: ['d', 'e']}); + expect(nthBatchKeys(onApplyRange, 1)).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: ['d', 'e']}); }); it('ends the session when notifyAnchor is called mid-session, so the next shift+click starts fresh', () => { - const onApplyRange = jest.fn(); + const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); act(() => result.current.notifyAnchor(ROWS[0])); act(() => { - result.current.applyShiftClick(ROWS[4], {shiftKey: true}); // session: anchor='a', prevEnd='e' + result.current.applyShiftClick(ROWS[4], {shiftKey: true}); }); - act(() => result.current.notifyAnchor(ROWS[2])); // plain click on 'c' → session ends + act(() => result.current.notifyAnchor(ROWS[2])); act(() => { - result.current.applyShiftClick(ROWS[3], {shiftKey: true}); // anchor='c', range ['c','d'], no deselect + result.current.applyShiftClick(ROWS[3], {shiftKey: true}); }); - expect(keys(onApplyRange.mock.calls[1][0])).toEqual({toSelect: ['c', 'd'], toDeselect: []}); + 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 = jest.fn(); + const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); act(() => result.current.notifyAnchor(ROWS[0])); act(() => { @@ -329,7 +343,7 @@ describe('useShiftRangeSelection', () => { result.current.applyShiftClick(ROWS[3], {shiftKey: true}); }); expect(onApplyRange).toHaveBeenCalledTimes(1); - expect(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['a', 'b', 'c', 'd'], toDeselect: []}); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'b', 'c', 'd'], toDeselect: []}); }); }); @@ -344,7 +358,7 @@ describe('useShiftRangeSelection', () => { }); it('steps down from anchor and applies the range', () => { - const onApplyRange = jest.fn(); + const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); act(() => result.current.notifyAnchor(ROWS[1])); let key: string | null = null; @@ -352,26 +366,26 @@ describe('useShiftRangeSelection', () => { key = result.current.extendByKeyboard('down'); }); expect(key).toBe('c'); - expect(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['b', 'c'], toDeselect: []}); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['b', 'c'], toDeselect: []}); }); it('steps up from session.prevEnd while continuing the session', () => { - const onApplyRange = jest.fn(); + const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); act(() => result.current.notifyAnchor(ROWS[1])); act(() => { - result.current.applyShiftClick(ROWS[4], {shiftKey: true}); // session: anchor='b', prevEnd='e' + result.current.applyShiftClick(ROWS[4], {shiftKey: true}); }); let key: string | null = null; act(() => { key = result.current.extendByKeyboard('up'); }); expect(key).toBe('d'); - expect(keys(onApplyRange.mock.calls[1][0])).toEqual({toSelect: ['b', 'c', 'd'], toDeselect: ['e']}); + expect(nthBatchKeys(onApplyRange, 1)).toEqual({toSelect: ['b', 'c', 'd'], toDeselect: ['e']}); }); it('skips headers and disabled rows when stepping', () => { - const onApplyRange = jest.fn(); + const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection( makeParams({ @@ -386,13 +400,13 @@ describe('useShiftRangeSelection', () => { act(() => { key = result.current.extendByKeyboard('down'); }); - expect(key).toBe('c'); // skips header h2 between b and c + expect(key).toBe('c'); }); it('returns null when stepping off the end of the list', () => { - const onApplyRange = jest.fn(); + const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); - act(() => result.current.notifyAnchor(ROWS[4])); // last + act(() => result.current.notifyAnchor(ROWS[4])); let key: string | null = 'sentinel'; act(() => { key = result.current.extendByKeyboard('down'); @@ -404,8 +418,8 @@ describe('useShiftRangeSelection', () => { describe('defensive bails', () => { it('returns false when every item is excluded and no anchor can be resolved', () => { - const onApplyRange = jest.fn(); - const allHeaders: Row[] = [ + const onApplyRange = makeApplyMock(); + const allHeaders: [Row, Row] = [ {keyForList: 'h1', isHeader: true}, {keyForList: 'h2', isHeader: true}, ]; @@ -427,8 +441,8 @@ describe('useShiftRangeSelection', () => { }); it('returns false when getItemKey returns null/undefined for the target', () => { - const onApplyRange = jest.fn(); - const items: Row[] = [{keyForList: 'a'}, {keyForList: 'b'}]; + const onApplyRange = makeApplyMock(); + const items: [Row, Row] = [{keyForList: 'a'}, {keyForList: 'b'}]; const {result} = renderHook(() => useShiftRangeSelection( makeParams({ @@ -446,9 +460,9 @@ describe('useShiftRangeSelection', () => { }); it('does not emit deselects from a vanished previous endpoint when items change mid-session', () => { - const onApplyRange = jest.fn(); + const onApplyRange = makeApplyMock(); const {result, rerender} = renderHook(({items}: {items: Row[]}) => useShiftRangeSelection(makeParams({items, onApplyRange})), { - initialProps: {items: ROWS}, + initialProps: {items: [...ROWS]}, }); act(() => result.current.notifyAnchor(ROWS[0])); act(() => { @@ -459,13 +473,13 @@ describe('useShiftRangeSelection', () => { act(() => { result.current.applyShiftClick(ROWS[1], {shiftKey: true}); }); - expect(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['a', 'b'], toDeselect: []}); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'b'], toDeselect: []}); }); it('extendByKeyboard returns null when the focused key no longer exists in items', () => { - const onApplyRange = jest.fn(); + const onApplyRange = makeApplyMock(); const {result, rerender} = renderHook(({items}: {items: Row[]}) => useShiftRangeSelection(makeParams({items, onApplyRange})), { - initialProps: {items: ROWS}, + initialProps: {items: [...ROWS]}, }); act(() => result.current.notifyAnchor(ROWS[2])); rerender({items: [{keyForList: 'x'}, {keyForList: 'y'}]}); @@ -480,18 +494,18 @@ describe('useShiftRangeSelection', () => { describe('end-to-end interaction flows', () => { it('includes the anchor row in the range when the user selected then deselected it before shift+clicking', () => { - const onApplyRange = jest.fn(); + const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); act(() => result.current.notifyAnchor(ROWS[1])); act(() => result.current.notifyAnchor(ROWS[1])); act(() => { result.current.applyShiftClick(ROWS[4], {shiftKey: true}); }); - expect(keys(onApplyRange.mock.calls[0][0])).toEqual({toSelect: ['b', 'c', 'd', 'e'], toDeselect: []}); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['b', 'c', 'd', 'e'], toDeselect: []}); }); it('shrinks the existing range when a second shift+click lands before the previous endpoint with no plain click in between', () => { - const onApplyRange = jest.fn(); + const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); act(() => result.current.notifyAnchor(ROWS[0])); act(() => { @@ -500,7 +514,7 @@ describe('useShiftRangeSelection', () => { act(() => { result.current.applyShiftClick(ROWS[2], {shiftKey: true}); }); - expect(keys(onApplyRange.mock.calls[1][0])).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: ['d', 'e']}); + expect(nthBatchKeys(onApplyRange, 1)).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: ['d', 'e']}); }); }); }); From efdfd4acf14adbd7a5ba14845486cea9854a026f Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 31 May 2026 22:43:50 +0300 Subject: [PATCH 07/19] fix: anchor resolution + Select All + native Shift+Arrow trigger --- src/CONST/index.ts | 4 +- .../MoneyRequestReportTransactionList.tsx | 4 ++ src/components/Search/index.tsx | 18 ++++-- .../unit/hooks/useShiftRangeSelection.test.ts | 55 +++++++++++++++++++ 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 7bf0e2ce4997..ac6a595c6139 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1181,13 +1181,13 @@ const CONST = { descriptionKey: null, shortcutKey: 'ArrowUp', modifiers: ['SHIFT'], - trigger: {DEFAULT: {input: keyInputUpArrow}}, + trigger: {DEFAULT: {input: keyInputUpArrow, modifierFlags: keyModifierShift}}, }, SHIFT_ARROW_DOWN: { descriptionKey: null, shortcutKey: 'ArrowDown', modifiers: ['SHIFT'], - trigger: {DEFAULT: {input: keyInputDownArrow}}, + trigger: {DEFAULT: {input: keyInputDownArrow, modifierFlags: keyModifierShift}}, }, TAB: { descriptionKey: null, diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 28f317e81529..4af475835f47 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -517,6 +517,10 @@ function MoneyRequestReportTransactionList({ 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); + } }, [groupedTransactions, selectedTransactionIDs, setSelectedTransactions, rangeApi, visualOrderTransactions], ); diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index aa33092f2281..45d9d63d9d47 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1165,6 +1165,12 @@ function Search({ return; } + // Anchor on a selectable child in grouped views — the group header itself is rejected by the hook. + 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; @@ -1189,7 +1195,7 @@ function Search({ ); setSelectedTransactions(updatedTransactions); updateSelectAllMatchingItemsState(updatedTransactions); - rangeApi.notifyAnchor(item); + rangeApi.notifyAnchor(anchorSource); return; } @@ -1214,7 +1220,7 @@ function Search({ delete reducedSelectedTransactions[reportKey]; setSelectedTransactions(reducedSelectedTransactions); updateSelectAllMatchingItemsState(reducedSelectedTransactions); - rangeApi.notifyAnchor(item); + rangeApi.notifyAnchor(anchorSource); return; } @@ -1225,7 +1231,7 @@ function Search({ }; setSelectedTransactions(updatedTransactions); updateSelectAllMatchingItemsState(updatedTransactions); - rangeApi.notifyAnchor(item); + rangeApi.notifyAnchor(anchorSource); return; } @@ -1240,7 +1246,7 @@ function Search({ setSelectedTransactions(reducedSelectedTransactions); updateSelectAllMatchingItemsState(reducedSelectedTransactions); - rangeApi.notifyAnchor(item); + rangeApi.notifyAnchor(anchorSource); return; } @@ -1273,7 +1279,7 @@ function Search({ }; setSelectedTransactions(updatedTransactions); updateSelectAllMatchingItemsState(updatedTransactions); - rangeApi.notifyAnchor(item); + rangeApi.notifyAnchor(anchorSource); }, [ selectedTransactions, @@ -1288,6 +1294,7 @@ function Search({ isProduction, rangeApi, flattenedShiftRangeItems, + validGroupBy, ], ); @@ -1598,6 +1605,7 @@ function Search({ } setSelectedTransactions(updatedTransactions, filteredData); updateSelectAllMatchingItemsState(updatedTransactions); + rangeApi.clearAnchor(); }, [ areItemsGrouped, selectedTransactions, diff --git a/tests/unit/hooks/useShiftRangeSelection.test.ts b/tests/unit/hooks/useShiftRangeSelection.test.ts index 358003d2a56a..e07facf0c915 100644 --- a/tests/unit/hooks/useShiftRangeSelection.test.ts +++ b/tests/unit/hooks/useShiftRangeSelection.test.ts @@ -476,6 +476,61 @@ describe('useShiftRangeSelection', () => { expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'b'], toDeselect: []}); }); + it('falls back to first-selected on the next shift+click when notifyAnchor was passed a header item', () => { + 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('falls back to first-visible on the next shift+click when notifyAnchor was passed a disabled item', () => { + 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: []}); + }); + + it('clearAnchor ends an active shift session so the next shift+click starts fresh with no prevEnd', () => { + 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: []}); + }); + it('extendByKeyboard returns null when the focused key no longer exists in items', () => { const onApplyRange = makeApplyMock(); const {result, rerender} = renderHook(({items}: {items: Row[]}) => useShiftRangeSelection(makeParams({items, onApplyRange})), { From 55b4e76ff28aee165839a0b72c3ac63591473de0 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 31 May 2026 22:59:42 +0300 Subject: [PATCH 08/19] fix: restore selection-button onSelectRow fallback when onSelectionButtonPress is undefined --- src/components/SelectionList/BaseSelectionList.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index eb1f0dd65d92..80df2c0554c3 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -280,10 +280,14 @@ function BaseSelectionList({ if (onShiftRangeApply && rangeApi.applyShiftClick(item, options)) { return; } - onSelectionButtonPress?.(item, itemTransactions, options); + if (onSelectionButtonPress) { + onSelectionButtonPress(item, itemTransactions, options); + } else { + onSelectRow(item); + } rangeApi.notifyAnchor(item); }, - [onShiftRangeApply, rangeApi, onSelectionButtonPress], + [onShiftRangeApply, rangeApi, onSelectionButtonPress, onSelectRow], ); const selectRow = useCallback( From 000258e7a20756b9057ac8486fc5b41f0e06fd5d Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 31 May 2026 23:10:21 +0300 Subject: [PATCH 09/19] fix: flatten Reports view children into shift-range items --- src/components/Search/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 45d9d63d9d47..803afedf3a3c 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1120,9 +1120,9 @@ function Search({ const {stableSortedData, hasCachedOptimisticItem} = useStableOptimisticSortedData(sortedData, searchResults, optimisticTrackingState); - // Mirrors stableSortedData; only flattens for Spend grouped views (validGroupBy) where rows render as [header, ...children]. + // Flatten any group-shaped data so children are anchorable; header exclusion is gated separately on validGroupBy. const flattenedShiftRangeItems = useMemo(() => { - if (!validGroupBy) { + if (!areItemsGrouped) { return stableSortedData; } const isGroupArray = (items: SearchListItem[]): items is TransactionGroupListItemType[] => items.every((g) => isTransactionGroupListItemType(g) && Array.isArray(g.transactions)); @@ -1130,7 +1130,7 @@ function Search({ return stableSortedData; } return stableSortedData.flatMap((g) => [g, ...(g.transactions ?? [])]); - }, [stableSortedData, validGroupBy]); + }, [stableSortedData, areItemsGrouped]); const selectedTransactionKeySet = useMemo(() => new Set(Object.keys(selectedTransactions ?? {})), [selectedTransactions]); From 28bd608062f3cf21dcdc511af70cd0ab21284ac4 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 31 May 2026 23:24:42 +0300 Subject: [PATCH 10/19] chore: rephrase comment to satisfy cspell --- src/components/Search/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 803afedf3a3c..72fbd7ff2c6c 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1120,7 +1120,7 @@ function Search({ const {stableSortedData, hasCachedOptimisticItem} = useStableOptimisticSortedData(sortedData, searchResults, optimisticTrackingState); - // Flatten any group-shaped data so children are anchorable; header exclusion is gated separately on validGroupBy. + // Flatten any group-shaped data so children appear in the range items; header exclusion is gated separately on validGroupBy. const flattenedShiftRangeItems = useMemo(() => { if (!areItemsGrouped) { return stableSortedData; From de19a1af145977b515ce4d50268adaf90e8e246f Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 31 May 2026 23:38:10 +0300 Subject: [PATCH 11/19] test: update header + group-item assertions for forwarded shiftKey arg --- tests/ui/CategoryListItemHeaderTest.tsx | 2 +- tests/ui/MerchantListItemHeaderTest.tsx | 2 +- tests/ui/MonthListItemHeaderTest.tsx | 2 +- tests/ui/WeekListItemHeaderTest.tsx | 2 +- tests/ui/YearListItemHeaderTest.tsx | 2 +- tests/unit/TransactionGroupListItemTest.tsx | 12 ++++++------ 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/ui/CategoryListItemHeaderTest.tsx b/tests/ui/CategoryListItemHeaderTest.tsx index c7c60a834b33..560f21c57661 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(categoryItem, {shiftKey: 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..6cf13eddc717 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(merchantItem, {shiftKey: 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..473fd55fd413 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(monthItem, {shiftKey: 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..fc41e381e774 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(weekItem, {shiftKey: 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..cc0be1c0dd52 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(yearItem, {shiftKey: 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..2ad073496011 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}); }); 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}); 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}); 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}); + expect(mockOnCheckboxPress).toHaveBeenNthCalledWith(2, mockEmptyReport, undefined, {shiftKey: false}); + expect(mockOnCheckboxPress).toHaveBeenNthCalledWith(3, mockEmptyReport, undefined, {shiftKey: false}); }); it('should show expandable content for non-empty reports', async () => { From e70bb1e66f38da1aaf1c60b9fc223ce9e70aace6 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Tue, 2 Jun 2026 15:17:07 +0300 Subject: [PATCH 12/19] chore: remove Shift+Arrow keyboard nav --- src/CONST/index.ts | 12 --- .../SelectionList/BaseSelectionList.tsx | 36 +------- src/hooks/useShiftRangeSelection.ts | 35 -------- .../unit/hooks/useShiftRangeSelection.test.ts | 84 ------------------- 4 files changed, 1 insertion(+), 166 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 784e7834d7a6..9bc2a3bb60a9 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1177,18 +1177,6 @@ const CONST = { [PLATFORM_IOS]: {input: keyInputRightArrow}, }, }, - SHIFT_ARROW_UP: { - descriptionKey: null, - shortcutKey: 'ArrowUp', - modifiers: ['SHIFT'], - trigger: {DEFAULT: {input: keyInputUpArrow, modifierFlags: keyModifierShift}}, - }, - SHIFT_ARROW_DOWN: { - descriptionKey: null, - shortcutKey: 'ArrowDown', - modifiers: ['SHIFT'], - trigger: {DEFAULT: {input: keyInputDownArrow, modifierFlags: keyModifierShift}}, - }, TAB: { descriptionKey: null, shortcutKey: 'Tab', diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 80df2c0554c3..7e6a66312997 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -379,33 +379,9 @@ function BaseSelectionList({ }, ); - const skipNextFocusAnchorRef = useRef(false); - const extendSelectionByKeyboard = useCallback( - (direction: 'up' | 'down') => { - if (!canSelectMultiple || !onShiftRangeApply) { - return; - } - const nextKey = rangeApi.extendByKeyboard(direction); - if (!nextKey) { - return; - } - const nextIdx = data.findIndex((row) => row.keyForList === nextKey); - if (nextIdx >= 0) { - // Paired with the focus effect below: skip the anchor-sync for shift-driven focus moves. - skipNextFocusAnchorRef.current = true; - setFocusedIndex(nextIdx); - } - }, - [canSelectMultiple, onShiftRangeApply, rangeApi, data, setFocusedIndex], - ); - - // Bump the hook's anchor on plain arrow-key focus moves; the ref dedupes across data re-references. + // Bump the hook's anchor on arrow-key focus moves; the ref dedupes across data re-references. const previousFocusAnchorKeyRef = useRef(null); useEffect(() => { - if (skipNextFocusAnchorRef.current) { - skipNextFocusAnchorRef.current = false; - return; - } if (focusedIndex < 0 || focusedIndex >= data.length) { return; } @@ -416,16 +392,6 @@ function BaseSelectionList({ previousFocusAnchorKeyRef.current = item.keyForList; rangeApi.notifyAnchor(item); }, [focusedIndex, data, rangeApi]); - const handleShiftArrowDown = useCallback(() => extendSelectionByKeyboard('down'), [extendSelectionByKeyboard]); - const handleShiftArrowUp = useCallback(() => extendSelectionByKeyboard('up'), [extendSelectionByKeyboard]); - useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.SHIFT_ARROW_DOWN, handleShiftArrowDown, { - captureOnInputs: false, - isActive: !disableKeyboardShortcuts && isFocused && canSelectMultiple && !!onShiftRangeApply, - }); - useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.SHIFT_ARROW_UP, handleShiftArrowUp, { - captureOnInputs: false, - isActive: !disableKeyboardShortcuts && isFocused && canSelectMultiple && !!onShiftRangeApply, - }); const textInputKeyPress = useCallback( (event: TextInputKeyPressEvent) => { if (event.nativeEvent.key !== CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) { diff --git a/src/hooks/useShiftRangeSelection.ts b/src/hooks/useShiftRangeSelection.ts index 1f076db6cabd..ba9c85a24222 100644 --- a/src/hooks/useShiftRangeSelection.ts +++ b/src/hooks/useShiftRangeSelection.ts @@ -20,8 +20,6 @@ type ShiftRangeBatch = { toDeselect: TItem[]; }; -type KeyboardDirection = 'up' | 'down'; - type Params = { items: TItem[]; getItemKey?: (item: TItem) => string | null | undefined; @@ -35,7 +33,6 @@ type Api = { applyShiftClick: (item: TItem, options?: {shiftKey?: boolean}) => boolean; notifyAnchor: (item: TItem) => void; clearAnchor: () => void; - extendByKeyboard: (direction: KeyboardDirection) => string | null; getAnchorKey: () => string | null; }; @@ -129,28 +126,6 @@ function useShiftRangeSelection(params: Params): Api { sessionRef.current = null; }, getAnchorKey: () => sessionRef.current?.anchor ?? anchorRef.current, - extendByKeyboard: (direction) => { - const p = paramsRef.current; - const session = sessionRef.current; - const fromKey = session?.prevEnd ?? anchorRef.current; - if (!fromKey) { - return null; - } - const fromIdx = indexOfKey(p, fromKey); - if (fromIdx < 0) { - return null; - } - const nextIdx = stepFocus(p, fromIdx, direction); - if (nextIdx < 0) { - return null; - } - const nextRow = p.items.at(nextIdx); - const nextKey = nextRow ? keyOf(p, nextRow) : null; - if (!nextRow || !nextKey || !runRange(nextRow)) { - return null; - } - return nextKey; - }, }; }, []); } @@ -224,16 +199,6 @@ function resolveAnchor(p: Params, source: string | null): string | return null; } -function stepFocus(p: Params, from: number, dir: KeyboardDirection): number { - const step = dir === 'up' ? -1 : 1; - for (let i = from + step; i >= 0 && i < p.items.length; i += step) { - if (!isExcluded(p, p.items.at(i))) { - return i; - } - } - return -1; -} - function getShiftKeyFromEvent(e?: ModifierEvent | null): boolean { return !!(e?.shiftKey ?? e?.nativeEvent?.shiftKey); } diff --git a/tests/unit/hooks/useShiftRangeSelection.test.ts b/tests/unit/hooks/useShiftRangeSelection.test.ts index e07facf0c915..d79ced17adeb 100644 --- a/tests/unit/hooks/useShiftRangeSelection.test.ts +++ b/tests/unit/hooks/useShiftRangeSelection.test.ts @@ -347,75 +347,6 @@ describe('useShiftRangeSelection', () => { }); }); - describe('extendByKeyboard', () => { - it('returns null when no anchor and no session', () => { - const {result} = renderHook(() => useShiftRangeSelection(makeParams())); - let key: string | null = 'sentinel'; - act(() => { - key = result.current.extendByKeyboard('down'); - }); - expect(key).toBeNull(); - }); - - it('steps down from anchor and applies the range', () => { - const onApplyRange = makeApplyMock(); - const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); - act(() => result.current.notifyAnchor(ROWS[1])); - let key: string | null = null; - act(() => { - key = result.current.extendByKeyboard('down'); - }); - expect(key).toBe('c'); - expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['b', 'c'], toDeselect: []}); - }); - - it('steps up from session.prevEnd while continuing the session', () => { - const onApplyRange = makeApplyMock(); - const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); - act(() => result.current.notifyAnchor(ROWS[1])); - act(() => { - result.current.applyShiftClick(ROWS[4], {shiftKey: true}); - }); - let key: string | null = null; - act(() => { - key = result.current.extendByKeyboard('up'); - }); - expect(key).toBe('d'); - expect(nthBatchKeys(onApplyRange, 1)).toEqual({toSelect: ['b', 'c', 'd'], toDeselect: ['e']}); - }); - - it('skips headers and disabled rows when stepping', () => { - const onApplyRange = makeApplyMock(); - const {result} = renderHook(() => - useShiftRangeSelection( - makeParams({ - items: GROUPED, - isHeaderItem: (r) => !!r.isHeader, - onApplyRange, - }), - ), - ); - act(() => result.current.notifyAnchor(GROUPED[2])); - let key: string | null = null; - act(() => { - key = result.current.extendByKeyboard('down'); - }); - expect(key).toBe('c'); - }); - - it('returns null when stepping off the end of the list', () => { - const onApplyRange = makeApplyMock(); - const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); - act(() => result.current.notifyAnchor(ROWS[4])); - let key: string | null = 'sentinel'; - act(() => { - key = result.current.extendByKeyboard('down'); - }); - expect(key).toBeNull(); - expect(onApplyRange).not.toHaveBeenCalled(); - }); - }); - describe('defensive bails', () => { it('returns false when every item is excluded and no anchor can be resolved', () => { const onApplyRange = makeApplyMock(); @@ -530,21 +461,6 @@ describe('useShiftRangeSelection', () => { }); expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['c', 'd'], toDeselect: []}); }); - - it('extendByKeyboard returns null when the focused key no longer exists in items', () => { - const onApplyRange = makeApplyMock(); - const {result, rerender} = renderHook(({items}: {items: Row[]}) => useShiftRangeSelection(makeParams({items, onApplyRange})), { - initialProps: {items: [...ROWS]}, - }); - act(() => result.current.notifyAnchor(ROWS[2])); - rerender({items: [{keyForList: 'x'}, {keyForList: 'y'}]}); - let key: string | null = 'sentinel'; - act(() => { - key = result.current.extendByKeyboard('down'); - }); - expect(key).toBeNull(); - expect(onApplyRange).not.toHaveBeenCalled(); - }); }); describe('end-to-end interaction flows', () => { From c9eb6c7ceeb7891565f23941ae014f50c41596f2 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Tue, 2 Jun 2026 16:40:48 +0300 Subject: [PATCH 13/19] feat: additive shift+click (Shift+Cmd / Shift+Ctrl) for range selection --- .../MoneyRequestReportGroupHeader.tsx | 7 +- .../MoneyRequestReportTransactionItem.tsx | 3 +- .../MoneyRequestReportTransactionList.tsx | 5 +- .../ListItem/BaseListItemHeader.tsx | 7 +- .../ListItem/CardListItemHeader.tsx | 7 +- .../ListItem/ExpenseReportListItem.tsx | 3 +- .../ExpenseReportListItemRowNarrow.tsx | 4 +- .../ExpenseReportListItemRowWide.tsx | 4 +- .../ExpenseReportListItemRow/types.ts | 3 +- .../ListItem/MemberListItemHeader.tsx | 7 +- .../ListItem/ReportListItemHeader.tsx | 9 ++- .../ListItem/TransactionGroupListItem.tsx | 5 +- .../ListItem/TransactionListItem/types.ts | 3 +- .../ListItem/WithdrawalIDListItemHeader.tsx | 7 +- src/components/Search/SearchList/index.tsx | 3 +- src/components/Search/index.tsx | 3 +- .../SelectionList/BaseSelectionList.tsx | 3 +- .../ListItem/ListItemRenderer.tsx | 3 +- .../SelectionList/ListItem/types.ts | 5 +- .../components/ListSelectionButton.tsx | 7 +- src/components/SelectionList/types.ts | 4 +- .../TransactionItemRowNarrow.tsx | 4 +- .../TransactionItemRowWide.tsx | 4 +- src/components/TransactionItemRow/types.ts | 3 +- src/hooks/useShiftRangeSelection.ts | 32 +++++--- .../expensifyCard/WorkspaceCardListRow.tsx | 7 +- .../WorkspaceExpensifyCardListPage.tsx | 3 +- .../unit/hooks/useShiftRangeSelection.test.ts | 77 ++++++++++++++++--- 28 files changed, 163 insertions(+), 69 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportGroupHeader.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportGroupHeader.tsx index 4fad59d1424a..9abf4ac437da 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportGroupHeader.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportGroupHeader.tsx @@ -7,7 +7,8 @@ import Text from '@components/Text'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; -import {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; +import {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; +import type {Modifiers} from '@hooks/useShiftRangeSelection'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {getDecodedLeafCategoryName} from '@libs/CategoryUtils'; @@ -43,7 +44,7 @@ type MoneyRequestReportGroupHeaderProps = { isDisabled?: boolean; /** Callback when group checkbox is toggled - receives groupKey */ - onToggleSelection?: (groupKey: string, options?: {shiftKey?: boolean}) => void; + onToggleSelection?: (groupKey: string, options?: Partial) => void; /** Pending action for offline feedback styling (Pattern B - Optimistic WITH Feedback) */ pendingAction?: PendingAction; @@ -80,7 +81,7 @@ function MoneyRequestReportGroupHeader({ const textStyle = shouldUseNarrowLayout ? {fontSize: variables.fontSizeLabel, lineHeight: 16} : [styles.labelStrong]; const handleToggleSelection = (event?: GestureResponderEvent | KeyboardEvent) => { - onToggleSelection?.(groupKey, {shiftKey: getShiftKeyFromEvent(event)}); + onToggleSelection?.(groupKey, getModifierKeysFromEvent(event)); }; const groupHeaderStyle = !shouldUseNarrowLayout diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx index 92c24363c4a0..fe9bc169a9e1 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx @@ -11,6 +11,7 @@ import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; +import type {Modifiers} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -41,7 +42,7 @@ type MoneyRequestReportTransactionItemProps = { isSelectionModeEnabled: boolean; /** Callback function triggered upon pressing a transaction checkbox. */ - toggleTransaction: (transactionID: string, options?: {shiftKey?: boolean}) => 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 7a3b43b463c5..9de7d3946ff1 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -34,6 +34,7 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; import useShiftRangeSelection, {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; +import type {Modifiers} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -443,7 +444,7 @@ function MoneyRequestReportTransactionList({ }); const toggleTransaction = useCallback( - (transactionID: string, options?: {shiftKey?: boolean}) => { + (transactionID: string, options?: Partial) => { const item = visualOrderTransactions.find((t) => t.transactionID === transactionID); if (item && rangeApi.applyShiftClick(item, options)) { return; @@ -500,7 +501,7 @@ function MoneyRequestReportTransactionList({ }, [groupedTransactions, selectedTransactionIDs]); const toggleGroupSelection = useCallback( - (groupKey: string, options?: {shiftKey?: boolean}) => { + (groupKey: string, options?: Partial) => { const group = groupedTransactions.find((g) => g.groupKey === groupKey); if (!group) { return; diff --git a/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx b/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx index ad7d2e343008..b201edb3b8ca 100644 --- a/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx @@ -6,7 +6,8 @@ import type {ListItem} from '@components/SelectionList/types'; import TextWithTooltip from '@components/TextWithTooltip'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; +import {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; +import type {Modifiers} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; @@ -61,7 +62,7 @@ type BaseListItemHeaderProps = { columnStyleKey: ColumnStyleKey; /** Callback to fire when a checkbox is pressed */ - onCheckboxPress?: (item: TItem, options?: {shiftKey?: boolean}) => void; + onCheckboxPress?: (item: TItem, options?: Partial) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -145,7 +146,7 @@ function BaseListItemHeader({ {!!canSelectMultiple && ( onCheckboxPress?.(item as unknown as TItem, {shiftKey: getShiftKeyFromEvent(event)})} + onPress={(event) => onCheckboxPress?.(item as unknown as TItem, 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 7ea3f2a34ced..eede52d6b5ac 100644 --- a/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx @@ -8,7 +8,8 @@ import TextWithTooltip from '@components/TextWithTooltip'; import UserDetailsTooltip from '@components/UserDetailsTooltip'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; +import {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; +import type {Modifiers} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -25,7 +26,7 @@ type CardListItemHeaderProps = { card: TransactionCardGroupListItemType; /** Callback to fire when a checkbox is pressed */ - onCheckboxPress?: (item: TItem, options?: {shiftKey?: boolean}) => void; + onCheckboxPress?: (item: TItem, options?: Partial) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -145,7 +146,7 @@ function CardListItemHeader({ {!!canSelectMultiple && ( onCheckboxPress?.(cardItem as unknown as TItem, {shiftKey: getShiftKeyFromEvent(event)})} + onPress={(event) => onCheckboxPress?.(cardItem as unknown as TItem, getModifierKeysFromEvent(event))} isChecked={isSelectAllChecked} isIndeterminate={isIndeterminate} disabled={!!isDisabled || cardItem.isDisabledCheckbox} diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx index 6052235a991d..4ca317bbc934 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx @@ -19,6 +19,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import type {Modifiers} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -217,7 +218,7 @@ function ExpenseReportListItem({ ]); const handleSelectionButtonPress = useCallback( - (_passedItem?: unknown, options?: {shiftKey?: boolean}) => { + (_passedItem?: unknown, options?: Partial) => { onSelectionButtonPress?.(reportItem as unknown as TItem, undefined, options); }, [onSelectionButtonPress, reportItem], diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx index dd3e6532621c..6e590d2e8289 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx @@ -4,7 +4,7 @@ import Checkbox from '@components/Checkbox'; import Text from '@components/Text'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; -import {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; +import {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; @@ -38,7 +38,7 @@ function ExpenseReportListItemRowNarrow({item, onCheckboxPress = () => {}, canSe > {!!canSelectMultiple && ( onCheckboxPress(undefined, {shiftKey: getShiftKeyFromEvent(event)})} + onPress={(event) => 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 b0f6e34f927d..6c5d4476f673 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx @@ -11,7 +11,7 @@ import TotalCell from '@components/Search/SearchList/ListItem/TotalCell'; import UserInfoCell from '@components/Search/SearchList/ListItem/UserInfoCell'; import WorkspaceCell from '@components/Search/SearchList/ListItem/WorkspaceCell'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; +import {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -217,7 +217,7 @@ function ExpenseReportListItemRowWide({ {!!canSelectMultiple && ( onCheckboxPress(undefined, {shiftKey: getShiftKeyFromEvent(event)})} + onPress={(event) => 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 020d868f15d0..3622888be52e 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 '@hooks/useShiftRangeSelection'; import type {ReportAction} from '@src/types/onyx'; type ExpenseReportListItemRowNarrowProps = { item: ExpenseReportListItemType; canSelectMultiple?: boolean; - onCheckboxPress?: (_unused?: unknown, options?: {shiftKey?: boolean}) => 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 d94eaf920529..7d822090ef3d 100644 --- a/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx @@ -7,7 +7,8 @@ import type {ListItem} from '@components/SelectionList/types'; import TextWithTooltip from '@components/TextWithTooltip'; import UserDetailsTooltip from '@components/UserDetailsTooltip'; import useLocalize from '@hooks/useLocalize'; -import {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; +import {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; +import type {Modifiers} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; @@ -22,7 +23,7 @@ type MemberListItemHeaderProps = { member: TransactionMemberGroupListItemType; /** Callback to fire when a checkbox is pressed */ - onCheckboxPress?: (item: TItem, options?: {shiftKey?: boolean}) => void; + onCheckboxPress?: (item: TItem, options?: Partial) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -130,7 +131,7 @@ function MemberListItemHeader({ {!!canSelectMultiple && ( onCheckboxPress?.(memberItem as unknown as TItem, {shiftKey: getShiftKeyFromEvent(event)})} + onPress={(event) => onCheckboxPress?.(memberItem as unknown as TItem, getModifierKeysFromEvent(event))} isChecked={isSelectAllChecked} isIndeterminate={isIndeterminate} disabled={!!isDisabled || memberItem.isDisabledCheckbox} diff --git a/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx b/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx index 9da015acef45..d1dc9fea6a57 100644 --- a/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx @@ -13,7 +13,8 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; +import {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; +import type {Modifiers} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -38,7 +39,7 @@ type ReportListItemHeaderProps = SearchListActionProps & onSelectRow: (item: TItem, event?: ModifiedMouseEvent) => void; /** Callback to fire when a checkbox is pressed */ - onCheckboxPress?: (item: TItem, options?: {shiftKey?: boolean}) => void; + onCheckboxPress?: (item: TItem, options?: Partial) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -70,7 +71,7 @@ type FirstRowReportHeaderProps = { report: TransactionReportGroupListItemType; /** Callback to fire when a checkbox is pressed */ - onCheckboxPress?: (item: TItem, options?: {shiftKey?: boolean}) => void; + onCheckboxPress?: (item: TItem, options?: Partial) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -131,7 +132,7 @@ function HeaderFirstRow({ {!!canSelectMultiple && ( onCheckboxPress?.(reportItem as unknown as TItem, {shiftKey: getShiftKeyFromEvent(event)})} + onPress={(event) => onCheckboxPress?.(reportItem as unknown as TItem, getModifierKeysFromEvent(event))} isChecked={isSelectAllChecked} isIndeterminate={isIndeterminate} containerStyle={styles.m0} diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index 7763f3865e46..f88e62448877 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -20,6 +20,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import type {Modifiers} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useSyncFocus from '@hooks/useSyncFocus'; import useTheme from '@hooks/useTheme'; @@ -292,10 +293,10 @@ function TransactionGroupListItem({ onLongPressRow?.(transaction as unknown as TItem); }; - const handleSelectionButtonPress = (val: TItem, options?: {shiftKey?: boolean}) => { + const handleSelectionButtonPress = (val: TItem, options?: Partial) => { onSelectionButtonPress?.(val, isExpenseReportType ? undefined : transactions, options); }; - const handleSelectionButtonPressForExpanded = (val: TItem, _itemTransactions?: TransactionListItemType[], options?: {shiftKey?: boolean}) => handleSelectionButtonPress(val, options); + const handleSelectionButtonPressForExpanded = (val: TItem, _itemTransactions?: TransactionListItemType[], options?: Partial) => handleSelectionButtonPress(val, options); const onExpandIconPress = () => { if (isEmpty && !shouldDisplayEmptyView) { diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts b/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts index 87ca8fa48bc3..ea7e700ae1d4 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts @@ -2,6 +2,7 @@ import type {TransactionListItemType} from '@components/Search/SearchList/ListIt import type {SearchColumnType} from '@components/Search/types'; import type {ListItemFocusEventHandler} from '@components/SelectionList/ListItem/types'; import type {ListItem} from '@components/SelectionList/types'; +import type {Modifiers} from '@hooks/useShiftRangeSelection'; import type {TransactionPreviewData} from '@libs/actions/Search'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; import type {CardList, ReportAction, TransactionViolation} from '@src/types/onyx'; @@ -14,7 +15,7 @@ type TransactionListItemSharedProps = { isDisabled?: boolean | null; canSelectMultiple?: boolean; onSelectRow: (item: TItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => void; - onCheckboxPress?: (item: TItem, itemTransactions?: TransactionListItemType[], options?: {shiftKey?: boolean}) => 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/WithdrawalIDListItemHeader.tsx b/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx index fa9fdfa25c6e..05a8839d76b6 100644 --- a/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx @@ -12,7 +12,8 @@ import useEnvironment from '@hooks/useEnvironment'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; +import {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; +import type {Modifiers} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -31,7 +32,7 @@ type WithdrawalIDListItemHeaderProps = { withdrawalID: TransactionWithdrawalIDGroupListItemType; /** Callback to fire when a checkbox is pressed */ - onCheckboxPress?: (item: TItem, options?: {shiftKey?: boolean}) => void; + onCheckboxPress?: (item: TItem, options?: Partial) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -186,7 +187,7 @@ function WithdrawalIDListItemHeader({ {!!canSelectMultiple && ( onCheckboxPress?.(withdrawalIDItem as unknown as TItem, {shiftKey: getShiftKeyFromEvent(event)})} + onPress={(event) => onCheckboxPress?.(withdrawalIDItem as unknown as TItem, getModifierKeysFromEvent(event))} isChecked={isSelectAllChecked} disabled={!!isDisabled || withdrawalIDItem.isDisabledCheckbox} accessibilityLabel={translate('common.select')} diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index c04f3a06fc11..386b83c1fc79 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -21,6 +21,7 @@ import useOnyx from '@hooks/useOnyx'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; +import type {Modifiers} from '@hooks/useShiftRangeSelection'; import useThemeStyles from '@hooks/useThemeStyles'; import useUndeleteTransactions from '@hooks/useUndeleteTransactions'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -82,7 +83,7 @@ type SearchListProps = Pick, 'onScroll' | 'conten canSelectMultiple: boolean; /** Callback to fire when a checkbox is pressed */ - onCheckboxPress: (item: SearchListItem, itemTransactions?: TransactionListItemType[], options?: {shiftKey?: boolean}) => 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 4ea3f1d2b9af..ca15dea50ba5 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -30,6 +30,7 @@ import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useSelfDMReport from '@hooks/useSelfDMReport'; import useShiftRangeSelection from '@hooks/useShiftRangeSelection'; +import type {Modifiers} from '@hooks/useShiftRangeSelection'; import useStableArrayReference from '@hooks/useStableArrayReference'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; @@ -1177,7 +1178,7 @@ function Search({ }); const toggleTransaction = useCallback( - (item: SearchListItem, itemTransactions?: TransactionListItemType[], options?: {shiftKey?: boolean}) => { + (item: SearchListItem, itemTransactions?: TransactionListItemType[], options?: Partial) => { if (isReportActionListItemType(item) || isTaskListItemType(item)) { return; } diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 7e6a66312997..5658f7749b16 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -15,6 +15,7 @@ import useKeyboardState from '@hooks/useKeyboardState'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useScrollEnabled from '@hooks/useScrollEnabled'; import useShiftRangeSelection from '@hooks/useShiftRangeSelection'; +import type {Modifiers} from '@hooks/useShiftRangeSelection'; import useSingleExecution from '@hooks/useSingleExecution'; import {focusedItemRef} from '@hooks/useSyncFocus/useSyncFocusImplementation'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -276,7 +277,7 @@ function BaseSelectionList({ }); const handleSelectionButtonPress = useCallback( - (item: TItem, itemTransactions?: unknown, options?: {shiftKey?: boolean}) => { + (item: TItem, itemTransactions?: unknown, options?: Partial) => { if (onShiftRangeApply && rangeApi.applyShiftClick(item, options)) { return; } diff --git a/src/components/SelectionList/ListItem/ListItemRenderer.tsx b/src/components/SelectionList/ListItem/ListItemRenderer.tsx index 4ca2bdc96d6c..8ae571ebf44f 100644 --- a/src/components/SelectionList/ListItem/ListItemRenderer.tsx +++ b/src/components/SelectionList/ListItem/ListItemRenderer.tsx @@ -3,6 +3,7 @@ import type {NativeSyntheticEvent, StyleProp, TextStyle, ViewStyle} from 'react- import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; import type {SelectionListProps} from '@components/SelectionList/types'; import type useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; +import type {Modifiers} from '@hooks/useShiftRangeSelection'; import type useSingleExecution from '@hooks/useSingleExecution'; import {isMobileChrome} from '@libs/Browser'; import {isTransactionGroupListItemType} from '@libs/SearchUIUtils'; @@ -62,7 +63,7 @@ function ListItemRenderer({ return onSelectionButtonPress; } return onSelectionButtonPress - ? (_passedItem: TItem, itemTransactions?: TransactionListItemType[], options?: {shiftKey?: boolean}) => onSelectionButtonPress(item, itemTransactions, options) + ? (_passedItem: TItem, itemTransactions?: TransactionListItemType[], options?: Partial) => onSelectionButtonPress(item, itemTransactions, options) : undefined; }; diff --git a/src/components/SelectionList/ListItem/types.ts b/src/components/SelectionList/ListItem/types.ts index 991b8c831c9e..b382322f6db1 100644 --- a/src/components/SelectionList/ListItem/types.ts +++ b/src/components/SelectionList/ListItem/types.ts @@ -5,6 +5,7 @@ import type {ValueOf} from 'type-fest'; import type {HoldMenuCallback} from '@components/Search'; import type {SearchRouterItem} from '@components/Search/SearchAutocompleteList'; import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; +import type {Modifiers} from '@hooks/useShiftRangeSelection'; import type {TransactionPreviewData} from '@libs/actions/Search'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; @@ -238,7 +239,7 @@ type ListItemProps = CommonListItemProps & { item: TItem; /** Callback to fire when the selection button is pressed */ - onSelectionButtonPress?: (item: TItem, itemTransactions?: TransactionListItemType[], options?: {shiftKey?: boolean}) => 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[], options?: {shiftKey?: boolean}) => 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 edb0136ffcd9..7ac91e7dbc7f 100644 --- a/src/components/SelectionList/components/ListSelectionButton.tsx +++ b/src/components/SelectionList/components/ListSelectionButton.tsx @@ -2,7 +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 {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; +import {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; +import type {Modifiers} from '@hooks/useShiftRangeSelection'; import CONST from '@src/CONST'; type ListSelectionButtonProps = { @@ -10,7 +11,7 @@ type ListSelectionButtonProps = { item: TItem; /** Callback to fire when the item is pressed */ - onSelectRow: (item: TItem, options?: {shiftKey?: boolean}) => void; + onSelectRow: (item: TItem, options?: Partial) => void; /** Custom accessibility label */ accessibilityLabel?: string; @@ -54,7 +55,7 @@ function ListSelectionButton({ role={role} accessibilityLabel={label} isChecked={item.isSelected ?? false} - onPress={(event) => onSelectRow(item, {shiftKey: getShiftKeyFromEvent(event)})} + 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 d54cbfded5cd..29c6303f5341 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -3,7 +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 {ShiftRangeBatch} from '@hooks/useShiftRangeSelection'; +import type {Modifiers, ShiftRangeBatch} from '@hooks/useShiftRangeSelection'; import type CONST from '@src/CONST'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type {ListItem, ValidListItem} from './ListItem/types'; @@ -45,7 +45,7 @@ type BaseSelectionListProps = { customListHeaderContent?: React.JSX.Element | null; /** Called when a selection button is pressed */ - onSelectionButtonPress?: (item: TItem, itemTransactions?: unknown, options?: {shiftKey?: boolean}) => void; + onSelectionButtonPress?: (item: TItem, itemTransactions?: unknown, options?: Partial) => void; /** Apply a shift+click range batch atomically. Opt-in: without it, shift+click falls through to per-item toggle. */ onShiftRangeApply?: (batch: ShiftRangeBatch) => void; diff --git a/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx b/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx index 44a6c0743249..4d981c9fa5fa 100644 --- a/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx @@ -5,7 +5,7 @@ import Icon from '@components/Icon'; import RadioButton from '@components/RadioButton'; import DateCell from '@components/Search/SearchList/ListItem/DateCell'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; +import {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -86,7 +86,7 @@ function TransactionItemRowNarrow({ { - onCheckboxPress(transactionItem.transactionID, {shiftKey: getShiftKeyFromEvent(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 c25bf6e60a87..315a30ca31e7 100644 --- a/src/components/TransactionItemRow/TransactionItemRowWide.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowWide.tsx @@ -18,7 +18,7 @@ import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; +import {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -582,7 +582,7 @@ function TransactionItemRowWide({ { - onCheckboxPress(transactionItem.transactionID, {shiftKey: getShiftKeyFromEvent(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 64146b196c14..3f701284b92b 100644 --- a/src/components/TransactionItemRow/types.ts +++ b/src/components/TransactionItemRow/types.ts @@ -1,6 +1,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 {Modifiers} from '@hooks/useShiftRangeSelection'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; import type {CardList, PersonalDetails, Policy, Report, ReportAction, TransactionViolation} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; @@ -64,7 +65,7 @@ type TransactionItemRowProps = { exportedColumnSize?: TableColumnSize; amountColumnSize: TableColumnSize; taxAmountColumnSize: TableColumnSize; - onCheckboxPress?: (transactionID: string, options?: {shiftKey?: boolean}) => 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 index ba9c85a24222..26a9e4a8fd66 100644 --- a/src/hooks/useShiftRangeSelection.ts +++ b/src/hooks/useShiftRangeSelection.ts @@ -1,20 +1,29 @@ import type {KeyboardEvent as ReactKeyboardEvent} from 'react'; import {useEffect, useMemo, useRef} from 'react'; import type {GestureResponderEvent} from 'react-native'; +import getOperatingSystem from '@libs/getOperatingSystem'; +import CONST from '@src/CONST'; /** * Excel/AG Grid-style shift+click range selection. Consumers call notifyAnchor on plain * clicks / focus changes and clearAnchor on select-all / deselect-all; the session lives * between shift+clicks and is ended by either notify. Headers and disabled rows are excluded. + * + * Holding the platform additive modifier (Cmd on Mac/iOS, Ctrl elsewhere) while shift+clicking + * extends the selection without deselecting the previous range — matches Excel/Finder/Classic. */ type ItemWithKey = {keyForList?: string | null}; type ModifierEvent = (GestureResponderEvent | KeyboardEvent | ReactKeyboardEvent | MouseEvent) & { shiftKey?: boolean; - nativeEvent?: {shiftKey?: boolean}; + metaKey?: boolean; + ctrlKey?: boolean; + nativeEvent?: {shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean}; }; +type Modifiers = {shiftKey: boolean; additive: boolean}; + type ShiftRangeBatch = { toSelect: TItem[]; toDeselect: TItem[]; @@ -30,12 +39,14 @@ type Params = { }; type Api = { - applyShiftClick: (item: TItem, options?: {shiftKey?: boolean}) => boolean; + applyShiftClick: (item: TItem, options?: Partial) => boolean; notifyAnchor: (item: TItem) => void; clearAnchor: () => void; getAnchorKey: () => string | null; }; +const ADDITIVE_VIA_META = getOperatingSystem() === CONST.OS.MAC_OS || getOperatingSystem() === CONST.OS.IOS; + type Session = {anchor: string; prevEnd: string}; function useShiftRangeSelection(params: Params): Api { @@ -47,7 +58,7 @@ function useShiftRangeSelection(params: Params): Api { const sessionRef = useRef(null); return useMemo>(() => { - const runRange = (target: TItem): boolean => { + const runRange = (target: TItem, additive: boolean): boolean => { const p = paramsRef.current; const targetKey = keyOf(p, target); if (!targetKey || isExcluded(p, target)) { @@ -77,7 +88,8 @@ function useShiftRangeSelection(params: Params): Api { const newRange = orderedRange(anchorIdx, targetIdx); // Guard against stale prevEnd: indexOfKey returns -1 → items.at(-1) would deselect the last row. - const prevEndIdx = prevEnd != null ? indexOfKey(p, prevEnd) : -1; + // In additive mode the previous range is preserved, so prevRange is intentionally null. + const prevEndIdx = !additive && prevEnd != null ? indexOfKey(p, prevEnd) : -1; const prevRange = prevEndIdx >= 0 ? orderedRange(anchorIdx, prevEndIdx) : null; const isUsable = (i: number) => !isExcluded(p, p.items.at(i)); @@ -113,7 +125,7 @@ function useShiftRangeSelection(params: Params): Api { }; return { - applyShiftClick: (item, options) => !!options?.shiftKey && runRange(item), + applyShiftClick: (item, options) => !!options?.shiftKey && runRange(item, !!options?.additive), notifyAnchor: (item) => { const k = keyOf(paramsRef.current, item); if (k) { @@ -199,8 +211,10 @@ function resolveAnchor(p: Params, source: string | null): string | return null; } -function getShiftKeyFromEvent(e?: ModifierEvent | null): boolean { - return !!(e?.shiftKey ?? e?.nativeEvent?.shiftKey); +function getModifierKeysFromEvent(e?: ModifierEvent | null): Modifiers { + const shiftKey = !!(e?.shiftKey ?? e?.nativeEvent?.shiftKey); + const additive = ADDITIVE_VIA_META ? !!(e?.metaKey ?? e?.nativeEvent?.metaKey) : !!(e?.ctrlKey ?? e?.nativeEvent?.ctrlKey); + return {shiftKey, additive}; } function applyShiftRangeBatchToKeySet( @@ -303,5 +317,5 @@ function isBlocked(item: unknown): boolean { } export default useShiftRangeSelection; -export {getShiftKeyFromEvent, applyShiftRangeBatchToKeySet, applyShiftRangeBatchToValueArray}; -export type {ShiftRangeBatch}; +export {getModifierKeysFromEvent, applyShiftRangeBatchToKeySet, applyShiftRangeBatchToValueArray}; +export type {ShiftRangeBatch, Modifiers}; diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx index aa358687473b..c34dc7910c02 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx @@ -8,7 +8,8 @@ import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import {getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; +import {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; +import type {Modifiers} from '@hooks/useShiftRangeSelection'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {getTranslationKeyForLimitType} from '@libs/CardUtils'; @@ -56,7 +57,7 @@ type WorkspacesListRowProps = { /** When set, shows a row checkbox for bulk selection */ bulkSelection?: { isSelected: boolean; - onToggle: (options?: {shiftKey?: boolean}) => void; + onToggle: (options?: Partial) => void; }; }; @@ -104,7 +105,7 @@ function WorkspaceCardListRow({ bulkSelection.onToggle({shiftKey: getShiftKeyFromEvent(event)})} + onPress={(event) => bulkSelection.onToggle(getModifierKeysFromEvent(event))} /> )} diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index e7d1e0e61957..0219efe4464a 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -30,6 +30,7 @@ import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchResults from '@hooks/useSearchResults'; import useShiftRangeSelection, {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; +import type {Modifiers} from '@hooks/useShiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -124,7 +125,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp getSelectedKeys: () => selectedCardIDs.map(String), onApplyRange: (batch) => setSelectedCardIDs((prev) => applyShiftRangeBatchToKeySet(batch, prev, (c) => c.cardID)), }); - const toggleCardSelection = (cardID: number, options?: {shiftKey?: boolean}) => { + const toggleCardSelection = (cardID: number, options?: Partial) => { const card = filteredSortedCards.find((c) => c.cardID === cardID); if (card && rangeApi.applyShiftClick(card, options)) { return; diff --git a/tests/unit/hooks/useShiftRangeSelection.test.ts b/tests/unit/hooks/useShiftRangeSelection.test.ts index d79ced17adeb..d0e1bf346191 100644 --- a/tests/unit/hooks/useShiftRangeSelection.test.ts +++ b/tests/unit/hooks/useShiftRangeSelection.test.ts @@ -1,5 +1,5 @@ import {act, renderHook} from '@testing-library/react-native'; -import useShiftRangeSelection, {applyShiftRangeBatchToKeySet, applyShiftRangeBatchToValueArray, getShiftKeyFromEvent} from '@hooks/useShiftRangeSelection'; +import useShiftRangeSelection, {applyShiftRangeBatchToKeySet, applyShiftRangeBatchToValueArray, getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; import type {ShiftRangeBatch} from '@hooks/useShiftRangeSelection'; type Row = {keyForList: string; isHeader?: boolean; isDisabled?: boolean}; @@ -347,6 +347,53 @@ describe('useShiftRangeSelection', () => { }); }); + 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 anchor fallback chain 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: []}); + }); + }); + describe('defensive bails', () => { it('returns false when every item is excluded and no anchor can be resolved', () => { const onApplyRange = makeApplyMock(); @@ -490,20 +537,32 @@ describe('useShiftRangeSelection', () => { }); }); -describe('getShiftKeyFromEvent', () => { - it('returns false for nullish input', () => { - expect(getShiftKeyFromEvent(undefined)).toBe(false); - expect(getShiftKeyFromEvent(null)).toBe(false); +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(getShiftKeyFromEvent({shiftKey: true} as Parameters[0])).toBe(true); - expect(getShiftKeyFromEvent({shiftKey: false} as Parameters[0])).toBe(false); + 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(getShiftKeyFromEvent({nativeEvent: {shiftKey: true}} as Parameters[0])).toBe(true); - expect(getShiftKeyFromEvent({nativeEvent: {shiftKey: false}} as Parameters[0])).toBe(false); + expect(getModifierKeysFromEvent({nativeEvent: {shiftKey: true}} as EventArg).shiftKey).toBe(true); + expect(getModifierKeysFromEvent({nativeEvent: {shiftKey: false}} as EventArg).shiftKey).toBe(false); + }); + + it('marks additive when both platform modifier keys are set', () => { + // Cross-platform: setting both metaKey and ctrlKey is true on every OS so the additive bit + // is positive regardless of which branch the helper selects. + expect(getModifierKeysFromEvent({metaKey: true, 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); }); }); From 27055cbb4d58834724247c3ad846d2560f29e1ea Mon Sep 17 00:00:00 2001 From: TaduJR Date: Tue, 2 Jun 2026 23:20:08 +0300 Subject: [PATCH 14/19] refactor: hook architecture, header generics --- .../MoneyRequestReportGroupHeader.tsx | 7 +- .../MoneyRequestReportTransactionItem.tsx | 2 +- .../MoneyRequestReportTransactionList.tsx | 31 +- .../ListItem/BaseListItemHeader.tsx | 15 +- .../ListItem/CardListItemHeader.tsx | 15 +- .../ListItem/CategoryListItemHeader.tsx | 24 +- .../ListItem/ExpenseReportListItem.tsx | 9 +- .../ExpenseReportListItemRowNarrow.tsx | 2 +- .../ExpenseReportListItemRowWide.tsx | 2 +- .../ExpenseReportListItemRow/types.ts | 2 +- .../ListItem/MemberListItemHeader.tsx | 15 +- .../ListItem/MerchantListItemHeader.tsx | 24 +- .../ListItem/MonthListItemHeader.tsx | 28 +- .../ListItem/QuarterListItemHeader.tsx | 28 +- .../ListItem/ReportListItemHeader.tsx | 27 +- .../SearchList/ListItem/TagListItemHeader.tsx | 24 +- .../ListItem/TransactionGroupListItem.tsx | 26 +- .../ListItem/TransactionListItem/types.ts | 2 +- .../ListItem/WeekListItemHeader.tsx | 28 +- .../ListItem/WithdrawalIDListItemHeader.tsx | 15 +- .../ListItem/YearListItemHeader.tsx | 28 +- src/components/Search/SearchList/index.tsx | 2 +- src/components/Search/index.tsx | 14 +- src/components/SelectionButton.tsx | 4 + .../SelectionList/BaseSelectionList.tsx | 54 +-- .../ListItem/ListItemRenderer.tsx | 2 +- .../SelectionList/ListItem/types.ts | 2 +- .../components/ListSelectionButton.tsx | 4 +- src/components/SelectionList/types.ts | 4 +- .../configuration/SpendRuleCategoryBase.tsx | 2 +- .../TransactionItemRowNarrow.tsx | 2 +- .../TransactionItemRowWide.tsx | 2 +- src/components/TransactionItemRow/types.ts | 2 +- src/hooks/useShiftRangeSelection.ts | 366 ++++++---------- .../applyShiftRangeBatchToKeySet.ts | 58 +++ .../farthestEndFromAnchor.ts | 9 + .../getModifierKeysFromEvent.ts | 11 + src/libs/shiftRangeSelection/index.ts | 4 + src/libs/shiftRangeSelection/types.ts | 21 + src/pages/ReportParticipantsPage.tsx | 2 +- src/pages/RoomMembersPage.tsx | 2 +- src/pages/settings/Rules/ExpenseRulesPage.tsx | 2 +- src/pages/workspace/WorkspaceMembersPage.tsx | 2 +- .../categories/WorkspaceCategoriesPage.tsx | 2 +- .../distanceRates/PolicyDistanceRatesPage.tsx | 2 +- .../expensifyCard/WorkspaceCardListRow.tsx | 4 +- .../WorkspaceExpensifyCardListPage.tsx | 5 +- .../perDiem/WorkspacePerDiemPage.tsx | 36 +- .../rules/SpendRules/SpendRuleCardPage.tsx | 2 +- .../workspace/tags/WorkspaceTagsPage.tsx | 2 +- .../workspace/tags/WorkspaceViewTagsPage.tsx | 2 +- .../workspace/taxes/WorkspaceTaxesPage.tsx | 2 +- .../unit/hooks/useShiftRangeSelection.test.ts | 394 ++++++++++-------- 53 files changed, 629 insertions(+), 747 deletions(-) create mode 100644 src/libs/shiftRangeSelection/applyShiftRangeBatchToKeySet.ts create mode 100644 src/libs/shiftRangeSelection/farthestEndFromAnchor.ts create mode 100644 src/libs/shiftRangeSelection/getModifierKeysFromEvent.ts create mode 100644 src/libs/shiftRangeSelection/index.ts create mode 100644 src/libs/shiftRangeSelection/types.ts diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportGroupHeader.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportGroupHeader.tsx index 9abf4ac437da..55a0525823df 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportGroupHeader.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportGroupHeader.tsx @@ -7,12 +7,12 @@ import Text from '@components/Text'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; -import {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; -import type {Modifiers} from '@hooks/useShiftRangeSelection'; 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'; @@ -92,10 +92,9 @@ function MoneyRequestReportGroupHeader({ styles.pv2, styles.ph3, styles.borderBottom, - styles.userSelectNone, isSelected && {borderColor: theme.buttonHoveredBG}, ] - : [styles.ph4, styles.pv3, styles.borderBottom, styles.userSelectNone]; + : [styles.ph4, styles.pv3, styles.borderBottom]; return ( diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx index fe9bc169a9e1..442fa6abfcd0 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx @@ -11,7 +11,7 @@ import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; -import type {Modifiers} from '@hooks/useShiftRangeSelection'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 9de7d3946ff1..d694e10a5028 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -33,8 +33,7 @@ import useOnyx from '@hooks/useOnyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; -import useShiftRangeSelection, {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; -import type {Modifiers} from '@hooks/useShiftRangeSelection'; +import useShiftRangeSelection from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -63,6 +62,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'; @@ -416,7 +417,7 @@ function MoneyRequestReportTransactionList({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [resolvedTransactions, currentGroupBy, report?.reportID, report?.currency, localeCompare, shouldShowGroupedTransactions]); - // Visually-rendered order, not the prop-level `transactions` (which is DB-insertion order). + // 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], @@ -508,23 +509,19 @@ function MoneyRequestReportTransactionList({ } const selectableChildren = group.transactions.filter((t) => !isTransactionPendingDelete(t)); - // Shift+click header extends through the group; target = farthest child from anchor so both group edges are covered. if (options?.shiftKey && selectableChildren.length > 0) { - const anchorKey = rangeApi.getAnchorKey(); - const anchorIdx = anchorKey ? visualOrderTransactions.findIndex((t) => t.transactionID === anchorKey) : -1; - let target = selectableChildren.at(-1) ?? selectableChildren.at(0); - if (anchorIdx >= 0 && target) { - const firstChild = selectableChildren.at(0); - const lastChild = selectableChildren.at(-1); - if (firstChild && lastChild) { - const firstIdx = visualOrderTransactions.findIndex((t) => t.transactionID === firstChild.transactionID); - const lastIdx = visualOrderTransactions.findIndex((t) => t.transactionID === lastChild.transactionID); - target = Math.abs(firstIdx - anchorIdx) > Math.abs(lastIdx - anchorIdx) ? firstChild : lastChild; + 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, {shiftKey: true})) { + return; } } - if (target && rangeApi.applyShiftClick(target, {shiftKey: true})) { - return; - } } const groupTransactionIDs = selectableChildren.map((t) => t.transactionID); diff --git a/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx b/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx index b201edb3b8ca..016192b9b1d2 100644 --- a/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx @@ -2,14 +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 {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; -import type {Modifiers} from '@hooks/useShiftRangeSelection'; 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'; @@ -48,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; @@ -62,7 +61,7 @@ type BaseListItemHeaderProps = { columnStyleKey: ColumnStyleKey; /** Callback to fire when a checkbox is pressed */ - onCheckboxPress?: (item: TItem, options?: Partial) => void; + onCheckboxPress?: (options?: Partial) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -86,7 +85,7 @@ type BaseListItemHeaderProps = { columns?: SearchColumnType[]; }; -function BaseListItemHeader({ +function BaseListItemHeader({ item, displayName, groupColumnKey, @@ -99,7 +98,7 @@ function BaseListItemHeader({ isExpanded, onDownArrowClick, columns, -}: BaseListItemHeaderProps) { +}: BaseListItemHeaderProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {isLargeScreenWidth} = useResponsiveLayout(); @@ -146,7 +145,7 @@ function BaseListItemHeader({ {!!canSelectMultiple && ( onCheckboxPress?.(item as unknown as TItem, getModifierKeysFromEvent(event))} + 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 eede52d6b5ac..b4e8a3323507 100644 --- a/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx @@ -3,17 +3,16 @@ 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'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; -import type {Modifiers} from '@hooks/useShiftRangeSelection'; 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'; @@ -21,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, options?: Partial) => void; + onCheckboxPress?: (options?: Partial) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -53,7 +52,7 @@ type CardListItemHeaderProps = { columns?: SearchColumnType[]; }; -function CardListItemHeader({ +function CardListItemHeader({ card: cardItem, onCheckboxPress, isDisabled, @@ -64,7 +63,7 @@ function CardListItemHeader({ onDownArrowClick, columns, isExpanded, -}: CardListItemHeaderProps) { +}: CardListItemHeaderProps) { const theme = useTheme(); const styles = useThemeStyles(); const {isLargeScreenWidth} = useResponsiveLayout(); @@ -146,7 +145,7 @@ function CardListItemHeader({ {!!canSelectMultiple && ( onCheckboxPress?.(cardItem as unknown as TItem, getModifierKeysFromEvent(event))} + 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 4ca317bbc934..8764905cd702 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx @@ -19,7 +19,6 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import type {Modifiers} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -29,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'; @@ -154,7 +154,7 @@ function ExpenseReportListItem({ handleActionButtonPress({ hash: currentSearchHash, item: reportItem, - goToItem: () => onSelectRow(reportItem as unknown as TItem), + goToItem: () => onSelectRow(item), snapshotReport, snapshotPolicy, policy: parentPolicy, @@ -196,6 +196,7 @@ function ExpenseReportListItem({ }, [ currentSearchHash, reportItem, + item, onSelectRow, snapshotReport, snapshotChatReport, @@ -219,9 +220,9 @@ function ExpenseReportListItem({ const handleSelectionButtonPress = useCallback( (_passedItem?: unknown, options?: Partial) => { - onSelectionButtonPress?.(reportItem as unknown as TItem, undefined, options); + onSelectionButtonPress?.(item, undefined, options); }, - [onSelectionButtonPress, reportItem], + [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 6e590d2e8289..6d2d694e3c92 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx @@ -4,7 +4,7 @@ import Checkbox from '@components/Checkbox'; import Text from '@components/Text'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; -import {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; +import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx index 6c5d4476f673..11d9263ab10e 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx @@ -11,7 +11,7 @@ import TotalCell from '@components/Search/SearchList/ListItem/TotalCell'; import UserInfoCell from '@components/Search/SearchList/ListItem/UserInfoCell'; import WorkspaceCell from '@components/Search/SearchList/ListItem/WorkspaceCell'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; +import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/types.ts b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/types.ts index 3622888be52e..1c65ba9c8ec1 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/types.ts +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/types.ts @@ -1,7 +1,7 @@ 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 '@hooks/useShiftRangeSelection'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import type {ReportAction} from '@src/types/onyx'; type ExpenseReportListItemRowNarrowProps = { diff --git a/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx b/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx index 7d822090ef3d..6ec78c5bcee9 100644 --- a/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx @@ -3,27 +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 {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; -import type {Modifiers} from '@hooks/useShiftRangeSelection'; 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, options?: Partial) => void; + onCheckboxPress?: (options?: Partial) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -50,7 +49,7 @@ type MemberListItemHeaderProps = { isLargeScreenWidth?: boolean; }; -function MemberListItemHeader({ +function MemberListItemHeader({ member: memberItem, onCheckboxPress, isDisabled, @@ -61,7 +60,7 @@ function MemberListItemHeader({ onDownArrowClick, columns, isLargeScreenWidth, -}: MemberListItemHeaderProps) { +}: MemberListItemHeaderProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate, formatPhoneNumber} = useLocalize(); @@ -131,7 +130,7 @@ function MemberListItemHeader({ {!!canSelectMultiple && ( onCheckboxPress?.(memberItem as unknown as TItem, getModifierKeysFromEvent(event))} + 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 d1dc9fea6a57..4a1c37dcac00 100644 --- a/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx @@ -7,19 +7,18 @@ 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'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; -import type {Modifiers} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; 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'; @@ -31,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, options?: Partial) => void; + onCheckboxPress?: (options?: Partial) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -66,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, options?: Partial) => void; + onCheckboxPress?: (options?: Partial) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -98,7 +97,7 @@ type FirstRowReportHeaderProps = { isExpanded?: boolean; }; -function HeaderFirstRow({ +function HeaderFirstRow({ report: reportItem, onCheckboxPress, isDisabled, @@ -109,7 +108,7 @@ function HeaderFirstRow({ isIndeterminate, onDownArrowClick, isExpanded, -}: FirstRowReportHeaderProps) { +}: FirstRowReportHeaderProps) { const icons = useMemoizedLazyExpensifyIcons(['DownArrow', 'UpArrow']); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -132,7 +131,7 @@ function HeaderFirstRow({ {!!canSelectMultiple && ( onCheckboxPress?.(reportItem as unknown as TItem, getModifierKeysFromEvent(event))} + onPress={(event) => onCheckboxPress?.(getModifierKeysFromEvent(event))} isChecked={isSelectAllChecked} isIndeterminate={isIndeterminate} containerStyle={styles.m0} @@ -196,7 +195,7 @@ function HeaderFirstRow({ ); } -function ReportListItemHeader({ +function ReportListItemHeader({ report: reportItem, onSelectRow, onCheckboxPress, @@ -212,7 +211,7 @@ function ReportListItemHeader({ personalPolicyID, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, -}: ReportListItemHeaderProps) { +}: ReportListItemHeaderProps) { const StyleUtils = useStyleUtils(); const styles = useThemeStyles(); const theme = useTheme(); @@ -241,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/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index f88e62448877..ef7d00e9b833 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -20,7 +20,6 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import type {Modifiers} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useSyncFocus from '@hooks/useSyncFocus'; import useTheme from '@hooks/useTheme'; @@ -30,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'; @@ -312,7 +312,7 @@ function TransactionGroupListItem({ [CONST.SEARCH.GROUP_BY.FROM]: ( handleSelectionButtonPress(item, options)} isDisabled={isDisabled} columns={columns} canSelectMultiple={canSelectMultiple} @@ -326,7 +326,7 @@ function TransactionGroupListItem({ [CONST.SEARCH.GROUP_BY.CARD]: ( handleSelectionButtonPress(item, options)} isDisabled={isDisabled} columns={columns} isFocused={isFocused} @@ -340,7 +340,7 @@ function TransactionGroupListItem({ [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: ( handleSelectionButtonPress(item, options)} isDisabled={isDisabled} columns={columns} canSelectMultiple={canSelectMultiple} @@ -353,7 +353,7 @@ function TransactionGroupListItem({ [CONST.SEARCH.GROUP_BY.CATEGORY]: ( handleSelectionButtonPress(item, options)} isDisabled={isDisabled} columns={columns} canSelectMultiple={canSelectMultiple} @@ -366,7 +366,7 @@ function TransactionGroupListItem({ [CONST.SEARCH.GROUP_BY.MERCHANT]: ( handleSelectionButtonPress(item, options)} isDisabled={isDisabled} columns={columns} canSelectMultiple={canSelectMultiple} @@ -379,7 +379,7 @@ function TransactionGroupListItem({ [CONST.SEARCH.GROUP_BY.TAG]: ( handleSelectionButtonPress(item, options)} isDisabled={isDisabled} columns={columns} canSelectMultiple={canSelectMultiple} @@ -392,7 +392,7 @@ function TransactionGroupListItem({ [CONST.SEARCH.GROUP_BY.MONTH]: ( handleSelectionButtonPress(item, options)} isDisabled={isDisabled} columns={columns} canSelectMultiple={canSelectMultiple} @@ -405,7 +405,7 @@ function TransactionGroupListItem({ [CONST.SEARCH.GROUP_BY.WEEK]: ( handleSelectionButtonPress(item, options)} isDisabled={isDisabled} columns={columns} canSelectMultiple={canSelectMultiple} @@ -418,7 +418,7 @@ function TransactionGroupListItem({ [CONST.SEARCH.GROUP_BY.YEAR]: ( handleSelectionButtonPress(item, options)} isDisabled={isDisabled} columns={columns} canSelectMultiple={canSelectMultiple} @@ -431,7 +431,7 @@ function TransactionGroupListItem({ [CONST.SEARCH.GROUP_BY.QUARTER]: ( handleSelectionButtonPress(item, options)} isDisabled={isDisabled} columns={columns} canSelectMultiple={canSelectMultiple} @@ -447,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} diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts b/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts index ea7e700ae1d4..24a05efe530a 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts @@ -2,7 +2,7 @@ import type {TransactionListItemType} from '@components/Search/SearchList/ListIt import type {SearchColumnType} from '@components/Search/types'; import type {ListItemFocusEventHandler} from '@components/SelectionList/ListItem/types'; import type {ListItem} from '@components/SelectionList/types'; -import type {Modifiers} from '@hooks/useShiftRangeSelection'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import type {TransactionPreviewData} from '@libs/actions/Search'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; import type {CardList, ReportAction, TransactionViolation} from '@src/types/onyx'; 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 05a8839d76b6..4f5435dc07ac 100644 --- a/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx @@ -5,20 +5,19 @@ 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'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; -import type {Modifiers} from '@hooks/useShiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; 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'; @@ -27,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, options?: Partial) => void; + onCheckboxPress?: (options?: Partial) => void; /** Whether this section items disabled for selection */ isDisabled?: boolean | null; @@ -56,7 +55,7 @@ type WithdrawalIDListItemHeaderProps = { columns?: SearchColumnType[]; }; -function WithdrawalIDListItemHeader({ +function WithdrawalIDListItemHeader({ withdrawalID: withdrawalIDItem, onCheckboxPress, isDisabled, @@ -66,7 +65,7 @@ function WithdrawalIDListItemHeader({ onDownArrowClick, isExpanded, columns, -}: WithdrawalIDListItemHeaderProps) { +}: WithdrawalIDListItemHeaderProps) { const {isLargeScreenWidth} = useResponsiveLayout(); const theme = useTheme(); const styles = useThemeStyles(); @@ -187,7 +186,7 @@ function WithdrawalIDListItemHeader({ {!!canSelectMultiple && ( onCheckboxPress?.(withdrawalIDItem as unknown as TItem, getModifierKeysFromEvent(event))} + 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 386b83c1fc79..59fc790c659b 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -21,7 +21,7 @@ import useOnyx from '@hooks/useOnyx'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; -import type {Modifiers} from '@hooks/useShiftRangeSelection'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import useThemeStyles from '@hooks/useThemeStyles'; import useUndeleteTransactions from '@hooks/useUndeleteTransactions'; import useWindowDimensions from '@hooks/useWindowDimensions'; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index ca15dea50ba5..b37bea09c081 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -30,7 +30,6 @@ import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useSelfDMReport from '@hooks/useSelfDMReport'; import useShiftRangeSelection from '@hooks/useShiftRangeSelection'; -import type {Modifiers} from '@hooks/useShiftRangeSelection'; import useStableArrayReference from '@hooks/useStableArrayReference'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; @@ -69,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'; @@ -1156,7 +1157,7 @@ function Search({ const {stableSortedData, hasCachedOptimisticItem} = useStableOptimisticSortedData(sortedData, searchResults, optimisticTrackingState); - // Flatten any group-shaped data so children appear in the range items; header exclusion is gated separately on validGroupBy. + // Children must be in the range items; header exclusion is gated on validGroupBy separately. const flattenedShiftRangeItems = useMemo(() => { if (!areItemsGrouped) { return stableSortedData; @@ -1182,16 +1183,15 @@ function Search({ if (isReportActionListItemType(item) || isTaskListItemType(item)) { return; } - // Shift+click on a group header extends through the group; target = farthest loaded child from anchor so both group edges are covered. if (options?.shiftKey && isTransactionGroupListItemType(item) && item.transactions && item.transactions.length > 0) { - const anchorKey = rangeApi.getAnchorKey(); - const anchorIdx = anchorKey ? flattenedShiftRangeItems.findIndex((r) => r.keyForList === anchorKey) : -1; 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 = anchorIdx >= 0 && Math.abs(firstIdx - anchorIdx) > Math.abs(lastIdx - anchorIdx) ? firstChild : lastChild; + const target = farthestEndFromAnchor(firstIdx, lastIdx, anchorIdx) === 'first' ? firstChild : lastChild; if (rangeApi.applyShiftClick(target, {shiftKey: true})) { return; } @@ -1201,7 +1201,7 @@ function Search({ return; } - // Anchor on a selectable child in grouped views — the group header itself is rejected by the hook. + // 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) 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 5658f7749b16..59d23b3c3f78 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -15,12 +15,12 @@ import useKeyboardState from '@hooks/useKeyboardState'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useScrollEnabled from '@hooks/useScrollEnabled'; import useShiftRangeSelection from '@hooks/useShiftRangeSelection'; -import type {Modifiers} 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'; @@ -217,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; @@ -258,24 +279,6 @@ function BaseSelectionList({ const extraData = useMemo(() => [data.length], [data.length]); const syncedSearchValue = searchValueForFocusSync ?? textInputOptions?.value; - const noopApply = useCallback(() => {}, []); - const rangeApi = useShiftRangeSelection({ - items: data, - getItemKey: (item) => item.keyForList ?? null, - getSelectedKeys: () => { - // Mirror isItemSelected so consumers using selectedItems / custom isSelected resolve an anchor too. - 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 ?? noopApply, - }); - const handleSelectionButtonPress = useCallback( (item: TItem, itemTransactions?: unknown, options?: Partial) => { if (onShiftRangeApply && rangeApi.applyShiftClick(item, options)) { @@ -380,19 +383,6 @@ function BaseSelectionList({ }, ); - // Bump the hook's anchor on arrow-key focus moves; the ref dedupes across data re-references. - const previousFocusAnchorKeyRef = useRef(null); - useEffect(() => { - if (focusedIndex < 0 || focusedIndex >= data.length) { - return; - } - const item = data.at(focusedIndex); - if (!item?.keyForList || previousFocusAnchorKeyRef.current === item.keyForList) { - return; - } - previousFocusAnchorKeyRef.current = item.keyForList; - rangeApi.notifyAnchor(item); - }, [focusedIndex, data, rangeApi]); const textInputKeyPress = useCallback( (event: TextInputKeyPressEvent) => { if (event.nativeEvent.key !== CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) { diff --git a/src/components/SelectionList/ListItem/ListItemRenderer.tsx b/src/components/SelectionList/ListItem/ListItemRenderer.tsx index 8ae571ebf44f..43261438ad3c 100644 --- a/src/components/SelectionList/ListItem/ListItemRenderer.tsx +++ b/src/components/SelectionList/ListItem/ListItemRenderer.tsx @@ -3,7 +3,7 @@ import type {NativeSyntheticEvent, StyleProp, TextStyle, ViewStyle} from 'react- import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; import type {SelectionListProps} from '@components/SelectionList/types'; import type useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; -import type {Modifiers} from '@hooks/useShiftRangeSelection'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import type useSingleExecution from '@hooks/useSingleExecution'; import {isMobileChrome} from '@libs/Browser'; import {isTransactionGroupListItemType} from '@libs/SearchUIUtils'; diff --git a/src/components/SelectionList/ListItem/types.ts b/src/components/SelectionList/ListItem/types.ts index b382322f6db1..dbf1404c88a8 100644 --- a/src/components/SelectionList/ListItem/types.ts +++ b/src/components/SelectionList/ListItem/types.ts @@ -5,7 +5,7 @@ import type {ValueOf} from 'type-fest'; import type {HoldMenuCallback} from '@components/Search'; import type {SearchRouterItem} from '@components/Search/SearchAutocompleteList'; import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; -import type {Modifiers} from '@hooks/useShiftRangeSelection'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import type {TransactionPreviewData} from '@libs/actions/Search'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; diff --git a/src/components/SelectionList/components/ListSelectionButton.tsx b/src/components/SelectionList/components/ListSelectionButton.tsx index 7ac91e7dbc7f..cf1696883416 100644 --- a/src/components/SelectionList/components/ListSelectionButton.tsx +++ b/src/components/SelectionList/components/ListSelectionButton.tsx @@ -2,8 +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 '@hooks/useShiftRangeSelection'; -import type {Modifiers} from '@hooks/useShiftRangeSelection'; +import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import CONST from '@src/CONST'; type ListSelectionButtonProps = { diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 29c6303f5341..4c981128c5dd 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -3,7 +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 '@hooks/useShiftRangeSelection'; +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'; @@ -47,7 +47,7 @@ type BaseSelectionListProps = { /** Called when a selection button is pressed */ onSelectionButtonPress?: (item: TItem, itemTransactions?: unknown, options?: Partial) => void; - /** Apply a shift+click range batch atomically. Opt-in: without it, shift+click falls through to per-item toggle. */ + /** 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 */ diff --git a/src/components/SpendRules/configuration/SpendRuleCategoryBase.tsx b/src/components/SpendRules/configuration/SpendRuleCategoryBase.tsx index 547ef1d4884b..35c1cc860378 100644 --- a/src/components/SpendRules/configuration/SpendRuleCategoryBase.tsx +++ b/src/components/SpendRules/configuration/SpendRuleCategoryBase.tsx @@ -10,7 +10,7 @@ import type {ListItem} from '@components/SelectionList/types'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; diff --git a/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx b/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx index 4d981c9fa5fa..7bc963c110e4 100644 --- a/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx @@ -5,7 +5,7 @@ import Icon from '@components/Icon'; import RadioButton from '@components/RadioButton'; import DateCell from '@components/Search/SearchList/ListItem/DateCell'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; +import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/components/TransactionItemRow/TransactionItemRowWide.tsx b/src/components/TransactionItemRow/TransactionItemRowWide.tsx index 315a30ca31e7..bb88d5743842 100644 --- a/src/components/TransactionItemRow/TransactionItemRowWide.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowWide.tsx @@ -18,7 +18,7 @@ import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; +import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/components/TransactionItemRow/types.ts b/src/components/TransactionItemRow/types.ts index 3f701284b92b..320665af94c1 100644 --- a/src/components/TransactionItemRow/types.ts +++ b/src/components/TransactionItemRow/types.ts @@ -1,7 +1,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 {Modifiers} from '@hooks/useShiftRangeSelection'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; import type {CardList, PersonalDetails, Policy, Report, ReportAction, TransactionViolation} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; diff --git a/src/hooks/useShiftRangeSelection.ts b/src/hooks/useShiftRangeSelection.ts index 26a9e4a8fd66..187dfffe0066 100644 --- a/src/hooks/useShiftRangeSelection.ts +++ b/src/hooks/useShiftRangeSelection.ts @@ -1,41 +1,15 @@ -import type {KeyboardEvent as ReactKeyboardEvent} from 'react'; -import {useEffect, useMemo, useRef} from 'react'; -import type {GestureResponderEvent} from 'react-native'; -import getOperatingSystem from '@libs/getOperatingSystem'; -import CONST from '@src/CONST'; - -/** - * Excel/AG Grid-style shift+click range selection. Consumers call notifyAnchor on plain - * clicks / focus changes and clearAnchor on select-all / deselect-all; the session lives - * between shift+clicks and is ended by either notify. Headers and disabled rows are excluded. - * - * Holding the platform additive modifier (Cmd on Mac/iOS, Ctrl elsewhere) while shift+clicking - * extends the selection without deselecting the previous range — matches Excel/Finder/Classic. - */ +import {useReducer} from 'react'; +import type {Modifiers, ShiftRangeBatch} from '@libs/shiftRangeSelection'; type ItemWithKey = {keyForList?: string | null}; -type ModifierEvent = (GestureResponderEvent | KeyboardEvent | ReactKeyboardEvent | MouseEvent) & { - shiftKey?: boolean; - metaKey?: boolean; - ctrlKey?: boolean; - nativeEvent?: {shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean}; -}; - -type Modifiers = {shiftKey: boolean; additive: boolean}; - -type ShiftRangeBatch = { - toSelect: TItem[]; - toDeselect: TItem[]; -}; - 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; + onApplyRange?: (batch: ShiftRangeBatch) => void; }; type Api = { @@ -45,277 +19,185 @@ type Api = { getAnchorKey: () => string | null; }; -const ADDITIVE_VIA_META = getOperatingSystem() === CONST.OS.MAC_OS || getOperatingSystem() === CONST.OS.IOS; +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}; -type Session = {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 { - const paramsRef = useRef(params); - useEffect(() => { - paramsRef.current = params; - }); - const anchorRef = useRef(null); - const sessionRef = useRef(null); + const [state, dispatch] = useReducer(sessionReducer, IDLE); - return useMemo>(() => { - const runRange = (target: TItem, additive: boolean): boolean => { - const p = paramsRef.current; - const targetKey = keyOf(p, target); - if (!targetKey || isExcluded(p, target)) { + return { + applyShiftClick: (target, options) => { + if (!options?.shiftKey) { return false; } - - const session = sessionRef.current; - let anchor: string; - let prevEnd: string | null; - if (session) { - anchor = session.anchor; - prevEnd = session.prevEnd; - } else { - const resolved = resolveAnchor(p, anchorRef.current); - if (!resolved) { - return false; - } - anchor = resolved; - prevEnd = null; - } - - const anchorIdx = indexOfKey(p, anchor); - const targetIdx = indexOfKey(p, targetKey); - if (anchorIdx < 0 || targetIdx < 0) { + const result = computeShiftRange(params, state, target, !!options.additive); + if (!result) { return false; } - - const newRange = orderedRange(anchorIdx, targetIdx); - // Guard against stale prevEnd: indexOfKey returns -1 → items.at(-1) would deselect the last row. - // In additive mode the previous range is preserved, so prevRange is intentionally null. - const prevEndIdx = !additive && prevEnd != null ? indexOfKey(p, prevEnd) : -1; - const prevRange = prevEndIdx >= 0 ? orderedRange(anchorIdx, prevEndIdx) : null; - const isUsable = (i: number) => !isExcluded(p, p.items.at(i)); - - const toSelect: TItem[] = []; - for (let i = newRange[0]; i <= newRange[1]; i++) { - if (isUsable(i)) { - const row = p.items.at(i); - if (row) { - toSelect.push(row); - } - } + if (result.batch.toSelect.length || result.batch.toDeselect.length) { + params.onApplyRange?.(result.batch); } - const toDeselect: TItem[] = []; - if (prevRange) { - for (let i = prevRange[0]; i <= prevRange[1]; i++) { - if (i >= newRange[0] && i <= newRange[1]) { - continue; - } - if (isUsable(i)) { - const row = p.items.at(i); - if (row) { - toDeselect.push(row); - } - } - } + dispatch({type: 'range', anchor: result.anchor, prevEnd: result.prevEnd}); + return true; + }, + notifyAnchor: (item) => { + const key = keyOf(params, item); + if (key) { + dispatch({type: 'notify', key}); } + }, + clearAnchor: () => dispatch({type: 'clear'}), + getAnchorKey: () => (state.kind === 'idle' ? null : state.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') { + anchor = state.anchor; + prevEnd = state.prevEnd; + } 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; - if (toSelect.length || toDeselect.length) { - p.onApplyRange({toSelect, toDeselect}); + 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; } - sessionRef.current = {anchor, prevEnd: targetKey}; - return true; - }; + const row = params.items.at(i); + if (row && !isExcluded(params, row)) { + toDeselect.push(row); + } + } + } - return { - applyShiftClick: (item, options) => !!options?.shiftKey && runRange(item, !!options?.additive), - notifyAnchor: (item) => { - const k = keyOf(paramsRef.current, item); - if (k) { - anchorRef.current = k; - } - sessionRef.current = null; - }, - clearAnchor: () => { - anchorRef.current = null; - sessionRef.current = null; - }, - getAnchorKey: () => sessionRef.current?.anchor ?? anchorRef.current, - }; - }, []); + 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(p: Params, item: TItem | null | undefined): string | null { +function keyOf(params: Params, item: TItem | null | undefined): string | null { if (item == null) { return null; } - if (p.getItemKey) { - return p.getItemKey(item) ?? null; + if (params.getItemKey) { + return params.getItemKey(item) ?? null; } return hasKeyForList(item) ? (item.keyForList ?? null) : null; } -function isExcluded(p: Params, item: TItem | null | undefined): boolean { +function isExcluded(params: Params, item: TItem | null | undefined): boolean { if (item == null) { return true; } - if (p.isHeaderItem?.(item)) { + if (params.isHeaderItem?.(item)) { return true; } - if (p.isDisabledItem?.(item)) { + if (params.isDisabledItem?.(item)) { return true; } return false; } -function indexOfKey(p: Params, key: string): number { - return p.items.findIndex((row) => keyOf(p, row) === key); +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(p: Params, source: string | null): string | null { +function resolveAnchor(params: Params, source: string | null): string | null { if (source) { - const idx = indexOfKey(p, source); - if (idx >= 0 && !isExcluded(p, p.items.at(idx))) { + const idx = indexOfKey(params, source); + if (idx >= 0 && !isExcluded(params, params.items.at(idx))) { return source; } } - if (p.getSelectedKeys) { - const sel = p.getSelectedKeys(); - const set: ReadonlySet = sel instanceof Set ? sel : new Set(sel); + if (params.getSelectedKeys) { + const selected = params.getSelectedKeys(); + const set: ReadonlySet = selected instanceof Set ? selected : new Set(selected); if (set.size) { - for (const row of p.items) { - if (isExcluded(p, row)) { + for (const row of params.items) { + if (isExcluded(params, row)) { continue; } - const k = keyOf(p, row); - if (k && set.has(k)) { - return k; + const key = keyOf(params, row); + if (key && set.has(key)) { + return key; } } } } - for (const row of p.items) { - if (isExcluded(p, row)) { + for (const row of params.items) { + if (isExcluded(params, row)) { continue; } - const k = keyOf(p, row); - if (k) { - return k; + const key = keyOf(params, row); + if (key) { + return key; } } return null; } -function getModifierKeysFromEvent(e?: ModifierEvent | null): Modifiers { - const shiftKey = !!(e?.shiftKey ?? e?.nativeEvent?.shiftKey); - const additive = ADDITIVE_VIA_META ? !!(e?.metaKey ?? e?.nativeEvent?.metaKey) : !!(e?.ctrlKey ?? e?.nativeEvent?.ctrlKey); - return {shiftKey, additive}; -} - -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 it of batch.toDeselect) { - const k = getKey(it); - if (k != null) { - removeSet.add(k); - } - } - const addOrdered: TKey[] = []; - const addSet = new Set(); - for (const it of batch.toSelect) { - const k = getKey(it); - if (k == null || addSet.has(k)) { - continue; - } - if (isSelectable ? !isSelectable(it) : isBlocked(it)) { - continue; - } - addSet.add(k); - addOrdered.push(k); - } - const next: TKey[] = []; - const seen = new Set(); - for (const k of prevKeys) { - if (removeSet.has(k) || seen.has(k)) { - continue; - } - seen.add(k); - next.push(k); - } - for (const k of addOrdered) { - if (!seen.has(k)) { - seen.add(k); - next.push(k); - } - } - return next; -} - -function applyShiftRangeBatchToValueArray( - batch: ShiftRangeBatch, - prevValues: readonly TValue[], - getItemKey: (item: TItem) => TKey | null | undefined, - getValueKey: (value: TValue) => TKey, - buildValue: (item: TItem) => TValue | null | undefined, - isSelectable?: (item: TItem) => boolean, -): TValue[] { - if (!batch.toSelect.length && !batch.toDeselect.length) { - return [...prevValues]; - } - const removeSet = new Set(); - for (const it of batch.toDeselect) { - const k = getItemKey(it); - if (k != null) { - removeSet.add(k); - } - } - const next: TValue[] = []; - const seen = new Set(); - for (const v of prevValues) { - const k = getValueKey(v); - if (removeSet.has(k) || seen.has(k)) { - continue; - } - seen.add(k); - next.push(v); - } - for (const it of batch.toSelect) { - const k = getItemKey(it); - if (k == null || seen.has(k)) { - continue; - } - if (isSelectable ? !isSelectable(it) : isBlocked(it)) { - continue; - } - const v = buildValue(it); - if (v != null) { - seen.add(k); - next.push(v); - } - } - 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 useShiftRangeSelection; -export {getModifierKeysFromEvent, applyShiftRangeBatchToKeySet, applyShiftRangeBatchToValueArray}; -export type {ShiftRangeBatch, Modifiers}; 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..d20ec16f8915 --- /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, ModifierEvent} 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 ac054fb1bf57..cf1ddfc91d5a 100755 --- a/src/pages/ReportParticipantsPage.tsx +++ b/src/pages/ReportParticipantsPage.tsx @@ -27,7 +27,7 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; diff --git a/src/pages/RoomMembersPage.tsx b/src/pages/RoomMembersPage.tsx index b775400cefa5..bdffe5465f53 100644 --- a/src/pages/RoomMembersPage.tsx +++ b/src/pages/RoomMembersPage.tsx @@ -25,7 +25,7 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {clearUserSearchPhrase, updateUserSearchPhrase} from '@libs/actions/RoomMembersUserSearchPhrase'; diff --git a/src/pages/settings/Rules/ExpenseRulesPage.tsx b/src/pages/settings/Rules/ExpenseRulesPage.tsx index 6df87f14236c..27ed4ac980b1 100644 --- a/src/pages/settings/Rules/ExpenseRulesPage.tsx +++ b/src/pages/settings/Rules/ExpenseRulesPage.tsx @@ -25,7 +25,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 27d9cfec6868..e51a2d69f753 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -38,7 +38,7 @@ import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index c3ffd3abe0fd..245c0b2eb820 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -37,7 +37,7 @@ import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index f0bf75226cf1..467ef3466b85 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -28,7 +28,7 @@ import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionViolation from '@hooks/useTransactionViolation'; diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx index c34dc7910c02..81894ea63666 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx @@ -8,8 +8,8 @@ import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import {getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; -import type {Modifiers} from '@hooks/useShiftRangeSelection'; +import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; +import type {Modifiers} from '@libs/shiftRangeSelection'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {getTranslationKeyForLimitType} from '@libs/CardUtils'; diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index 0219efe4464a..c0b2b48ddd5e 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -29,8 +29,7 @@ import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchResults from '@hooks/useSearchResults'; -import useShiftRangeSelection, {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; -import type {Modifiers} from '@hooks/useShiftRangeSelection'; +import useShiftRangeSelection from '@hooks/useShiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -42,6 +41,8 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {canMemberWrite, 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'; diff --git a/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx index 1ccb83814d55..3643608f5ccc 100644 --- a/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx +++ b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx @@ -29,7 +29,6 @@ import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToValueArray} from '@hooks/useShiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; @@ -502,15 +501,32 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { selectAllAccessibilityLabel={translate('accessibilityHints.selectAllPerDiemRates')} onSelectionButtonPress={toggleSubRate} onShiftRangeApply={(batch) => - setSelectedPerDiem((prev) => - applyShiftRangeBatchToValueArray( - batch, - prev, - (r) => r.subRateID, - (v) => v.subRateID, - (r) => generateSingleSubRateData(allRatesArray, r.rateID, r.subRateID) ?? null, - ), - ) + 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)} diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx index b69bca3af54b..f89012ebba20 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx @@ -18,7 +18,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import useThemeIllustrations from '@hooks/useThemeIllustrations'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateDraftSpendRule} from '@libs/actions/User'; diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index cb090e4a7e17..8abff99da159 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -35,7 +35,7 @@ import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx index f70c3793237e..b7037d8d0a79 100644 --- a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx @@ -27,7 +27,7 @@ import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx index e337e6c6ed6c..46dca6f00d45 100644 --- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx @@ -26,7 +26,7 @@ import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToKeySet} from '@hooks/useShiftRangeSelection'; +import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; diff --git a/tests/unit/hooks/useShiftRangeSelection.test.ts b/tests/unit/hooks/useShiftRangeSelection.test.ts index d0e1bf346191..6b5b1e11c441 100644 --- a/tests/unit/hooks/useShiftRangeSelection.test.ts +++ b/tests/unit/hooks/useShiftRangeSelection.test.ts @@ -1,9 +1,14 @@ import {act, renderHook} from '@testing-library/react-native'; -import useShiftRangeSelection, {applyShiftRangeBatchToKeySet, applyShiftRangeBatchToValueArray, getModifierKeysFromEvent} from '@hooks/useShiftRangeSelection'; -import type {ShiftRangeBatch} from '@hooks/useShiftRangeSelection'; +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'}]; @@ -28,24 +33,18 @@ function makeParams(overrides: Partial): {toSelect: string[]; toDeselect: string[]} { +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), }; } -type RowApplyMock = jest.MockedFunction<(batch: ShiftRangeBatch) => void>; - -function makeApplyMock(): RowApplyMock { - return jest.fn]>(); -} - -function nthBatchKeys(mockFn: RowApplyMock, n: number): {toSelect: string[]; toDeselect: string[]} { - const batch = mockFn.mock.calls.at(n)?.at(0); - return keys(batch ?? {toSelect: [], toDeselect: []}); -} - describe('useShiftRangeSelection', () => { describe('getAnchorKey', () => { it('returns null when no anchor and no session', () => { @@ -59,7 +58,7 @@ describe('useShiftRangeSelection', () => { expect(result.current.getAnchorKey()).toBe('c'); }); - it('returns the session anchor while a shift session is active', () => { + it('returns the active anchor across a shift+click sequence', () => { const {result} = renderHook(() => useShiftRangeSelection(makeParams())); act(() => result.current.notifyAnchor(ROWS[1])); act(() => { @@ -68,7 +67,7 @@ describe('useShiftRangeSelection', () => { expect(result.current.getAnchorKey()).toBe('b'); }); - it('clearAnchor wipes both anchor and session', () => { + it('clearAnchor resets the hook', () => { const {result} = renderHook(() => useShiftRangeSelection(makeParams())); act(() => result.current.notifyAnchor(ROWS[0])); act(() => { @@ -94,6 +93,26 @@ describe('useShiftRangeSelection', () => { 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(() => @@ -134,10 +153,10 @@ describe('useShiftRangeSelection', () => { expect(onApplyRange).not.toHaveBeenCalled(); }); - it('returns false when target has no key', () => { + it("returns false when the target row's key is empty", () => { const onApplyRange = makeApplyMock(); const missingKeyRow: Row = {keyForList: ''}; - const itemsWithMissingKey: [Row, Row, Row, Row, Row, Row] = [...ROWS, missingKeyRow]; + const itemsWithMissingKey: Tuple6 = [...ROWS, missingKeyRow]; const {result} = renderHook(() => useShiftRangeSelection(makeParams({items: itemsWithMissingKey, onApplyRange}))); act(() => result.current.notifyAnchor(ROWS[0])); let applied = true; @@ -146,6 +165,25 @@ describe('useShiftRangeSelection', () => { }); 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', () => { @@ -160,7 +198,7 @@ describe('useShiftRangeSelection', () => { expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['b', 'c', 'd'], toDeselect: []}); }); - it('falls back to first-selected when no notifyAnchor', () => { + it('uses the first selected row as anchor when no plain click came first', () => { const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection( @@ -192,7 +230,7 @@ describe('useShiftRangeSelection', () => { expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['c', 'd', 'e'], toDeselect: []}); }); - it('falls back to first-visible when no anchor and no selection', () => { + it('uses the first selectable row as anchor when nothing is selected', () => { const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); act(() => { @@ -201,7 +239,7 @@ describe('useShiftRangeSelection', () => { expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: []}); }); - it('first-visible fallback skips headers', () => { + it('the row-fallback anchor skips header rows', () => { const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection( @@ -217,9 +255,46 @@ describe('useShiftRangeSelection', () => { }); 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 — direction', () => { + 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}))); @@ -275,6 +350,26 @@ describe('useShiftRangeSelection', () => { }); 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', () => { @@ -317,7 +412,7 @@ describe('useShiftRangeSelection', () => { expect(nthBatchKeys(onApplyRange, 1)).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: ['d', 'e']}); }); - it('ends the session when notifyAnchor is called mid-session, so the next shift+click starts fresh', () => { + it('notifyAnchor mid-session ends the session', () => { const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); act(() => result.current.notifyAnchor(ROWS[0])); @@ -345,6 +440,24 @@ describe('useShiftRangeSelection', () => { 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', () => { @@ -384,7 +497,7 @@ describe('useShiftRangeSelection', () => { expect(nthBatchKeys(onApplyRange, 1)).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: ['d', 'e']}); }); - it('uses the anchor fallback chain when no prior notify happened', () => { + it('uses the selected-row fallback when no prior notify happened', () => { const onApplyRange = makeApplyMock(); const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange, getSelectedKeys: () => ['b']}))); act(() => { @@ -392,52 +505,32 @@ describe('useShiftRangeSelection', () => { }); expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['b', 'c', 'd'], toDeselect: []}); }); - }); - describe('defensive bails', () => { - it('returns false when every item is excluded and no anchor can be resolved', () => { + it('uses the first selectable row when additive shift+click has no anchor and no selection', () => { const onApplyRange = makeApplyMock(); - const allHeaders: [Row, Row] = [ - {keyForList: 'h1', isHeader: true}, - {keyForList: 'h2', isHeader: true}, - ]; - const {result} = renderHook(() => - useShiftRangeSelection( - makeParams({ - items: allHeaders, - isHeaderItem: (r) => !!r.isHeader, - onApplyRange, - }), - ), - ); - let applied = true; + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); act(() => { - applied = result.current.applyShiftClick(allHeaders[0], {shiftKey: true}); + result.current.applyShiftClick(ROWS[2], {shiftKey: true, additive: true}); }); - expect(applied).toBe(false); - expect(onApplyRange).not.toHaveBeenCalled(); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: []}); }); - it('returns false when getItemKey returns null/undefined for the target', () => { + it('extends additively after a non-additive range was emitted', () => { const onApplyRange = makeApplyMock(); - const items: [Row, Row] = [{keyForList: 'a'}, {keyForList: 'b'}]; - const {result} = renderHook(() => - useShiftRangeSelection( - makeParams({ - items, - getItemKey: () => null, - onApplyRange, - }), - ), - ); - let applied = true; + const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + act(() => result.current.notifyAnchor(ROWS[0])); act(() => { - applied = result.current.applyShiftClick(items[1], {shiftKey: true}); + result.current.applyShiftClick(ROWS[2], {shiftKey: true}); }); - expect(applied).toBe(false); + act(() => { + result.current.applyShiftClick(ROWS[4], {shiftKey: true, additive: true}); + }); + expect(nthBatchKeys(onApplyRange, 1)).toEqual({toSelect: ['a', 'b', 'c', 'd', 'e'], toDeselect: []}); }); + }); - it('does not emit deselects from a vanished previous endpoint when items change mid-session', () => { + 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]}, @@ -454,85 +547,56 @@ describe('useShiftRangeSelection', () => { expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'b'], toDeselect: []}); }); - it('falls back to first-selected on the next shift+click when notifyAnchor was passed a header item', () => { + it('keeps the anchor active when items shrink mid-session', () => { 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])); + 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(GROUPED[5], {shiftKey: true}); + result.current.applyShiftClick(ROWS[2], {shiftKey: true}); }); - expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['c', 'd'], toDeselect: []}); + expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['a', 'b', 'c'], toDeselect: []}); }); + }); - it('falls back to first-visible on the next shift+click when notifyAnchor was passed a disabled item', () => { + 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: MIXED, - isDisabledItem: (r) => !!r.isDisabled, + items: allHeaders, + isHeaderItem: (r) => !!r.isHeader, 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: []}); - }); - - it('clearAnchor ends an active shift session so the next shift+click starts fresh with no prevEnd', () => { - 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]); - }); + let applied = true; act(() => { - result.current.applyShiftClick(ROWS[3], {shiftKey: true}); + applied = result.current.applyShiftClick(allHeaders[0], {shiftKey: true}); }); - expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['c', 'd'], toDeselect: []}); + expect(applied).toBe(false); + expect(onApplyRange).not.toHaveBeenCalled(); }); - }); - describe('end-to-end interaction flows', () => { - it('includes the anchor row in the range when the user selected then deselected it before shift+clicking', () => { - const onApplyRange = makeApplyMock(); - const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); - act(() => result.current.notifyAnchor(ROWS[1])); - act(() => result.current.notifyAnchor(ROWS[1])); - act(() => { - result.current.applyShiftClick(ROWS[4], {shiftKey: true}); - }); - expect(nthBatchKeys(onApplyRange, 0)).toEqual({toSelect: ['b', 'c', 'd', 'e'], toDeselect: []}); + it('ignores notifyAnchor when the item key is empty', () => { + const {result} = renderHook(() => useShiftRangeSelection(makeParams())); + act(() => result.current.notifyAnchor({keyForList: ''})); + expect(result.current.getAnchorKey()).toBeNull(); }); - it('shrinks the existing range when a second shift+click lands before the previous endpoint with no plain click in between', () => { + it('clearAnchor on an idle hook is a no-op', () => { 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']}); + act(() => result.current.clearAnchor()); + expect(onApplyRange).not.toHaveBeenCalled(); + expect(result.current.getAnchorKey()).toBeNull(); }); }); }); @@ -555,10 +619,18 @@ describe('getModifierKeysFromEvent', () => { expect(getModifierKeysFromEvent({nativeEvent: {shiftKey: false}} as EventArg).shiftKey).toBe(false); }); - it('marks additive when both platform modifier keys are set', () => { - // Cross-platform: setting both metaKey and ctrlKey is true on every OS so the additive bit - // is positive regardless of which branch the helper selects. - expect(getModifierKeysFromEvent({metaKey: true, ctrlKey: true} as EventArg).additive).toBe(true); + 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', () => { @@ -577,6 +649,11 @@ describe('applyShiftRangeBatchToKeySet', () => { 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']); @@ -592,12 +669,17 @@ describe('applyShiftRangeBatchToKeySet', () => { 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('default isSelectable skips items with isDisabled or isDisabledCheckbox', () => { + 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']); }); @@ -609,70 +691,16 @@ describe('applyShiftRangeBatchToKeySet', () => { }); }); -describe('applyShiftRangeBatchToValueArray', () => { - type Item = {id: string; isDisabled?: boolean}; - type Value = {id: string; label: string}; - const getItemKey = (i: Item) => i.id; - const getValueKey = (v: Value) => v.id; - const buildValue = (i: Item): Value => ({id: i.id, label: i.id.toUpperCase()}); - - it('returns a copy of prevValues for empty batches', () => { - const prev: Value[] = [{id: 'a', label: 'A'}]; - const out = applyShiftRangeBatchToValueArray({toSelect: [], toDeselect: []}, prev, getItemKey, getValueKey, buildValue); - expect(out).toEqual(prev); - expect(out).not.toBe(prev); +describe('farthestEndFromAnchor', () => { + it('returns last when the anchor is above the group', () => { + expect(farthestEndFromAnchor(3, 6, 0)).toBe('last'); }); - it('builds and appends new values for toSelect items', () => { - const out = applyShiftRangeBatchToValueArray({toSelect: [{id: 'b'}, {id: 'c'}], toDeselect: []}, [{id: 'a', label: 'A'}], getItemKey, getValueKey, buildValue); - expect(out).toEqual([ - {id: 'a', label: 'A'}, - {id: 'b', label: 'B'}, - {id: 'c', label: 'C'}, - ]); + it('returns first when the anchor is below the group', () => { + expect(farthestEndFromAnchor(3, 6, 10)).toBe('first'); }); - it('removes prev values whose key matches toDeselect', () => { - const out = applyShiftRangeBatchToValueArray( - {toSelect: [], toDeselect: [{id: 'b'}]}, - [ - {id: 'a', label: 'A'}, - {id: 'b', label: 'B'}, - {id: 'c', label: 'C'}, - ], - getItemKey, - getValueKey, - buildValue, - ); - expect(out).toEqual([ - {id: 'a', label: 'A'}, - {id: 'c', label: 'C'}, - ]); - }); - - it('skips toSelect items whose key is already present in prev', () => { - const out = applyShiftRangeBatchToValueArray({toSelect: [{id: 'a'}, {id: 'b'}], toDeselect: []}, [{id: 'a', label: 'A'}], getItemKey, getValueKey, buildValue); - expect(out).toEqual([ - {id: 'a', label: 'A'}, - {id: 'b', label: 'B'}, - ]); - }); - - it('skips items where buildValue returns null/undefined', () => { - const out = applyShiftRangeBatchToValueArray({toSelect: [{id: 'b'}, {id: 'c'}], toDeselect: []}, [{id: 'a', label: 'A'}], getItemKey, getValueKey, (i) => - i.id === 'b' ? null : buildValue(i), - ); - expect(out).toEqual([ - {id: 'a', label: 'A'}, - {id: 'c', label: 'C'}, - ]); - }); - - it('skips items where isSelectable returns false', () => { - const out = applyShiftRangeBatchToValueArray({toSelect: [{id: 'b'}, {id: 'c'}], toDeselect: []}, [{id: 'a', label: 'A'}], getItemKey, getValueKey, buildValue, (i) => i.id !== 'b'); - expect(out).toEqual([ - {id: 'a', label: 'A'}, - {id: 'c', label: 'C'}, - ]); + it('returns last when there is no anchor', () => { + expect(farthestEndFromAnchor(3, 6, -1)).toBe('last'); }); }); From 3e8a13e530ab1e28ca03ae06debe8b49abdc94cf Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 3 Jun 2026 00:12:35 +0300 Subject: [PATCH 15/19] fix: preserve additive modifier in group-header shift+click --- .../MoneyRequestReportTransactionItem.tsx | 2 +- .../MoneyRequestReportTransactionList.tsx | 2 +- .../ExpenseReportListItemRowNarrow.tsx | 2 +- .../ExpenseReportListItemRowWide.tsx | 2 +- .../SearchList/ListItem/TransactionListItem/types.ts | 2 +- src/components/Search/SearchList/index.tsx | 2 +- src/components/Search/index.tsx | 2 +- src/components/SelectionList/BaseSelectionList.tsx | 1 - .../SelectionList/ListItem/ListItemRenderer.tsx | 2 +- src/components/SelectionList/ListItem/types.ts | 2 +- .../configuration/SpendRuleCategoryBase.tsx | 2 +- .../TransactionItemRow/TransactionItemRowNarrow.tsx | 2 +- .../TransactionItemRow/TransactionItemRowWide.tsx | 2 +- src/components/TransactionItemRow/types.ts | 2 +- src/libs/shiftRangeSelection/index.ts | 2 +- src/pages/ReportParticipantsPage.tsx | 2 +- src/pages/RoomMembersPage.tsx | 2 +- src/pages/settings/Rules/ExpenseRulesPage.tsx | 2 +- src/pages/workspace/WorkspaceMembersPage.tsx | 2 +- .../workspace/categories/WorkspaceCategoriesPage.tsx | 2 +- .../distanceRates/PolicyDistanceRatesPage.tsx | 2 +- .../workspace/expensifyCard/WorkspaceCardListRow.tsx | 4 ++-- .../expensifyCard/WorkspaceExpensifyCardListPage.tsx | 7 ++++++- .../workspace/rules/SpendRules/SpendRuleCardPage.tsx | 2 +- src/pages/workspace/tags/WorkspaceTagsPage.tsx | 2 +- src/pages/workspace/tags/WorkspaceViewTagsPage.tsx | 2 +- src/pages/workspace/taxes/WorkspaceTaxesPage.tsx | 2 +- tests/ui/CategoryListItemHeaderTest.tsx | 2 +- tests/ui/MerchantListItemHeaderTest.tsx | 2 +- tests/ui/MonthListItemHeaderTest.tsx | 2 +- tests/ui/WeekListItemHeaderTest.tsx | 2 +- tests/ui/YearListItemHeaderTest.tsx | 2 +- tests/unit/TransactionGroupListItemTest.tsx | 12 ++++++------ 33 files changed, 43 insertions(+), 39 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx index 442fa6abfcd0..7a5eaeb6a9c8 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx @@ -11,7 +11,6 @@ import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; -import type {Modifiers} from '@libs/shiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -19,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'; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index d694e10a5028..4801621d759a 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -518,7 +518,7 @@ function MoneyRequestReportTransactionList({ 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, {shiftKey: true})) { + if (rangeApi.applyShiftClick(target, options)) { return; } } diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx index 6d2d694e3c92..96598a494606 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowNarrow.tsx @@ -4,9 +4,9 @@ import Checkbox from '@components/Checkbox'; import Text from '@components/Text'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; -import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; 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'; diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx index 11d9263ab10e..09f9c95a9862 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx @@ -11,11 +11,11 @@ import TotalCell from '@components/Search/SearchList/ListItem/TotalCell'; import UserInfoCell from '@components/Search/SearchList/ListItem/UserInfoCell'; import WorkspaceCell from '@components/Search/SearchList/ListItem/WorkspaceCell'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; 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'; diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts b/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts index 24a05efe530a..1a573dc0e035 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts @@ -2,9 +2,9 @@ import type {TransactionListItemType} from '@components/Search/SearchList/ListIt import type {SearchColumnType} from '@components/Search/types'; import type {ListItemFocusEventHandler} from '@components/SelectionList/ListItem/types'; import type {ListItem} from '@components/SelectionList/types'; -import type {Modifiers} from '@libs/shiftRangeSelection'; 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 = { diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index 59fc790c659b..b5223cb1d54a 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -21,7 +21,6 @@ import useOnyx from '@hooks/useOnyx'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; -import type {Modifiers} from '@libs/shiftRangeSelection'; import useThemeStyles from '@hooks/useThemeStyles'; import useUndeleteTransactions from '@hooks/useUndeleteTransactions'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -30,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'; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index b37bea09c081..cbb25ea36926 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1192,7 +1192,7 @@ function Search({ 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, {shiftKey: true})) { + if (rangeApi.applyShiftClick(target, options)) { return; } } diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 59d23b3c3f78..8e47f15e608c 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -382,7 +382,6 @@ function BaseSelectionList({ isActive: !disableKeyboardShortcuts && isFocused && !confirmButtonOptions?.isDisabled, }, ); - const textInputKeyPress = useCallback( (event: TextInputKeyPressEvent) => { if (event.nativeEvent.key !== CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) { diff --git a/src/components/SelectionList/ListItem/ListItemRenderer.tsx b/src/components/SelectionList/ListItem/ListItemRenderer.tsx index 43261438ad3c..d4e5817f495b 100644 --- a/src/components/SelectionList/ListItem/ListItemRenderer.tsx +++ b/src/components/SelectionList/ListItem/ListItemRenderer.tsx @@ -3,10 +3,10 @@ import type {NativeSyntheticEvent, StyleProp, TextStyle, ViewStyle} from 'react- import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; import type {SelectionListProps} from '@components/SelectionList/types'; import type useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; -import type {Modifiers} from '@libs/shiftRangeSelection'; 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'> & diff --git a/src/components/SelectionList/ListItem/types.ts b/src/components/SelectionList/ListItem/types.ts index dbf1404c88a8..35fca956e200 100644 --- a/src/components/SelectionList/ListItem/types.ts +++ b/src/components/SelectionList/ListItem/types.ts @@ -5,10 +5,10 @@ import type {ValueOf} from 'type-fest'; import type {HoldMenuCallback} from '@components/Search'; import type {SearchRouterItem} from '@components/Search/SearchAutocompleteList'; import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; -import type {Modifiers} from '@libs/shiftRangeSelection'; 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 diff --git a/src/components/SpendRules/configuration/SpendRuleCategoryBase.tsx b/src/components/SpendRules/configuration/SpendRuleCategoryBase.tsx index 35c1cc860378..773549d3fe13 100644 --- a/src/components/SpendRules/configuration/SpendRuleCategoryBase.tsx +++ b/src/components/SpendRules/configuration/SpendRuleCategoryBase.tsx @@ -10,10 +10,10 @@ import type {ListItem} from '@components/SelectionList/types'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; 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'; diff --git a/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx b/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx index 7bc963c110e4..5a2f552f8b0c 100644 --- a/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx @@ -5,10 +5,10 @@ import Icon from '@components/Icon'; import RadioButton from '@components/RadioButton'; import DateCell from '@components/Search/SearchList/ListItem/DateCell'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; 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'; diff --git a/src/components/TransactionItemRow/TransactionItemRowWide.tsx b/src/components/TransactionItemRow/TransactionItemRowWide.tsx index bb88d5743842..6dc5c387a4dd 100644 --- a/src/components/TransactionItemRow/TransactionItemRowWide.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowWide.tsx @@ -18,13 +18,13 @@ import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; 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, diff --git a/src/components/TransactionItemRow/types.ts b/src/components/TransactionItemRow/types.ts index 320665af94c1..73406a0d23c9 100644 --- a/src/components/TransactionItemRow/types.ts +++ b/src/components/TransactionItemRow/types.ts @@ -1,8 +1,8 @@ import type {StyleProp, ViewStyle} from 'react-native'; import type {TransactionWithOptionalHighlight} from '@components/MoneyRequestReportView/MoneyRequestReportTransactionList'; import type {SearchColumnType, TableColumnSize} from '@components/Search/types'; -import type {Modifiers} from '@libs/shiftRangeSelection'; 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'; diff --git a/src/libs/shiftRangeSelection/index.ts b/src/libs/shiftRangeSelection/index.ts index d20ec16f8915..9467eacc91fa 100644 --- a/src/libs/shiftRangeSelection/index.ts +++ b/src/libs/shiftRangeSelection/index.ts @@ -1,4 +1,4 @@ export {default as getModifierKeysFromEvent} from './getModifierKeysFromEvent'; export {default as applyShiftRangeBatchToKeySet} from './applyShiftRangeBatchToKeySet'; export {default as farthestEndFromAnchor} from './farthestEndFromAnchor'; -export type {Modifiers, ShiftRangeBatch, ModifierEvent} from './types'; +export type {Modifiers, ShiftRangeBatch} from './types'; diff --git a/src/pages/ReportParticipantsPage.tsx b/src/pages/ReportParticipantsPage.tsx index cf1ddfc91d5a..7352b2b2c7f7 100755 --- a/src/pages/ReportParticipantsPage.tsx +++ b/src/pages/ReportParticipantsPage.tsx @@ -27,7 +27,6 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; @@ -53,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'; diff --git a/src/pages/RoomMembersPage.tsx b/src/pages/RoomMembersPage.tsx index bdffe5465f53..6f21d11cf9fc 100644 --- a/src/pages/RoomMembersPage.tsx +++ b/src/pages/RoomMembersPage.tsx @@ -25,7 +25,6 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {clearUserSearchPhrase, updateUserSearchPhrase} from '@libs/actions/RoomMembersUserSearchPhrase'; @@ -48,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'; diff --git a/src/pages/settings/Rules/ExpenseRulesPage.tsx b/src/pages/settings/Rules/ExpenseRulesPage.tsx index 27ed4ac980b1..f113c0447816 100644 --- a/src/pages/settings/Rules/ExpenseRulesPage.tsx +++ b/src/pages/settings/Rules/ExpenseRulesPage.tsx @@ -25,7 +25,6 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; @@ -34,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'; diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index e51a2d69f753..08c302852c27 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -38,7 +38,6 @@ import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -78,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'; diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 245c0b2eb820..1c4dcc1662e3 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -37,7 +37,6 @@ import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -54,6 +53,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'; diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index 467ef3466b85..1b8acf372f88 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -28,7 +28,6 @@ import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionViolation from '@hooks/useTransactionViolation'; @@ -46,6 +45,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'; diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx index 81894ea63666..6e0bf96ea301 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx @@ -8,14 +8,14 @@ import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import {getModifierKeysFromEvent} from '@libs/shiftRangeSelection'; -import type {Modifiers} from '@libs/shiftRangeSelection'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; 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'; diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index c0b2b48ddd5e..81691643dbe6 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -140,7 +140,12 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp if (selectableCardIDs.length === 0) { return; } - setSelectedCardIDs((prev) => (prev.length > 0 ? [] : [...selectableCardIDs])); + setSelectedCardIDs((prev) => { + if (prev.length > 0) { + return []; + } + return [...selectableCardIDs]; + }); rangeApi.clearAnchor(); }; const isSelectAllChecked = selectedCardIDs.length > 0 && selectedCardIDs.length === selectableCardIDs.length; diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx index f89012ebba20..aaf99eeeac5e 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx @@ -18,7 +18,6 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import useThemeIllustrations from '@hooks/useThemeIllustrations'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateDraftSpendRule} from '@libs/actions/User'; @@ -28,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'; diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index 8abff99da159..41788b150d64 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -35,7 +35,6 @@ import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -73,6 +72,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'; diff --git a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx index b7037d8d0a79..40ee17f362f5 100644 --- a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx @@ -27,7 +27,6 @@ import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; @@ -46,6 +45,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'; diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx index 46dca6f00d45..44eb97b087eb 100644 --- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx @@ -26,7 +26,6 @@ import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useSearchResults from '@hooks/useSearchResults'; -import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; @@ -44,6 +43,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'; diff --git a/tests/ui/CategoryListItemHeaderTest.tsx b/tests/ui/CategoryListItemHeaderTest.tsx index 560f21c57661..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, {shiftKey: false}); + 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 6cf13eddc717..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, {shiftKey: false}); + 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 473fd55fd413..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, {shiftKey: false}); + 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 fc41e381e774..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, {shiftKey: false}); + 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 cc0be1c0dd52..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, {shiftKey: false}); + 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 2ad073496011..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, {shiftKey: false}); + 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, {shiftKey: false}); + 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, {shiftKey: false}); + 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, {shiftKey: false}); - expect(mockOnCheckboxPress).toHaveBeenNthCalledWith(2, mockEmptyReport, undefined, {shiftKey: false}); - expect(mockOnCheckboxPress).toHaveBeenNthCalledWith(3, mockEmptyReport, undefined, {shiftKey: false}); + 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 () => { From 25aea5e7045685ded07f1b1f889fdb6f685eb56e Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 3 Jun 2026 00:27:20 +0300 Subject: [PATCH 16/19] fix: exclude non-empty group rows from shift-range in Reports view --- src/components/Search/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index cbb25ea36926..e564fabffe92 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1174,7 +1174,7 @@ function Search({ const rangeApi = useShiftRangeSelection({ items: flattenedShiftRangeItems, onApplyRange: onApplyShiftRange, - isHeaderItem: validGroupBy ? isTransactionGroupListItemType : undefined, + isHeaderItem: (item) => isTransactionGroupListItemType(item) && (!!validGroupBy || (Array.isArray(item.transactions) && item.transactions.length > 0)), getSelectedKeys: () => selectedTransactionKeySet, }); From 4516bb046dcd0d7e56699d2dfdcab63aebf67177 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 3 Jun 2026 00:41:14 +0300 Subject: [PATCH 17/19] fix: tag shift-range child transactions with parent groupKey --- src/components/Search/index.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index e564fabffe92..96a3f9fd2aea 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1047,6 +1047,20 @@ function Search({ 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; @@ -1068,7 +1082,8 @@ function Search({ selfDMReport, isProduction, ); - updated[k] = info; + 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)) { @@ -1118,6 +1133,7 @@ function Search({ searchResults?.data, selfDMReport, isProduction, + areItemsGrouped, ], ); From 3a4ae64a0eb7a269c85a42b56a9f2476bd8a60e8 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 3 Jun 2026 10:04:21 +0300 Subject: [PATCH 18/19] fix: useRef for session state; getAnchorKey mirrors applyShiftClick resolution --- src/hooks/useShiftRangeSelection.ts | 17 ++++++++------ .../unit/hooks/useShiftRangeSelection.test.ts | 22 ++++++++++++++----- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/hooks/useShiftRangeSelection.ts b/src/hooks/useShiftRangeSelection.ts index 187dfffe0066..80d739633978 100644 --- a/src/hooks/useShiftRangeSelection.ts +++ b/src/hooks/useShiftRangeSelection.ts @@ -1,4 +1,4 @@ -import {useReducer} from 'react'; +import {useRef} from 'react'; import type {Modifiers, ShiftRangeBatch} from '@libs/shiftRangeSelection'; type ItemWithKey = {keyForList?: string | null}; @@ -42,31 +42,34 @@ function sessionReducer(state: SessionState, event: SessionEvent): SessionState /** 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 { - const [state, dispatch] = useReducer(sessionReducer, IDLE); + // 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, state, target, !!options.additive); + 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); } - dispatch({type: 'range', anchor: result.anchor, prevEnd: result.prevEnd}); + sessionRef.current = sessionReducer(sessionRef.current, {type: 'range', anchor: result.anchor, prevEnd: result.prevEnd}); return true; }, notifyAnchor: (item) => { const key = keyOf(params, item); if (key) { - dispatch({type: 'notify', key}); + sessionRef.current = sessionReducer(sessionRef.current, {type: 'notify', key}); } }, - clearAnchor: () => dispatch({type: 'clear'}), - getAnchorKey: () => (state.kind === 'idle' ? null : state.anchor), + clearAnchor: () => { + sessionRef.current = sessionReducer(sessionRef.current, {type: 'clear'}); + }, + getAnchorKey: () => (sessionRef.current.kind === 'idle' ? resolveAnchor(params, null) : sessionRef.current.anchor), }; } diff --git a/tests/unit/hooks/useShiftRangeSelection.test.ts b/tests/unit/hooks/useShiftRangeSelection.test.ts index 6b5b1e11c441..8c5c6c4055ef 100644 --- a/tests/unit/hooks/useShiftRangeSelection.test.ts +++ b/tests/unit/hooks/useShiftRangeSelection.test.ts @@ -47,8 +47,18 @@ function nthBatchKeys(mockFn: ReturnType, n: number): {toS describe('useShiftRangeSelection', () => { describe('getAnchorKey', () => { - it('returns null when no anchor and no session', () => { + 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(); }); @@ -67,14 +77,14 @@ describe('useShiftRangeSelection', () => { expect(result.current.getAnchorKey()).toBe('b'); }); - it('clearAnchor resets the hook', () => { + 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()).toBeNull(); + expect(result.current.getAnchorKey()).toBe('a'); }); }); @@ -586,14 +596,14 @@ describe('useShiftRangeSelection', () => { }); it('ignores notifyAnchor when the item key is empty', () => { - const {result} = renderHook(() => useShiftRangeSelection(makeParams())); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({items: []}))); act(() => result.current.notifyAnchor({keyForList: ''})); expect(result.current.getAnchorKey()).toBeNull(); }); - it('clearAnchor on an idle hook is a no-op', () => { + it('clearAnchor on an idle hook does not emit a batch', () => { const onApplyRange = makeApplyMock(); - const {result} = renderHook(() => useShiftRangeSelection(makeParams({onApplyRange}))); + const {result} = renderHook(() => useShiftRangeSelection(makeParams({items: [], onApplyRange}))); act(() => result.current.clearAnchor()); expect(onApplyRange).not.toHaveBeenCalled(); expect(result.current.getAnchorKey()).toBeNull(); From 769f535dfd6e17a64c33c59215e243fa8bf722bd Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 3 Jun 2026 10:48:02 +0300 Subject: [PATCH 19/19] fix: resolve stale ranging anchor before computing a shift+click range --- src/hooks/useShiftRangeSelection.ts | 8 ++++++-- .../unit/hooks/useShiftRangeSelection.test.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/hooks/useShiftRangeSelection.ts b/src/hooks/useShiftRangeSelection.ts index 80d739633978..f6b9f06b5f29 100644 --- a/src/hooks/useShiftRangeSelection.ts +++ b/src/hooks/useShiftRangeSelection.ts @@ -88,8 +88,12 @@ function computeShiftRange(params: Params, state: SessionState, ta let anchor: string; let prevEnd: string | null; if (state.kind === 'ranging') { - anchor = state.anchor; - prevEnd = state.prevEnd; + 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); diff --git a/tests/unit/hooks/useShiftRangeSelection.test.ts b/tests/unit/hooks/useShiftRangeSelection.test.ts index 8c5c6c4055ef..e2210a54a6e9 100644 --- a/tests/unit/hooks/useShiftRangeSelection.test.ts +++ b/tests/unit/hooks/useShiftRangeSelection.test.ts @@ -569,6 +569,25 @@ describe('useShiftRangeSelection', () => { }); 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', () => {