Skip to content

Use a floating action bar for bulk actions - #100291

Open
Krishna2323 wants to merge 31 commits into
Expensify:mainfrom
Krishna2323:krishna2323/issue/98651
Open

Use a floating action bar for bulk actions #100291
Krishna2323 wants to merge 31 commits into
Expensify:mainfrom
Krishna2323:krishna2323/issue/98651

Conversation

@Krishna2323

@Krishna2323 Krishna2323 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

Fixed Issues

$ #98651
PROPOSAL:

Tests

  1. Wide layout, Spend → Expenses: Select 2 rows. Floating bar appears centred at the bottom; search input and filter chips remain visible.
  2. Bar geometry: 68px tall, 20px from the list bottom, 16px radius. Buttons are 28px, count is 13px, close button matches button height.
  3. ≤3 actions: All render as buttons, no More.
  4. >3 actions: Exactly 3 buttons + More; remaining actions are in More.
  5. More menu: Opens upward, right-aligned with More, uses the inverted dark theme with dividers. Caret changes to .
  6. Sub-menu (Export): Opens upward, aligned to the button's left edge. Selecting an item performs the action and closes the menu.
  7. Hover: Leading icon turns green, matching More menu items.
  8. Bottom of long list: Last row clears the bar with a 16px gap and remains fully readable/clickable. Deselecting causes no visible jump.
  9. Select all matching: Show a spinner instead of the count until the server total arrives; don't flash the loaded-row count.
  10. Bulk pay: Select payable expenses → Pay → choose payment method. Verify KYC wall for an unverified account and successful payment.
  11. Expense reports & grouped rows: Bar appears and counts correctly; these use a different selection path.
  12. Card statements / totals footer: Bar sits above the footer.
  13. Narrow layout: Unchanged — old green N selected dropdown, no floating bar.
  14. Report view: Unchanged — header dropdown. Out of scope.
  15. Dark theme: Bar renders light (true inversion). Confirm this is expected.
  • Verify that no errors appear in the JS console

Offline tests

  1. Go Offline: With rows selected, verify the offline indicator and bar don't overlap.

QA Steps

  • Same as tests

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
    • MacOS: Desktop
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I verified there are no new alerts related to the canBeMissing param for useOnyx
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
      • If any non-english text was added/modified, I used JaimeGPT to get English > Spanish translation. I then posted it in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message:
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
android_hybrid.mp4
Android: mWeb Chrome
android_mWeb.mp4
iOS: Native
ios_hybrid.mp4
iOS: mWeb Safari
ios_mWeb.mp4
MacOS: Chrome / Safari
web_chrome.mp4

Signed-off-by: krishna2323 <belivethatkg@gmail.com>
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.

Files with missing lines Coverage Δ
src/CONST/index.ts 91.52% <ø> (ø)
src/components/BulkActionBar/popoverPosition.ts 100.00% <100.00%> (ø)
...ponents/Search/hooks/useShouldShowBulkActionBar.ts 100.00% <100.00%> (ø)
src/components/Search/index.tsx 57.90% <100.00%> (+0.18%) ⬆️
src/hooks/useSearchBulkActions.ts 72.18% <100.00%> (+0.04%) ⬆️
src/pages/Search/SearchPageWide.tsx 0.00% <ø> (ø)
src/styles/index.ts 64.03% <100.00%> (+0.17%) ⬆️
src/styles/variables.ts 100.00% <ø> (ø)
src/hooks/useInvertedThemePreference.ts 50.00% <50.00%> (ø)
src/components/Search/SearchBulkActionsButton.tsx 89.24% <66.66%> (-0.76%) ⬇️
... and 5 more
... and 12 files with indirect coverage changes

Signed-off-by: krishna2323 <belivethatkg@gmail.com>
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🚧 dannymcclain has triggered a test Expensify/App build. You can view the workflow run here.

@github-actions

This comment has been minimized.

@shawnborton

Copy link
Copy Markdown
Contributor

Is it possible to make the action bar slide in a bit when it first appears? Mostly just to help draw the eye to it, since it will be in a new position.

@shawnborton

Copy link
Copy Markdown
Contributor

The inbetweener sizes can get smushed, we might want to guard against that somehow (throw buttons in the more Menu so we have max two visible buttons at the small inbetweener size?)

@dannymcclain

Copy link
Copy Markdown
Contributor

Is it possible to make the action bar slide in a bit when it first appears? Mostly just to help draw the eye to it, since it will be in a new position.

Agree, I think this would be really helpful.

The inbetweener sizes can get smushed, we might want to guard against that somehow (throw buttons in the more Menu so we have max two visible buttons at the small inbetweener size?)

Also agree. I did some quick Figma explorations for changing the layout/wrapping the buttons when it gets to the those weird inbetweener sizes, but just moving things into the more menu that don't fit would be SO much better.

@dannymcclain

Copy link
Copy Markdown
Contributor

This is feeling pretty good to me overall!

@Expensify/product @Expensify/design Another common interaction I see with this type of bulk select is being able to hit Esc to deselect/dismiss the floating bar—are y'all against adding that behavior here?

@shawnborton

Copy link
Copy Markdown
Contributor

Love that idea Danny.

@dubielzyk-expensify

Copy link
Copy Markdown
Contributor

I wonder if we should have a min-width on the number here itself so we don't see this minor pixel jumping:

CleanShot.2026-09-04.at.10.08.18.mp4

I was kinda expecting the popover menu to be in light mode but I guess it being dark makes sense!?

CleanShot 2026-09-04 at 10 08 57@2x

@JmillsExpensify

Copy link
Copy Markdown
Contributor

Adhoc looks very slick.

One NAB thought: Is the bulk actions menu too small? We'll be working against over a decade of customer muscle memory to start, since bulk actions have been top-anchored for that long. I almost wonder if we want to start with a bigger FAB so that it's obvious to start (and we could dial in size over time).

@shawnborton

Copy link
Copy Markdown
Contributor

Hmm that's a fair shout, but our next button size up is quite large. Something we are planning to tackle... it might be nice if these were 32 instead of 28 for instance. Anyhoo, I say we keep the size for now.

I wonder if we should have a min-width on the number here itself so we don't see this minor pixel jumping:

I like this idea a lot! We should probably optimize for 3 digits since users can select more than the current view.

I was kinda expecting the popover menu to be in light mode but I guess it being dark makes sense!?

Similar feelings... I would be curious to see it in light mode popovers though, it might make the bar standout more in a good way?

@dannymcclain

Copy link
Copy Markdown
Contributor

We should probably optimize for 3 digits since users can select more than the current view.

Makes sense to meeee

I was kinda expecting the popover menu to be in light mode but I guess it being dark makes sense!?

Similar feelings... I would be curious to see it in light mode popovers though, it might make the bar standout more in a good way?

I intentionally designed these popovers to be inverted to match/keep them "linked" to the bar—but I'm not against trying them out the other way!

@dubielzyk-expensify

Copy link
Copy Markdown
Contributor

I'd love to see how it looks even just a mock

Signed-off-by: krishna2323 <belivethatkg@gmail.com>
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
@Krishna2323

Copy link
Copy Markdown
Contributor Author

@Expensify/design @JmillsExpensify

All the feedback above is in. Recapping what changed:

  • Fixed width for selected count
  • Slide-in animation
  • Overflow at in-between widths is fixed. The bar measures itself against the container and moves buttons into More before reaching the edges, so it can’t overflow anymore. In-between widths default to 2 buttons + More, and that only drops further if the content genuinely doesn’t fit.
  • Esc clears the selection.
Monosnap.screencast.2026-09-07.16-19-50.mp4

Also, here’s how the actions bar would look if we don’t invert the theme.

dark_mode light_mode

@dubielzyk-expensify

Copy link
Copy Markdown
Contributor

I think I prefer the more button without the three dots.

Dig the subtle animation too 👍

Signed-off-by: krishna2323 <belivethatkg@gmail.com>
@Krishna2323

Copy link
Copy Markdown
Contributor Author

I think I prefer the more button without the three dots.

Updated:

Monosnap.screencast.2026-09-09.09-43-21.mp4

@dubielzyk-expensify

Copy link
Copy Markdown
Contributor

