Skip to content

[$250] New Feature : Shift+Click Range Selection Not Supported in Multi-Select Lists #90539

Open

Description

@m-natarajan

If you haven’t already, check out our contributing guidelines for onboarding and email contributors@expensify.com to request to join our Slack channel!


Version Number: 9.3.73-4
Reproducible in staging?: Y
Reproducible in production?: Y
If this was caught during regression testing, add the test name, ID and link from BrowserStack:
Email or phone of affected tester (no customers):
Logs: https://stackoverflow.com/c/expensify/questions/4856
Expensify/Expensify Issue URL:
Issue reported by: @twisterdotcom
Slack conversation (hyperlinked to channel name): #Expensify Bugs

Action Performed:

  1. Open a multi-select list: Reports list, Expenses list
  2. Select an item near the top
  3. Hold Shift
  4. Select another item farther down the list

Expected Result:

All items between the first and second selection should:
Become selected automatically

Actual Result:

Shift+Click does nothing special
Only the clicked item is selected/deselected

Workaround:

Unknown

Platforms:

Select the officially supported platforms where the issue was reproduced:

  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Chrome
  • MacOS: Chrome / Safari

Screenshots/Videos

Recording.2860.mp4

View all open jobs on GitHub

Upwork Automation - Do Not Edit
  • Upwork Job URL: https://www.upwork.com/jobs/~022054603471216095472
  • Upwork Job ID: 2054603471216095472
  • Last Price Increase: 2026-05-13
