Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
e3d26d7
Fix table page header scrolling
KJ21-ENG Jun 17, 2026
6c116b3
Fix table sticky header initial load
KJ21-ENG Jun 18, 2026
881019c
Harden table sticky header timing
KJ21-ENG Jun 18, 2026
39a3c36
Address table review style findings
KJ21-ENG Jun 18, 2026
fc98458
Simplify workspace header rendering
KJ21-ENG Jun 19, 2026
991a2f6
Fix sticky table header background
KJ21-ENG Jun 19, 2026
57b38bf
Merge remote-tracking branch 'upstream/main' into KJ21-ENG/92930-rais…
KJ21-ENG Jun 19, 2026
58b94e0
Merge remote-tracking branch 'upstream/main' into KJ21-ENG/92930-rais…
KJ21-ENG Jun 22, 2026
05f9b6b
Fix native table sticky header behavior
KJ21-ENG Jun 23, 2026
8c7f7e5
Merge remote-tracking branch 'upstream/main' into KJ21-ENG/92930-rais…
KJ21-ENG Jun 25, 2026
ee2cc99
Fix table sticky header flow
KJ21-ENG Jun 25, 2026
48776b9
Address table sticky header review feedback
KJ21-ENG Jun 30, 2026
bf21245
Merge upstream main into 92930 raising flow
KJ21-ENG Jun 30, 2026
07fb1e5
Merge upstream main into 92930 raising flow
KJ21-ENG Jul 2, 2026
08528b2
Cover missed table pages in scroll flow
KJ21-ENG Jul 2, 2026
f81ecff
Avoid empty table page-header rows
KJ21-ENG Jul 2, 2026
ec561c7
Centralize table header composition
KJ21-ENG Jul 2, 2026
1979820
Fix sticky table header background
KJ21-ENG Jul 3, 2026
549388a
Merge upstream main into 92930 raising flow
KJ21-ENG Jul 3, 2026
3489b80
Fix Oxfmt formatting
KJ21-ENG Jul 3, 2026
fa531c6
Address table review feedback
KJ21-ENG Jul 6, 2026
8c1cb9a
Fix table helper knip export
KJ21-ENG Jul 6, 2026
b0557dc
Merge remote-tracking branch 'upstream/main' into KJ21-ENG/92930-rais…
KJ21-ENG Jul 7, 2026
0775e98
Merge remote-tracking branch 'upstream/main' into KJ21-ENG/92930-rais…
KJ21-ENG Jul 11, 2026
54ea891
Render table empty states inside the scrolling table list
KJ21-ENG Jul 11, 2026
26d7ce9
Scroll focused table search input above the keyboard
KJ21-ENG Jul 11, 2026
b73dbbe
Cover in-list table empty states with UI tests
KJ21-ENG Jul 11, 2026
c10e338
Re-apply table header scroll pattern onto the empty-state redesign
KJ21-ENG Jul 11, 2026
a277414
Scroll Agents page controls with the agents table
KJ21-ENG Jul 11, 2026
479e2c3
Justify requireActual type imports in table test mocks
KJ21-ENG Jul 11, 2026
17b98a6
Scroll Expensify Card and report participants page controls with thei…
KJ21-ENG Jul 13, 2026
8ca41b5
Merge remote-tracking branch 'upstream/main' into KJ21-ENG/92930-rais…
KJ21-ENG Jul 13, 2026
3db8b82
Expose composeTableHeaderComponent in company cards table test mock
KJ21-ENG Jul 13, 2026
d8b3800
Merge remote-tracking branch 'upstream/main' into KJ21-ENG/92930-rais…
KJ21-ENG Jul 13, 2026
4718441
Add agents and policy rooms loaded flags to safe Onyx export keys
KJ21-ENG Jul 13, 2026
5f27b5f
Fix sticky table header initialization
KJ21-ENG Jul 15, 2026
3adf17a
Merge upstream/main into KJ21-ENG/92930-raising-flow
KJ21-ENG Jul 15, 2026
7e47821
Fix table page compiler compatibility
KJ21-ENG Jul 15, 2026
9f910d5
Remove orphaned members search ref
KJ21-ENG Jul 15, 2026
92de38f
Fix TableBody compiler divergence
KJ21-ENG Jul 15, 2026
6102778
Fix TableBody compiler divergence
KJ21-ENG Jul 15, 2026
922478c
Merge validated compiler fix history
KJ21-ENG Jul 15, 2026
1c0d498
Merge upstream/main into KJ21-ENG/92930-raising-flow
KJ21-ENG Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 57 additions & 4 deletions src/components/Table/Table.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import MenuItem from '@components/MenuItem';
import Modal from '@components/Modal';
import useScrollToFocusedInput from '@components/SelectionList/hooks/useScrollToFocusedInput';