Let's hear what Danny and Shawn thinks. I think either direction works

@shawnborton

Copy link
Copy Markdown
Contributor

Nice, I can get down with that.

@dannymcclain

Copy link
Copy Markdown
Contributor

Yeah let's roll without it since the More buttons in our headers also do not show an icon 👍

@shubham1206agra shubham1206agra left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Shubham's AI) Automated review — 6 findings, all verified against the diff at ed39042. Inline comments below.

Ranked: the Esc handler (clears the selection when dismissing a menu) and the 68px-vs-56px bar height (fails the PR's own test step 2) are the two I'd want resolved before merge. The rest are a no-op pressable empty-state button, a dropped menuHeaderText on the wide path, a fitting-pass re-paint at full width, and a stylesheet rebuild on the selection hot path.

Checked and clean: the SENTRY_LABEL.SEARCH.BULK_ACTIONS_DROPDOWN and searchBulkActionsButton removals leave no dangling references; clearSelectedTransactions() with no args does reset areAllMatchingItemsSelected, so that comment is accurate; barRef matches KYCWall's anchorRef type; INVERTED_THEMES covers all four non-system themes; contentContainerStyle is forwarded by all five search views with no caller overriding the reserved padding; the fitting while loop terminates; handleBulkPayItemSelected early-returns for non-pay items, so routing all More-menu selections through onSubItemSelected isn't a regression.

Comment thread src/components/BulkActionBar/index.tsx Outdated
Comment thread src/components/BulkActionBar/index.tsx
sentryLabel={CONST.SENTRY_LABEL.SEARCH.BULK_ACTIONS_DROPDOWN}
/>
</View>
<BulkActionBar

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Shubham's AI) bulkActionsMenuHeaderText is dropped on the wide path.

The old ButtonWithDropdownMenu passed menuHeaderText={bulkActionsMenuHeaderText}, and the narrow path still does. BulkActionBar takes no equivalent prop, so the header is silently lost here.

That matters when Export is the only available action: its sub-options get flattened straight into the bar as bare buttons with nothing labelling what they belong to, so the wide layout loses context the narrow layout keeps. Either thread the header text through to the bar's menus or confirm it's intentionally unnecessary in this layout.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Threaded through to the More menu.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Shubham's AI) Re-reviewed at 9f7d9dd. Threaded through correctly, but headerText={menuHeaderText} is inside the hasMoreMenu && (...) block, so it only renders on the More popover.

That leaves the original case unfixed. The header exists for select-all/Export-only, where useSearchBulkActions hoists the export sub-items to top level and sets the header to give them context. With ≤3 export options there is no More menu, so the heading never renders and the bare template names still show as unlabelled buttons in the bar.

Needs the header surfaced in the bar itself for that case, not only in the overflow menu.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, was scoped to the More popover only. Now the bar renders the heading itself when everything fits inline.

Comment thread src/components/BulkActionBar/index.tsx Outdated
Comment thread src/styles/variables.ts
Comment thread src/components/BulkActionBar/index.tsx
@shubham1206agra

Copy link
Copy Markdown
Contributor

@Krishna2323 Can you address these comments please? Thanks

Signed-off-by: krishna2323 <belivethatkg@gmail.com>
/>
</View>
<BulkActionBar
selectedCount={selectedBulkActionsCount}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Shubham's AI) Wide layout drops the "All matching items selected" label; the bar always shows a number.

selectionButtonText (lines 195-197) picks search.exportAll.allMatchingItemsSelected when shouldShowAllMatchingItemsSelected holds, and the narrow ButtonWithDropdownMenu still passes it as customText. This path passes only selectedCount, a number, so BulkActionBar always renders workspace.common.selected with a count.

That's not just a wording difference. When allMatchingItemsCount isn't a number — offline, or after a search error — selectedAllMatchingItemsCount falls back to selectedItemsCount (the loaded page). So a 5,000-item select-all reads "50 selected" in the wide bar while the action still applies to every matching item. The narrow dropdown shows the correct label in exactly that case.

Suggest letting the bar take the resolved label (or a flag) rather than deriving its text from the count alone.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, real bug in the offline fallback. Added customText, matching ButtonWithDropdownMenu's prop, wired to selectionButtonText.

@shubham1206agra

Copy link
Copy Markdown
Contributor

@Krishna2323 Please merge main

@shubham1206agra

Copy link
Copy Markdown
Contributor

@Krishna2323 Just address all the comments last time before I start testing on platforms

Signed-off-by: krishna2323 <belivethatkg@gmail.com>
@Krishna2323

Copy link
Copy Markdown
Contributor Author

@shubham1206agra done.

Krishna2323 and others added 4 commits September 11, 2026 00:03
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
The merge commit's staged Mobile-Expensify gitlink was the submodule's
current local checkout, not either side's actual commit. Our side never
moved the submodule since the last sync, so the real merge result was a
clean fast-forward to upstream's pointer. Restore that.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
// left `onLayout` with nothing to fire and the bar stuck hidden).
const [measuredWidths, setMeasuredWidths] = useState<Record<string, number>>({});

const getMeasurementKey = (actionCount: number) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Shubham's AI) Re-reviewed at d8f6d9a. The stuck-hidden case is fixed — thanks, keying on what's actually on screen is the right shape. But the key is now missing the leading label, which opens the same problem from the other direction.

getMeasurementKey covers everything that affects the bar's width except the label at its head: customText, selectedCount and isSelectedCountLoading are all absent, and each of them changes how wide the bar renders.

Where it bites:

  1. Select all matchingcustomText is the long All matching items selected. Say that pushes the bar past widthBudget at count 3: measuredWidths[K3] records the wide value and the fitting loop sheds to 2.
  2. Exclude one rowshouldShowAllMatchingItemsSelected goes false, customText becomes undefined, and the label collapses to a short N selected.
  3. The options haven't changed, so K3 and K2 are the same keys as before. The loop still reads the stale wide K3, still sheds, and the bar stays at 2 inline actions.
  4. Because it never re-renders at count 3, K3 is never re-measured. It stays stale for the rest of the selection.

So the bar sits one action short — the shed action is only reachable through More — until the selection is cleared (unmount) or the option set changes. selectedCount crossing a digit boundary (9 → 10 → 100 selected) is a milder version of the same thing, and the isSelectedCountLoading spinner → text swap is another.

These don't strand the bar hidden, because the width does change and onLayout fires and overwrites the entry — the damage is that the entry it overwrites is the one for the count currently on screen, never the wider count the loop is still gating on.

Fix is the same principle already applied: include the rendered label in the key, e.g. append customText ?? translate('workspace.common.selected', {count: selectedCount}) plus the loading flag, so a label change invalidates the cached widths the same way an action-label change does.

@shubham1206agra

shubham1206agra commented Sep 11, 2026

Copy link
Copy Markdown
Contributor
Screen.Recording.2026-09-11.at.6.21.21.PM.mov

@Krishna2323 This view looks off. cc @Expensify/design

This should be under Export menu and then all possible options. Or there should be no More menu when there is single item in it. Am I missing something here?

@dannymcclain

dannymcclain commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

This should be under Export menu and then all possible options. Or there should be no More menu when there is single item in it. Am I missing something here?

I agree. I would expect all those export options to live under a single Export dropdown button. And I don't think I'd ever expect the bar show a popover heading inside it.

I would expect each of those situations to look like this:

image image

@Expensify/design for a gut check there.

@shawnborton

Copy link
Copy Markdown
Contributor

Makes sense to me.

Small nit: do we always align the popover to the button, either on the left or right edge? Or just center it up like you have it?

@dannymcclain

Copy link
Copy Markdown
Contributor

Small nit: do we always align the popover to the button, either on the left or right edge? Or just center it up like you have it?

Oh I think you're right - I think it always gets aligned to either the left or right. I'll edit my comment above to replace the mocks. I picked the left edge for the mocks, but let's just align it however it would normally be aligned—no special alignment or anything needed.

Signed-off-by: krishna2323 <belivethatkg@gmail.com>
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
@Krishna2323

Copy link
Copy Markdown
Contributor Author

@Expensify/design @shubham1206agra fixed:

Monosnap.screencast.2026-09-14.03-56-41.mp4

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants