diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 01526ef5f00a..e4d5d2ece0d6 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6654,6 +6654,35 @@ 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, + + /** 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, + + /** Breathing room left between the bar and the last row of the list it floats over. */ + LIST_GAP: 12, + + /** How far below its resting place the bar starts before it springs up into view. */ + 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: { LEFT: 'left', RIGHT: 'right', @@ -8952,6 +8981,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', }, @@ -9084,7 +9117,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/BulkActionBarButton.tsx b/src/components/BulkActionBar/BulkActionBarButton.tsx new file mode 100644 index 000000000000..5de4191be6b4 --- /dev/null +++ b/src/components/BulkActionBar/BulkActionBarButton.tsx @@ -0,0 +1,110 @@ +import Button from '@components/Button'; +import PopoverMenu from '@components/PopoverMenu'; + +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'; + +import type {View} from 'react-native'; + +import React, {useEffect, useRef, useState} from 'react'; + +import type {BulkActionBarButtonProps} from './types'; + +import BulkActionBarMenuTheme from './BulkActionBarMenuTheme'; +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 theme = useTheme(); + 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; + } + + // 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 ( + <> + + {hasSubMenu && !!anchorPosition && ( + + ({...subItem, shouldCallAfterModalHide: true}))} + onClose={() => setIsMenuVisible(false)} + onItemSelected={(selectedSubItem, index, event) => { + onSubItemSelected?.(selectedSubItem, index, event); + if (selectedSubItem.shouldCloseModalOnSelect === false) { + return; + } + setIsMenuVisible(false); + }} + shouldUseScrollView={shouldPopoverUseScrollView(subMenuItems)} + /> + + )} + + ); +} + +export default BulkActionBarButton; diff --git a/src/components/BulkActionBar/BulkActionBarMenuTheme.tsx b/src/components/BulkActionBar/BulkActionBarMenuTheme.tsx new file mode 100644 index 000000000000..b37216cedccc --- /dev/null +++ b/src/components/BulkActionBar/BulkActionBarMenuTheme.tsx @@ -0,0 +1,26 @@ +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} + + ); +} + +export default BulkActionBarMenuTheme; diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx new file mode 100644 index 000000000000..0b4bc97ce018 --- /dev/null +++ b/src/components/BulkActionBar/index.tsx @@ -0,0 +1,311 @@ +import ActivityIndicator from '@components/ActivityIndicator'; +import Button from '@components/Button'; +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 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'; +import useThemeStyles from '@hooks/useThemeStyles'; + +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'; +import {View} from 'react-native'; +import Animated, {useAnimatedStyle, useSharedValue, withSpring} from 'react-native-reanimated'; + +import type {BulkActionBarProps} from './types'; + +import BulkActionBarButton from './BulkActionBarButton'; +import BulkActionBarMenuTheme from './BulkActionBarMenuTheme'; +import {defaultPopoverAnchorPosition, MORE_MENU_ANCHOR_ALIGNMENT} from './popoverPosition'; + +/** + * 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. + */ +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; + + /** 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; +}; + +function BulkActionBarContent({ + countLabel, + isSelectedCountLoading, + options, + noticeText, + onClearSelection, + onSubItemSelected, + barRef, + inlineActionCount, + onBarLayout, +}: BulkActionBarContentProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Close', 'DownArrow', '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". 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) : []; + + // 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?.willAlertModalBecomeVisible && !modal?.isVisible}); + + useEffect(() => { + if (!moreAnchorRef.current || !isMoreMenuVisible) { + return; + } + + // 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 ( + 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. */} + + {isSelectedCountLoading ? : {countLabel}} + + {!!noticeText && {noticeText}} + {inlineOptions.map((option) => ( + + ))} + {hasMoreMenu && ( + <> + + {!!moreMenuAnchorPosition && ( + + setIsMoreMenuVisible(false)} + onItemSelected={(selectedItem, index, event) => { + onSubItemSelected?.(selectedItem, index, event); + if (selectedItem.shouldCloseModalOnSelect === false) { + return; + } + setIsMoreMenuVisible(false); + }} + shouldUseScrollView={shouldPopoverUseScrollView(moreOptions)} + 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, + customText, + isSelectedCountLoading, + options: allOptions, + onClearSelection, + onSubItemSelected, + barRef, + style, +}: BulkActionBarProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + 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; + + // 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(); + + // 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 + // because it moves every resize frame, and so are the labels behind "More" because they do not reach this width. + const [measuredWidths, setMeasuredWidths] = useState>({}); + + 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; + const inlineLabels = options + .slice(0, inlineCount) + .map((option) => option.text) + .join('|'); + 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. + const widthBudget = availableWidth === undefined ? undefined : availableWidth - CONST.BULK_ACTION_BAR.EDGE_MARGIN; + + // 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[getMeasurementKey(inlineActionCount)] ?? 0) > widthBudget) { + inlineActionCount -= 1; + } + + // 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, CONST.BULK_ACTION_BAR.SLIDE_IN_SPRING)); + }, [isReducedMotionEnabled, translateY]); + + const layerAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{translateY: translateY.get()}], + })); + + 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. */} + + + + setMeasuredWidths((widths) => { + const measurementKey = getMeasurementKey(inlineActionCount); + return widths[measurementKey] === width ? widths : {...widths, [measurementKey]: width}; + }) + } + /> + + + + ); +} + +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..41dc6050bb63 --- /dev/null +++ b/src/components/BulkActionBar/types.ts @@ -0,0 +1,58 @@ +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, 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. + */ + 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; + + /** + * 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..16abcc01f7e0 --- /dev/null +++ b/src/components/Search/SearchBulkActionsBarWide.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import type {SearchQueryJSON} from './types'; + +import useShouldShowBulkActionBar from './hooks/useShouldShowBulkActionBar'; +import SearchBulkActionsButton from './SearchBulkActionsButton'; + +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 shouldShowBulkActions = useShouldShowBulkActionBar(queryJSON); + + if (!shouldShowBulkActions) { + return null; + } + + return ; +} + +export default SearchBulkActionsBarWide; diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index 33544ce6bc74..5a8e0442d74e 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(); @@ -70,6 +72,7 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const { headerButtonsOptions, + dropdownButtonsOptions, bulkActionsMenuHeaderText, selectedPolicyIDs, selectedTransactionReportIDs, @@ -120,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) { @@ -193,12 +196,11 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { } else { selectedAllMatchingItemsCount = isExpenseType ? Math.max(allMatchingItemsCount - excludedItemsCount, 0) : allMatchingItemsCount; } + const selectedBulkActionsCount = areAllMatchingItemsSelected ? selectedAllMatchingItemsCount : selectedItemsCount; const shouldShowAllMatchingItemsSelected = isExpenseType && areAllMatchingItemsSelected && Object.keys(excludedTransactions).length === 0; const selectionButtonText = shouldShowAllMatchingItemsSelected ? translate('search.exportAll.allMatchingItemsSelected') - : translate('workspace.common.selected', { - count: areAllMatchingItemsSelected ? selectedAllMatchingItemsCount : selectedItemsCount, - }); + : translate('workspace.common.selected', {count: selectedBulkActionsCount}); return ( <> @@ -239,12 +241,12 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { null} shouldPopoverUseScrollView={popoverUseScrollView} onSubItemSelected={(subItem) => payBulkSelectedItem(subItem, triggerKYCFlow)} @@ -260,26 +262,16 @@ 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()} + 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..72da74ce490f 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,23 @@ 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; return ( - {shouldShowBulkActions ? ( - - - - ) : ( - <> - - - - - - - - - - - - )} + + + + + + + + + + ); } diff --git a/src/components/Search/hooks/useShouldShowBulkActionBar.ts b/src/components/Search/hooks/useShouldShowBulkActionBar.ts new file mode 100644 index 000000000000..e1cf50e10971 --- /dev/null +++ b/src/components/Search/hooks/useShouldShowBulkActionBar.ts @@ -0,0 +1,30 @@ +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'; + +/** + * 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. + * + * 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; +} + +export default useShouldShowBulkActionBar; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 5a3aecbe6dc4..72110bde2843 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -102,6 +102,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'; @@ -158,6 +159,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); const [offset, setOffset] = useState(0); const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); @@ -1285,7 +1288,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/hooks/useInvertedThemePreference.ts b/src/hooks/useInvertedThemePreference.ts new file mode 100644 index 000000000000..8e1d2e2c63fd --- /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 colors 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/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index e41dbb007667..917b4f3f9786 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1808,9 +1808,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] ?? {})); @@ -2180,16 +2181,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 @@ -2207,7 +2217,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }; if (areAllMatchingItemsSelected) { - return openExportOptionsDirectlyIfSoleAction(shouldShowPayOption ? [payButtonOption, exportButtonOption] : [exportButtonOption]); + return buildResult(shouldShowPayOption ? [payButtonOption, exportButtonOption] : [exportButtonOption]); } if (allSelectedAreDeleted) { @@ -2242,7 +2252,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }); } - return deletedTransactionOptions; + return buildResult(deletedTransactionOptions); } const isExpenseReportSearch = isExpenseReportType || searchResults?.search.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; @@ -2862,7 +2872,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }); } - return openExportOptionsDirectlyIfSoleAction(options); + return buildResult(options); }, [ selectedTransactionsKeys, hash, @@ -2956,11 +2966,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(() => { @@ -3011,6 +3021,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return { headerButtonsOptions, + dropdownButtonsOptions, bulkActionsMenuHeaderText, selectedPolicyIDs, selectedTransactionReportIDs, 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); } diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index 6e1e0647c7ef..0d4b03497640 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'; @@ -141,6 +142,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 cfe1e700e945..28371fa3527f 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, @@ -5480,10 +5485,51 @@ 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 center 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 colored by that same theme rather than styled specially. + bulkActionBar: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingVertical: variables.bulkActionBarPaddingVertical, + paddingLeft: 20, + paddingRight: 16, + borderRadius: variables.componentBorderRadiusLarge, + backgroundColor: theme.appBG, + 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: 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 + // 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, + width: variables.componentSizeSmall, + alignItems: 'center', + justifyContent: 'center', }, filtersBar: { @@ -7147,7 +7193,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, }), diff --git a/src/styles/variables.ts b/src/styles/variables.ts index c2acf27f8813..b6e09ce0d44e 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -146,6 +146,7 @@ export default { htmlTableChevronColumnWidth: 20, tableGroupRowPaddingVertical: 4, tableGroupRowHeight: 36, + bulkActionBarPaddingVertical: 20, tableCheckboxColumnWidth: 20, tableStatusColumnWidth: 56, tableTypeColumnWidth: 84, diff --git a/tests/unit/Search/SearchBulkActionsButtonTest.tsx b/tests/unit/Search/SearchBulkActionsButtonTest.tsx index 211ee55dac2d..36db5dc08790 100644 --- a/tests/unit/Search/SearchBulkActionsButtonTest.tsx +++ b/tests/unit/Search/SearchBulkActionsButtonTest.tsx @@ -9,11 +9,17 @@ import CONST from '@src/CONST'; import React from 'react'; +type MockBulkActionBarProps = { + selectedCount: number; + isSelectedCountLoading: boolean; +}; + type MockButtonProps = { customText: string; isLoading: boolean; }; +const mockBulkActionBar = jest.fn(() => null); const mockButtonWithDropdownMenu = jest.fn(() => null); let mockExcludedTransactions: SelectedTransactions = {}; let mockSearchCount: number | undefined; @@ -21,7 +27,12 @@ let mockSearchReportCount: number | undefined; let mockSearchIsLoading = false; let mockIsOffline = false; let mockAreAllMatchingItemsSelected = true; +let mockShouldUseNarrowLayout = false; +jest.mock('@components/BulkActionBar', () => ({ + __esModule: true, + default: (props: MockBulkActionBarProps) => mockBulkActionBar(props), +})); jest.mock('@components/ButtonWithDropdownMenu', () => ({ __esModule: true, default: (props: MockButtonProps) => mockButtonWithDropdownMenu(props), @@ -50,7 +61,7 @@ jest.mock('@hooks/useLocalize', () => ({ jest.mock('@hooks/useNetwork', () => ({__esModule: true, default: () => ({isOffline: mockIsOffline})})); jest.mock('@hooks/useResponsiveLayout', () => ({ __esModule: true, - default: () => ({shouldUseNarrowLayout: false, isSmallScreenWidth: false}), + default: () => ({shouldUseNarrowLayout: mockShouldUseNarrowLayout, isSmallScreenWidth: mockShouldUseNarrowLayout}), })); jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({__esModule: true, default: () => ({accountID: 1})})); jest.mock('@hooks/useOnyx', () => ({__esModule: true, default: () => [undefined]})); @@ -60,6 +71,7 @@ jest.mock('@hooks/useSearchBulkActions', () => ({ __esModule: true, default: () => ({ headerButtonsOptions: [], + dropdownButtonsOptions: [], selectedPolicyIDs: [], selectedTransactionReportIDs: [], selectedReportIDs: [], @@ -79,6 +91,9 @@ jest.mock('@components/Search/SearchContext', () => ({ selectedReports: [], areAllMatchingItemsSelected: mockAreAllMatchingItemsSelected, }), + useSearchSelectionActions: () => ({ + clearSelectedTransactions: jest.fn(), + }), useSearchResultsContext: () => ({ currentSearchResults: {search: {count: mockSearchCount, reportCount: mockSearchReportCount, isLoading: mockSearchIsLoading}}, }), @@ -118,7 +133,17 @@ function makeTransaction(): SelectedTransactions[string] { }; } -function getButtonProps(): {customText: string; isLoading: boolean} { +/** The wide layout's floating bar, which labels the selection with a count of its own. */ +function getBarProps(): {selectedCount: number; isSelectedCountLoading: boolean} { + const props = mockBulkActionBar.mock.calls.at(-1)?.at(0); + if (!props) { + throw new Error('BulkActionBar was not rendered'); + } + return {selectedCount: props.selectedCount, isSelectedCountLoading: props.isSelectedCountLoading}; +} + +/** The narrow layout's dropdown, which is the only place the selection label itself is rendered. */ +function getButtonProps(): MockButtonProps { const props = mockButtonWithDropdownMenu.mock.calls.at(-1)?.at(0); if (!props) { throw new Error('ButtonWithDropdownMenu was not rendered'); @@ -135,9 +160,11 @@ describe('SearchBulkActionsButton all-matching label', () => { mockSearchIsLoading = false; mockIsOffline = false; mockAreAllMatchingItemsSelected = true; + mockShouldUseNarrowLayout = false; }); it('shows the all-matching label and keeps loading while the server count is missing', () => { + mockShouldUseNarrowLayout = true; mockSearchIsLoading = true; render(); @@ -146,6 +173,7 @@ describe('SearchBulkActionsButton all-matching label', () => { }); it('keeps the all-matching label when the server count arrives and there are no exclusions', () => { + mockShouldUseNarrowLayout = true; mockSearchCount = 172; render(); @@ -153,16 +181,33 @@ describe('SearchBulkActionsButton all-matching label', () => { expect(getButtonProps()).toEqual({customText: 'search.exportAll.allMatchingItemsSelected', isLoading: false}); }); + it('counts the whole matching set on the bar, which has no room for the all-matching label', () => { + mockSearchCount = 172; + + render(); + + expect(getBarProps()).toEqual({selectedCount: 172, isSelectedCountLoading: false}); + }); + + it('keeps the bar loading while the server count is missing, falling back to the loaded count', () => { + mockSearchIsLoading = true; + + render(); + + expect(getBarProps()).toEqual({selectedCount: 1, isSelectedCountLoading: true}); + }); + it('shows the exact count after an item is excluded', () => { mockSearchCount = 172; mockExcludedTransactions = {tx2: makeTransaction()}; render(); - expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:171', isLoading: false}); + expect(getBarProps()).toEqual({selectedCount: 171, isSelectedCountLoading: false}); }); it('keeps the numeric label for page-only selection', () => { + mockShouldUseNarrowLayout = true; mockAreAllMatchingItemsSelected = false; render(); @@ -176,7 +221,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', () => { @@ -185,7 +230,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('keeps loading for expense reports while the server report count is missing, falling back to the loaded report count', () => { @@ -193,7 +238,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('labels expense reports with the server report count, not the expense count', () => { @@ -204,7 +249,7 @@ describe('SearchBulkActionsButton all-matching label', () => { render(); - expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:50', isLoading: false}); + expect(getBarProps()).toEqual({selectedCount: 50, isSelectedCountLoading: false}); }); it('falls back to the loaded report count for expense reports offline before the report count arrives', () => { @@ -212,6 +257,6 @@ describe('SearchBulkActionsButton all-matching label', () => { render(); - expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:1', isLoading: false}); + expect(getBarProps()).toEqual({selectedCount: 1, isSelectedCountLoading: false}); }); }); diff --git a/tests/unit/hooks/useSearchBulkActionsExportTest.ts b/tests/unit/hooks/useSearchBulkActionsExportTest.ts index f2a8d0b6afc4..d60cdc19ddda 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'); });