From 95eb956f194f2b4c0d9a333505947dec7e166544 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 3 Sep 2026 16:43:46 +0530 Subject: [PATCH 01/28] feat: add floating BulkActionBar and use it on the spend page Signed-off-by: krishna2323 --- src/CONST/index.ts | 12 ++ .../BulkActionBar/BulkActionBarButton.tsx | 85 ++++++++++ src/components/BulkActionBar/index.tsx | 159 ++++++++++++++++++ .../BulkActionBar/popoverPosition.ts | 23 +++ src/components/BulkActionBar/types.ts | 44 +++++ .../Search/SearchBulkActionsBarWide.tsx | 34 ++++ .../Search/SearchBulkActionsButton.tsx | 36 ++-- .../SearchPageHeader/SearchActionsBarWide.tsx | 45 ++--- src/hooks/useInvertedThemePreference.ts | 25 +++ src/pages/Search/SearchPageWide.tsx | 3 + src/styles/index.ts | 31 ++++ 11 files changed, 444 insertions(+), 53 deletions(-) create mode 100644 src/components/BulkActionBar/BulkActionBarButton.tsx create mode 100644 src/components/BulkActionBar/index.tsx create mode 100644 src/components/BulkActionBar/popoverPosition.ts create mode 100644 src/components/BulkActionBar/types.ts create mode 100644 src/components/Search/SearchBulkActionsBarWide.tsx create mode 100644 src/hooks/useInvertedThemePreference.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 7fbb6973ba6d..d6d4e62783bb 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6421,6 +6421,14 @@ const CONST = { DANGER: 'danger', }, + BULK_ACTION_BAR: { + /** How many of a selection's actions get a button of their own in the bar before the rest move behind "More". */ + MAX_INLINE_ACTIONS: 3, + + /** How far the bar floats above the bottom of the container it is rendered in. */ + BOTTOM_OFFSET: 20, + }, + BUTTON_REMOVE_BORDER_RADIUS: { LEFT: 'left', RIGHT: 'right', @@ -8674,6 +8682,10 @@ const CONST = { BILLING_BANNER: { RIGHT_ICON: 'BillingBanner-RightIcon', }, + BULK_ACTION_BAR: { + CLEAR_SELECTION: 'BulkActionBar-ClearSelection', + MORE: 'BulkActionBar-More', + }, ACCOUNT_MANAGER_BOOK_CALL: { BUTTON: 'AccountManagerBookCallButton-Button', }, diff --git a/src/components/BulkActionBar/BulkActionBarButton.tsx b/src/components/BulkActionBar/BulkActionBarButton.tsx new file mode 100644 index 000000000000..c0c9739ae2fa --- /dev/null +++ b/src/components/BulkActionBar/BulkActionBarButton.tsx @@ -0,0 +1,85 @@ +import Button from '@components/ButtonComposed'; +import PopoverMenu from '@components/PopoverMenu'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import usePopoverPosition from '@hooks/usePopoverPosition'; + +import CONST from '@src/CONST'; +import type {AnchorPosition} from '@src/styles'; + +import type {View} from 'react-native'; + +import React, {useEffect, useRef, useState} from 'react'; + +import type {BulkActionBarButtonProps} from './types'; + +import {defaultPopoverAnchorPosition, SUB_MENU_ANCHOR_ALIGNMENT} from './popoverPosition'; + +/** + * A single action in the bulk action bar. An action that carries `subMenuItems` renders a dropdown caret and opens + * those items in a menu above itself; every other action runs `onSelected` directly. + */ +function BulkActionBarButton({option, onSubItemSelected}: BulkActionBarButtonProps) { + const icons = useMemoizedLazyExpensifyIcons(['DownArrow', 'UpArrow']); + const {calculatePopoverPosition} = usePopoverPosition(); + + const anchorRef = useRef(null); + const [isMenuVisible, setIsMenuVisible] = useState(false); + const [anchorPosition, setAnchorPosition] = useState(defaultPopoverAnchorPosition); + + const subMenuItems = option.subMenuItems; + const hasSubMenu = !!subMenuItems?.length; + + useEffect(() => { + if (!anchorRef.current || !isMenuVisible) { + return; + } + + calculatePopoverPosition(anchorRef, SUB_MENU_ANCHOR_ALIGNMENT).then(setAnchorPosition); + }, [isMenuVisible, calculatePopoverPosition]); + + return ( + <> + + {hasSubMenu && !!anchorPosition && ( + ({...subItem, shouldCallAfterModalHide: true}))} + onClose={() => setIsMenuVisible(false)} + onItemSelected={(selectedSubItem, index, event) => { + onSubItemSelected?.(selectedSubItem, index, event); + if (selectedSubItem.shouldCloseModalOnSelect === false) { + return; + } + setIsMenuVisible(false); + }} + shouldUseScrollView={subMenuItems.length >= CONST.DROPDOWN_SCROLL_THRESHOLD} + /> + )} + + ); +} + +BulkActionBarButton.displayName = 'BulkActionBarButton'; + +export default BulkActionBarButton; diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx new file mode 100644 index 000000000000..b1f3c1e34985 --- /dev/null +++ b/src/components/BulkActionBar/index.tsx @@ -0,0 +1,159 @@ +import Button from '@components/ButtonComposed'; +import Icon from '@components/Icon'; +import PopoverMenu from '@components/PopoverMenu'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import Text from '@components/Text'; +import ThemeProvider from '@components/ThemeProvider'; +import ThemeStylesProvider from '@components/ThemeStylesContextProvider'; + +import useInvertedThemePreference from '@hooks/useInvertedThemePreference'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import usePopoverPosition from '@hooks/usePopoverPosition'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; +import type {AnchorPosition} from '@src/styles'; + +import React, {useEffect, useRef, useState} from 'react'; +import {View} from 'react-native'; + +import type {BulkActionBarProps} from './types'; + +import BulkActionBarButton from './BulkActionBarButton'; +import {defaultPopoverAnchorPosition, MORE_MENU_ANCHOR_ALIGNMENT} from './popoverPosition'; + +/** + * The bar's contents. Everything here takes its colours from the theme it is rendered under, which `BulkActionBar` + * inverts — so the surface, the buttons and the "More" menu all read as one layer without any of them being styled + * specially. Split out from `BulkActionBar` because these styles have to resolve from the inverted theme, while the + * positioning layer around it belongs to the page's own. + */ +function BulkActionBarContent({selectedCount, options, onClearSelection, onSubItemSelected, barRef}: Omit, 'style'>) { + const theme = useTheme(); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Close', 'DownArrow', 'ThreeDots', 'UpArrow']); + const {calculatePopoverPosition} = usePopoverPosition(); + + const moreAnchorRef = useRef(null); + const [isMoreMenuVisible, setIsMoreMenuVisible] = useState(false); + const [moreMenuAnchorPosition, setMoreMenuAnchorPosition] = useState(defaultPopoverAnchorPosition); + + // Only the highest-priority actions are given a button of their own; the rest stay reachable behind "More". + const hasMoreMenu = options.length > CONST.BULK_ACTION_BAR.MAX_INLINE_ACTIONS; + const inlineOptions = hasMoreMenu ? options.slice(0, CONST.BULK_ACTION_BAR.MAX_INLINE_ACTIONS) : options; + const moreOptions = hasMoreMenu ? options.slice(CONST.BULK_ACTION_BAR.MAX_INLINE_ACTIONS) : []; + + useEffect(() => { + if (!moreAnchorRef.current || !isMoreMenuVisible) { + return; + } + + calculatePopoverPosition(moreAnchorRef, MORE_MENU_ANCHOR_ALIGNMENT).then(setMoreMenuAnchorPosition); + }, [isMoreMenuVisible, calculatePopoverPosition]); + + return ( + + {translate('workspace.common.selected', {count: selectedCount})} + {inlineOptions.map((option) => ( + + ))} + {hasMoreMenu && ( + <> + + {!!moreMenuAnchorPosition && ( + setIsMoreMenuVisible(false)} + onItemSelected={(selectedItem, index, event) => { + onSubItemSelected?.(selectedItem, index, event); + if (selectedItem.shouldCloseModalOnSelect === false) { + return; + } + setIsMoreMenuVisible(false); + }} + shouldUseScrollView={moreOptions.length >= CONST.DROPDOWN_SCROLL_THRESHOLD} + menuItems={moreOptions.map((option) => ({ + ...option, + shouldCallAfterModalHide: true, + subMenuItems: option.subMenuItems?.map((subItem) => ({...subItem, shouldCallAfterModalHide: true})), + }))} + /> + )} + + )} + + + + + ); +} + +/** + * A floating bar of bulk actions for the current selection. It floats over the bottom of the container it is rendered + * in, so render it as the last child of the view the table fills — pass a `bottom` through `style` to clear anything + * else pinned to that container, such as a totals footer. + * + * The bar renders under the inverted theme so that it stands out against the table behind it. That also inverts its + * "More" menu, which reads the theme itself and could not be inverted through style props alone. + */ +function BulkActionBar({selectedCount, options, onClearSelection, onSubItemSelected, barRef, style}: BulkActionBarProps) { + const styles = useThemeStyles(); + const invertedTheme = useInvertedThemePreference(); + + return ( + + {/* ThemeStylesProvider has to come with ThemeProvider: without it `useThemeStyles` keeps resolving against + the page's theme while `useTheme` resolves against this one, and the bar renders half-inverted. */} + + + + + + + ); +} + +BulkActionBar.displayName = 'BulkActionBar'; + +export default BulkActionBar; diff --git a/src/components/BulkActionBar/popoverPosition.ts b/src/components/BulkActionBar/popoverPosition.ts new file mode 100644 index 000000000000..3abaad22e67f --- /dev/null +++ b/src/components/BulkActionBar/popoverPosition.ts @@ -0,0 +1,23 @@ +import CONST from '@src/CONST'; +import type {AnchorPosition} from '@src/styles'; +import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; + +/** + * The bar floats at the bottom of its container, so both of its menus open upwards (`BOTTOM` anchors the menu to the + * top edge of the button). An action's sub-menu lines up with the left edge of its own button, while the "More" menu + * lines up with the right edge of the bar's last button so it stays inside the bar's width. + */ +const SUB_MENU_ANCHOR_ALIGNMENT: AnchorAlignment = { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, +}; + +const MORE_MENU_ANCHOR_ALIGNMENT: AnchorAlignment = { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, +}; + +// In tests, skip the popover anchor position calculation. The default values are needed for popover menu to be rendered in tests. +const defaultPopoverAnchorPosition: AnchorPosition | null = process.env.NODE_ENV === 'test' ? {horizontal: 100, vertical: 100} : null; + +export {SUB_MENU_ANCHOR_ALIGNMENT, MORE_MENU_ANCHOR_ALIGNMENT, defaultPopoverAnchorPosition}; diff --git a/src/components/BulkActionBar/types.ts b/src/components/BulkActionBar/types.ts new file mode 100644 index 000000000000..b139c416e04d --- /dev/null +++ b/src/components/BulkActionBar/types.ts @@ -0,0 +1,44 @@ +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; + +import type {RefObject} from 'react'; +import type {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native'; + +type BulkActionBarProps = { + /** How many rows the selection covers. Rendered as the bar's leading "N selected" label. */ + selectedCount: number; + + /** + * The actions the selection supports, in priority order. The first `CONST.BULK_ACTION_BAR.MAX_INLINE_ACTIONS` are + * rendered as buttons in the bar and the rest are moved into the bar's "More" menu. + */ + options: Array>; + + /** Called when the bar's close button is pressed. Expected to clear the selection, which unmounts the bar. */ + onClearSelection: () => void; + + /** + * Called when an item inside an action's sub-menu, or inside the "More" menu, is selected. Mirrors the callback of + * the same name on `ButtonWithDropdownMenu`, which callers such as Search's bulk pay flow already rely on. + */ + onSubItemSelected?: (item: PopoverMenuItem, index: number, event?: GestureResponderEvent | KeyboardEvent) => void; + + /** + * Anchor for popovers a caller opens against the bar, such as the KYC wall Search puts behind its pay action. + * Attached to the bar itself, since which button opened the flow is not something the bar exposes. + */ + barRef?: RefObject; + + /** + * Extra styles for the absolutely positioned layer the bar floats in. Pass a `bottom` here to lift the bar above + * content pinned to the bottom of the same container, such as a totals footer. + */ + style?: StyleProp; +}; + +type BulkActionBarButtonProps = Pick, 'onSubItemSelected'> & { + /** The action this button performs. Rendered with a dropdown caret when it carries `subMenuItems`. */ + option: DropdownOption; +}; + +export type {BulkActionBarProps, BulkActionBarButtonProps}; diff --git a/src/components/Search/SearchBulkActionsBarWide.tsx b/src/components/Search/SearchBulkActionsBarWide.tsx new file mode 100644 index 000000000000..586bbb93dd93 --- /dev/null +++ b/src/components/Search/SearchBulkActionsBarWide.tsx @@ -0,0 +1,34 @@ +import CONST from '@src/CONST'; + +import React from 'react'; + +import type {SearchQueryJSON} from './types'; + +import SearchBulkActionsButton from './SearchBulkActionsButton'; +import {useSearchSelectionContext} from './SearchContext'; +import {useSelectionCounts} from './SearchSelectionProvider'; + +type SearchBulkActionsBarWideProps = { + queryJSON: SearchQueryJSON; +}; + +/** + * Mounts the wide layout's bulk actions only while rows are selected, so the work `useSearchBulkActions` does to build + * the action list stays off the page until it is needed. Kept separate from the page so that reading the selection + * re-renders this component alone rather than the list beside it. + */ +function SearchBulkActionsBarWide({queryJSON}: SearchBulkActionsBarWideProps) { + const {hasSelectedTransactions} = useSearchSelectionContext(); + const {selected} = useSelectionCounts(); + const shouldShowBulkActions = queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE ? hasSelectedTransactions : selected > 0; + + if (!shouldShowBulkActions) { + return null; + } + + return ; +} + +SearchBulkActionsBarWide.displayName = 'SearchBulkActionsBarWide'; + +export default SearchBulkActionsBarWide; diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index 3b615bac6a98..c0157b283075 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -1,3 +1,4 @@ +import BulkActionBar from '@components/BulkActionBar'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import DecisionModal from '@components/DecisionModal'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; @@ -39,7 +40,7 @@ import type {BulkPaySelectionData, SearchQueryJSON, SelectedTransactions} from ' import BulkDuplicateHandler from './BulkDuplicateHandler'; import BulkDuplicateReportHandler from './BulkDuplicateReportHandler'; -import {useSearchResultsContext, useSearchSelectionContext} from './SearchContext'; +import {useSearchResultsContext, useSearchSelectionActions, useSearchSelectionContext} from './SearchContext'; type SearchBulkActionsButtonProps = { queryJSON: SearchQueryJSON; @@ -53,6 +54,7 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); const {selectedTransactions, excludedTransactions = getEmptyObject(), selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext(); + const {clearSelectedTransactions} = useSearchSelectionActions(); const {currentSearchResults} = useSearchResultsContext(); const kycWallRef = useContext(KYCWallContext); const {isAccountLocked} = useLockedAccountState(); @@ -180,9 +182,8 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { } else { selectedAllMatchingItemsCount = isExpenseType ? Math.max(allMatchingItemsCount - excludedItemsCount, 0) : allMatchingItemsCount; } - const selectionButtonText = translate('workspace.common.selected', { - count: areAllMatchingItemsSelected ? selectedAllMatchingItemsCount : selectedItemsCount, - }); + const selectedBulkActionsCount = areAllMatchingItemsSelected ? selectedAllMatchingItemsCount : selectedItemsCount; + const selectionButtonText = translate('workspace.common.selected', {count: selectedBulkActionsCount}); return ( <> @@ -244,26 +245,13 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { /> ) : ( - - null} - shouldAlwaysShowDropdownMenu - customText={selectionButtonText} - isLoading={isAllMatchingItemsCountLoading} - options={headerButtonsOptions} - menuHeaderText={bulkActionsMenuHeaderText} - shouldPopoverUseScrollView={popoverUseScrollView} - onSubItemSelected={(subItem) => payBulkSelectedItem(subItem, triggerKYCFlow)} - isSplitButton={false} - buttonRef={buttonRef} - anchorAlignment={{ - horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, - vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, - }} - sentryLabel={CONST.SENTRY_LABEL.SEARCH.BULK_ACTIONS_DROPDOWN} - /> - + clearSelectedTransactions(true)} + onSubItemSelected={(subItem) => payBulkSelectedItem(subItem, triggerKYCFlow)} + barRef={buttonRef} + /> ) } diff --git a/src/components/Search/SearchPageHeader/SearchActionsBarWide.tsx b/src/components/Search/SearchPageHeader/SearchActionsBarWide.tsx index da53a25662a9..cb45943acce4 100644 --- a/src/components/Search/SearchPageHeader/SearchActionsBarWide.tsx +++ b/src/components/Search/SearchPageHeader/SearchActionsBarWide.tsx @@ -1,11 +1,7 @@ -import SearchBulkActionsButton from '@components/Search/SearchBulkActionsButton'; -import {useSearchSelectionContext} from '@components/Search/SearchContext'; -import {useSelectionCounts} from '@components/Search/SearchSelectionProvider'; import type {SearchQueryJSON} from '@components/Search/types'; import useThemeStyles from '@hooks/useThemeStyles'; -import CONST from '@src/CONST'; import type {SearchResults} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -28,34 +24,25 @@ type SearchActionsBarWideProps = { function SearchActionsBarWide({queryJSON, searchResults, onSort}: SearchActionsBarWideProps) { const styles = useThemeStyles(); - const {hasSelectedTransactions} = useSearchSelectionContext(); - const {selected} = useSelectionCounts(); - const shouldShowBulkActions = queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE ? hasSelectedTransactions : selected > 0; + // Selecting rows no longer swaps this bar out for the bulk actions: those moved to the floating BulkActionBar over + // the list, so the search input and filters stay available while a selection is being built up. return ( - {shouldShowBulkActions ? ( - - - - ) : ( - <> - - - - - - - - - - - - )} + + + + + + + + + + ); } diff --git a/src/hooks/useInvertedThemePreference.ts b/src/hooks/useInvertedThemePreference.ts new file mode 100644 index 000000000000..49d8f3628108 --- /dev/null +++ b/src/hooks/useInvertedThemePreference.ts @@ -0,0 +1,25 @@ +import type {ThemePreferenceWithoutSystem} from '@styles/theme/types'; + +import CONST from '@src/CONST'; + +import useThemePreference from './useThemePreference'; + +const INVERTED_THEMES: Record = { + [CONST.THEME.LIGHT]: CONST.THEME.DARK, + [CONST.THEME.DARK]: CONST.THEME.LIGHT, + [CONST.THEME.LIGHT_CONTRAST]: CONST.THEME.DARK_CONTRAST, + [CONST.THEME.DARK_CONTRAST]: CONST.THEME.LIGHT_CONTRAST, +}; + +/** + * The opposite of the theme the app is currently showing, for a surface that is meant to stand out against the page + * behind it. The user's contrast preference is carried across, so a contrast theme inverts to the other contrast theme. + * + * Pass the result to `` to render a subtree inverted, which colours everything inside it — + * including popovers, which read the theme themselves and so cannot be inverted with style props. + */ +function useInvertedThemePreference(): ThemePreferenceWithoutSystem { + return INVERTED_THEMES[useThemePreference()]; +} + +export default useInvertedThemePreference; diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index 8b2f97dbb86e..05917644dd6d 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -3,6 +3,7 @@ import {useSearchSidebarContentOffsetStyle} from '@components/Navigation/SearchS import ReceiptScanDropZone from '@components/ReceiptScanDropZone'; import ScreenWrapper from '@components/ScreenWrapper'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; +import SearchBulkActionsBarWide from '@components/Search/SearchBulkActionsBarWide'; import {useSearchQueryContext, useSearchSelectionContext} from '@components/Search/SearchContext'; import SearchLoadingSkeleton from '@components/Search/SearchLoadingSkeleton'; import SearchActionsBarWide from '@components/Search/SearchPageHeader/SearchActionsBarWide'; @@ -137,6 +138,8 @@ function SearchPageWide({ /> )} {!!searchOverlayContent && {searchOverlayContent}} + {/* Floats over the bottom of the list, which already ends above SearchSelectionFooter. */} + diff --git a/src/styles/index.ts b/src/styles/index.ts index df3221a74e28..105b97f6db9d 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5407,6 +5407,37 @@ const staticStyles = (theme: ThemeColors) => marginVertical: -3, }, + // The layer BulkActionBar floats in. It covers its container so the bar can centre itself over the table, and + // passes touches through everywhere except the bar itself. + bulkActionBarLayer: { + position: 'absolute', + bottom: CONST.BULK_ACTION_BAR.BOTTOM_OFFSET, + left: 0, + right: 0, + alignItems: 'center', + }, + + // Resolved under the inverted theme BulkActionBar renders its contents in, so `appBG` here is the opposite of + // the page's background. Everything inside the bar is coloured by that same theme rather than styled specially. + bulkActionBar: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingVertical: 12, + paddingLeft: 20, + paddingRight: 12, + borderRadius: variables.componentBorderRadiusRounded, + backgroundColor: theme.appBG, + boxShadow: theme.shadow, + }, + + bulkActionBarCloseButton: { + height: variables.componentSizeNormal, + width: variables.componentSizeNormal, + alignItems: 'center', + justifyContent: 'center', + }, + filtersBar: { flexDirection: 'row', gap: 8, From 0697580021591d2fbaf7ea4cb5909f56404869f5 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 3 Sep 2026 17:01:36 +0530 Subject: [PATCH 02/28] fix: match BulkActionBar sizing and hover states to the design Signed-off-by: krishna2323 --- src/components/BulkActionBar/BulkActionBarButton.tsx | 10 +++++++++- src/components/BulkActionBar/index.tsx | 8 ++++++-- src/styles/index.ts | 4 ++-- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/components/BulkActionBar/BulkActionBarButton.tsx b/src/components/BulkActionBar/BulkActionBarButton.tsx index c0c9739ae2fa..a5df5640cec4 100644 --- a/src/components/BulkActionBar/BulkActionBarButton.tsx +++ b/src/components/BulkActionBar/BulkActionBarButton.tsx @@ -3,6 +3,7 @@ import PopoverMenu from '@components/PopoverMenu'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import usePopoverPosition from '@hooks/usePopoverPosition'; +import useTheme from '@hooks/useTheme'; import CONST from '@src/CONST'; import type {AnchorPosition} from '@src/styles'; @@ -20,6 +21,7 @@ import {defaultPopoverAnchorPosition, SUB_MENU_ANCHOR_ALIGNMENT} from './popover * those items in a menu above itself; every other action runs `onSelected` directly. */ function BulkActionBarButton({option, onSubItemSelected}: BulkActionBarButtonProps) { + const theme = useTheme(); const icons = useMemoizedLazyExpensifyIcons(['DownArrow', 'UpArrow']); const {calculatePopoverPosition} = usePopoverPosition(); @@ -50,11 +52,17 @@ function BulkActionBarButton({option, onSubItemSelected}: BulkAction option.onSelected?.(); }} + size={CONST.BUTTON_SIZE.SMALL} isDisabled={option.disabled} accessibilityLabel={option.text} sentryLabel={option.sentryLabel} > - {!!option.icon && } + {!!option.icon && ( + + )} {option.text} {hasSubMenu && } diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index b1f3c1e34985..50ddfc77b679 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -59,7 +59,7 @@ function BulkActionBarContent({selectedCount, options, onClearSelect ref={barRef} style={styles.bulkActionBar} > - {translate('workspace.common.selected', {count: selectedCount})} + {translate('workspace.common.selected', {count: selectedCount})} {inlineOptions.map((option) => ( ({selectedCount, options, onClearSelect <> diff --git a/src/styles/index.ts b/src/styles/index.ts index 105b97f6db9d..6e20583175ba 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5423,10 +5423,10 @@ const staticStyles = (theme: ThemeColors) => flexDirection: 'row', alignItems: 'center', gap: 8, - paddingVertical: 12, + paddingVertical: 14, paddingLeft: 20, paddingRight: 12, - borderRadius: variables.componentBorderRadiusRounded, + borderRadius: variables.componentBorderRadiusLarge, backgroundColor: theme.appBG, boxShadow: theme.shadow, }, From 93344fc3f18d508cd03973fd92067c12f47d1537 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 3 Sep 2026 17:19:49 +0530 Subject: [PATCH 03/28] fix: restore the select-all-matching loading state and drop dead code Signed-off-by: krishna2323 --- src/CONST/index.ts | 1 - src/components/BulkActionBar/index.tsx | 15 ++++++++++++--- src/components/BulkActionBar/types.ts | 7 +++++++ src/components/Search/SearchBulkActionsButton.tsx | 1 + src/styles/index.ts | 8 +------- 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index d6d4e62783bb..6d127488a3c8 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8815,7 +8815,6 @@ const CONST = { FILTER_LIMIT: 'Search-FilterLimit', ADVANCED_FILTERS_BUTTON: 'Search-AdvancedFiltersButton', COLUMNS_BUTTON: 'Search-ColumnsButton', - BULK_ACTIONS_DROPDOWN: 'Search-BulkActionsDropdown', SELECT_ALL_CHECKBOX: 'Search-SelectAllCheckbox', SELECTION_MODE_MENU_ITEM: 'Search-SelectionModeMenuItem', FILTER_RESET_BUTTON: 'Search-FilterResetButton', diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index 50ddfc77b679..511c5687815d 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -1,3 +1,4 @@ +import ActivityIndicator from '@components/ActivityIndicator'; import Button from '@components/ButtonComposed'; import Icon from '@components/Icon'; import PopoverMenu from '@components/PopoverMenu'; @@ -30,7 +31,7 @@ import {defaultPopoverAnchorPosition, MORE_MENU_ANCHOR_ALIGNMENT} from './popove * specially. Split out from `BulkActionBar` because these styles have to resolve from the inverted theme, while the * positioning layer around it belongs to the page's own. */ -function BulkActionBarContent({selectedCount, options, onClearSelection, onSubItemSelected, barRef}: Omit, 'style'>) { +function BulkActionBarContent({selectedCount, isSelectedCountLoading, options, onClearSelection, onSubItemSelected, barRef}: Omit, 'style'>) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -59,7 +60,14 @@ function BulkActionBarContent({selectedCount, options, onClearSelect ref={barRef} style={styles.bulkActionBar} > - {translate('workspace.common.selected', {count: selectedCount})} + {isSelectedCountLoading ? ( + + ) : ( + {translate('workspace.common.selected', {count: selectedCount})} + )} {inlineOptions.map((option) => ( ({selectedCount, options, onClearSelect * The bar renders under the inverted theme so that it stands out against the table behind it. That also inverts its * "More" menu, which reads the theme itself and could not be inverted through style props alone. */ -function BulkActionBar({selectedCount, options, onClearSelection, onSubItemSelected, barRef, style}: BulkActionBarProps) { +function BulkActionBar({selectedCount, isSelectedCountLoading, options, onClearSelection, onSubItemSelected, barRef, style}: BulkActionBarProps) { const styles = useThemeStyles(); const invertedTheme = useInvertedThemePreference(); @@ -147,6 +155,7 @@ function BulkActionBar({selectedCount, options, onClearSelection, on = { */ options: Array>; + /** + * Whether `selectedCount` is still being resolved — a "select all matching" selection only learns its real size + * once the server reports it. The bar shows a spinner in place of the count while this is true, rather than a + * number that is about to change. + */ + isSelectedCountLoading?: boolean; + /** Called when the bar's close button is pressed. Expected to clear the selection, which unmounts the bar. */ onClearSelection: () => void; diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index c0157b283075..8219b9b04a26 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -247,6 +247,7 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { ) : ( clearSelectedTransactions(true)} onSubItemSelected={(subItem) => payBulkSelectedItem(subItem, triggerKYCFlow)} diff --git a/src/styles/index.ts b/src/styles/index.ts index 6e20583175ba..d86916b2046a 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5401,12 +5401,6 @@ const staticStyles = (theme: ThemeColors) => minHeight: variables.componentSizeSmall, }, - // The filter bar row is 34px tall, but the default (larger) bulk-action button is 40px. - // To keep the bar from growing, we pull the button up/down by half the difference: (40 - 34) / 2 = 3. - searchBulkActionsButton: { - marginVertical: -3, - }, - // The layer BulkActionBar floats in. It covers its container so the bar can centre itself over the table, and // passes touches through everywhere except the bar itself. bulkActionBarLayer: { @@ -7103,7 +7097,7 @@ const dynamicStyles = (theme: ThemeColors) => }), // The 40px bulk-actions button swaps in for the table filter bar row (32px search bar on wide layouts, 44px on narrow), - // so offset its vertical margin to keep the row height identical and prevent the table from shifting (see searchBulkActionsButton). + // so offset its vertical margin to keep the row height identical and prevent the table from shifting. tableBulkActionsButton: (shouldUseNarrowTableLayout: boolean) => ({ marginVertical: shouldUseNarrowTableLayout ? 2 : -4, }), From 52a13bd9bb6f1f81642d08f5c12bbdbc684a88a2 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 3 Sep 2026 17:45:42 +0530 Subject: [PATCH 04/28] fix: stop the bulk action bar covering the last rows of the list Signed-off-by: krishna2323 --- src/CONST/index.ts | 9 ++++++++ .../Search/SearchBulkActionsBarWide.tsx | 9 ++------ .../hooks/useShouldShowBulkActionBar.ts | 22 +++++++++++++++++++ src/components/Search/index.tsx | 5 ++++- src/styles/index.ts | 12 ++++++++-- 5 files changed, 47 insertions(+), 10 deletions(-) create mode 100644 src/components/Search/hooks/useShouldShowBulkActionBar.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 6d127488a3c8..fcfbd1df10fe 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6427,6 +6427,15 @@ const CONST = { /** How far the bar floats above the bottom of the container it is rendered in. */ BOTTOM_OFFSET: 20, + + /** + * The bar's height: a small (28px) button plus its 14px of padding above and below. The bar sizes itself from + * its contents, so this is only used to reserve the space it floats over — keep it in step with `bulkActionBar`. + */ + HEIGHT: 56, + + /** Breathing room left between the bar and the last row of the list it floats over. */ + LIST_GAP: 12, }, BUTTON_REMOVE_BORDER_RADIUS: { diff --git a/src/components/Search/SearchBulkActionsBarWide.tsx b/src/components/Search/SearchBulkActionsBarWide.tsx index 586bbb93dd93..bd1e00899abd 100644 --- a/src/components/Search/SearchBulkActionsBarWide.tsx +++ b/src/components/Search/SearchBulkActionsBarWide.tsx @@ -1,12 +1,9 @@ -import CONST from '@src/CONST'; - import React from 'react'; import type {SearchQueryJSON} from './types'; +import useShouldShowBulkActionBar from './hooks/useShouldShowBulkActionBar'; import SearchBulkActionsButton from './SearchBulkActionsButton'; -import {useSearchSelectionContext} from './SearchContext'; -import {useSelectionCounts} from './SearchSelectionProvider'; type SearchBulkActionsBarWideProps = { queryJSON: SearchQueryJSON; @@ -18,9 +15,7 @@ type SearchBulkActionsBarWideProps = { * re-renders this component alone rather than the list beside it. */ function SearchBulkActionsBarWide({queryJSON}: SearchBulkActionsBarWideProps) { - const {hasSelectedTransactions} = useSearchSelectionContext(); - const {selected} = useSelectionCounts(); - const shouldShowBulkActions = queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE ? hasSelectedTransactions : selected > 0; + const shouldShowBulkActions = useShouldShowBulkActionBar(queryJSON); if (!shouldShowBulkActions) { return null; diff --git a/src/components/Search/hooks/useShouldShowBulkActionBar.ts b/src/components/Search/hooks/useShouldShowBulkActionBar.ts new file mode 100644 index 000000000000..bbc6f930c4a6 --- /dev/null +++ b/src/components/Search/hooks/useShouldShowBulkActionBar.ts @@ -0,0 +1,22 @@ +import {useSearchSelectionContext} from '@components/Search/SearchContext'; +import {useSelectionCounts} from '@components/Search/SearchSelectionProvider'; +import type {SearchQueryJSON} from '@components/Search/types'; + +import CONST from '@src/CONST'; + +/** + * Whether the wide layout's floating bulk action bar is showing for the current selection. + * + * Both the bar and the list underneath it depend on this: the list has to reserve the space the bar floats over, or its + * last rows sit behind the bar once you scroll to the bottom. Keeping the rule here stops the two from drifting apart. + * + * Expense searches track their selection as transactions, while every other type counts selected rows. + */ +function useShouldShowBulkActionBar(queryJSON: SearchQueryJSON): boolean { + const {hasSelectedTransactions} = useSearchSelectionContext(); + const {selected} = useSelectionCounts(); + + return queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE ? hasSelectedTransactions : selected > 0; +} + +export default useShouldShowBulkActionBar; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 4cd448cc5001..a4b04f7d727a 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -99,6 +99,7 @@ import ExpenseFlatSearchView from './ExpenseFlatSearchView'; import ExpenseGroupedSearchView from './ExpenseGroupedSearchView'; import ExpenseReportSearchView from './ExpenseReportSearchView'; import useSearchSnapshot from './hooks/useSearchSnapshot'; +import useShouldShowBulkActionBar from './hooks/useShouldShowBulkActionBar'; import SearchChartView from './SearchChartView'; import SearchChartWrapper from './SearchChartWrapper'; import {useSearchQueryActions, useSearchQueryContext, useSearchResultsActions, useSearchResultsContext, useSearchSelectionActions, useSearchSelectionContext} from './SearchContext'; @@ -155,6 +156,8 @@ function Search({ const {setShouldShowFiltersBarLoading} = useSearchResultsActions(); const {clearSelectedTransactions} = useSearchSelectionActions(); const {areAllMatchingItemsSelected} = useSearchSelectionContext(); + // Wide layout floats the bulk action bar over the end of the list, so the list has to leave room for it. + const shouldReserveBulkActionBarSpace = useShouldShowBulkActionBar(queryJSON) && !shouldUseNarrowLayout; const [offset, setOffset] = useState(0); const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); @@ -1223,7 +1226,7 @@ function Search({ canSelectMultiple, SearchTableHeader: searchTableHeader, tableHeaderVisible, - contentContainerStyle: [styles.pb3, contentContainerStyle], + contentContainerStyle: [styles.pb3, shouldReserveBulkActionBarSpace && styles.bulkActionBarListSpacing, contentContainerStyle], containerStyle: [styles.pv0], onScroll: onSearchListScroll, onEndReached: fetchMoreResults, diff --git a/src/styles/index.ts b/src/styles/index.ts index d86916b2046a..19c2712fbcd4 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5425,9 +5425,17 @@ const staticStyles = (theme: ThemeColors) => boxShadow: theme.shadow, }, + // Reserves the space the bar floats over at the end of the list it covers, so the last rows can still be + // scrolled clear of it. Applied to the list's content rather than its container: content grows below the + // viewport, so the rows on screen stay where they are when a selection shows or hides the bar. + bulkActionBarListSpacing: { + paddingBottom: CONST.BULK_ACTION_BAR.HEIGHT + CONST.BULK_ACTION_BAR.BOTTOM_OFFSET + CONST.BULK_ACTION_BAR.LIST_GAP, + }, + + // Matches the height of the bar's buttons: as the tallest item in the row it would otherwise set the bar's height. bulkActionBarCloseButton: { - height: variables.componentSizeNormal, - width: variables.componentSizeNormal, + height: variables.componentSizeSmall, + width: variables.componentSizeSmall, alignItems: 'center', justifyContent: 'center', }, From b6ecad778b9ee1066899d6dfdf94c39ff82c7594 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 3 Sep 2026 18:00:34 +0530 Subject: [PATCH 05/28] fix CI failures. Signed-off-by: krishna2323 --- src/components/BulkActionBar/index.tsx | 2 +- src/hooks/useInvertedThemePreference.ts | 2 +- src/styles/index.ts | 4 +- .../Search/SearchBulkActionsButtonTest.tsx | 37 ++++++++++--------- 4 files changed, 24 insertions(+), 21 deletions(-) diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index 511c5687815d..57ef8f668fbb 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -26,7 +26,7 @@ import BulkActionBarButton from './BulkActionBarButton'; import {defaultPopoverAnchorPosition, MORE_MENU_ANCHOR_ALIGNMENT} from './popoverPosition'; /** - * The bar's contents. Everything here takes its colours from the theme it is rendered under, which `BulkActionBar` + * The bar's contents. Everything here takes its colors from the theme it is rendered under, which `BulkActionBar` * inverts — so the surface, the buttons and the "More" menu all read as one layer without any of them being styled * specially. Split out from `BulkActionBar` because these styles have to resolve from the inverted theme, while the * positioning layer around it belongs to the page's own. diff --git a/src/hooks/useInvertedThemePreference.ts b/src/hooks/useInvertedThemePreference.ts index 49d8f3628108..68212b7dd13c 100644 --- a/src/hooks/useInvertedThemePreference.ts +++ b/src/hooks/useInvertedThemePreference.ts @@ -15,7 +15,7 @@ const INVERTED_THEMES: Record` to render a subtree inverted, which colours everything inside it — + * Pass the result to `` to render a subtree inverted, which colors everything inside it — * including popovers, which read the theme themselves and so cannot be inverted with style props. */ function useInvertedThemePreference(): ThemePreferenceWithoutSystem { diff --git a/src/styles/index.ts b/src/styles/index.ts index 19c2712fbcd4..2968bcfeae57 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5401,7 +5401,7 @@ const staticStyles = (theme: ThemeColors) => minHeight: variables.componentSizeSmall, }, - // The layer BulkActionBar floats in. It covers its container so the bar can centre itself over the table, and + // The layer BulkActionBar floats in. It covers its container so the bar can center itself over the table, and // passes touches through everywhere except the bar itself. bulkActionBarLayer: { position: 'absolute', @@ -5412,7 +5412,7 @@ const staticStyles = (theme: ThemeColors) => }, // Resolved under the inverted theme BulkActionBar renders its contents in, so `appBG` here is the opposite of - // the page's background. Everything inside the bar is coloured by that same theme rather than styled specially. + // the page's background. Everything inside the bar is colored by that same theme rather than styled specially. bulkActionBar: { flexDirection: 'row', alignItems: 'center', diff --git a/tests/unit/Search/SearchBulkActionsButtonTest.tsx b/tests/unit/Search/SearchBulkActionsButtonTest.tsx index 5e876676a443..e3a79ef8e792 100644 --- a/tests/unit/Search/SearchBulkActionsButtonTest.tsx +++ b/tests/unit/Search/SearchBulkActionsButtonTest.tsx @@ -9,20 +9,20 @@ import CONST from '@src/CONST'; import React from 'react'; -type MockButtonProps = { - customText: string; - isLoading: boolean; +type MockBulkActionBarProps = { + selectedCount: number; + isSelectedCountLoading: boolean; }; -const mockButtonWithDropdownMenu = jest.fn(() => null); +const mockBulkActionBar = jest.fn(() => null); let mockExcludedTransactions: SelectedTransactions = {}; let mockSearchCount: number | undefined; let mockSearchIsLoading = false; let mockIsOffline = false; -jest.mock('@components/ButtonWithDropdownMenu', () => ({ +jest.mock('@components/BulkActionBar', () => ({ __esModule: true, - default: (props: MockButtonProps) => mockButtonWithDropdownMenu(props), + default: (props: MockBulkActionBarProps) => mockBulkActionBar(props), })); jest.mock('@components/DecisionModal', () => () => null); jest.mock('@components/HoldOrRejectEducationalModal', () => () => null); @@ -77,6 +77,9 @@ jest.mock('@components/Search/SearchContext', () => ({ selectedReports: [], areAllMatchingItemsSelected: true, }), + useSearchSelectionActions: () => ({ + clearSelectedTransactions: jest.fn(), + }), useSearchResultsContext: () => ({ currentSearchResults: {search: {count: mockSearchCount, isLoading: mockSearchIsLoading}}, }), @@ -115,12 +118,12 @@ function makeTransaction(): SelectedTransactions[string] { }; } -function getButtonProps(): {customText: string; isLoading: boolean} { - const props = mockButtonWithDropdownMenu.mock.calls.at(-1)?.at(0); +function getBarProps(): {selectedCount: number; isSelectedCountLoading: boolean} { + const props = mockBulkActionBar.mock.calls.at(-1)?.at(0); if (!props) { - throw new Error('ButtonWithDropdownMenu was not rendered'); + throw new Error('BulkActionBar was not rendered'); } - return {customText: props.customText, isLoading: props.isLoading}; + return {selectedCount: props.selectedCount, isSelectedCountLoading: props.isSelectedCountLoading}; } describe('SearchBulkActionsButton all-matching label', () => { @@ -137,7 +140,7 @@ describe('SearchBulkActionsButton all-matching label', () => { render(); - expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:1', isLoading: true}); + expect(getBarProps()).toEqual({selectedCount: 1, isSelectedCountLoading: true}); }); it('shows the server count when it arrives and there are no exclusions', () => { @@ -145,7 +148,7 @@ describe('SearchBulkActionsButton all-matching label', () => { render(); - expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:172', isLoading: false}); + expect(getBarProps()).toEqual({selectedCount: 172, isSelectedCountLoading: false}); }); it('shows the exact count after an item is excluded', () => { @@ -154,7 +157,7 @@ describe('SearchBulkActionsButton all-matching label', () => { render(); - expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:171', isLoading: false}); + expect(getBarProps()).toEqual({selectedCount: 171, isSelectedCountLoading: false}); }); it('keeps loading when an exclusion exists before the count arrives', () => { @@ -163,7 +166,7 @@ describe('SearchBulkActionsButton all-matching label', () => { render(); - expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:1', isLoading: true}); + expect(getBarProps()).toEqual({selectedCount: 1, isSelectedCountLoading: true}); }); it('shows the loaded selected count when an expense is excluded offline before the server count is available', () => { @@ -172,7 +175,7 @@ describe('SearchBulkActionsButton all-matching label', () => { render(); - expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:1', isLoading: false}); + expect(getBarProps()).toEqual({selectedCount: 1, isSelectedCountLoading: false}); }); it('retains the expense-report loading behavior while the server count is missing', () => { @@ -180,7 +183,7 @@ describe('SearchBulkActionsButton all-matching label', () => { render(); - expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:0', isLoading: true}); + expect(getBarProps()).toEqual({selectedCount: 0, isSelectedCountLoading: true}); }); it('uses the unmodified server count for expense reports', () => { @@ -189,6 +192,6 @@ describe('SearchBulkActionsButton all-matching label', () => { render(); - expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:320', isLoading: false}); + expect(getBarProps()).toEqual({selectedCount: 320, isSelectedCountLoading: false}); }); }); From 64c364d123bbef2fdf8eb4e0a5cff487565bfe1d Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 3 Sep 2026 18:27:57 +0530 Subject: [PATCH 06/28] fix: make the bulk action bar's close button clear the selection Signed-off-by: krishna2323 --- src/components/Search/SearchBulkActionsButton.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index 5ecab0f58787..c0b573f772d1 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -257,7 +257,10 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { selectedCount={selectedBulkActionsCount} isSelectedCountLoading={isAllMatchingItemsCountLoading} options={headerButtonsOptions} - onClearSelection={() => clearSelectedTransactions(true)} + // Called with no argument so the whole search selection is reset. Passing the boolean flag + // instead only clears `selectedTransactionIDs`, which is the report view's selection, and + // would leave this page's `selectedTransactions` in place with the bar still showing. + onClearSelection={() => clearSelectedTransactions()} onSubItemSelected={(subItem) => payBulkSelectedItem(subItem, triggerKYCFlow)} barRef={buttonRef} /> From 0d465e2354dd346f083db724ab5f9825914e859c Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 7 Sep 2026 15:29:12 +0530 Subject: [PATCH 07/28] feat: polish the bulk action bar from design review Signed-off-by: krishna2323 --- src/CONST/index.ts | 10 ++++++ src/components/BulkActionBar/index.tsx | 47 +++++++++++++++++++------- src/styles/index.ts | 8 +++++ 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index fcfbd1df10fe..12ec83d3f632 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6425,6 +6425,12 @@ const CONST = { /** How many of a selection's actions get a button of their own in the bar before the rest move behind "More". */ MAX_INLINE_ACTIONS: 3, + /** + * The same, at the in-between widths that are wide enough for the bar but too narrow for three buttons — a + * third button would squeeze the bar's contents, so it moves behind "More" instead. + */ + MAX_INLINE_ACTIONS_MEDIUM_SCREEN: 2, + /** How far the bar floats above the bottom of the container it is rendered in. */ BOTTOM_OFFSET: 20, @@ -6436,6 +6442,10 @@ const CONST = { /** Breathing room left between the bar and the last row of the list it floats over. */ LIST_GAP: 12, + + /** How far the bar rises into place when it appears, and how long that takes. */ + SLIDE_IN_DISTANCE: 12, + SLIDE_IN_DURATION: 240, }, BUTTON_REMOVE_BORDER_RADIUS: { diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index 57ef8f668fbb..7812b059746f 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -8,23 +8,34 @@ import ThemeProvider from '@components/ThemeProvider'; import ThemeStylesProvider from '@components/ThemeStylesContextProvider'; import useInvertedThemePreference from '@hooks/useInvertedThemePreference'; +import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import usePopoverPosition from '@hooks/usePopoverPosition'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import Accessibility from '@libs/Accessibility'; + import CONST from '@src/CONST'; import type {AnchorPosition} from '@src/styles'; import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; +import Animated, {Easing, Keyframe} from 'react-native-reanimated'; import type {BulkActionBarProps} from './types'; import BulkActionBarButton from './BulkActionBarButton'; import {defaultPopoverAnchorPosition, MORE_MENU_ANCHOR_ALIGNMENT} from './popoverPosition'; +// The bar appears in a spot nothing else occupies, so it rises the last few pixels into place to draw the eye there. +const SlideIn = new Keyframe({ + from: {opacity: 0, transform: [{translateY: CONST.BULK_ACTION_BAR.SLIDE_IN_DISTANCE}]}, + to: {opacity: 1, transform: [{translateY: 0}], easing: Easing.bezier(0.76, 0.0, 0.24, 1.0).factory()}, +}).duration(CONST.BULK_ACTION_BAR.SLIDE_IN_DURATION); + /** * The bar's contents. Everything here takes its colors from the theme it is rendered under, which `BulkActionBar` * inverts — so the surface, the buttons and the "More" menu all read as one layer without any of them being styled @@ -37,15 +48,22 @@ function BulkActionBarContent({selectedCount, isSelectedCountLoading const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['Close', 'DownArrow', 'ThreeDots', 'UpArrow']); const {calculatePopoverPosition} = usePopoverPosition(); + const {isMediumScreenWidth} = useResponsiveLayout(); const moreAnchorRef = useRef(null); const [isMoreMenuVisible, setIsMoreMenuVisible] = useState(false); const [moreMenuAnchorPosition, setMoreMenuAnchorPosition] = useState(defaultPopoverAnchorPosition); // Only the highest-priority actions are given a button of their own; the rest stay reachable behind "More". - const hasMoreMenu = options.length > CONST.BULK_ACTION_BAR.MAX_INLINE_ACTIONS; - const inlineOptions = hasMoreMenu ? options.slice(0, CONST.BULK_ACTION_BAR.MAX_INLINE_ACTIONS) : options; - const moreOptions = hasMoreMenu ? options.slice(CONST.BULK_ACTION_BAR.MAX_INLINE_ACTIONS) : []; + // One fewer fits at the in-between widths, where three buttons would squeeze the bar. + const maxInlineActions = isMediumScreenWidth ? CONST.BULK_ACTION_BAR.MAX_INLINE_ACTIONS_MEDIUM_SCREEN : CONST.BULK_ACTION_BAR.MAX_INLINE_ACTIONS; + const hasMoreMenu = options.length > maxInlineActions; + const inlineOptions = hasMoreMenu ? options.slice(0, maxInlineActions) : options; + const moreOptions = hasMoreMenu ? options.slice(maxInlineActions) : []; + + // Esc dismisses the selection, as it does for this kind of bulk-select bar elsewhere. It sits below the default + // priority so that an open menu's own Esc handling closes the menu first rather than clearing the selection. + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, onClearSelection, {priority: 1}); useEffect(() => { if (!moreAnchorRef.current || !isMoreMenuVisible) { @@ -60,14 +78,15 @@ function BulkActionBarContent({selectedCount, isSelectedCountLoading ref={barRef} style={styles.bulkActionBar} > - {isSelectedCountLoading ? ( - - ) : ( - {translate('workspace.common.selected', {count: selectedCount})} - )} + {/* Sized for a three-digit count so the bar keeps still as the selection grows, and so swapping the + spinner for the count does not resize it either. */} + + {isSelectedCountLoading ? ( + + ) : ( + {translate('workspace.common.selected', {count: selectedCount})} + )} + {inlineOptions.map((option) => ( ({selectedCount, isSelectedCountLoading function BulkActionBar({selectedCount, isSelectedCountLoading, options, onClearSelection, onSubItemSelected, barRef, style}: BulkActionBarProps) { const styles = useThemeStyles(); const invertedTheme = useInvertedThemePreference(); + const isReducedMotionEnabled = Accessibility.useReducedMotion(); return ( - @@ -163,7 +184,7 @@ function BulkActionBar({selectedCount, isSelectedCountLoading, optio /> - + ); } diff --git a/src/styles/index.ts b/src/styles/index.ts index 2968bcfeae57..1c59698eb867 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5432,6 +5432,14 @@ const staticStyles = (theme: ThemeColors) => paddingBottom: CONST.BULK_ACTION_BAR.HEIGHT + CONST.BULK_ACTION_BAR.BOTTOM_OFFSET + CONST.BULK_ACTION_BAR.LIST_GAP, }, + // Wide enough for a three-digit count, so the bar does not resize as the selection grows past 9 or 99 — a + // selection can cover far more rows than are on screen. Also keeps the width steady while the count loads. + bulkActionBarCount: { + minWidth: 88, + marginRight: 4, + justifyContent: 'center', + }, + // Matches the height of the bar's buttons: as the tallest item in the row it would otherwise set the bar's height. bulkActionBarCloseButton: { height: variables.componentSizeSmall, From 1882a953722da8b10a35895538dfa7f64206e41f Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 7 Sep 2026 15:39:00 +0530 Subject: [PATCH 08/28] feat: spring the bulk action bar into place on appear Signed-off-by: krishna2323 --- src/CONST/index.ts | 3 +-- src/components/BulkActionBar/index.tsx | 28 +++++++++++++++++--------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 12ec83d3f632..9cf9d449da26 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6443,9 +6443,8 @@ const CONST = { /** Breathing room left between the bar and the last row of the list it floats over. */ LIST_GAP: 12, - /** How far the bar rises into place when it appears, and how long that takes. */ + /** How far below its resting place the bar starts before it springs up into view. */ SLIDE_IN_DISTANCE: 12, - SLIDE_IN_DURATION: 240, }, BUTTON_REMOVE_BORDER_RADIUS: { diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index 7812b059746f..a96e58878809 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -23,19 +23,13 @@ import type {AnchorPosition} from '@src/styles'; import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; -import Animated, {Easing, Keyframe} from 'react-native-reanimated'; +import Animated, {useAnimatedStyle, useSharedValue, withSpring} from 'react-native-reanimated'; import type {BulkActionBarProps} from './types'; import BulkActionBarButton from './BulkActionBarButton'; import {defaultPopoverAnchorPosition, MORE_MENU_ANCHOR_ALIGNMENT} from './popoverPosition'; -// The bar appears in a spot nothing else occupies, so it rises the last few pixels into place to draw the eye there. -const SlideIn = new Keyframe({ - from: {opacity: 0, transform: [{translateY: CONST.BULK_ACTION_BAR.SLIDE_IN_DISTANCE}]}, - to: {opacity: 1, transform: [{translateY: 0}], easing: Easing.bezier(0.76, 0.0, 0.24, 1.0).factory()}, -}).duration(CONST.BULK_ACTION_BAR.SLIDE_IN_DURATION); - /** * The bar's contents. Everything here takes its colors from the theme it is rendered under, which `BulkActionBar` * inverts — so the surface, the buttons and the "More" menu all read as one layer without any of them being styled @@ -164,10 +158,26 @@ function BulkActionBar({selectedCount, isSelectedCountLoading, optio const invertedTheme = useInvertedThemePreference(); const isReducedMotionEnabled = Accessibility.useReducedMotion(); + // The bar appears where nothing was before, so it springs up into place to draw the eye there, the same way the + // report's floating message counter animates itself in. + const translateY = useSharedValue(CONST.BULK_ACTION_BAR.SLIDE_IN_DISTANCE); + + useEffect(() => { + if (isReducedMotionEnabled) { + translateY.set(0); + return; + } + + translateY.set(withSpring(0)); + }, [isReducedMotionEnabled, translateY]); + + const layerAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{translateY: translateY.get()}], + })); + return ( {/* ThemeStylesProvider has to come with ThemeProvider: without it `useThemeStyles` keeps resolving against From c55dee72d876767e607870feb06d6afedadb3f75 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 7 Sep 2026 15:41:16 +0530 Subject: [PATCH 09/28] fix text alignment. Signed-off-by: krishna2323 --- src/components/BulkActionBar/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index a96e58878809..8c9d5bb98607 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -78,7 +78,7 @@ function BulkActionBarContent({selectedCount, isSelectedCountLoading {isSelectedCountLoading ? ( ) : ( - {translate('workspace.common.selected', {count: selectedCount})} + {translate('workspace.common.selected', {count: selectedCount})} )} {inlineOptions.map((option) => ( From 0bbcdc91af3407fc93fdeb2f574ab687ea34b049 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 7 Sep 2026 15:53:12 +0530 Subject: [PATCH 10/28] fix: fit the bulk action bar to its container, and tune its entrance Signed-off-by: krishna2323 --- src/CONST/index.ts | 14 ++--- src/components/BulkActionBar/index.tsx | 81 ++++++++++++++++++++++---- 2 files changed, 76 insertions(+), 19 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 9cf9d449da26..b99f41a51ef0 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6425,12 +6425,6 @@ const CONST = { /** How many of a selection's actions get a button of their own in the bar before the rest move behind "More". */ MAX_INLINE_ACTIONS: 3, - /** - * The same, at the in-between widths that are wide enough for the bar but too narrow for three buttons — a - * third button would squeeze the bar's contents, so it moves behind "More" instead. - */ - MAX_INLINE_ACTIONS_MEDIUM_SCREEN: 2, - /** How far the bar floats above the bottom of the container it is rendered in. */ BOTTOM_OFFSET: 20, @@ -6444,7 +6438,13 @@ const CONST = { LIST_GAP: 12, /** How far below its resting place the bar starts before it springs up into view. */ - SLIDE_IN_DISTANCE: 12, + SLIDE_IN_DISTANCE: 24, + + /** + * Spring the bar settles into place with. Overdamped, matching the canvas gestures, so it arrives quickly and + * without a bounce — the default spring wobbles noticeably over this short a travel. + */ + SLIDE_IN_SPRING: {mass: 1, stiffness: 1000, damping: 500}, }, BUTTON_REMOVE_BORDER_RADIUS: { diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index 8c9d5bb98607..006ba54686c6 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -12,7 +12,6 @@ import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import usePopoverPosition from '@hooks/usePopoverPosition'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -36,24 +35,51 @@ import {defaultPopoverAnchorPosition, MORE_MENU_ANCHOR_ALIGNMENT} from './popove * specially. Split out from `BulkActionBar` because these styles have to resolve from the inverted theme, while the * positioning layer around it belongs to the page's own. */ -function BulkActionBarContent({selectedCount, isSelectedCountLoading, options, onClearSelection, onSubItemSelected, barRef}: Omit, 'style'>) { +type BulkActionBarContentProps = Omit, 'style'> & { + /** The width the bar has to fit into, once the layer around it has been laid out. */ + availableWidth: number | undefined; + + /** Called once the bar has been laid out at a width that fits, so it can be revealed and animated in. */ + onSettled: () => void; +}; + +function BulkActionBarContent({ + selectedCount, + isSelectedCountLoading, + options, + onClearSelection, + onSubItemSelected, + barRef, + availableWidth, + onSettled, +}: BulkActionBarContentProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['Close', 'DownArrow', 'ThreeDots', 'UpArrow']); const {calculatePopoverPosition} = usePopoverPosition(); - const {isMediumScreenWidth} = useResponsiveLayout(); const moreAnchorRef = useRef(null); const [isMoreMenuVisible, setIsMoreMenuVisible] = useState(false); const [moreMenuAnchorPosition, setMoreMenuAnchorPosition] = useState(defaultPopoverAnchorPosition); // Only the highest-priority actions are given a button of their own; the rest stay reachable behind "More". - // One fewer fits at the in-between widths, where three buttons would squeeze the bar. - const maxInlineActions = isMediumScreenWidth ? CONST.BULK_ACTION_BAR.MAX_INLINE_ACTIONS_MEDIUM_SCREEN : CONST.BULK_ACTION_BAR.MAX_INLINE_ACTIONS; - const hasMoreMenu = options.length > maxInlineActions; - const inlineOptions = hasMoreMenu ? options.slice(0, maxInlineActions) : options; - const moreOptions = hasMoreMenu ? options.slice(maxInlineActions) : []; + // + // How many that is has to come from the width the bar actually has, not from a screen breakpoint: the bar sits in a + // content pane whose width depends on the sidebar and the layout around it, and the buttons' own widths depend on + // how long their labels are in the viewer's language. So the bar is laid out at the largest count, and drops one + // action at a time into "More" until it fits. `hasSettled` keeps it hidden until it does, so an overflowing first + // pass is never shown. + const [fittedLayout, setFittedLayout] = useState<{availableWidth: number; actionCount: number; optionCount: number}>(); + + // A fitted count only holds for the width and action list it was measured against. Anything else — a container that + // grew, a selection whose actions changed — starts again from the largest count, so a wider bar fills up again. + const hasFittedLayout = fittedLayout?.availableWidth === availableWidth && fittedLayout?.optionCount === options.length; + const inlineActionCount = hasFittedLayout ? fittedLayout.actionCount : CONST.BULK_ACTION_BAR.MAX_INLINE_ACTIONS; + + const hasMoreMenu = options.length > inlineActionCount; + const inlineOptions = hasMoreMenu ? options.slice(0, inlineActionCount) : options; + const moreOptions = hasMoreMenu ? options.slice(inlineActionCount) : []; // Esc dismisses the selection, as it does for this kind of bulk-select bar elsewhere. It sits below the default // priority so that an open menu's own Esc handling closes the menu first rather than clearing the selection. @@ -71,6 +97,21 @@ function BulkActionBarContent({selectedCount, isSelectedCountLoading { + const {width} = event.nativeEvent.layout; + if (availableWidth === undefined) { + return; + } + + // Shedding an action always makes the bar narrower, so this settles rather than oscillating. At zero + // inline actions only the count, "More" and the close button remain, which fits any usable width. + if (width > availableWidth && inlineActionCount > 0) { + setFittedLayout({availableWidth, actionCount: inlineActionCount - 1, optionCount: options.length}); + return; + } + + onSettled(); + }} > {/* Sized for a three-digit count so the bar keeps still as the selection grows, and so swapping the spinner for the count does not resize it either. */} @@ -158,18 +199,31 @@ function BulkActionBar({selectedCount, isSelectedCountLoading, optio const invertedTheme = useInvertedThemePreference(); const isReducedMotionEnabled = Accessibility.useReducedMotion(); + // This layer spans the container, so laying it out measures the width the bar has to fit into. + const [availableWidth, setAvailableWidth] = useState(); + const [settledWidth, setSettledWidth] = useState(); + + // A change in available width sends the bar back to the largest number of actions, so it has to prove it fits again + // before being shown — hence comparing against the width it last settled at rather than keeping a plain flag. + const hasSettled = availableWidth !== undefined && settledWidth === availableWidth; + // The bar appears where nothing was before, so it springs up into place to draw the eye there, the same way the - // report's floating message counter animates itself in. + // report's floating message counter animates itself in. It waits for the fitting pass so the motion is only ever + // run on the layout the viewer actually sees. const translateY = useSharedValue(CONST.BULK_ACTION_BAR.SLIDE_IN_DISTANCE); useEffect(() => { + if (!hasSettled) { + return; + } + if (isReducedMotionEnabled) { translateY.set(0); return; } - translateY.set(withSpring(0)); - }, [isReducedMotionEnabled, translateY]); + translateY.set(withSpring(0, CONST.BULK_ACTION_BAR.SLIDE_IN_SPRING)); + }, [hasSettled, isReducedMotionEnabled, translateY]); const layerAnimatedStyle = useAnimatedStyle(() => ({ transform: [{translateY: translateY.get()}], @@ -177,8 +231,9 @@ function BulkActionBar({selectedCount, isSelectedCountLoading, optio return ( setAvailableWidth(event.nativeEvent.layout.width)} > {/* ThemeStylesProvider has to come with ThemeProvider: without it `useThemeStyles` keeps resolving against the page's theme while `useTheme` resolves against this one, and the bar renders half-inverted. */} @@ -191,6 +246,8 @@ function BulkActionBar({selectedCount, isSelectedCountLoading, optio onClearSelection={onClearSelection} onSubItemSelected={onSubItemSelected} barRef={barRef} + availableWidth={availableWidth} + onSettled={() => setSettledWidth(availableWidth)} /> From af54aa05eac79910d7ace98d10b05ada5eecdc3e Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 7 Sep 2026 15:59:54 +0530 Subject: [PATCH 11/28] fix: keep the bulk action bar's measurement across a fitting reset Signed-off-by: krishna2323 --- src/components/BulkActionBar/index.tsx | 69 ++++++++++++++------------ 1 file changed, 36 insertions(+), 33 deletions(-) diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index 006ba54686c6..72be72045ed6 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -36,11 +36,11 @@ import {defaultPopoverAnchorPosition, MORE_MENU_ANCHOR_ALIGNMENT} from './popove * positioning layer around it belongs to the page's own. */ type BulkActionBarContentProps = Omit, 'style'> & { - /** The width the bar has to fit into, once the layer around it has been laid out. */ - availableWidth: number | undefined; + /** How many actions to give a button of their own; the rest go behind "More". Decided by the fitting pass. */ + inlineActionCount: number; - /** Called once the bar has been laid out at a width that fits, so it can be revealed and animated in. */ - onSettled: () => void; + /** Reports the width the bar wants at this action count, so the fitting pass can tell whether it fits. */ + onBarLayout: (width: number) => void; }; function BulkActionBarContent({ @@ -50,8 +50,8 @@ function BulkActionBarContent({ onClearSelection, onSubItemSelected, barRef, - availableWidth, - onSettled, + inlineActionCount, + onBarLayout, }: BulkActionBarContentProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -70,12 +70,6 @@ function BulkActionBarContent({ // how long their labels are in the viewer's language. So the bar is laid out at the largest count, and drops one // action at a time into "More" until it fits. `hasSettled` keeps it hidden until it does, so an overflowing first // pass is never shown. - const [fittedLayout, setFittedLayout] = useState<{availableWidth: number; actionCount: number; optionCount: number}>(); - - // A fitted count only holds for the width and action list it was measured against. Anything else — a container that - // grew, a selection whose actions changed — starts again from the largest count, so a wider bar fills up again. - const hasFittedLayout = fittedLayout?.availableWidth === availableWidth && fittedLayout?.optionCount === options.length; - const inlineActionCount = hasFittedLayout ? fittedLayout.actionCount : CONST.BULK_ACTION_BAR.MAX_INLINE_ACTIONS; const hasMoreMenu = options.length > inlineActionCount; const inlineOptions = hasMoreMenu ? options.slice(0, inlineActionCount) : options; @@ -97,21 +91,7 @@ function BulkActionBarContent({ { - const {width} = event.nativeEvent.layout; - if (availableWidth === undefined) { - return; - } - - // Shedding an action always makes the bar narrower, so this settles rather than oscillating. At zero - // inline actions only the count, "More" and the close button remain, which fits any usable width. - if (width > availableWidth && inlineActionCount > 0) { - setFittedLayout({availableWidth, actionCount: inlineActionCount - 1, optionCount: options.length}); - return; - } - - onSettled(); - }} + onLayout={(event) => onBarLayout(event.nativeEvent.layout.width)} > {/* Sized for a three-digit count so the bar keeps still as the selection grows, and so swapping the spinner for the count does not resize it either. */} @@ -201,11 +181,34 @@ function BulkActionBar({selectedCount, isSelectedCountLoading, optio // This layer spans the container, so laying it out measures the width the bar has to fit into. const [availableWidth, setAvailableWidth] = useState(); - const [settledWidth, setSettledWidth] = useState(); + const [inlineActionCount, setInlineActionCount] = useState(CONST.BULK_ACTION_BAR.MAX_INLINE_ACTIONS); + const [fitKey, setFitKey] = useState(); + + // A measurement is tagged with the action count it was taken at, so it can be recognised as stale rather than + // discarded. `onLayout` only fires when a view's size changes, so a measurement thrown away while the bar happens + // to stay the same size is never replaced — which would leave the bar hidden for good. + const [measurement, setMeasurement] = useState<{width: number; actionCount: number}>(); + + // The fit is derived rather than decided in a layout handler: the bar and the layer around it are laid out in + // whichever order the platform chooses, so this has to re-run whenever either measurement lands. + const currentFitKey = `${availableWidth}|${options.length}`; + const isMeasurementCurrent = measurement?.actionCount === inlineActionCount; + if (currentFitKey !== fitKey) { + // The space or the action list changed, so start again from the largest count: a container that grew can fill + // back up, and a shrunken one sheds again from the top. Any measurement taken at that count still applies. + setFitKey(currentFitKey); + setInlineActionCount(CONST.BULK_ACTION_BAR.MAX_INLINE_ACTIONS); + } else if (isMeasurementCurrent && availableWidth !== undefined && measurement.width > availableWidth && inlineActionCount > 0) { + // Shedding an action always makes the bar narrower, so this settles rather than oscillating. Changing the count + // changes the bar's size, so a fresh measurement is guaranteed to follow. + setInlineActionCount(inlineActionCount - 1); + } - // A change in available width sends the bar back to the largest number of actions, so it has to prove it fits again - // before being shown — hence comparing against the width it last settled at rather than keeping a plain flag. - const hasSettled = availableWidth !== undefined && settledWidth === availableWidth; + // Hidden only while the fitting pass has something left to do: a measurement that does not fit and an action still + // to shed, or a stale measurement about to be replaced. Everything else is shown — in particular, a layer that has + // not reported a width yet, so that a measurement which never arrives cannot leave the bar permanently invisible. + const isAwaitingFit = availableWidth !== undefined && (!isMeasurementCurrent || (measurement.width > availableWidth && inlineActionCount > 0)); + const hasSettled = !isAwaitingFit; // The bar appears where nothing was before, so it springs up into place to draw the eye there, the same way the // report's floating message counter animates itself in. It waits for the fitting pass so the motion is only ever @@ -246,8 +249,8 @@ function BulkActionBar({selectedCount, isSelectedCountLoading, optio onClearSelection={onClearSelection} onSubItemSelected={onSubItemSelected} barRef={barRef} - availableWidth={availableWidth} - onSettled={() => setSettledWidth(availableWidth)} + inlineActionCount={inlineActionCount} + onBarLayout={(width) => setMeasurement({width, actionCount: inlineActionCount})} /> From a455bffdff71b432c0ceaa787adcf3c771c9c0a7 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 7 Sep 2026 16:04:49 +0530 Subject: [PATCH 12/28] fix: start the bulk action bar at its breakpoint's button count Signed-off-by: krishna2323 --- src/CONST/index.ts | 9 +++++ src/components/BulkActionBar/index.tsx | 47 +++++++++++++++----------- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index b99f41a51ef0..2e02a928cb39 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6425,6 +6425,15 @@ const CONST = { /** How many of a selection's actions get a button of their own in the bar before the rest move behind "More". */ MAX_INLINE_ACTIONS: 3, + /** The same at the in-between widths, which do not have the room for a third button. */ + MAX_INLINE_ACTIONS_MEDIUM_SCREEN: 2, + + /** + * Room left between the bar and the edges of the container it floats in. The bar sheds an action before it gets + * this close to an edge, rather than only once it has already touched one. + */ + EDGE_MARGIN: 24, + /** How far the bar floats above the bottom of the container it is rendered in. */ BOTTOM_OFFSET: 20, diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index 72be72045ed6..1de63438acfc 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -12,6 +12,7 @@ import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import usePopoverPosition from '@hooks/usePopoverPosition'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -63,14 +64,8 @@ function BulkActionBarContent({ const [isMoreMenuVisible, setIsMoreMenuVisible] = useState(false); const [moreMenuAnchorPosition, setMoreMenuAnchorPosition] = useState(defaultPopoverAnchorPosition); - // Only the highest-priority actions are given a button of their own; the rest stay reachable behind "More". - // - // How many that is has to come from the width the bar actually has, not from a screen breakpoint: the bar sits in a - // content pane whose width depends on the sidebar and the layout around it, and the buttons' own widths depend on - // how long their labels are in the viewer's language. So the bar is laid out at the largest count, and drops one - // action at a time into "More" until it fits. `hasSettled` keeps it hidden until it does, so an overflowing first - // pass is never shown. - + // Only the highest-priority actions are given a button of their own; the rest stay reachable behind "More". How + // many that is comes from the bar's own fitting pass — see `BulkActionBar` below. const hasMoreMenu = options.length > inlineActionCount; const inlineOptions = hasMoreMenu ? options.slice(0, inlineActionCount) : options; const moreOptions = hasMoreMenu ? options.slice(inlineActionCount) : []; @@ -179,36 +174,48 @@ function BulkActionBar({selectedCount, isSelectedCountLoading, optio const invertedTheme = useInvertedThemePreference(); const isReducedMotionEnabled = Accessibility.useReducedMotion(); + const {isMediumScreenWidth} = useResponsiveLayout(); + + // The number of buttons to start from, which is what the bar shows on a container roomy enough for them. The + // in-between widths start one lower, so they land on their usual layout without having to be measured out of a + // wider one first. + const startingActionCount = isMediumScreenWidth ? CONST.BULK_ACTION_BAR.MAX_INLINE_ACTIONS_MEDIUM_SCREEN : CONST.BULK_ACTION_BAR.MAX_INLINE_ACTIONS; + // This layer spans the container, so laying it out measures the width the bar has to fit into. const [availableWidth, setAvailableWidth] = useState(); - const [inlineActionCount, setInlineActionCount] = useState(CONST.BULK_ACTION_BAR.MAX_INLINE_ACTIONS); + const [inlineActionCount, setInlineActionCount] = useState(startingActionCount); const [fitKey, setFitKey] = useState(); - // A measurement is tagged with the action count it was taken at, so it can be recognised as stale rather than + // A measurement is tagged with the action count it was taken at, so it can be recognized as stale rather than // discarded. `onLayout` only fires when a view's size changes, so a measurement thrown away while the bar happens // to stay the same size is never replaced — which would leave the bar hidden for good. const [measurement, setMeasurement] = useState<{width: number; actionCount: number}>(); + // The width the bar has to stay within, keeping it clear of the container's edges rather than flush against them. + // // The fit is derived rather than decided in a layout handler: the bar and the layer around it are laid out in // whichever order the platform chooses, so this has to re-run whenever either measurement lands. - const currentFitKey = `${availableWidth}|${options.length}`; + const widthBudget = availableWidth === undefined ? undefined : availableWidth - CONST.BULK_ACTION_BAR.EDGE_MARGIN; + + const currentFitKey = `${availableWidth}|${options.length}|${startingActionCount}`; const isMeasurementCurrent = measurement?.actionCount === inlineActionCount; + const isOverflowing = isMeasurementCurrent && widthBudget !== undefined && measurement.width > widthBudget; + if (currentFitKey !== fitKey) { - // The space or the action list changed, so start again from the largest count: a container that grew can fill - // back up, and a shrunken one sheds again from the top. Any measurement taken at that count still applies. + // The space, the action list or the breakpoint changed, so start again from the top: a container that grew can + // fill back up, and a shrunken one sheds again. Any measurement taken at that count still applies. setFitKey(currentFitKey); - setInlineActionCount(CONST.BULK_ACTION_BAR.MAX_INLINE_ACTIONS); - } else if (isMeasurementCurrent && availableWidth !== undefined && measurement.width > availableWidth && inlineActionCount > 0) { + setInlineActionCount(startingActionCount); + } else if (isOverflowing && inlineActionCount > 0) { // Shedding an action always makes the bar narrower, so this settles rather than oscillating. Changing the count // changes the bar's size, so a fresh measurement is guaranteed to follow. setInlineActionCount(inlineActionCount - 1); } - // Hidden only while the fitting pass has something left to do: a measurement that does not fit and an action still - // to shed, or a stale measurement about to be replaced. Everything else is shown — in particular, a layer that has - // not reported a width yet, so that a measurement which never arrives cannot leave the bar permanently invisible. - const isAwaitingFit = availableWidth !== undefined && (!isMeasurementCurrent || (measurement.width > availableWidth && inlineActionCount > 0)); - const hasSettled = !isAwaitingFit; + // Shown straight away at the starting count, so a container with the room for it needs no measuring pass to appear. + // It is only hidden once a measurement says it overflows and there is still an action to shed, which is the one case + // where what is on screen is about to be replaced. + const hasSettled = !(isOverflowing && inlineActionCount > 0); // The bar appears where nothing was before, so it springs up into place to draw the eye there, the same way the // report's floating message counter animates itself in. It waits for the fitting pass so the motion is only ever From 38e70752ffca841625c9c499c329f98da9a20e66 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 7 Sep 2026 16:13:52 +0530 Subject: [PATCH 13/28] fix: stop the bulk action bar flashing wide while it is resized Signed-off-by: krishna2323 --- src/components/BulkActionBar/index.tsx | 55 ++++++++++++++------------ 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index 1de63438acfc..b29aa0a29cff 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -183,39 +183,42 @@ function BulkActionBar({selectedCount, isSelectedCountLoading, optio // This layer spans the container, so laying it out measures the width the bar has to fit into. const [availableWidth, setAvailableWidth] = useState(); - const [inlineActionCount, setInlineActionCount] = useState(startingActionCount); + + // The width the bar took at each action count it has been laid out at. The bar is sized by its contents, so a given + // count always comes out the same width whatever the container is doing — which makes these worth keeping. Once a + // count has been measured, resizing picks the right one outright instead of laying the bar out to find it again. + const [measuredWidths, setMeasuredWidths] = useState>({}); const [fitKey, setFitKey] = useState(); - // A measurement is tagged with the action count it was taken at, so it can be recognized as stale rather than - // discarded. `onLayout` only fires when a view's size changes, so a measurement thrown away while the bar happens - // to stay the same size is never replaced — which would leave the bar hidden for good. - const [measurement, setMeasurement] = useState<{width: number; actionCount: number}>(); + // The measurements describe one particular set of buttons, so they are dropped when that set changes. The + // container's width is deliberately not part of this: it changes on every frame of a resize, and throwing the + // measurements away that often is what makes the bar lay itself out wide before shedding back down. + const currentFitKey = `${options.map((option) => option.text).join('|')}|${startingActionCount}`; + + if (currentFitKey !== fitKey) { + setFitKey(currentFitKey); + setMeasuredWidths({}); + } // The width the bar has to stay within, keeping it clear of the container's edges rather than flush against them. - // - // The fit is derived rather than decided in a layout handler: the bar and the layer around it are laid out in - // whichever order the platform chooses, so this has to re-run whenever either measurement lands. const widthBudget = availableWidth === undefined ? undefined : availableWidth - CONST.BULK_ACTION_BAR.EDGE_MARGIN; - const currentFitKey = `${availableWidth}|${options.length}|${startingActionCount}`; - const isMeasurementCurrent = measurement?.actionCount === inlineActionCount; - const isOverflowing = isMeasurementCurrent && widthBudget !== undefined && measurement.width > widthBudget; - - if (currentFitKey !== fitKey) { - // The space, the action list or the breakpoint changed, so start again from the top: a container that grew can - // fill back up, and a shrunken one sheds again. Any measurement taken at that count still applies. - setFitKey(currentFitKey); - setInlineActionCount(startingActionCount); - } else if (isOverflowing && inlineActionCount > 0) { - // Shedding an action always makes the bar narrower, so this settles rather than oscillating. Changing the count - // changes the bar's size, so a fresh measurement is guaranteed to follow. - setInlineActionCount(inlineActionCount - 1); + // Shed from the starting count until the bar is known to fit. A count that has never been measured is assumed to + // fit, so a roomy container draws the bar at full width immediately rather than measuring its way up to it. Since + // dropping an action only ever makes the bar narrower, this walks in one direction and settles. + let inlineActionCount = startingActionCount; + while (inlineActionCount > 0 && widthBudget !== undefined && (measuredWidths[inlineActionCount] ?? 0) > widthBudget) { + inlineActionCount -= 1; } - // Shown straight away at the starting count, so a container with the room for it needs no measuring pass to appear. - // It is only hidden once a measurement says it overflows and there is still an action to shed, which is the one case - // where what is on screen is about to be replaced. - const hasSettled = !(isOverflowing && inlineActionCount > 0); + // Laying out a count for the first time is a guess that may not survive its own measurement, so it is kept hidden + // until it lands — otherwise a bar that turns out to be too wide is briefly on screen at that width. The exception + // is the very first layout of all, which shows immediately: there is nothing on screen yet for a correction to + // disturb, and waiting for a measurement there is what would make the bar late to appear. + // + // A hidden layout is always resolved: changing the count changes the bar's width, so its `onLayout` is certain to + // follow, and every count below one already measured has itself been measured on the way down. + const hasSettled = measuredWidths[inlineActionCount] !== undefined || Object.keys(measuredWidths).length === 0; // The bar appears where nothing was before, so it springs up into place to draw the eye there, the same way the // report's floating message counter animates itself in. It waits for the fitting pass so the motion is only ever @@ -257,7 +260,7 @@ function BulkActionBar({selectedCount, isSelectedCountLoading, optio onSubItemSelected={onSubItemSelected} barRef={barRef} inlineActionCount={inlineActionCount} - onBarLayout={(width) => setMeasurement({width, actionCount: inlineActionCount})} + onBarLayout={(width) => setMeasuredWidths((widths) => (widths[inlineActionCount] === width ? widths : {...widths, [inlineActionCount]: width}))} /> From b5f6f4a3510abeda1c5ba8e314098922b2769ecd Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 8 Sep 2026 12:19:36 +0530 Subject: [PATCH 14/28] update filter bar padding. Signed-off-by: krishna2323 --- src/styles/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/styles/index.ts b/src/styles/index.ts index b5ccf66466bb..df154d81b58d 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5425,9 +5425,9 @@ const staticStyles = (theme: ThemeColors) => flexDirection: 'row', alignItems: 'center', gap: 8, - paddingVertical: 14, + paddingVertical: 20, paddingLeft: 20, - paddingRight: 12, + paddingRight: 16, borderRadius: variables.componentBorderRadiusLarge, backgroundColor: theme.appBG, boxShadow: theme.shadow, From b31edbe2c80712ff5b04c9c77ca38948ef9cd55e Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 8 Sep 2026 21:22:54 +0530 Subject: [PATCH 15/28] feat: render the bulk action bar's menus in the app's own theme Signed-off-by: krishna2323 --- .../BulkActionBar/BulkActionBarButton.tsx | 35 ++++++++------- .../BulkActionBar/BulkActionBarMenuTheme.tsx | 28 ++++++++++++ src/components/BulkActionBar/index.tsx | 43 ++++++++++--------- 3 files changed, 70 insertions(+), 36 deletions(-) create mode 100644 src/components/BulkActionBar/BulkActionBarMenuTheme.tsx diff --git a/src/components/BulkActionBar/BulkActionBarButton.tsx b/src/components/BulkActionBar/BulkActionBarButton.tsx index a5df5640cec4..fc0d2b89886b 100644 --- a/src/components/BulkActionBar/BulkActionBarButton.tsx +++ b/src/components/BulkActionBar/BulkActionBarButton.tsx @@ -14,6 +14,7 @@ import React, {useEffect, useRef, useState} from 'react'; import type {BulkActionBarButtonProps} from './types'; +import BulkActionBarMenuTheme from './BulkActionBarMenuTheme'; import {defaultPopoverAnchorPosition, SUB_MENU_ANCHOR_ALIGNMENT} from './popoverPosition'; /** @@ -67,22 +68,24 @@ function BulkActionBarButton({option, onSubItemSelected}: BulkAction {hasSubMenu && } {hasSubMenu && !!anchorPosition && ( - ({...subItem, shouldCallAfterModalHide: true}))} - onClose={() => setIsMenuVisible(false)} - onItemSelected={(selectedSubItem, index, event) => { - onSubItemSelected?.(selectedSubItem, index, event); - if (selectedSubItem.shouldCloseModalOnSelect === false) { - return; - } - setIsMenuVisible(false); - }} - shouldUseScrollView={subMenuItems.length >= CONST.DROPDOWN_SCROLL_THRESHOLD} - /> + + ({...subItem, shouldCallAfterModalHide: true}))} + onClose={() => setIsMenuVisible(false)} + onItemSelected={(selectedSubItem, index, event) => { + onSubItemSelected?.(selectedSubItem, index, event); + if (selectedSubItem.shouldCloseModalOnSelect === false) { + return; + } + setIsMenuVisible(false); + }} + shouldUseScrollView={subMenuItems.length >= CONST.DROPDOWN_SCROLL_THRESHOLD} + /> + )} ); diff --git a/src/components/BulkActionBar/BulkActionBarMenuTheme.tsx b/src/components/BulkActionBar/BulkActionBarMenuTheme.tsx new file mode 100644 index 000000000000..8eee0b4ea9c5 --- /dev/null +++ b/src/components/BulkActionBar/BulkActionBarMenuTheme.tsx @@ -0,0 +1,28 @@ +import ThemeProvider from '@components/ThemeProvider'; +import ThemeStylesProvider from '@components/ThemeStylesContextProvider'; + +import useThemePreference from '@hooks/useThemePreference'; + +import React from 'react'; + +/** + * Puts the app's own theme back for the menus that open off the bar, which would otherwise inherit the inverted theme + * the bar itself renders under. The bar stays inverted so it stands out against the table; its menus read as the + * popovers they are everywhere else in the app. + * + * `useThemePreference` reads the preference rather than the surrounding context, so this reports the page's theme even + * from inside the bar's inverted subtree. + */ +function BulkActionBarMenuTheme({children}: React.PropsWithChildren) { + const pageThemePreference = useThemePreference(); + + return ( + + {children} + + ); +} + +BulkActionBarMenuTheme.displayName = 'BulkActionBarMenuTheme'; + +export default BulkActionBarMenuTheme; diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index b29aa0a29cff..25eadaed8880 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -28,6 +28,7 @@ import Animated, {useAnimatedStyle, useSharedValue, withSpring} from 'react-nati import type {BulkActionBarProps} from './types'; import BulkActionBarButton from './BulkActionBarButton'; +import BulkActionBarMenuTheme from './BulkActionBarMenuTheme'; import {defaultPopoverAnchorPosition, MORE_MENU_ANCHOR_ALIGNMENT} from './popoverPosition'; /** @@ -121,26 +122,28 @@ function BulkActionBarContent({ {!!moreMenuAnchorPosition && ( - setIsMoreMenuVisible(false)} - onItemSelected={(selectedItem, index, event) => { - onSubItemSelected?.(selectedItem, index, event); - if (selectedItem.shouldCloseModalOnSelect === false) { - return; - } - setIsMoreMenuVisible(false); - }} - shouldUseScrollView={moreOptions.length >= CONST.DROPDOWN_SCROLL_THRESHOLD} - menuItems={moreOptions.map((option) => ({ - ...option, - shouldCallAfterModalHide: true, - subMenuItems: option.subMenuItems?.map((subItem) => ({...subItem, shouldCallAfterModalHide: true})), - }))} - /> + + setIsMoreMenuVisible(false)} + onItemSelected={(selectedItem, index, event) => { + onSubItemSelected?.(selectedItem, index, event); + if (selectedItem.shouldCloseModalOnSelect === false) { + return; + } + setIsMoreMenuVisible(false); + }} + shouldUseScrollView={moreOptions.length >= CONST.DROPDOWN_SCROLL_THRESHOLD} + menuItems={moreOptions.map((option) => ({ + ...option, + shouldCallAfterModalHide: true, + subMenuItems: option.subMenuItems?.map((subItem) => ({...subItem, shouldCallAfterModalHide: true})), + }))} + /> + )} )} From ed8ab51a8f743bdafe68ecb419eacf1d4790cfe5 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 8 Sep 2026 22:37:34 +0530 Subject: [PATCH 16/28] fix: follow the comment-style and async-effect standards in the bulk action bar Signed-off-by: krishna2323 --- src/CONST/index.ts | 4 +-- .../BulkActionBar/BulkActionBarButton.tsx | 18 +++++++++-- .../BulkActionBar/BulkActionBarMenuTheme.tsx | 2 +- src/components/BulkActionBar/index.tsx | 30 ++++++++++++++----- src/components/BulkActionBar/types.ts | 2 +- src/hooks/useInvertedThemePreference.ts | 2 +- src/styles/index.ts | 2 +- 7 files changed, 44 insertions(+), 16 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 5accae7fde97..7b2113cb68f6 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6508,7 +6508,7 @@ const CONST = { /** * The bar's height: a small (28px) button plus its 14px of padding above and below. The bar sizes itself from - * its contents, so this is only used to reserve the space it floats over — keep it in step with `bulkActionBar`. + * its contents, so this is only used to reserve the space it floats over. Keep it in step with `bulkActionBar`. */ HEIGHT: 56, @@ -6520,7 +6520,7 @@ const CONST = { /** * Spring the bar settles into place with. Overdamped, matching the canvas gestures, so it arrives quickly and - * without a bounce — the default spring wobbles noticeably over this short a travel. + * without a bounce. The default spring wobbles noticeably over this short a travel. */ SLIDE_IN_SPRING: {mass: 1, stiffness: 1000, damping: 500}, }, diff --git a/src/components/BulkActionBar/BulkActionBarButton.tsx b/src/components/BulkActionBar/BulkActionBarButton.tsx index fc0d2b89886b..082e93211558 100644 --- a/src/components/BulkActionBar/BulkActionBarButton.tsx +++ b/src/components/BulkActionBar/BulkActionBarButton.tsx @@ -19,7 +19,7 @@ import {defaultPopoverAnchorPosition, SUB_MENU_ANCHOR_ALIGNMENT} from './popover /** * A single action in the bulk action bar. An action that carries `subMenuItems` renders a dropdown caret and opens - * those items in a menu above itself; every other action runs `onSelected` directly. + * those items in a menu above itself. Every other action runs `onSelected` directly. */ function BulkActionBarButton({option, onSubItemSelected}: BulkActionBarButtonProps) { const theme = useTheme(); @@ -38,7 +38,21 @@ function BulkActionBarButton({option, onSubItemSelected}: BulkAction return; } - calculatePopoverPosition(anchorRef, SUB_MENU_ANCHOR_ALIGNMENT).then(setAnchorPosition); + // The position is measured asynchronously, so a measurement still in flight when the menu is reopened would + // otherwise land after the newer one and place the menu against the button's previous position. + let ignore = false; + + calculatePopoverPosition(anchorRef, SUB_MENU_ANCHOR_ALIGNMENT).then((position) => { + if (ignore) { + return; + } + + setAnchorPosition(position); + }); + + return () => { + ignore = true; + }; }, [isMenuVisible, calculatePopoverPosition]); return ( diff --git a/src/components/BulkActionBar/BulkActionBarMenuTheme.tsx b/src/components/BulkActionBar/BulkActionBarMenuTheme.tsx index 8eee0b4ea9c5..dc3e0ef335a3 100644 --- a/src/components/BulkActionBar/BulkActionBarMenuTheme.tsx +++ b/src/components/BulkActionBar/BulkActionBarMenuTheme.tsx @@ -7,7 +7,7 @@ import React from 'react'; /** * Puts the app's own theme back for the menus that open off the bar, which would otherwise inherit the inverted theme - * the bar itself renders under. The bar stays inverted so it stands out against the table; its menus read as the + * the bar itself renders under. The bar stays inverted so it stands out against the table. Its menus read as the * popovers they are everywhere else in the app. * * `useThemePreference` reads the preference rather than the surrounding context, so this reports the page's theme even diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index 25eadaed8880..a61706e05af3 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -33,12 +33,12 @@ import {defaultPopoverAnchorPosition, MORE_MENU_ANCHOR_ALIGNMENT} from './popove /** * The bar's contents. Everything here takes its colors from the theme it is rendered under, which `BulkActionBar` - * inverts — so the surface, the buttons and the "More" menu all read as one layer without any of them being styled + * inverts, so the surface, the buttons and the "More" menu all read as one layer without any of them being styled * specially. Split out from `BulkActionBar` because these styles have to resolve from the inverted theme, while the * positioning layer around it belongs to the page's own. */ type BulkActionBarContentProps = Omit, 'style'> & { - /** How many actions to give a button of their own; the rest go behind "More". Decided by the fitting pass. */ + /** How many actions to give a button of their own. The rest go behind "More". Decided by the fitting pass. */ inlineActionCount: number; /** Reports the width the bar wants at this action count, so the fitting pass can tell whether it fits. */ @@ -65,8 +65,8 @@ function BulkActionBarContent({ const [isMoreMenuVisible, setIsMoreMenuVisible] = useState(false); const [moreMenuAnchorPosition, setMoreMenuAnchorPosition] = useState(defaultPopoverAnchorPosition); - // Only the highest-priority actions are given a button of their own; the rest stay reachable behind "More". How - // many that is comes from the bar's own fitting pass — see `BulkActionBar` below. + // Only the highest-priority actions are given a button of their own. The rest stay reachable behind "More". How + // many that is comes from the bar's own fitting pass. See `BulkActionBar` below. const hasMoreMenu = options.length > inlineActionCount; const inlineOptions = hasMoreMenu ? options.slice(0, inlineActionCount) : options; const moreOptions = hasMoreMenu ? options.slice(inlineActionCount) : []; @@ -80,7 +80,21 @@ function BulkActionBarContent({ return; } - calculatePopoverPosition(moreAnchorRef, MORE_MENU_ANCHOR_ALIGNMENT).then(setMoreMenuAnchorPosition); + // The position is measured asynchronously, so a measurement still in flight when the menu is reopened would + // otherwise land after the newer one and place the menu against the bar's previous position. + let ignore = false; + + calculatePopoverPosition(moreAnchorRef, MORE_MENU_ANCHOR_ALIGNMENT).then((position) => { + if (ignore) { + return; + } + + setMoreMenuAnchorPosition(position); + }); + + return () => { + ignore = true; + }; }, [isMoreMenuVisible, calculatePopoverPosition]); return ( @@ -166,7 +180,7 @@ function BulkActionBarContent({ /** * A floating bar of bulk actions for the current selection. It floats over the bottom of the container it is rendered - * in, so render it as the last child of the view the table fills — pass a `bottom` through `style` to clear anything + * in, so render it as the last child of the view the table fills. Pass a `bottom` through `style` to clear anything * else pinned to that container, such as a totals footer. * * The bar renders under the inverted theme so that it stands out against the table behind it. That also inverts its @@ -188,7 +202,7 @@ function BulkActionBar({selectedCount, isSelectedCountLoading, optio const [availableWidth, setAvailableWidth] = useState(); // The width the bar took at each action count it has been laid out at. The bar is sized by its contents, so a given - // count always comes out the same width whatever the container is doing — which makes these worth keeping. Once a + // count always comes out the same width whatever the container is doing, which makes these worth keeping. Once a // count has been measured, resizing picks the right one outright instead of laying the bar out to find it again. const [measuredWidths, setMeasuredWidths] = useState>({}); const [fitKey, setFitKey] = useState(); @@ -215,7 +229,7 @@ function BulkActionBar({selectedCount, isSelectedCountLoading, optio } // Laying out a count for the first time is a guess that may not survive its own measurement, so it is kept hidden - // until it lands — otherwise a bar that turns out to be too wide is briefly on screen at that width. The exception + // until it lands. Otherwise a bar that turns out to be too wide is briefly on screen at that width. The exception // is the very first layout of all, which shows immediately: there is nothing on screen yet for a correction to // disturb, and waiting for a measurement there is what would make the bar late to appear. // diff --git a/src/components/BulkActionBar/types.ts b/src/components/BulkActionBar/types.ts index 5efe58014e75..c8167ad29d64 100644 --- a/src/components/BulkActionBar/types.ts +++ b/src/components/BulkActionBar/types.ts @@ -15,7 +15,7 @@ type BulkActionBarProps = { options: Array>; /** - * Whether `selectedCount` is still being resolved — a "select all matching" selection only learns its real size + * Whether `selectedCount` is still being resolved. A "select all matching" selection only learns its real size * once the server reports it. The bar shows a spinner in place of the count while this is true, rather than a * number that is about to change. */ diff --git a/src/hooks/useInvertedThemePreference.ts b/src/hooks/useInvertedThemePreference.ts index 68212b7dd13c..8e1d2e2c63fd 100644 --- a/src/hooks/useInvertedThemePreference.ts +++ b/src/hooks/useInvertedThemePreference.ts @@ -15,7 +15,7 @@ const INVERTED_THEMES: Record` to render a subtree inverted, which colors everything inside it — + * Pass the result to `` to render a subtree inverted, which colors everything inside it, * including popovers, which read the theme themselves and so cannot be inverted with style props. */ function useInvertedThemePreference(): ThemePreferenceWithoutSystem { diff --git a/src/styles/index.ts b/src/styles/index.ts index 08e38c326671..ae59b9164591 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5451,7 +5451,7 @@ const staticStyles = (theme: ThemeColors) => paddingBottom: CONST.BULK_ACTION_BAR.HEIGHT + CONST.BULK_ACTION_BAR.BOTTOM_OFFSET + CONST.BULK_ACTION_BAR.LIST_GAP, }, - // Wide enough for a three-digit count, so the bar does not resize as the selection grows past 9 or 99 — a + // Wide enough for a three-digit count, so the bar does not resize as the selection grows past 9 or 99. A // selection can cover far more rows than are on screen. Also keeps the width steady while the count loads. bulkActionBarCount: { minWidth: 88, From 7831630cea42ecdb5174f9a22207754763661822 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 8 Sep 2026 22:48:02 +0530 Subject: [PATCH 17/28] fix: scroll long bulk action submenus and reserve the bar's real height Signed-off-by: krishna2323 --- src/CONST/index.ts | 6 ------ src/components/BulkActionBar/index.tsx | 3 ++- src/styles/index.ts | 9 +++++++-- src/styles/variables.ts | 1 + 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 7b2113cb68f6..2d06c49ab65f 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6506,12 +6506,6 @@ const CONST = { /** How far the bar floats above the bottom of the container it is rendered in. */ BOTTOM_OFFSET: 20, - /** - * The bar's height: a small (28px) button plus its 14px of padding above and below. The bar sizes itself from - * its contents, so this is only used to reserve the space it floats over. Keep it in step with `bulkActionBar`. - */ - HEIGHT: 56, - /** Breathing room left between the bar and the last row of the list it floats over. */ LIST_GAP: 12, diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index a61706e05af3..3eb0574c3466 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -17,6 +17,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import Accessibility from '@libs/Accessibility'; +import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; import CONST from '@src/CONST'; import type {AnchorPosition} from '@src/styles'; @@ -150,7 +151,7 @@ function BulkActionBarContent({ } setIsMoreMenuVisible(false); }} - shouldUseScrollView={moreOptions.length >= CONST.DROPDOWN_SCROLL_THRESHOLD} + shouldUseScrollView={shouldPopoverUseScrollView(moreOptions)} menuItems={moreOptions.map((option) => ({ ...option, shouldCallAfterModalHide: true, diff --git a/src/styles/index.ts b/src/styles/index.ts index ae59b9164591..f2dd3bff3155 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -127,6 +127,11 @@ const touchCalloutNone: Pick = isMobileSafari() // to prevent vertical text offset in Safari for badges, new lineHeight values have been added const lineHeightBadge: Pick = isSafari() ? {lineHeight: variables.lineHeightXSmall} : {lineHeight: variables.lineHeightNormal}; +// The bulk action bar's height, which the space reserved for it at the end of a list has to match. Derived from the +// bar's own padding and its tallest item, a small button, rather than written down a second time: a written height +// silently stops matching when either of those changes, and it cannot follow `componentSizeSmall` across pixel ratios. +const bulkActionBarHeight = variables.componentSizeSmall + variables.bulkActionBarPaddingVertical * 2; + const picker = (theme: ThemeColors) => ({ backgroundColor: theme.transparent, @@ -5436,7 +5441,7 @@ const staticStyles = (theme: ThemeColors) => flexDirection: 'row', alignItems: 'center', gap: 8, - paddingVertical: 20, + paddingVertical: variables.bulkActionBarPaddingVertical, paddingLeft: 20, paddingRight: 16, borderRadius: variables.componentBorderRadiusLarge, @@ -5448,7 +5453,7 @@ const staticStyles = (theme: ThemeColors) => // scrolled clear of it. Applied to the list's content rather than its container: content grows below the // viewport, so the rows on screen stay where they are when a selection shows or hides the bar. bulkActionBarListSpacing: { - paddingBottom: CONST.BULK_ACTION_BAR.HEIGHT + CONST.BULK_ACTION_BAR.BOTTOM_OFFSET + CONST.BULK_ACTION_BAR.LIST_GAP, + paddingBottom: bulkActionBarHeight + CONST.BULK_ACTION_BAR.BOTTOM_OFFSET + CONST.BULK_ACTION_BAR.LIST_GAP, }, // Wide enough for a three-digit count, so the bar does not resize as the selection grows past 9 or 99. A diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 92711a20f215..6e884389d658 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -141,6 +141,7 @@ export default { htmlTableChevronColumnWidth: 20, tableGroupRowPaddingVertical: 4, tableGroupRowHeight: 36, + bulkActionBarPaddingVertical: 20, tableCheckboxColumnWidth: 20, tableStatusColumnWidth: 56, tableTypeColumnWidth: 84, From ed3904249b4f8772c72b47518b2d6ede729e7888 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 9 Sep 2026 09:43:53 +0530 Subject: [PATCH 18/28] feat: drop the three dots from the bulk action bar's More button Signed-off-by: krishna2323 --- src/components/BulkActionBar/index.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index 3eb0574c3466..1b208ae037c7 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -59,7 +59,7 @@ function BulkActionBarContent({ const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['Close', 'DownArrow', 'ThreeDots', 'UpArrow']); + const icons = useMemoizedLazyExpensifyIcons(['Close', 'DownArrow', 'UpArrow']); const {calculatePopoverPosition} = usePopoverPosition(); const moreAnchorRef = useRef(null); @@ -129,10 +129,6 @@ function BulkActionBarContent({ accessibilityLabel={translate('common.more')} sentryLabel={CONST.SENTRY_LABEL.BULK_ACTION_BAR.MORE} > - {translate('common.more')} From 9f7d9dd964e56901074bd589fce454e2049bec0f Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 9 Sep 2026 22:30:43 +0530 Subject: [PATCH 19/28] fix: address review findings on the bulk action bar Signed-off-by: krishna2323 --- src/components/BulkActionBar/index.tsx | 82 +++++++++++++------ src/components/BulkActionBar/types.ts | 6 ++ .../Search/SearchBulkActionsButton.tsx | 1 + 3 files changed, 64 insertions(+), 25 deletions(-) diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index 1b208ae037c7..090afd8d8ec2 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -11,6 +11,7 @@ import useInvertedThemePreference from '@hooks/useInvertedThemePreference'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import usePopoverPosition from '@hooks/usePopoverPosition'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; @@ -20,6 +21,7 @@ import Accessibility from '@libs/Accessibility'; import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {AnchorPosition} from '@src/styles'; import React, {useEffect, useRef, useState} from 'react'; @@ -42,6 +44,9 @@ type BulkActionBarContentProps = Omit /** How many actions to give a button of their own. The rest go behind "More". Decided by the fitting pass. */ inlineActionCount: number; + /** Shown in place of the actions when the selection has none, explaining why there is nothing to press. */ + noticeText?: string; + /** Reports the width the bar wants at this action count, so the fitting pass can tell whether it fits. */ onBarLayout: (width: number) => void; }; @@ -50,6 +55,8 @@ function BulkActionBarContent({ selectedCount, isSelectedCountLoading, options, + noticeText, + menuHeaderText, onClearSelection, onSubItemSelected, barRef, @@ -72,9 +79,12 @@ function BulkActionBarContent({ const inlineOptions = hasMoreMenu ? options.slice(0, inlineActionCount) : options; const moreOptions = hasMoreMenu ? options.slice(inlineActionCount) : []; - // Esc dismisses the selection, as it does for this kind of bulk-select bar elsewhere. It sits below the default - // priority so that an open menu's own Esc handling closes the menu first rather than clearing the selection. - useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, onClearSelection, {priority: 1}); + // Esc dismisses the selection, as it does for this kind of bulk-select bar elsewhere, but only while nothing is + // open in front of the bar. A modal or popover dismisses itself on the key going back up, and shortcuts run on the + // way down, so a menu open over the bar cannot be given the keystroke first by ordering the handlers: Esc would + // clear the selection and take the bar away underneath the menu the viewer was backing out of. + const [modal] = useOnyx(ONYXKEYS.MODAL); + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, onClearSelection, {isActive: !modal?.willAlertModalBecomeVisible}); useEffect(() => { if (!moreAnchorRef.current || !isMoreMenuVisible) { @@ -113,6 +123,7 @@ function BulkActionBarContent({ {translate('workspace.common.selected', {count: selectedCount})} )} + {!!noticeText && {noticeText}} {inlineOptions.map((option) => ( ({ anchorRef={moreAnchorRef} anchorPosition={moreMenuAnchorPosition} anchorAlignment={MORE_MENU_ANCHOR_ALIGNMENT} + headerText={menuHeaderText} onClose={() => setIsMoreMenuVisible(false)} onItemSelected={(selectedItem, index, event) => { onSubItemSelected?.(selectedItem, index, event); @@ -183,7 +195,16 @@ function BulkActionBarContent({ * The bar renders under the inverted theme so that it stands out against the table behind it. That also inverts its * "More" menu, which reads the theme itself and could not be inverted through style props alone. */ -function BulkActionBar({selectedCount, isSelectedCountLoading, options, onClearSelection, onSubItemSelected, barRef, style}: BulkActionBarProps) { +function BulkActionBar({ + selectedCount, + isSelectedCountLoading, + options: allOptions, + menuHeaderText, + onClearSelection, + onSubItemSelected, + barRef, + style, +}: BulkActionBarProps) { const styles = useThemeStyles(); const invertedTheme = useInvertedThemePreference(); const isReducedMotionEnabled = Accessibility.useReducedMotion(); @@ -195,24 +216,28 @@ function BulkActionBar({selectedCount, isSelectedCountLoading, optio // wider one first. const startingActionCount = isMediumScreenWidth ? CONST.BULK_ACTION_BAR.MAX_INLINE_ACTIONS_MEDIUM_SCREEN : CONST.BULK_ACTION_BAR.MAX_INLINE_ACTIONS; + // A selection with nothing to act on is described by a non-interactive option saying so, which the menus this bar + // replaces know to draw as a plain row. A button is not that: it would look pressable and do nothing, and the + // fitting pass could hide the one thing explaining the absent actions behind "More". Keep those out of the actions + // and let the bar say it plainly instead. + const options = allOptions.filter((option) => option.interactive !== false); + const noticeText = allOptions.find((option) => option.interactive === false)?.text; + // This layer spans the container, so laying it out measures the width the bar has to fit into. const [availableWidth, setAvailableWidth] = useState(); - // The width the bar took at each action count it has been laid out at. The bar is sized by its contents, so a given - // count always comes out the same width whatever the container is doing, which makes these worth keeping. Once a - // count has been measured, resizing picks the right one outright instead of laying the bar out to find it again. - const [measuredWidths, setMeasuredWidths] = useState>({}); - const [fitKey, setFitKey] = useState(); - - // The measurements describe one particular set of buttons, so they are dropped when that set changes. The - // container's width is deliberately not part of this: it changes on every frame of a resize, and throwing the - // measurements away that often is what makes the bar lay itself out wide before shedding back down. - const currentFitKey = `${options.map((option) => option.text).join('|')}|${startingActionCount}`; + // The width the bar took at each layout it has been through, keyed by the buttons it was showing at the time. The + // bar is sized by its contents, so a given set of buttons always comes out the same width whatever the container is + // doing. Keeping them all means a layout the bar has already been through is recognized rather than measured again. + // + // The container's width is deliberately not part of the key: it changes on every frame of a resize, and a key that + // moved with it would throw the measurements away that often, which is what made the bar lay itself out at full + // width before shedding back down. The action labels are part of it because they decide how wide each button is, + // and the selection changes them as often as it changes the actions themselves. + const [measuredWidths, setMeasuredWidths] = useState>({}); - if (currentFitKey !== fitKey) { - setFitKey(currentFitKey); - setMeasuredWidths({}); - } + const actionSetKey = `${options.map((option) => option.text).join('|')}|${startingActionCount}`; + const getMeasurementKey = (actionCount: number) => `${actionSetKey}|${actionCount}`; // The width the bar has to stay within, keeping it clear of the container's edges rather than flush against them. const widthBudget = availableWidth === undefined ? undefined : availableWidth - CONST.BULK_ACTION_BAR.EDGE_MARGIN; @@ -221,18 +246,18 @@ function BulkActionBar({selectedCount, isSelectedCountLoading, optio // fit, so a roomy container draws the bar at full width immediately rather than measuring its way up to it. Since // dropping an action only ever makes the bar narrower, this walks in one direction and settles. let inlineActionCount = startingActionCount; - while (inlineActionCount > 0 && widthBudget !== undefined && (measuredWidths[inlineActionCount] ?? 0) > widthBudget) { + while (inlineActionCount > 0 && widthBudget !== undefined && (measuredWidths[getMeasurementKey(inlineActionCount)] ?? 0) > widthBudget) { inlineActionCount -= 1; } - // Laying out a count for the first time is a guess that may not survive its own measurement, so it is kept hidden - // until it lands. Otherwise a bar that turns out to be too wide is briefly on screen at that width. The exception - // is the very first layout of all, which shows immediately: there is nothing on screen yet for a correction to - // disturb, and waiting for a measurement there is what would make the bar late to appear. + // Laying out a set of buttons for the first time is a guess that may not survive its own measurement, so it is kept + // hidden until it lands. Otherwise a bar that turns out to be too wide is briefly on screen at that width. The + // exception is the very first layout of all, which shows immediately: there is nothing on screen yet for a + // correction to disturb, and waiting for a measurement there is what would make the bar late to appear. // // A hidden layout is always resolved: changing the count changes the bar's width, so its `onLayout` is certain to // follow, and every count below one already measured has itself been measured on the way down. - const hasSettled = measuredWidths[inlineActionCount] !== undefined || Object.keys(measuredWidths).length === 0; + const hasSettled = measuredWidths[getMeasurementKey(inlineActionCount)] !== undefined || Object.keys(measuredWidths).length === 0; // The bar appears where nothing was before, so it springs up into place to draw the eye there, the same way the // report's floating message counter animates itself in. It waits for the fitting pass so the motion is only ever @@ -270,11 +295,18 @@ function BulkActionBar({selectedCount, isSelectedCountLoading, optio selectedCount={selectedCount} isSelectedCountLoading={isSelectedCountLoading} options={options} + noticeText={noticeText} + menuHeaderText={menuHeaderText} onClearSelection={onClearSelection} onSubItemSelected={onSubItemSelected} barRef={barRef} inlineActionCount={inlineActionCount} - onBarLayout={(width) => setMeasuredWidths((widths) => (widths[inlineActionCount] === width ? widths : {...widths, [inlineActionCount]: width}))} + onBarLayout={(width) => + setMeasuredWidths((widths) => { + const measurementKey = getMeasurementKey(inlineActionCount); + return widths[measurementKey] === width ? widths : {...widths, [measurementKey]: width}; + }) + } /> diff --git a/src/components/BulkActionBar/types.ts b/src/components/BulkActionBar/types.ts index c8167ad29d64..f38c6ec7a346 100644 --- a/src/components/BulkActionBar/types.ts +++ b/src/components/BulkActionBar/types.ts @@ -21,6 +21,12 @@ type BulkActionBarProps = { */ isSelectedCountLoading?: boolean; + /** + * A heading for the "More" menu, naming what its items belong to. Set when the options are one action's own + * sub-items hoisted to the top level, which leaves them with nothing else saying what they are. + */ + menuHeaderText?: string; + /** Called when the bar's close button is pressed. Expected to clear the selection, which unmounts the bar. */ onClearSelection: () => void; diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index f82bb2daf049..61d0be86a755 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -260,6 +260,7 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { selectedCount={selectedBulkActionsCount} isSelectedCountLoading={isAllMatchingItemsCountLoading} options={headerButtonsOptions} + menuHeaderText={bulkActionsMenuHeaderText} // Called with no argument so the whole search selection is reset. Passing the boolean flag // instead only clears `selectedTransactionIDs`, which is the report view's selection, and // would leave this page's `selectedTransactions` in place with the bar still showing. From f78f93511285f8da2ea7c3cb9cbc1641d5093d01 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 10 Sep 2026 17:48:09 +0530 Subject: [PATCH 20/28] fix: surface the export header and all-matching label on the wide bar Signed-off-by: krishna2323 --- src/components/BulkActionBar/index.tsx | 8 +++++++- src/components/BulkActionBar/types.ts | 9 ++++++++- src/components/Search/SearchBulkActionsButton.tsx | 1 + 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index 090afd8d8ec2..226322db9c42 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -53,6 +53,7 @@ type BulkActionBarContentProps = Omit function BulkActionBarContent({ selectedCount, + customText, isSelectedCountLoading, options, noticeText, @@ -120,10 +121,13 @@ function BulkActionBarContent({ {isSelectedCountLoading ? ( ) : ( - {translate('workspace.common.selected', {count: selectedCount})} + {customText ?? translate('workspace.common.selected', {count: selectedCount})} )} {!!noticeText && {noticeText}} + {/* The "More" menu carries this heading itself. Without one, these buttons are the hoisted options it + would otherwise label, so the heading has to stand alone here instead. */} + {!hasMoreMenu && !!menuHeaderText && {menuHeaderText}} {inlineOptions.map((option) => ( ({ */ function BulkActionBar({ selectedCount, + customText, isSelectedCountLoading, options: allOptions, menuHeaderText, @@ -293,6 +298,7 @@ function BulkActionBar({ = { - /** How many rows the selection covers. Rendered as the bar's leading "N selected" label. */ + /** How many rows the selection covers. Rendered as the bar's leading "N selected" label, unless `customText` overrides it. */ selectedCount: number; + /** + * Replaces the "N selected" label outright, for a selection `selectedCount` cannot describe on its own, such as + * "All matching items selected" when the true total is still unknown. Mirrors `ButtonWithDropdownMenu`'s prop of + * the same name, which callers already compute this text for. + */ + customText?: string; + /** * The actions the selection supports, in priority order. The first `CONST.BULK_ACTION_BAR.MAX_INLINE_ACTIONS` are * rendered as buttons in the bar and the rest are moved into the bar's "More" menu. diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index b22898759957..c632febacb4f 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -263,6 +263,7 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { ) : ( Date: Fri, 11 Sep 2026 00:03:04 +0530 Subject: [PATCH 21/28] fix: stop the bar from sticking hidden and Esc from breaking RHP Signed-off-by: krishna2323 --- src/components/BulkActionBar/index.tsx | 30 +++++++++---------- .../Search/SearchBulkActionsButton.tsx | 4 +-- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index 226322db9c42..c8c4f0e4f301 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -80,12 +80,10 @@ function BulkActionBarContent({ const inlineOptions = hasMoreMenu ? options.slice(0, inlineActionCount) : options; const moreOptions = hasMoreMenu ? options.slice(inlineActionCount) : []; - // Esc dismisses the selection, as it does for this kind of bulk-select bar elsewhere, but only while nothing is - // open in front of the bar. A modal or popover dismisses itself on the key going back up, and shortcuts run on the - // way down, so a menu open over the bar cannot be given the keystroke first by ordering the handlers: Esc would - // clear the selection and take the bar away underneath the menu the viewer was backing out of. + // Esc clears the selection, but not while a popover or RHP is open over the bar: modals dismiss on keyup, shortcuts + // run on keydown, so ordering can't defer to them, and `isVisible` is what both set. const [modal] = useOnyx(ONYXKEYS.MODAL); - useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, onClearSelection, {isActive: !modal?.willAlertModalBecomeVisible}); + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, onClearSelection, {isActive: !modal?.isVisible}); useEffect(() => { if (!moreAnchorRef.current || !isMoreMenuVisible) { @@ -231,18 +229,20 @@ function BulkActionBar({ // This layer spans the container, so laying it out measures the width the bar has to fit into. const [availableWidth, setAvailableWidth] = useState(); - // The width the bar took at each layout it has been through, keyed by the buttons it was showing at the time. The - // bar is sized by its contents, so a given set of buttons always comes out the same width whatever the container is - // doing. Keeping them all means a layout the bar has already been through is recognized rather than measured again. - // - // The container's width is deliberately not part of the key: it changes on every frame of a resize, and a key that - // moved with it would throw the measurements away that often, which is what made the bar lay itself out at full - // width before shedding back down. The action labels are part of it because they decide how wide each button is, - // and the selection changes them as often as it changes the actions themselves. + // The width the bar took at each layout, keyed by what was actually on screen: the container's width is excluded + // (it moves every resize frame) and so are labels behind "More" (they don't affect this width, so keying on them + // left `onLayout` with nothing to fire and the bar stuck hidden). const [measuredWidths, setMeasuredWidths] = useState>({}); - const actionSetKey = `${options.map((option) => option.text).join('|')}|${startingActionCount}`; - const getMeasurementKey = (actionCount: number) => `${actionSetKey}|${actionCount}`; + const getMeasurementKey = (actionCount: number) => { + const hasMoreMenuAtCount = options.length > actionCount; + const inlineLabels = options + .slice(0, actionCount) + .map((option) => option.text) + .join('|'); + const standingText = hasMoreMenuAtCount ? '' : (menuHeaderText ?? ''); + return `${inlineLabels}|${hasMoreMenuAtCount}|${noticeText ?? ''}|${standingText}`; + }; // The width the bar has to stay within, keeping it clear of the container's edges rather than flush against them. const widthBudget = availableWidth === undefined ? undefined : availableWidth - CONST.BULK_ACTION_BAR.EDGE_MARGIN; diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index c632febacb4f..cce385d04930 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -267,9 +267,7 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { isSelectedCountLoading={isAllMatchingItemsCountLoading} options={headerButtonsOptions} menuHeaderText={bulkActionsMenuHeaderText} - // Called with no argument so the whole search selection is reset. Passing the boolean flag - // instead only clears `selectedTransactionIDs`, which is the report view's selection, and - // would leave this page's `selectedTransactions` in place with the bar still showing. + // No argument: the boolean flag only clears the report view's `selectedTransactionIDs`. onClearSelection={() => clearSelectedTransactions()} onSubItemSelected={(subItem) => payBulkSelectedItem(subItem, triggerKYCFlow)} barRef={buttonRef} From d97c7d1db0df87ea2dd728cff672a048f119d866 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 11 Sep 2026 00:05:24 +0530 Subject: [PATCH 22/28] add willAlertModalBecomeVisible condition. Signed-off-by: krishna2323 --- src/components/BulkActionBar/index.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index c8c4f0e4f301..8b0124b85bd6 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -80,10 +80,11 @@ function BulkActionBarContent({ const inlineOptions = hasMoreMenu ? options.slice(0, inlineActionCount) : options; const moreOptions = hasMoreMenu ? options.slice(inlineActionCount) : []; - // Esc clears the selection, but not while a popover or RHP is open over the bar: modals dismiss on keyup, shortcuts - // run on keydown, so ordering can't defer to them, and `isVisible` is what both set. + // Esc clears the selection, but not while a popover or RHP is open (or opening) over the bar: modals dismiss on + // keyup, shortcuts run on keydown, so ordering can't defer to them. `willAlertModalBecomeVisible` covers the open + // animation, `isVisible` covers everything after, and an RHP only ever sets the latter. const [modal] = useOnyx(ONYXKEYS.MODAL); - useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, onClearSelection, {isActive: !modal?.isVisible}); + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, onClearSelection, {isActive: !modal?.willAlertModalBecomeVisible && !modal?.isVisible}); useEffect(() => { if (!moreAnchorRef.current || !isMoreMenuVisible) { From d8f6d9ae555e4f3f1db58fa805aeba01429278cf Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 11 Sep 2026 00:26:22 +0530 Subject: [PATCH 23/28] fix: restore Mobile-Expensify to its clean merge pointer 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 Signed-off-by: krishna2323 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 080a428b5fa3..f8859173b0e1 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 080a428b5fa3347b2b86f336563b630e0618db01 +Subproject commit f8859173b0e1f7a250a536704a843696c5f0ba8e From 713b19281263635671125f98f8efeb6dbe411f76 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 14 Sep 2026 03:43:44 +0530 Subject: [PATCH 24/28] fix: keep Export under one button and measure the bar against its label Signed-off-by: krishna2323 --- src/components/BulkActionBar/index.tsx | 44 +++++++++---------- src/components/BulkActionBar/types.ts | 6 --- .../Search/SearchBulkActionsButton.tsx | 8 ++-- src/hooks/useSearchBulkActions.ts | 33 +++++++++----- 4 files changed, 48 insertions(+), 43 deletions(-) diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index 79bcf87b7fb6..f326c7bb34f5 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -40,7 +40,10 @@ import {defaultPopoverAnchorPosition, MORE_MENU_ANCHOR_ALIGNMENT} from './popove * specially. Split out from `BulkActionBar` because these styles have to resolve from the inverted theme, while the * positioning layer around it belongs to the page's own. */ -type BulkActionBarContentProps = Omit, 'style'> & { +type BulkActionBarContentProps = Omit, 'style' | 'selectedCount' | 'customText'> & { + /** What the bar says the selection is. Built by `BulkActionBar`, which also measures the bar against it. */ + countLabel: string; + /** How many actions to give a button of their own. The rest go behind "More". Decided by the fitting pass. */ inlineActionCount: number; @@ -52,12 +55,10 @@ type BulkActionBarContentProps = Omit }; function BulkActionBarContent({ - selectedCount, - customText, + countLabel, isSelectedCountLoading, options, noticeText, - menuHeaderText, onClearSelection, onSubItemSelected, barRef, @@ -117,16 +118,9 @@ function BulkActionBarContent({ {/* Sized for a three-digit count so the bar keeps still as the selection grows, and so swapping the spinner for the count does not resize it either. */} - {isSelectedCountLoading ? ( - - ) : ( - {customText ?? translate('workspace.common.selected', {count: selectedCount})} - )} + {isSelectedCountLoading ? : {countLabel}} {!!noticeText && {noticeText}} - {/* The "More" menu carries this heading itself. Without one, these buttons are the hoisted options it - would otherwise label, so the heading has to stand alone here instead. */} - {!hasMoreMenu && !!menuHeaderText && {menuHeaderText}} {inlineOptions.map((option) => ( ({ anchorRef={moreAnchorRef} anchorPosition={moreMenuAnchorPosition} anchorAlignment={MORE_MENU_ANCHOR_ALIGNMENT} - headerText={menuHeaderText} onClose={() => setIsMoreMenuVisible(false)} onItemSelected={(selectedItem, index, event) => { onSubItemSelected?.(selectedItem, index, event); @@ -203,13 +196,13 @@ function BulkActionBar({ customText, isSelectedCountLoading, options: allOptions, - menuHeaderText, onClearSelection, onSubItemSelected, barRef, style, }: BulkActionBarProps) { const styles = useThemeStyles(); + const {translate} = useLocalize(); const invertedTheme = useInvertedThemePreference(); const isReducedMotionEnabled = Accessibility.useReducedMotion(); @@ -230,19 +223,28 @@ function BulkActionBar({ // This layer spans the container, so laying it out measures the width the bar has to fit into. const [availableWidth, setAvailableWidth] = useState(); + // A "More" menu holding a single action is that action wearing a worse label, so give it its own button instead. + // The button it replaces is about as wide, so hoisting cannot push the bar past the width the count was fitted to. + const getInlineCount = (actionCount: number) => (options.length === actionCount + 1 ? options.length : actionCount); + // The width the bar took at each layout, keyed by what was actually on screen: the container's width is excluded // (it moves every resize frame) and so are labels behind "More" (they don't affect this width, so keying on them // left `onLayout` with nothing to fire and the bar stuck hidden). const [measuredWidths, setMeasuredWidths] = useState>({}); + // The count label is as much of the bar's width as the buttons are, and it changes without the options changing + // ("All matching items selected" against "3 selected", or the spinner against either). Leaving it out let a width + // measured under a long label go on gating a count the bar had since re-rendered short enough to fit. + const countLabel = isSelectedCountLoading ? '' : (customText ?? translate('workspace.common.selected', {count: selectedCount})); + const getMeasurementKey = (actionCount: number) => { - const hasMoreMenuAtCount = options.length > actionCount; + const inlineCount = getInlineCount(actionCount); + const hasMoreMenuAtCount = options.length > inlineCount; const inlineLabels = options - .slice(0, actionCount) + .slice(0, inlineCount) .map((option) => option.text) .join('|'); - const standingText = hasMoreMenuAtCount ? '' : (menuHeaderText ?? ''); - return `${inlineLabels}|${hasMoreMenuAtCount}|${noticeText ?? ''}|${standingText}`; + return `${inlineLabels}|${hasMoreMenuAtCount}|${noticeText ?? ''}|${countLabel}|${isSelectedCountLoading ?? false}`; }; // The width the bar has to stay within, keeping it clear of the container's edges rather than flush against them. @@ -298,16 +300,14 @@ function BulkActionBar({ setMeasuredWidths((widths) => { const measurementKey = getMeasurementKey(inlineActionCount); diff --git a/src/components/BulkActionBar/types.ts b/src/components/BulkActionBar/types.ts index 0cd5d008b2db..41dc6050bb63 100644 --- a/src/components/BulkActionBar/types.ts +++ b/src/components/BulkActionBar/types.ts @@ -28,12 +28,6 @@ type BulkActionBarProps = { */ isSelectedCountLoading?: boolean; - /** - * A heading for the "More" menu, naming what its items belong to. Set when the options are one action's own - * sub-items hoisted to the top level, which leaves them with nothing else saying what they are. - */ - menuHeaderText?: string; - /** Called when the bar's close button is pressed. Expected to clear the selection, which unmounts the bar. */ onClearSelection: () => void; diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index cce385d04930..5a8e0442d74e 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -72,6 +72,7 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const { headerButtonsOptions, + dropdownButtonsOptions, bulkActionsMenuHeaderText, selectedPolicyIDs, selectedTransactionReportIDs, @@ -122,7 +123,7 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { const isExpenseType = queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE; const isExpenseReportType = queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; - const popoverUseScrollView = shouldPopoverUseScrollView(headerButtonsOptions); + const popoverUseScrollView = shouldPopoverUseScrollView(dropdownButtonsOptions); const {selectedItemsCount, excludedItemsCount} = useMemo(() => { const getItemsCount = (transactionsToCount: typeof selectedTransactions) => { if (isExpenseReportType) { @@ -240,12 +241,12 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { null} shouldPopoverUseScrollView={popoverUseScrollView} onSubItemSelected={(subItem) => payBulkSelectedItem(subItem, triggerKYCFlow)} @@ -266,7 +267,6 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { customText={shouldShowAllMatchingItemsSelected ? selectionButtonText : undefined} isSelectedCountLoading={isAllMatchingItemsCountLoading} options={headerButtonsOptions} - menuHeaderText={bulkActionsMenuHeaderText} // No argument: the boolean flag only clears the report view's `selectedTransactionIDs`. onClearSelection={() => clearSelectedTransactions()} onSubItemSelected={(subItem) => payBulkSelectedItem(subItem, triggerKYCFlow)} diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 1189c691aaf1..1543e113c136 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1814,9 +1814,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }); }, [selectedReports, currentSearchResults?.data, isTrackIntentUser, policies, selectedTransactions, rules]); - const headerButtonsOptions = useMemo(() => { + const {headerButtonsOptions, dropdownButtonsOptions} = useMemo(() => { if ((selectedTransactionsKeys.length === 0 && !(isExpenseType && areAllMatchingItemsSelected)) || !hash) { - return CONST.EMPTY_ARRAY as unknown as Array>; + const noOptions = CONST.EMPTY_ARRAY as unknown as Array>; + return {headerButtonsOptions: noOptions, dropdownButtonsOptions: noOptions}; } const allSelectedAreDeleted = selectedTransactionsKeys.length > 0 && selectedTransactionsKeys.every((id) => isDeletedTransaction(selectedTransactions[id] ?? {})); @@ -2186,16 +2187,25 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }; /** - * Export is sometimes the only bulk action available (most commonly under "select all"). There is no main menu - * to go back to then, so the dropdown opens directly onto the export options rather than nesting them behind + * Export is sometimes the only bulk action available (most commonly under "select all"). The dropdown has no + * main menu to go back to then, so it opens directly onto the export options rather than nesting them behind * an "Export" row whose submenu would render a back arrow leading nowhere. The "Export" label is not lost: * `bulkActionsMenuHeaderText` below puts it back as a plain (non-interactive) header above the options. + * + * This only suits the dropdown. The bulk action bar renders each action as its own button, so flattening + * there would spread the export options across the bar under a heading instead of keeping them under one + * "Export" button. It takes the nested options, where "Export" opens its menu directly and has no back arrow. */ const openExportOptionsDirectlyIfSoleAction = (builtOptions: Array>): Array> => { const isExportTheOnlyAction = builtOptions.length === 1 && builtOptions.at(0)?.value === CONST.SEARCH.BULK_ACTION_TYPES.EXPORT; return isExportTheOnlyAction && subMenuItems.length > 0 ? subMenuItems : builtOptions; }; + const buildResult = (builtOptions: Array>) => ({ + headerButtonsOptions: builtOptions, + dropdownButtonsOptions: openExportOptionsDirectlyIfSoleAction(builtOptions), + }); + const {shouldEnableBulkPayOption} = getPayOption(selectedReports, selectedTransactions, lastPaymentMethods, selectedReportIDs, personalPolicyID); const hasLoadedPayableReport = payableSelectedReports.length > 0 || selectedReports.length === 0; const shouldShowPayOption = areAllMatchingItemsSelected @@ -2213,7 +2223,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }; if (areAllMatchingItemsSelected) { - return openExportOptionsDirectlyIfSoleAction(shouldShowPayOption ? [payButtonOption, exportButtonOption] : [exportButtonOption]); + return buildResult(shouldShowPayOption ? [payButtonOption, exportButtonOption] : [exportButtonOption]); } if (allSelectedAreDeleted) { @@ -2248,7 +2258,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }); } - return deletedTransactionOptions; + return buildResult(deletedTransactionOptions); } const isExpenseReportSearch = isExpenseReportType || searchResults?.search.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; @@ -2828,7 +2838,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }); } - return openExportOptionsDirectlyIfSoleAction(options); + return buildResult(options); }, [ selectedTransactionsKeys, hash, @@ -2922,11 +2932,11 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { rules, ]); - // When the export options are surfaced directly there is no "Export" row above them, so on its own the list gives - // no clue what the options refer to. Put "Export" back as a plain header — it reads the same as the label the back - // button normally carries, minus the caret, since there is no menu to go back to. + // When the dropdown surfaces the export options directly there is no "Export" row above them, so on its own the + // list gives no clue what the options refer to. Put "Export" back as a plain header. It reads the same as the + // label the back button normally carries, minus the caret, since there is no menu to go back to. const isShowingExportOptionsDirectly = - headerButtonsOptions.length > 0 && headerButtonsOptions.every((option) => option.value === CONST.SEARCH.BULK_ACTION_TYPES.EXPORT && !option.subMenuItems); + dropdownButtonsOptions.length > 0 && dropdownButtonsOptions.every((option) => option.value === CONST.SEARCH.BULK_ACTION_TYPES.EXPORT && !option.subMenuItems); const bulkActionsMenuHeaderText = isShowingExportOptionsDirectly ? translate('common.export') : undefined; const handleOfflineModalClose = useCallback(() => { @@ -2977,6 +2987,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return { headerButtonsOptions, + dropdownButtonsOptions, bulkActionsMenuHeaderText, selectedPolicyIDs, selectedTransactionReportIDs, From 217260a46f4a2aab913b758541294c7f713761a0 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 14 Sep 2026 03:55:17 +0530 Subject: [PATCH 25/28] fix tests. Signed-off-by: krishna2323 --- .../Search/SearchBulkActionsButtonTest.tsx | 1 + .../hooks/useSearchBulkActionsExportTest.ts | 20 ++++++++++--------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/unit/Search/SearchBulkActionsButtonTest.tsx b/tests/unit/Search/SearchBulkActionsButtonTest.tsx index 19a6dd47d77f..36db5dc08790 100644 --- a/tests/unit/Search/SearchBulkActionsButtonTest.tsx +++ b/tests/unit/Search/SearchBulkActionsButtonTest.tsx @@ -71,6 +71,7 @@ jest.mock('@hooks/useSearchBulkActions', () => ({ __esModule: true, default: () => ({ headerButtonsOptions: [], + dropdownButtonsOptions: [], selectedPolicyIDs: [], selectedTransactionReportIDs: [], selectedReportIDs: [], diff --git a/tests/unit/hooks/useSearchBulkActionsExportTest.ts b/tests/unit/hooks/useSearchBulkActionsExportTest.ts index cf34615bd244..365d6522ed98 100644 --- a/tests/unit/hooks/useSearchBulkActionsExportTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsExportTest.ts @@ -1273,9 +1273,11 @@ describe('useSearchBulkActions - export options', () => { }); it('opens directly onto the single export option when Export is the only bulk action', async () => { - // Export is the only bulk action offered under select all, so there is no main menu to go back to. The one - // export option is surfaced directly instead of behind an "Export" row whose submenu would render a back - // arrow leading nowhere, with "Export" kept as a plain dropdown header so the option still has context. + // Export is the only bulk action offered under select all, so the dropdown has no main menu to go back to. + // The one export option is surfaced directly instead of behind an "Export" row whose submenu would render a + // back arrow leading nowhere, with "Export" kept as a plain dropdown header so the option still has context. + // This only applies to the dropdown: the bar renders each action as its own button, so `headerButtonsOptions` + // keeps the nested shape regardless. mockAreAllMatchingItemsSelected = true; mockSelectedTransactions = { tx1: makeSelectedTransaction({ @@ -1287,10 +1289,10 @@ describe('useSearchBulkActions - export options', () => { const {result} = renderHook(() => useSearchBulkActions({queryJSON: groupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); await waitFor(() => { - expect(result.current.headerButtonsOptions.map((option) => option.text)).toEqual(['export.currentView']); + expect(result.current.dropdownButtonsOptions.map((option) => option.text)).toEqual(['export.currentView']); }); - const soleOption = result.current.headerButtonsOptions.at(0); + const soleOption = result.current.dropdownButtonsOptions.at(0); expect(soleOption?.value).toBe(CONST.SEARCH.BULK_ACTION_TYPES.EXPORT); expect(soleOption?.subMenuItems).toBeUndefined(); expect(soleOption?.backButtonText).toBeUndefined(); @@ -1304,13 +1306,13 @@ describe('useSearchBulkActions - export options', () => { const {result} = renderHook(() => useSearchBulkActions({queryJSON: groupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); await waitFor(() => { - expect(result.current.headerButtonsOptions.length).toBeGreaterThan(1); + expect(result.current.dropdownButtonsOptions.length).toBeGreaterThan(1); }); // Every entry is an export option itself — there is no "Export" row wrapping them and so no back arrow. - expect(result.current.headerButtonsOptions.every((option) => option.value === CONST.SEARCH.BULK_ACTION_TYPES.EXPORT)).toBe(true); - expect(result.current.headerButtonsOptions.some((option) => option.text === 'common.export')).toBe(false); - expect(result.current.headerButtonsOptions.some((option) => !!option.subMenuItems)).toBe(false); + expect(result.current.dropdownButtonsOptions.every((option) => option.value === CONST.SEARCH.BULK_ACTION_TYPES.EXPORT)).toBe(true); + expect(result.current.dropdownButtonsOptions.some((option) => option.text === 'common.export')).toBe(false); + expect(result.current.dropdownButtonsOptions.some((option) => !!option.subMenuItems)).toBe(false); // "Export" moves to the dropdown header instead, so the options are still labeled without a back caret. expect(result.current.bulkActionsMenuHeaderText).toBe('common.export'); }); From b3fb97e8902acac7448deca07c56377444b42161 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 15 Sep 2026 16:13:02 +0530 Subject: [PATCH 26/28] fix: keep the bulk action bar on screen when its measurement key changes Signed-off-by: krishna2323 --- src/components/BulkActionBar/index.tsx | 35 ++++++++------------------ 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index f326c7bb34f5..f4b317a00887 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -227,16 +227,17 @@ function BulkActionBar({ // The button it replaces is about as wide, so hoisting cannot push the bar past the width the count was fitted to. const getInlineCount = (actionCount: number) => (options.length === actionCount + 1 ? options.length : actionCount); - // The width the bar took at each layout, keyed by what was actually on screen: the container's width is excluded - // (it moves every resize frame) and so are labels behind "More" (they don't affect this width, so keying on them - // left `onLayout` with nothing to fire and the bar stuck hidden). + // The width the bar took at each layout, keyed by what was actually on screen. The container's width is excluded + // because it moves every resize frame, and so are the labels behind "More" because they do not reach this width. const [measuredWidths, setMeasuredWidths] = useState>({}); - // The count label is as much of the bar's width as the buttons are, and it changes without the options changing - // ("All matching items selected" against "3 selected", or the spinner against either). Leaving it out let a width - // measured under a long label go on gating a count the bar had since re-rendered short enough to fit. const countLabel = isSelectedCountLoading ? '' : (customText ?? translate('workspace.common.selected', {count: selectedCount})); + // Only a custom label can outgrow the width the count is given, so it is the one part of the label the bar's own + // width depends on. A plain count stays inside that width at any size the selection reaches, and keying on it would + // re-fit the bar on every selection change over a width that never moved. + const widthAffectingLabel = isSelectedCountLoading ? '' : (customText ?? ''); + const getMeasurementKey = (actionCount: number) => { const inlineCount = getInlineCount(actionCount); const hasMoreMenuAtCount = options.length > inlineCount; @@ -244,7 +245,7 @@ function BulkActionBar({ .slice(0, inlineCount) .map((option) => option.text) .join('|'); - return `${inlineLabels}|${hasMoreMenuAtCount}|${noticeText ?? ''}|${countLabel}|${isSelectedCountLoading ?? false}`; + return `${inlineLabels}|${hasMoreMenuAtCount}|${noticeText ?? ''}|${widthAffectingLabel}`; }; // The width the bar has to stay within, keeping it clear of the container's edges rather than flush against them. @@ -258,32 +259,18 @@ function BulkActionBar({ inlineActionCount -= 1; } - // Laying out a set of buttons for the first time is a guess that may not survive its own measurement, so it is kept - // hidden until it lands. Otherwise a bar that turns out to be too wide is briefly on screen at that width. The - // exception is the very first layout of all, which shows immediately: there is nothing on screen yet for a - // correction to disturb, and waiting for a measurement there is what would make the bar late to appear. - // - // A hidden layout is always resolved: changing the count changes the bar's width, so its `onLayout` is certain to - // follow, and every count below one already measured has itself been measured on the way down. - const hasSettled = measuredWidths[getMeasurementKey(inlineActionCount)] !== undefined || Object.keys(measuredWidths).length === 0; - // The bar appears where nothing was before, so it springs up into place to draw the eye there, the same way the - // report's floating message counter animates itself in. It waits for the fitting pass so the motion is only ever - // run on the layout the viewer actually sees. + // report's floating message counter animates itself in. const translateY = useSharedValue(CONST.BULK_ACTION_BAR.SLIDE_IN_DISTANCE); useEffect(() => { - if (!hasSettled) { - return; - } - if (isReducedMotionEnabled) { translateY.set(0); return; } translateY.set(withSpring(0, CONST.BULK_ACTION_BAR.SLIDE_IN_SPRING)); - }, [hasSettled, isReducedMotionEnabled, translateY]); + }, [isReducedMotionEnabled, translateY]); const layerAnimatedStyle = useAnimatedStyle(() => ({ transform: [{translateY: translateY.get()}], @@ -291,7 +278,7 @@ function BulkActionBar({ return ( setAvailableWidth(event.nativeEvent.layout.width)} > From 5831bd6b449a311265a96ddc6dbf6e3e374e1f41 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 16 Sep 2026 22:17:51 +0530 Subject: [PATCH 27/28] fix: give a bulk action button's submenu the same scroll check as the More menu Signed-off-by: krishna2323 --- src/components/BulkActionBar/BulkActionBarButton.tsx | 4 +++- src/libs/shouldPopoverUseScrollView.ts | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/BulkActionBar/BulkActionBarButton.tsx b/src/components/BulkActionBar/BulkActionBarButton.tsx index 6f910d6c0b6f..0f1e42a3893d 100644 --- a/src/components/BulkActionBar/BulkActionBarButton.tsx +++ b/src/components/BulkActionBar/BulkActionBarButton.tsx @@ -5,6 +5,8 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import usePopoverPosition from '@hooks/usePopoverPosition'; import useTheme from '@hooks/useTheme'; +import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; + import CONST from '@src/CONST'; import type {AnchorPosition} from '@src/styles'; @@ -97,7 +99,7 @@ function BulkActionBarButton({option, onSubItemSelected}: BulkAction } setIsMenuVisible(false); }} - shouldUseScrollView={subMenuItems.length >= CONST.DROPDOWN_SCROLL_THRESHOLD} + shouldUseScrollView={shouldPopoverUseScrollView(subMenuItems)} /> )} diff --git a/src/libs/shouldPopoverUseScrollView.ts b/src/libs/shouldPopoverUseScrollView.ts index 7a18ed75da60..a778755c6439 100644 --- a/src/libs/shouldPopoverUseScrollView.ts +++ b/src/libs/shouldPopoverUseScrollView.ts @@ -1,8 +1,6 @@ -import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; - import CONST from '@src/CONST'; -function shouldPopoverUseScrollView(options: Array>): boolean { +function shouldPopoverUseScrollView(options: Array<{subMenuItems?: unknown[]}>): boolean { return options.length >= CONST.DROPDOWN_SCROLL_THRESHOLD || options.some((option) => (option.subMenuItems?.length ?? 0) >= CONST.DROPDOWN_SCROLL_THRESHOLD); } From edc412855d70e70c19ee3d6f423fad18c274a0a9 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 19:42:17 +0530 Subject: [PATCH 28/28] fix: address review comments. Signed-off-by: krishna2323 --- src/components/BulkActionBar/BulkActionBarButton.tsx | 2 -- .../BulkActionBar/BulkActionBarMenuTheme.tsx | 2 -- src/components/BulkActionBar/index.tsx | 2 -- src/components/Search/SearchBulkActionsBarWide.tsx | 2 -- .../Search/SearchPageHeader/SearchActionsBarWide.tsx | 2 -- .../Search/hooks/useShouldShowBulkActionBar.ts | 10 +++++++++- src/components/Search/index.tsx | 2 +- 7 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/components/BulkActionBar/BulkActionBarButton.tsx b/src/components/BulkActionBar/BulkActionBarButton.tsx index 0f1e42a3893d..5de4191be6b4 100644 --- a/src/components/BulkActionBar/BulkActionBarButton.tsx +++ b/src/components/BulkActionBar/BulkActionBarButton.tsx @@ -107,6 +107,4 @@ function BulkActionBarButton({option, onSubItemSelected}: BulkAction ); } -BulkActionBarButton.displayName = 'BulkActionBarButton'; - export default BulkActionBarButton; diff --git a/src/components/BulkActionBar/BulkActionBarMenuTheme.tsx b/src/components/BulkActionBar/BulkActionBarMenuTheme.tsx index dc3e0ef335a3..b37216cedccc 100644 --- a/src/components/BulkActionBar/BulkActionBarMenuTheme.tsx +++ b/src/components/BulkActionBar/BulkActionBarMenuTheme.tsx @@ -23,6 +23,4 @@ function BulkActionBarMenuTheme({children}: React.PropsWithChildren) { ); } -BulkActionBarMenuTheme.displayName = 'BulkActionBarMenuTheme'; - export default BulkActionBarMenuTheme; diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index f4b317a00887..0b4bc97ce018 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -308,6 +308,4 @@ function BulkActionBar({ ); } -BulkActionBar.displayName = 'BulkActionBar'; - export default BulkActionBar; diff --git a/src/components/Search/SearchBulkActionsBarWide.tsx b/src/components/Search/SearchBulkActionsBarWide.tsx index bd1e00899abd..16abcc01f7e0 100644 --- a/src/components/Search/SearchBulkActionsBarWide.tsx +++ b/src/components/Search/SearchBulkActionsBarWide.tsx @@ -24,6 +24,4 @@ function SearchBulkActionsBarWide({queryJSON}: SearchBulkActionsBarWideProps) { return ; } -SearchBulkActionsBarWide.displayName = 'SearchBulkActionsBarWide'; - export default SearchBulkActionsBarWide; diff --git a/src/components/Search/SearchPageHeader/SearchActionsBarWide.tsx b/src/components/Search/SearchPageHeader/SearchActionsBarWide.tsx index cb45943acce4..72da74ce490f 100644 --- a/src/components/Search/SearchPageHeader/SearchActionsBarWide.tsx +++ b/src/components/Search/SearchPageHeader/SearchActionsBarWide.tsx @@ -25,8 +25,6 @@ type SearchActionsBarWideProps = { function SearchActionsBarWide({queryJSON, searchResults, onSort}: SearchActionsBarWideProps) { const styles = useThemeStyles(); - // Selecting rows no longer swaps this bar out for the bulk actions: those moved to the floating BulkActionBar over - // the list, so the search input and filters stay available while a selection is being built up. return ( diff --git a/src/components/Search/hooks/useShouldShowBulkActionBar.ts b/src/components/Search/hooks/useShouldShowBulkActionBar.ts index bbc6f930c4a6..e1cf50e10971 100644 --- a/src/components/Search/hooks/useShouldShowBulkActionBar.ts +++ b/src/components/Search/hooks/useShouldShowBulkActionBar.ts @@ -2,6 +2,8 @@ import {useSearchSelectionContext} from '@components/Search/SearchContext'; import {useSelectionCounts} from '@components/Search/SearchSelectionProvider'; import type {SearchQueryJSON} from '@components/Search/types'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + import CONST from '@src/CONST'; /** @@ -10,12 +12,18 @@ import CONST from '@src/CONST'; * Both the bar and the list underneath it depend on this: the list has to reserve the space the bar floats over, or its * last rows sit behind the bar once you scroll to the bottom. Keeping the rule here stops the two from drifting apart. * - * Expense searches track their selection as transactions, while every other type counts selected rows. + * An expense search asks the selection whether anything is selected rather than counting rows, because selecting every + * matching item is recorded as a flag instead of a row per item, which a count cannot see. */ function useShouldShowBulkActionBar(queryJSON: SearchQueryJSON): boolean { + const {shouldUseNarrowLayout} = useResponsiveLayout(); const {hasSelectedTransactions} = useSearchSelectionContext(); const {selected} = useSelectionCounts(); + if (shouldUseNarrowLayout) { + return false; + } + return queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE ? hasSelectedTransactions : selected > 0; } diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 7edd46e6242c..72110bde2843 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -160,7 +160,7 @@ function Search({ const {clearSelectedTransactions} = useSearchSelectionActions(); const {areAllMatchingItemsSelected} = useSearchSelectionContext(); // Wide layout floats the bulk action bar over the end of the list, so the list has to leave room for it. - const shouldReserveBulkActionBarSpace = useShouldShowBulkActionBar(queryJSON) && !shouldUseNarrowLayout; + const shouldReserveBulkActionBarSpace = useShouldShowBulkActionBar(queryJSON); const [offset, setOffset] = useState(0); const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION);