Use a floating action bar for bulk actions - #100291
Conversation
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 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.
|
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
|
🚧 dannymcclain has triggered a test Expensify/App build. You can view the workflow run here. |
This comment has been minimized.
This comment has been minimized.
|
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. |
|
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?) |
Agree, I think this would be really helpful.
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. |
|
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 |
|
Love that idea Danny. |
|
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). |
|
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 like this idea a lot! We should probably optimize for 3 digits since users can select more than the current view.
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? |
Makes sense to meeee
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! |
|
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>
|
@Expensify/design @JmillsExpensify All the feedback above is in. Recapping what changed:
Monosnap.screencast.2026-09-07.16-19-50.mp4Also, here’s how the actions bar would look if we don’t invert the theme.
|
|
I think I prefer the more button without the three dots. Dig the subtle animation too 👍 |
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
Updated: Monosnap.screencast.2026-09-09.09-43-21.mp4 |
|
Let's hear what Danny and Shawn thinks. I think either direction works |
|
Nice, I can get down with that. |
|
Yeah let's roll without it since the |
shubham1206agra
left a comment
There was a problem hiding this comment.
(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.
| sentryLabel={CONST.SENTRY_LABEL.SEARCH.BULK_ACTIONS_DROPDOWN} | ||
| /> | ||
| </View> | ||
| <BulkActionBar |
There was a problem hiding this comment.
(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.
There was a problem hiding this comment.
Threaded through to the More menu.
There was a problem hiding this comment.
(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.
There was a problem hiding this comment.
Right, was scoped to the More popover only. Now the bar renders the heading itself when everything fits inline.
|
@Krishna2323 Can you address these comments please? Thanks |
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
| /> | ||
| </View> | ||
| <BulkActionBar | ||
| selectedCount={selectedBulkActionsCount} |
There was a problem hiding this comment.
(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.
There was a problem hiding this comment.
Good catch, real bug in the offline fallback. Added customText, matching ButtonWithDropdownMenu's prop, wired to selectionButtonText.
|
@Krishna2323 Please merge main |
|
@Krishna2323 Just address all the comments last time before I start testing on platforms |
Signed-off-by: krishna2323 <belivethatkg@gmail.com>
|
@shubham1206agra done. |
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) => { |
There was a problem hiding this comment.
(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:
- Select all matching →
customTextis the longAll matching items selected. Say that pushes the bar pastwidthBudgetat count 3:measuredWidths[K3]records the wide value and the fitting loop sheds to 2. - Exclude one row →
shouldShowAllMatchingItemsSelectedgoes false,customTextbecomesundefined, and the label collapses to a shortN selected. - The options haven't changed, so
K3andK2are the same keys as before. The loop still reads the stale wideK3, still sheds, and the bar stays at 2 inline actions. - Because it never re-renders at count 3,
K3is 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.
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? |
|
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? |
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>
|
@Expensify/design @shubham1206agra fixed: Monosnap.screencast.2026-09-14.03-56-41.mp4 |





Explanation of Change
Fixed Issues
$ #98651
PROPOSAL:
Tests
More.More; remaining actions are inMore.Moremenu: Opens upward, right-aligned withMore, uses the inverted dark theme with dividers. Caret changes to⌃.Export): Opens upward, aligned to the button's left edge. Selecting an item performs the action and closes the menu.Moremenu items.Pay→ choose payment method. Verify KYC wall for an unverified account and successful payment.N selecteddropdown, no floating bar.Offline tests
QA Steps
Same as tests
Verify that no errors appear in the JS console
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectioncanBeMissingparam foruseOnyxtoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.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