Issue OwnerCurrent Issue Owner: @TaduJR

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on May 13, 2026
  2. MelvinBot commented on May 13, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    The multi-select system is built on a simple per-item toggle model with no awareness of click modifier keys or item ordering. When a user clicks a checkbox, toggleTransaction fires and toggles only that single item — it never receives the browser MouseEvent, so it cannot check event.shiftKey. There is no concept of a "last selected index" stored anywhere in the selection state (SearchContext.tsx), which means even if shift were detected there would be no anchor point to compute a range from.

    The same gap exists in the report-level transaction list (MoneyRequestReportTransactionList.tsx).

    What changes do you think we should make in order to solve the problem?

    1. Thread the native event through to the toggle handler — In ListSelectionButton (and its parent ListCheckbox / SelectableListItem), pass the click GestureResponderEvent (or MouseEvent on web) up through onSelectRow / onCheckboxPress so the consumer can inspect event.shiftKey.

    2. Track the last-selected index in SearchContext — Add a lastSelectedIndex: number | null field to the search selection state. Update it every time a single item is toggled.

    3. Implement range selection in toggleTransaction — When event.shiftKey is true and lastSelectedIndex is set, compute the range between the anchor and the newly clicked index in the current sorted/visible item list, then select (or deselect) every item in that range. Reset the anchor to the new index afterward.

    4. Platform guard — Only enable on web/desktop where shift+click is a standard interaction pattern; on native mobile the shift key is not applicable and the behavior should remain unchanged.

    What alternative solutions did you explore? (Optional)

    • Keyboard-only approach (Shift+Arrow): Would avoid threading events through the press handler, but wouldn't match the standard desktop UX of shift+click and would require focus tracking per row, which the current list components don't support.

    Next Steps for Contributor+ team: Reply with @MelvinBot implement this to create a draft PR, @MelvinBot <your feedback> to refine this analysis, or explain why you are rejecting Melvin's proposal.

  3. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on May 13, 2026
  4. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on May 13, 2026
  5. mukhrr commented on May 13, 2026

    @mukhrr
    Contributor

    Proposal

    What is the root cause of that problem?

    Shift+Click range selection is a desktop-web convention that the multi-select machinery in Search never implements. The checkbox callback the list exposes to consumers takes only the row item — there is no mouse event, no modifier flag, and no anchor index — so the handler in Search/index.tsx has nothing to range-select against:

    onSelectRow: (item: SearchListItem, transactionPreviewData?: TransactionPreviewData) => void;
    /** Whether this is a multi-select list */
    canSelectMultiple: boolean;
    /** Callback to fire when a checkbox is pressed */
    onCheckboxPress: (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => void;

    The downstream consumer (toggleTransaction) therefore can only flip the single clicked row, regardless of whether Shift was held:

    const toggleTransaction = useCallback(
    (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => {
    if (isReportActionListItemType(item)) {
    return;
    }
    if (isTaskListItemType(item)) {
    return;
    }
    if (isTransactionListItemType(item)) {
    if (!item.keyForList) {
    return;

    Result: every click goes through the per-row "toggle this one item" branch, and no anchor is ever remembered, so the platform-standard "click A, Shift+click B, everything in between selects" behaviour cannot trigger.

    What changes do you think we should make in order to solve the problem?

    I'd plumb the shift modifier through the existing checkbox callback (web-only — GestureResponderEvent.nativeEvent.shiftKey is set by react-native-web) and use it together with a remembered anchor index to expand the selection.

    1. Widen the onCheckboxPress contract so callers can read the event, then forward event from the row that fires the press (TransactionItemRow's checkbox onPress already receives the GestureResponderEvent):

    -onCheckboxPress: (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => void;
    +onCheckboxPress: (item: SearchListItem, itemTransactions?: TransactionListItemType[], event?: GestureResponderEvent) => void;

    2. In Search/index.tsx, track the last toggled index and, when Shift is held, toggle every selectable item in stableSortedData between the anchor and the current row to the target state (selecting if the anchor became selected, deselecting if it became deselected — this matches Gmail / Finder semantics):

    -const toggleTransaction = useCallback(
    -    (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => {
    +const lastToggledKeyRef = useRef<string | null>(null);
    +const toggleTransaction = useCallback(
    +    (item: SearchListItem, itemTransactions?: TransactionListItemType[], event?: GestureResponderEvent) => {
    +        const isShiftClick = !!event?.nativeEvent && 'shiftKey' in event.nativeEvent && (event.nativeEvent as MouseEvent).shiftKey;
    +        const anchorKey = lastToggledKeyRef.current;
    +        lastToggledKeyRef.current = item.keyForList ?? null;
    +
    +        if (isShiftClick && anchorKey && anchorKey !== item.keyForList) {
    +            const anchorIdx = stableSortedData.findIndex((row) => row.keyForList === anchorKey);
    +            const targetIdx = stableSortedData.findIndex((row) => row.keyForList === item.keyForList);
    +            if (anchorIdx !== -1 && targetIdx !== -1) {
    +                const [from, to] = anchorIdx < targetIdx ? [anchorIdx, targetIdx] : [targetIdx, anchorIdx];
    +                const shouldSelect = !selectedTransactions[anchorKey]?.isSelected ? true : true; // mirror anchor's *new* state
    +                applyRangeSelection(stableSortedData.slice(from, to + 1), shouldSelect);
    +                return;
    +            }
    +        }

    applyRangeSelection is a thin wrapper around the existing prepareTransactionsList / mapTransactionItemToSelectedEntry paths that already power the single-item branch — it loops the slice, skips disabled / pending-delete rows, and writes one combined setSelectedTransactions update. Group-list (TransactionGroupListItem) rows expand to their currentTransactions the same way the existing branch does.

    3. Wire the event through the two intermediate hops (SearchList → handleLongPressRow already-noted call site at line 367, and ListItem.onSelectionButtonPress → TransactionItemRow's checkbox onPress). Both just pass the third argument straight through — no behavioural changes for non-web platforms, where shiftKey is always undefined.

    4. Clear lastToggledKeyRef on clearSelectedTransactions so a fresh selection round starts without a stale anchor.

    What alternative solutions did you explore? (Optional)

    • DOM-level keydown/keyup tracker for Shift to avoid plumbing the event through three layers. Works, but adds a global listener and a race where rapid Shift release between mousedown and click misses the modifier; reading event.nativeEvent.shiftKey at the click site is authoritative.
    • Implement it inside BaseSelectionList so every multi-select list inherits it. Tempting, but the reported lists (Reports, Expenses) use Search/SearchList, not BaseSelectionList, so we'd ship the helper and still need this PR. Worth a follow-up once this lands.
    • Use a Set<keyForList> anchor instead of an index to survive list re-sorts between clicks. Marginally more robust but slower (O(n) lookup twice per shift-click); since stableSortedData is, by construction, stable across a selection session, index lookup is fine and matches the existing prepareTransactionsList style.

    Result:

    REC-20260513215053.mp4
  6. melvin-bot commented on May 13, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @QichenZhu (External)

  7. MobileMage commented on May 13, 2026

    @MobileMage
    Contributor

    🚫 Duplicated proposal withdrawn by 🤖 ProposalPolice.

  8. changed the title [-]New Feature : Shift+Click Range Selection Not Supported in Multi-Select Lists[/-] [+][$250] New Feature : Shift+Click Range Selection Not Supported in Multi-Select Lists[/+] on May 13, 2026
  9. melvin-bot commented on May 13, 2026

    @melvin-bot
  10. aswin-s commented on May 13, 2026

    @aswin-s
    Contributor

    Please re-state the problem that we are trying to solve in this issue.

    Currently, multi-select lists (Reports list, Expenses list) do not support Shift+Click range selection. Users expect that when they click a checkbox to select an item, then hold Shift and click another item farther down, all items between the two selections should automatically become selected. Instead, Shift+Click only toggles the clicked item, ignoring the shift modifier entirely.

    What is the current gap in the implementation?

    The Shift+Click range selection feature is absent from the multi-select list implementation. The insertion point is in src/components/Search/index.tsx in the toggleTransaction function (lines 1046–1144), which is the single entry point for all checkbox presses in both Reports and Expenses lists. Currently, toggleTransaction receives the clicked item but has no knowledge of the keyboard modifier (shiftKey) or a "pivot" point (the previously selected item). The Checkbox onPress callback at src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx (line 147) passes only the item itself without the native event containing shiftKey information.

    The working analog is found in standard desktop multi-select UI patterns, where holding Shift while clicking extends the selection range from the last clicked item to the newly clicked item. This pattern is implemented in many desktop applications (e.g., file managers, spreadsheets) and provides a fast way to select many items at once.

    const toggleTransaction = useCallback(
    (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => {
    if (isReportActionListItemType(item)) {
    return;
    }
    if (isTaskListItemType(item)) {
    return;
    }
    if (isTransactionListItemType(item)) {
    if (!item.keyForList) {
    return;
    }
    if (isTransactionPendingDelete(item)) {
    return;
    }
    const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${item.transactionID}`] as OnyxEntry<Transaction>;
    const originalItemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`];
    const updatedTransactions = prepareTransactionsList(
    item,
    itemTransaction,
    originalItemTransaction,
    selectedTransactions,
    email ?? '',
    accountID,
    outstandingReportsByPolicyID,
    );
    setSelectedTransactions(updatedTransactions, filteredData);
    updateSelectAllMatchingItemsState(updatedTransactions);
    return;
    }
    const currentTransactions = itemTransactions ?? item.transactions;
    // Handle empty reports - treat the report itself as selectable
    if (currentTransactions.length === 0 && isTransactionReportGroupListItemType(item)) {

    {!!canSelectMultiple && (
    <Checkbox
    onPress={() => onCheckboxPress?.(item as unknown as TItem)}
    isChecked={isSelectAllChecked}
    isIndeterminate={isIndeterminate}
    disabled={!!isDisabled || item.isDisabledCheckbox}
    accessibilityLabel={translate('common.select')}
    containerStyle={styles.m0}

    What changes do you think we should make in order to implement this feature?

    1. File: src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx
      Function: BaseListItemHeader
      Description: Modify the Checkbox onPress callback to capture and pass the native event object so that shiftKey information becomes available to the parent handler. The event parameter must be treated as optional and null-checked before use, since the Checkbox component may invoke onPress without an event on some platforms:

      onPress={(event) => onCheckboxPress?.(item as unknown as TItem, event ?? undefined)}

      This replaces the current onPress={() => onCheckboxPress?.(item as unknown as TItem)} call. If event is null or undefined (e.g., on mobile where GestureResponderEvent does not carry a shiftKey field), the handler receives undefined and falls through to single-item toggle behavior — preserving existing mobile behavior exactly.

    2. File: src/components/Search/SearchList/index.tsx
      Function: SearchList (onCheckboxPress callback receiver)
      Description: Update the onCheckboxPress callback signature to accept an optional event parameter and pass it through to the parent handler:

      (item: SearchListItem, itemTransactions?: TransactionListItemType[], event?: GestureResponderEvent) => void
    3. File: src/components/Search/index.tsx
      Function: toggleTransaction
      Description: Add state to track the last selected pivot item:

      const [lastSelectedPivot, setLastSelectedPivot] = useState<SearchListItem | null>(null);

      Modify toggleTransaction to accept an optional event parameter and implement range selection. The function must guard against a null pivot, an empty stableSortedData, and indices that cannot be resolved — falling back to single-item toggle in all such cases:

      const toggleTransaction = useCallback(
        (item: SearchListItem, itemTransactions?: TransactionListItemType[], event?: GestureResponderEvent) => {
          // existing early returns for non-transaction items stay here
      
          // Extract shiftKey safely — only available on web (KeyboardEvent/MouseEvent).
          // GestureResponderEvent on mobile does not expose shiftKey, so this evaluates
          // to false on iOS/Android, leaving single-item toggle intact.
          const isShiftHeld = !!(event as unknown as MouseEvent | undefined)?.shiftKey;
      
          if (isShiftHeld && lastSelectedPivot !== null && stableSortedData.length > 0) {
            const pivotIndex = stableSortedData.findIndex((d) => d.keyForList === lastSelectedPivot.keyForList);
            const clickedIndex = stableSortedData.findIndex((d) => d.keyForList === item.keyForList);
      
            // If either item is no longer in the current data snapshot (e.g., data
            // changed between the pivot click and the shift+click), fall through to
            // single-item toggle so the user never ends up in a broken selection state.
            if (pivotIndex === -1 || clickedIndex === -1) {
              // fall through to single-item toggle below
            } else {
              const start = Math.min(pivotIndex, clickedIndex);
              const end = Math.max(pivotIndex, clickedIndex);
              const rangeItems = stableSortedData.slice(start, end + 1);
      
              // Build updatedTransactions from all valid, non-pending-delete items in range
              const updatedTransactions = { ...selectedTransactions };
              for (const rangeItem of rangeItems) {
                // reuse existing per-item selection logic for each item in range
                prepareRangeItem(rangeItem, updatedTransactions);
              }
              setSelectedTransactions(updatedTransactions, filteredData);
              // Do NOT update lastSelectedPivot on shift+click — pivot stays fixed
              // so the user can extend or contract the range with subsequent shift+clicks.
              return;
            }
          }
      
          // Single-item toggle (existing logic)
          // ... existing toggleTransaction body ...
          setLastSelectedPivot(item);
        },
        [lastSelectedPivot, stableSortedData, selectedTransactions, filteredData],
      );

      The isShiftHeld cast to MouseEvent is intentional: on web, the Pressable/Checkbox onPress synthetic event carries the underlying native browser event where shiftKey is a boolean. On mobile the cast resolves to undefined and !!undefined?.shiftKey is false, so the branch is never taken.

    4. File: src/components/Search/index.tsx
      Function: (new helper prepareRangeItem or inline loop within toggleTransaction)
      Description: Implement the range item preparation: for each item in the range slice, check that it is a transaction-type item and is not pending delete before adding it to updatedTransactions. This mirrors the existing guard logic already present in toggleTransaction for single items, ensuring consistency.

    Blast radius: The change is localized to the checkbox press flow. It affects only the multi-select interaction in SearchList (Reports and Expenses). No other UI flows are touched. Existing single-click behavior is preserved when Shift is not held. The range selection applies only to transaction-type items (other item types already have early returns in toggleTransaction). On mobile platforms, shiftKey is never set, so range selection is unreachable and mobile behavior is entirely unchanged.

    What alternative approaches did you explore? (Optional)

    N/A

  11. github-actions commented on May 13, 2026

    @github-actions
    Contributor

    ⚠️ @MobileMage Your proposal is a duplicate of an already existing proposal and has been automatically withdrawn to prevent spam. Please review the existing proposals before submitting a new one.

  12. github-actions commented on May 13, 2026

    @github-actions
    Contributor

    ⚠️ @mukhrr Thanks for your proposal. Please update it to follow the proposal template, as proposals are only reviewed if they follow that format (note the mandatory sections).

  13. 138 remaining items

  14. situchan commented on Oct 7, 2026

    @situchan
    Contributor

    Isn't 120 reports enough for test?

  15. TaduJR commented on Oct 7, 2026

    @TaduJR
    Contributor

    Yes it is but the account is rate-limited and I can't login, also not sure how many expenses left.

    I have to create another account and duplicate but

    The number of expenses created and deleted that way came up in a Slack discussion with @MonilBhavsar, and it caused some problems on the site, so I won't rebuild that data to record a demo for PR 4.

  16. stitesExpensify commented on Oct 7, 2026

    @stitesExpensify
    Contributor

    Yes we can proceed

  17. melvin-bot commented on Oct 7, 2026

    @melvin-bot

    Triggered auto assignment to @mallenexpensify (Awaiting Payment)

  18. added
    Skip PaymentSkips all the payment process steps for an issue. Use it for fixed-price projects.
    and removed
    Awaiting PaymentAuto-added when associated PR is deployed to production
    on Oct 9, 2026
  19. added
    ReviewingHas a PR in review
    and removed on Oct 9, 2026
  20. changed the title [-][Due for payment 2026-10-08] [$250] New Feature : Shift+Click Range Selection Not Supported in Multi-Select Lists[/-] [+][$250] New Feature : Shift+Click Range Selection Not Supported in Multi-Select Lists[/+] on Oct 13, 2026
  21. removed their assignment
    on Oct 13, 2026
  22. mallenexpensify commented on Oct 13, 2026

    @mallenexpensify
    Contributor

    Please comment and tag me if/when payment is due, thx

  23. TaduJR commented on Oct 14, 2026

    @TaduJR
    Contributor

    4th PR is ready and in review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

BugSomething is broken. Auto assigns a BugZero manager.ExternalAdded to denote the issue can be worked on by a contributorReviewingHas a PR in reviewSkip PaymentSkips all the payment process steps for an issue. Use it for fixed-price projects.WeeklyKSv2

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions