diff --git a/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx b/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx
index 8749340f6923..b862b7d3e447 100644
--- a/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx
+++ b/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx
@@ -54,6 +54,9 @@ type FilterListProps = FilterItemCallbacks & {
/** Content at the top of the list, scrolled with its rows. Pass a falsy value when the search hides it, so the list knows nothing is listed. */
headerContent?: ReactNode;
+ /** Content below the rows, scrolled with them. */
+ children?: ReactNode;
+
/** Called as the cursor travels over the list. Web only - it reports the movement inside a row, which `onHoverIn` only reports entering. */
onPointerMove?: (event: {clientX: number; clientY: number}) => void;
@@ -135,6 +138,7 @@ function FilterList({
onSearchValueChange,
searchInputRef,
headerContent,
+ children,
onHoverIn,
onFocus,
onPress,
@@ -196,6 +200,7 @@ function FilterList({
))}
))}
+ {children}
);
diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx
index 45f62e682641..8db80163bd88 100644
--- a/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx
+++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx
@@ -13,6 +13,7 @@ import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import usePermissions from '@hooks/usePermissions';
+import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -38,6 +39,7 @@ function SearchAdvancedFiltersBase() {
const {currentDraftFilters, shouldShowResetFilters} = useContext(SearchAdvancedFiltersContext);
const {applyFilters, resetFilters} = useContext(SearchAdvancedFiltersActionContext);
const isInLandscapeMode = useIsInLandscapeMode();
+ const shouldFooterBeInsideList = useShouldFooterBeInsideList();
const StyleUtils = useStyleUtils();
const {isBetaEnabled} = usePermissions();
const canUseNLFilters = isBetaEnabled(CONST.BETAS.NL_FILTERS);
@@ -49,6 +51,28 @@ function SearchAdvancedFiltersBase() {
const filters = getMatchingFilters(allFilters, currentDraftFilters.type, listedSearchValue, translate);
const isDescribeSearchListed = canUseNLFilters && isLabelMatchingSearchValue(translate('search.filters.describeSearch.title'), listedSearchValue);
+ const footer = (
+
+ {shouldShowResetFilters && (
+
+ )}
+
+
+ );
+
return (
Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS_CONTENT.getRoute(filterKey))}
- />
-
- {shouldShowResetFilters && (
-
- )}
-
-
+ >
+ {shouldFooterBeInsideList && footer}
+
+ {!shouldFooterBeInsideList && footer}
);
}
diff --git a/tests/ui/SearchAdvancedFiltersBaseTest.tsx b/tests/ui/SearchAdvancedFiltersBaseTest.tsx
new file mode 100644
index 000000000000..85a4e74a0550
--- /dev/null
+++ b/tests/ui/SearchAdvancedFiltersBaseTest.tsx
@@ -0,0 +1,76 @@
+import {render, screen, within} from '@testing-library/react-native';
+
+import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList';
+
+import IntlStore from '@src/languages/IntlStore';
+import SearchAdvancedFiltersBase from '@src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase';
+
+import type {ReactNode} from 'react';
+
+import {NavigationContainer} from '@react-navigation/native';
+import React from 'react';
+import {View} from 'react-native';
+
+import type * as TestHelper from '../utils/TestHelper';
+
+// jest.mock factories can't reference imported bindings, but `mock`-prefixed locals are allowed.
+const MockView = View;
+
+// Stand-in for the filter list that only renders what the page puts inside its scroll area.
+jest.mock('@components/Search/FilterComponents/AdvancedFilters/FilterList', () => ({
+ __esModule: true,
+ default: ({children}: {children?: ReactNode}) => {children},
+}));
+
+jest.mock('@hooks/useShouldFooterBeInsideList', () => jest.fn(() => false));
+
+jest.mock('@hooks/useAdvancedSearchFilters', () => jest.fn(() => []));
+
+jest.mock('@hooks/usePermissions', () => ({
+ __esModule: true,
+ default: () => ({isBetaEnabled: () => false}),
+}));
+
+// The default locale context translates every label to an empty string.
+jest.mock('@hooks/useLocalize', () => {
+ const {translateLocal, localeCompare} = jest.requireActual('../utils/TestHelper');
+ return () => ({translate: translateLocal, localeCompare});
+});
+
+function renderFiltersPage() {
+ render(
+
+
+ ,
+ );
+}
+
+beforeAll(async () => {
+ await IntlStore.load('en');
+});
+
+describe('SearchAdvancedFiltersBase', () => {
+ it('pins Apply filters below the list by default', () => {
+ // Given a layout with room for a fixed footer
+ jest.mocked(useShouldFooterBeInsideList).mockReturnValue(false);
+
+ // When the Filters page renders
+ renderFiltersPage();
+
+ // Then Apply filters is outside the scrolling list
+ expect(screen.getByText('Apply filters')).toBeOnTheScreen();
+ expect(within(screen.getByTestId('filter-list')).queryByText('Apply filters')).not.toBeOnTheScreen();
+ });
+
+ it('scrolls Apply filters with the list in landscape with the keyboard open', () => {
+ // Given landscape mode with the keyboard open, where a fixed footer would push the search input out of view
+ jest.mocked(useShouldFooterBeInsideList).mockReturnValue(true);
+
+ // When the Filters page renders
+ renderFiltersPage();
+
+ // Then Apply filters is rendered once, inside the scrolling list
+ expect(screen.getAllByText('Apply filters')).toHaveLength(1);
+ expect(within(screen.getByTestId('filter-list')).getByText('Apply filters')).toBeOnTheScreen();
+ });
+});