import useKeyboardState from '@hooks/useKeyboardState';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useMobileSelectionMode from '@hooks/useMobileSelectionMode';
Expand All @@ -12,18 +14,22 @@ import genericMemo from '@libs/genericMemo';
import CONST from '@src/CONST';

import type {FlashListRef} from '@shopify/flash-list';
import type {ReactElement} from 'react';

import React, {useImperativeHandle, useRef} from 'react';

import type {TableContextValue} from './TableContext';
import type {TableData, TableHandle, TableMethods, TableProps} from './types';

import {getTableListMetadata} from './buildTableListData';
import useFiltering from './middlewares/filtering';
import useHighlighting from './middlewares/highlight';
import useSearching from './middlewares/searching';
import useSelection from './middlewares/selection';
import useSorting from './middlewares/sorting';
import TableContext from './TableContext';
import TableEmptyState from './TableEmptyStates/TableEmptyState';
import TableNoResultsState from './TableEmptyStates/TableNoResultsState';

/**
* A composable table component that provides filtering, search, and sorting functionality.
Expand Down Expand Up @@ -157,6 +163,8 @@ function Table<DataType extends TableData, ColumnKey extends string = string, Fi
isItemInSearch,
initialSortColumn,
narrowLayoutSortColumn,
headerComponent,
shouldUseStickyColumnHeader = false,
children,
selectionEnabled,
shouldEnableSelectionInNarrowPaneModal,
Expand Down Expand Up @@ -206,6 +214,11 @@ function Table<DataType extends TableData, ColumnKey extends string = string, Fi

const listRef = useRef<FlashListRef<DataType>>(null);

// Keeps the table search input visible above the keyboard when it is focused inside the
// scrolling list (native only; the web variant of the hook is a no-op).
const {isKeyboardShown} = useKeyboardState();
const {containerRef: listContainerRef, trackScrollOffset, scrollInputIntoView} = useScrollToFocusedInput(listRef, isKeyboardShown);

const tableMethods: TableMethods<ColumnKey, FilterKey> = {
...filterMethods,
...sortMethods,
Expand All @@ -214,6 +227,30 @@ function Table<DataType extends TableData, ColumnKey extends string = string, Fi
...highlightingMethods,
};

const originalDataLength = data?.length ?? 0;
const isEmptyResult = processedData.length === 0 && originalDataLength > 0 && (hasActiveSearchString || hasActiveFilters);
const shouldRenderStickyHeader = shouldUseStickyColumnHeader && processedData.length > 0 && !isEmptyResult && !(shouldUseNarrowTableLayout && !title);

// When the page header scrolls inside the list, TableBody renders the table's empty states as
// list rows below the header. They are extracted from the direct children here so they don't
// render a second time as siblings of the list.
const childrenArray = React.Children.toArray(children);
const emptyStateElement = childrenArray.find((child): child is ReactElement => React.isValidElement(child) && child.type === TableEmptyState);
const noResultsStateElement = childrenArray.find((child): child is ReactElement => React.isValidElement(child) && child.type === TableNoResultsState);

const tableListMetadata = getTableListMetadata({
headerComponent,
listHeaderComponent: listProps.ListHeaderComponent,
listEmptyComponent: listProps.ListEmptyComponent,
hasEmptyStateContent: !!emptyStateElement,
processedData,
isEmptyResult,
shouldRenderStickyHeader,
});
const renderedChildren = tableListMetadata.hasPageHeader
? childrenArray.filter((child) => !(React.isValidElement(child) && (child.type === TableEmptyState || child.type === TableNoResultsState)))
: children;

/**
* Exposes table control methods through the ref.
* Uses a Proxy to also forward FlashList methods (like scrollToIndex).
Expand All @@ -229,14 +266,22 @@ function Table<DataType extends TableData, ColumnKey extends string = string, Fi
return () => processedData;
}

if (property === 'scrollToIndex') {
Comment thread
KJ21-ENG marked this conversation as resolved.
const scrollToIndex = listRef.current?.scrollToIndex;
if (tableListMetadata.listDataRowOffset === 0 || !scrollToIndex) {
return scrollToIndex;
}

return (params: Parameters<FlashListRef<DataType>['scrollToIndex']>[0]) => {
scrollToIndex({...params, index: params.index + tableListMetadata.listDataRowOffset});
};
}

return listRef.current?.[property as keyof FlashListRef<DataType>];
},
}) as TableHandle<DataType, ColumnKey, FilterKey>;
});

const originalDataLength = data?.length ?? 0;
const isEmptyResult = processedData.length === 0 && originalDataLength > 0 && (hasActiveSearchString || hasActiveFilters);

const handleMobileSelectionPress = () => {
turnOnMobileSelectionMode();

Expand All @@ -249,7 +294,13 @@ function Table<DataType extends TableData, ColumnKey extends string = string, Fi
// eslint-disable-next-line react/jsx-no-constructed-context-values
const contextValue: TableContextValue<DataType, ColumnKey, FilterKey> = {
title,
headerComponent,
emptyStateElement,
noResultsStateElement,
listRef,
listContainerRef,
trackScrollOffset,
scrollInputIntoView,
listProps,
processedData,
originalDataLength,
Expand All @@ -261,6 +312,8 @@ function Table<DataType extends TableData, ColumnKey extends string = string, Fi
tableMethods,
hasActiveFilters,
hasSearchString: hasActiveSearchString,
shouldRenderStickyHeader,
tableListMetadata,
isEmptyResult,
shouldUseNarrowTableLayout,
selectionEnabled,
Expand All @@ -271,7 +324,7 @@ function Table<DataType extends TableData, ColumnKey extends string = string, Fi

return (
<TableContext.Provider value={contextValue as unknown as TableContextValue<TableData, string, string>}>
{children}
{renderedChildren}

<Modal
shouldPreventScrollOnFocus
Expand Down
144 changes: 135 additions & 9 deletions src/components/Table/TableBody.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import Text from '@components/Text';

import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle';
import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';

import type {ListRenderItemInfo} from '@shopify/flash-list';
import type {StyleProp, ViewProps, ViewStyle} from 'react-native';

import {FlashList} from '@shopify/flash-list';
Expand All @@ -11,7 +14,9 @@ import {StyleSheet, View} from 'react-native';

import type {TableData} from '.';

import {buildTableListData, getAdjustedStickyHeaderIndices, getDataIndex, getSyntheticRowKind} from './buildTableListData';
import {useTableContext} from './TableContext';
import TableHeader from './TableHeader';

/**
* Props for the TableBody component.
Expand Down Expand Up @@ -49,21 +54,40 @@ type TableBodyProps = ViewProps & {
* </Table>
* ```
*/
function TableBody<DataType extends TableData>({contentContainerStyle, style, ...props}: TableBodyProps) {
function TableBody({contentContainerStyle, style, ...props}: TableBodyProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const [isListLoaded, setIsListLoaded] = React.useState(false);
const [hasActivatedStickyHeader, setHasActivatedStickyHeader] = React.useState(false);
const {
processedData: filteredAndSortedData,
activeSearchString,
listProps,
listRef,
listContainerRef,
trackScrollOffset,
shouldUseNarrowTableLayout,
hasActiveFilters,
hasSearchString,
headerComponent,
emptyStateElement,
noResultsStateElement,
isEmptyResult,
originalDataLength,
} = useTableContext<DataType>();
const {contentContainerStyle: listContentContainerStyle, ListEmptyComponent, ListHeaderComponent, ...restListProps} = listProps ?? {};
tableListMetadata,
} = useTableContext<TableData>();
const {
ListEmptyComponent,
ListHeaderComponent,
contentContainerStyle: listContentContainerStyle,
getItemType,
keyExtractor,
onLoad,
onScroll,
renderItem,
stickyHeaderIndices,
...restListProps
} = listProps ?? {};

const tableBodyContentContainerStyle = useBottomSafeSafeAreaPaddingStyle({
addBottomSafeAreaPadding: true,
Expand All @@ -88,21 +112,118 @@ function TableBody<DataType extends TableData>({contentContainerStyle, style, ..

useDebouncedAccessibilityAnnouncement(message, isEmptyResult, activeSearchString);

if ((isEmptyResult || !originalDataLength) && !ListEmptyComponent && !ListHeaderComponent) {
React.useEffect(() => {
if (!tableListMetadata.shouldRenderStickyHeader || !isListLoaded || hasActivatedStickyHeader) {
return undefined;
}

const frame = requestAnimationFrame(() => setHasActivatedStickyHeader(true));
return () => cancelAnimationFrame(frame);
}, [hasActivatedStickyHeader, isListLoaded, tableListMetadata.shouldRenderStickyHeader]);

// Tables without a scrolling page header keep the default contract: an empty table renders
// nothing here so the declarative Table.EmptyState/Table.NoResultsState siblings take over.
// With a page header (or a ListEmptyComponent) the list must stay mounted even when empty,
// otherwise the header (tabs, buttons, search) or the empty view would disappear with the rows.
if (!tableListMetadata.hasPageHeader && (isEmptyResult || !originalDataLength) && !ListEmptyComponent) {
return null;
}

const renderListComponent = (component: typeof ListHeaderComponent | typeof ListEmptyComponent) => {
if (!component) {
return null;
}

if (React.isValidElement(component)) {
return component;
}

return React.createElement(component);
};

const pageHeaderElement = (
<>
{renderListComponent(ListHeaderComponent)}
{headerComponent}
</>
);
const listData = buildTableListData<TableData>(filteredAndSortedData, tableListMetadata);
const adjustedStickyHeaderIndices = getAdjustedStickyHeaderIndices(tableListMetadata, stickyHeaderIndices);
const canRenderStickyHeader = !tableListMetadata.shouldRenderStickyHeader || (isListLoaded && hasActivatedStickyHeader);

const handleLoad: NonNullable<typeof onLoad> = (info) => {
setIsListLoaded(true);
onLoad?.(info);
};

const EmptyResultComponent = (
<View style={[styles.ph5, styles.pt3, styles.pb5]}>
<Text
style={[styles.textNormal, styles.colorMuted]}
aria-hidden
>
{message}
</Text>
</View>
);
let listEmptyComponent = ListEmptyComponent;
if (tableListMetadata.shouldRenderSyntheticEmptyRow) {
listEmptyComponent = undefined;
}

const renderListItem = (info: ListRenderItemInfo<TableData>) => {
const rowKind = getSyntheticRowKind(info.index, tableListMetadata);

switch (rowKind) {
case 'pageHeader':
return pageHeaderElement;
case 'tableHeader':
return <TableHeader isStickyListHeader />;
case 'emptyResult':
return noResultsStateElement ?? EmptyResultComponent;
case 'listEmpty':
return emptyStateElement ?? renderListComponent(ListEmptyComponent);
case 'data':
default:
return renderItem?.({...info, index: getDataIndex(info.index, tableListMetadata)}) ?? null;
}
};

const keyExtractorForList = (item: TableData, index: number) => {
const rowKind = getSyntheticRowKind(index, tableListMetadata);

if (rowKind !== 'data') {
return item.keyForList;
}

return keyExtractor?.(item, getDataIndex(index, tableListMetadata)) ?? item.keyForList;
};

const getItemTypeForList = (item: TableData, index: number, extraData: unknown) => {
const rowKind = getSyntheticRowKind(index, tableListMetadata);

if (rowKind !== 'data') {
return item.keyForList;
}

return getItemType?.(item, getDataIndex(index, tableListMetadata), extraData);
};

return (
<View
ref={listContainerRef}
style={[styles.flex1, styles.mnh0, style]}
{...props}
>
<FlashList<DataType>
<FlashList<TableData>
ref={listRef}
data={filteredAndSortedData}
data={listData}
style={[styles.flex1, styles.mnh0]}
showsVerticalScrollIndicator={false}
maintainVisibleContentPosition={{disabled: true}}
ListEmptyComponent={listEmptyComponent}
onLoad={handleLoad}
stickyHeaderIndices={canRenderStickyHeader ? adjustedStickyHeaderIndices : undefined}
contentContainerStyle={[
filteredAndSortedData.length === 0 && styles.flexGrow1,
listContentContainerStyle,
Expand All @@ -113,12 +234,17 @@ function TableBody<DataType extends TableData>({contentContainerStyle, style, ..
typeof tableBodyBottomPadding === 'number' && {minHeight: contentMinHeight + tableBodyBottomPadding},
]}
keyboardShouldPersistTaps="handled"
ListHeaderComponent={ListHeaderComponent}
ListEmptyComponent={ListEmptyComponent}
renderItem={renderListItem}
keyExtractor={keyExtractorForList}
getItemType={getItemTypeForList}
onScroll={(event) => {
trackScrollOffset(event);
onScroll?.(event);
}}
{...restListProps}
/>
</View>
);
}

export default TableBody;
export default React.memo(TableBody);
Loading
Loading