Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
818736f
feat: create initial generic table component
chrispader Dec 16, 2025
ca9fa29
fix: remove merge conflicts for initial table
chrispader Dec 16, 2025
73ed5ec
fix: types for compound components
chrispader Dec 16, 2025
f3165ff
refactor: fix eslint/ts errors and restructure exports
chrispader Dec 16, 2025
5b40adf
refactor: improve generic table component
chrispader Dec 16, 2025
8b2e1d4
feat: implement sorting and `TableHeader` component
chrispader Dec 16, 2025
7f4d04d
feat: migrate `WorkspaceCompanyCardList` to generic table
chrispader Dec 16, 2025
1eb24c2
feat: Add search bar, filter buttons, and responsive header layout to…
ikevin127 Dec 17, 2025
b3a27ee
refactor: fixed single-select label
ikevin127 Dec 17, 2025
f682cb9
refactor: use FlashList
chrispader Dec 16, 2025
c9fa96a
fix: update keyExtractor callback
chrispader Dec 16, 2025
efb34f9
fix: row gap of 8px
chrispader Dec 16, 2025
44bcefd
fix: remove extra padding in search bar
chrispader Dec 17, 2025
afb9c7a
fix: remove unnecessary variable alias
chrispader Dec 17, 2025
d4cfbfa
refactor: remove extra callbacks
chrispader Dec 17, 2025
4a0dd3d
fix: typo
chrispader Dec 17, 2025
a054cab
fix: TS error `no-lonely-if`, prefer early return
chrispader Dec 17, 2025
6619b18
fix: clean up and simplify
chrispader Dec 17, 2025
1242e33
feat: add imperative functions for getting current sorting/filtering/…
chrispader Dec 17, 2025
94adeb5
feat: preserve widescreen sorting
chrispader Dec 17, 2025
822fe5b
fix: add a ref to prevent recurrent state updates
chrispader Dec 17, 2025
a47ce9a
fix: toggle sort order only once
chrispader Dec 17, 2025
5f2f483
fix: update sorting when we switch layout
chrispader Dec 17, 2025
784f86f
feat: hide header in narrow layout
chrispader Dec 17, 2025
6d134e0
fix: imperative functions
chrispader Dec 17, 2025
8502a20
refactor: re-structure FilterButtons implementation
chrispader Dec 17, 2025
1172c35
fix: remove `displayName` and add `ViewProps`
chrispader Dec 17, 2025
40dc504
fix: responsive layout in company cards
chrispader Dec 17, 2025
081efe9
refactor: update `useTableContext` imports
chrispader Dec 17, 2025
96ace13
fix: remove unused TableHeaderContainer
chrispader Dec 17, 2025
50a8dc8
fix: remove TableHeaderContainer import
chrispader Dec 17, 2025
7ac42f5
refactor: remove unused Table.SortButtons
chrispader Dec 17, 2025
da32c86
fix: Table test errors and refactor
chrispader Dec 17, 2025
fab329f
fix: Expensicons error
chrispader Dec 17, 2025
71909c7
fix: remove manual memo
chrispader Dec 17, 2025
4ce427c
fix: dependency cycle
chrispader Dec 17, 2025
fe1a2a3
fix: Expensicons
chrispader Dec 17, 2025
f52716a
Merge branch 'byoc-bulk-card-assign-r1' into byoc-bulk-card-assign-r1…
chrispader Dec 17, 2025
2477218
fix: rename list components
chrispader Dec 17, 2025
171ddad
fix: `WorkspaceCompanyCardsTableHeaderButtons` import
chrispader Dec 17, 2025
7f6185c
Update WorkspaceMemberDetailsPage.tsx
chrispader Dec 17, 2025
7d03cc8
Update WorkspaceMemberDetailsPage.tsx
chrispader Dec 17, 2025
5bbe140
refactor: inline methods
chrispader Dec 17, 2025
438b5ba
refactor: Make Table.FilterButtons generic and refactor header buttons
chrispader Dec 17, 2025
9bee8ac
fix: filter buttons in header bar
chrispader Dec 17, 2025
3bc2932
feat: allow searching for "unassigned"/"assigned"
chrispader Dec 17, 2025
76a8a90
refactor: add `contentContainerStyle` prop to `TableBody` and remove …
chrispader Dec 17, 2025
0c06ccc
refactor: improve sorting
chrispader Dec 17, 2025
a747738
refactor: extract middlewares
chrispader Dec 17, 2025
98b2b88
feature: added documentation and fixed comments
ikevin127 Dec 18, 2025
b28a3ba
fix: types
chrispader Dec 18, 2025
37ab5ef
temp: rename files for git
chrispader Dec 18, 2025
1fa4e18
temp: rename files back
chrispader Dec 18, 2025
dc98628
docs: add more comments
chrispader Dec 18, 2025
62f7946
Merge branch 'byoc-bulk-card-assign-r1' into byoc-bulk-card-assign-r1…
chrispader Dec 18, 2025
8ceeb1d
Merge branch 'byoc-bulk-card-assign-r1' into byoc-bulk-card-assign-r1…
chrispader Dec 18, 2025
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
249 changes: 249 additions & 0 deletions contributingGuides/TABLE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
# Table Component

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very lovely, thank you!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @ikevin127 ! 🙌🏼❤️


A composable, generic table component with built-in filtering, search, and sorting capabilities.

## Quick Start

```tsx
import Table from '@components/Table';
import type { TableColumn, CompareItemsCallback } from '@components/Table';

type Item = { id: string; name: string; status: string };
type ColumnKey = 'name' | 'status';

const columns: Array<TableColumn<ColumnKey>> = [
{ key: 'name', label: 'Name' },
{ key: 'status', label: 'Status' },
];

function MyTable() {
return (
<Table<Item, ColumnKey>
data={items}
columns={columns}
renderItem={({ item }) => <ItemRow item={item} />}
keyExtractor={(item) => item.id}
>
<Table.Header />
<Table.Body />
</Table>
);
}
```

## Compositional Pattern

The Table uses a **compound component pattern** where the parent `<Table>` manages all state and child components render specific UI parts:

| Component | Purpose |
|-----------|---------|
| `<Table>` | Parent container that manages state and provides context |
| `<Table.Header>` | Renders sortable column headers |
| `<Table.Body>` | Renders data rows using FlashList |
| `<Table.SearchBar>` | Search input that filters data |
| `<Table.FilterButtons>` | Dropdown filter buttons |

### Flexible Composition

You only include the components you need:

```tsx
// Minimal: just data rows
<Table data={items} columns={columns} renderItem={renderItem}>
<Table.Body />
</Table>

// With search
<Table data={items} columns={columns} renderItem={renderItem} isItemInSearch={searchFn}>
<Table.SearchBar />
<Table.Body />
</Table>

// Full featured
<Table
data={items}
columns={columns}
renderItem={renderItem}
isItemInSearch={searchFn}
isItemInFilter={filterFn}
compareItems={compareFn}
filters={filterConfig}
>
<Table.SearchBar />
<Table.FilterButtons />
<Table.Header />
<Table.Body />
</Table>
```

## Features

### Sorting

Enable by providing `compareItems`:

```tsx
const compareItems: CompareItemsCallback<Item, ColumnKey> = (a, b, { columnKey, order }) => {
const multiplier = order === 'asc' ? 1 : -1;
return a[columnKey].localeCompare(b[columnKey]) * multiplier;
};

<Table
data={items}
columns={columns}
renderItem={renderItem}
compareItems={compareItems}
>
<Table.Header /> {/* Clicking headers toggles sort */}
<Table.Body />
</Table>
```

Header click behavior: `ascending → descending → reset`

### Searching

Enable by providing `isItemInSearch`:

```tsx
const isItemInSearch = (item: Item, searchString: string) =>
item.name.toLowerCase().includes(searchString.toLowerCase());

<Table
data={items}
columns={columns}
renderItem={renderItem}
isItemInSearch={isItemInSearch}
>
<Table.SearchBar />
<Table.Body />
</Table>
```

### Filtering

Enable by providing `filters` config and `isItemInFilter`:

```tsx
import type { FilterConfig, IsItemInFilterCallback } from '@components/Table';

const filterConfig: FilterConfig = {
status: {
filterType: 'single-select', // or 'multi-select'
options: [
{ label: 'All', value: 'all' },
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
],
default: 'all',
},
};

const isItemInFilter: IsItemInFilterCallback<Item> = (item, filterValues) => {
if (filterValues.includes('all')) return true;
return filterValues.includes(item.status);
};

<Table
data={items}
columns={columns}
renderItem={renderItem}
filters={filterConfig}
isItemInFilter={isItemInFilter}
>
<Table.FilterButtons />
<Table.Body />
</Table>
```

## Programmatic Control

Access table methods via ref:

```tsx
import type { TableHandle } from '@components/Table';

const tableRef = useRef<TableHandle<Item, ColumnKey>>(null);

// Update sorting programmatically
tableRef.current?.updateSorting({ columnKey: 'name', order: 'desc' });

// Update search
tableRef.current?.updateSearchString('query');

// Get current state
const sorting = tableRef.current?.getActiveSorting();
const searchString = tableRef.current?.getActiveSearchString();

// FlashList methods also available
tableRef.current?.scrollToIndex({ index: 0 });

<Table ref={tableRef} {...props}>
<Table.Body />
</Table>
```

## Type Parameters

| Parameter | Description |
|-----------|-------------|
| `T` | Type of items in the data array |
| `ColumnKey` | String literal union of column keys (e.g., `'name' \| 'status'`) |
| `FilterKey` | String literal union of filter keys |

## Architecture

### Middleware Pipeline

Data processing flows through three middlewares:

```
data → [Filtering] → [Searching] → [Sorting] → processedData
```

Each middleware transforms the data array. The order is fixed: filters first, then search, then sort.

### Context

All sub-components access shared state via `TableContext`. You can create custom sub-components using `useTableContext`:

```tsx
import { useTableContext } from '@components/Table/TableContext';

function CustomComponent<T>() {
const { processedData, activeSorting, updateSorting } = useTableContext<T>();
// Build custom UI using context data...
}
```

## Column Configuration

```tsx
type TableColumn<ColumnKey> = {
key: ColumnKey; // Unique identifier
label: string; // Display text
styling?: {
flex?: number; // Column width ratio
containerStyles?: StyleProp<ViewStyle>;
labelStyles?: StyleProp<TextStyle>;
};
};
```

## Props Reference

### Table Props

| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `data` | `T[]` | Yes | Array of items to display |
| `columns` | `TableColumn<ColumnKey>[]` | Yes | Column configuration |
| `renderItem` | FlashList's `renderItem` | Yes | Row renderer |
| `keyExtractor` | FlashList's `keyExtractor` | Yes | Unique key generator |
| `compareItems` | `CompareItemsCallback<T, ColumnKey>` | No | Sorting comparator |
| `isItemInSearch` | `IsItemInSearchCallback<T>` | No | Search predicate |
| `isItemInFilter` | `IsItemInFilterCallback<T>` | No | Filter predicate |
| `filters` | `FilterConfig<FilterKey>` | No | Filter dropdown config |
| `ref` | `Ref<TableHandle<T, ColumnKey, FilterKey>>` | No | Ref for programmatic control |

Plus all FlashList props except `data`.
13 changes: 8 additions & 5 deletions src/components/CaretWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,32 @@
import React from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
import type ChildrenProps from '@src/types/utils/ChildrenProps';
import Icon from './Icon';
import * as Expensicons from './Icon/Expensicons';

type CaretWrapperProps = ChildrenProps & {
style?: StyleProp<ViewStyle>;
caretWidth?: number;
caretHeight?: number;
};

function CaretWrapper({children, style}: CaretWrapperProps) {
function CaretWrapper({children, style, caretWidth, caretHeight}: CaretWrapperProps) {
const theme = useTheme();
const styles = useThemeStyles();
const expensifyIcons = useMemoizedLazyExpensifyIcons(['DownArrow'] as const);

return (
<View style={[styles.flexRow, styles.gap1, styles.alignItemsCenter, style]}>
{children}
<Icon
src={Expensicons.DownArrow}
src={expensifyIcons.DownArrow}
fill={theme.icon}
width={variables.iconSizeExtraSmall}
height={variables.iconSizeExtraSmall}
width={caretWidth ?? variables.iconSizeExtraSmall}
height={caretHeight ?? variables.iconSizeExtraSmall}
/>
</View>
);
Expand Down
4 changes: 1 addition & 3 deletions src/components/FeedSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React from 'react';
import {View} from 'react-native';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
Expand Down Expand Up @@ -35,13 +34,12 @@ type Props = {
function FeedSelector({onFeedSelect, cardIcon, feedName, supportingText, shouldShowRBR = false, plaidUrl = null}: Props) {
const styles = useThemeStyles();
const theme = useTheme();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const Expensicons = useMemoizedLazyExpensifyIcons(['DotIndicator'] as const);
return (
<PressableWithFeedback
onPress={onFeedSelect}
wrapperStyle={styles.flex1}
style={[styles.flexRow, styles.alignItemsCenter, styles.gap3, shouldUseNarrowLayout && styles.mb3]}
style={[styles.flexRow, styles.alignItemsCenter, styles.gap3]}
accessibilityLabel={feedName ?? ''}
>
{plaidUrl ? (
Expand Down
37 changes: 31 additions & 6 deletions src/components/Search/FilterDropdowns/DropdownButton.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {willAlertModalBecomeVisibleSelector} from '@selectors/Modal';
import type {ReactNode} from 'react';
import React, {useCallback, useMemo, useRef, useState} from 'react';
import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
import {View} from 'react-native';
import Button from '@components/Button';
import CaretWrapper from '@components/CaretWrapper';
Expand All @@ -13,6 +14,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';

Expand All @@ -32,6 +34,21 @@ type DropdownButtonProps = {

/** The component to render in the popover */
PopoverComponent: (props: PopoverComponentProps) => ReactNode;

/** Whether to use medium size button instead of small */
medium?: boolean;

/** Button inner styles */
innerStyles?: StyleProp<ViewStyle>;

/** Button label style */
labelStyle?: StyleProp<TextStyle>;

/** Carret wrapper style */
carretWrapperStyle?: StyleProp<ViewStyle>;

/** Wrapper style for the outer view */
wrapperStyle?: StyleProp<ViewStyle>;
};

const PADDING_MODAL = 8;
Expand All @@ -41,7 +58,7 @@ const ANCHOR_ORIGIN = {
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
};

function DropdownButton({label, value, viewportOffsetTop, PopoverComponent}: DropdownButtonProps) {
function DropdownButton({label, value, viewportOffsetTop, PopoverComponent, medium = false, labelStyle, innerStyles, carretWrapperStyle, wrapperStyle}: DropdownButtonProps) {
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to distinguish RHL and narrow layout
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
Expand Down Expand Up @@ -108,18 +125,26 @@ function DropdownButton({label, value, viewportOffsetTop, PopoverComponent}: Dro
}, [PopoverComponent, toggleOverlay]);

return (
<View ref={anchorRef}>
<View
ref={anchorRef}
style={wrapperStyle}
>
{/* Dropdown Trigger */}
<Button
small
ref={triggerRef}
innerStyles={[isOverlayVisible && styles.buttonHoveredBG, {maxWidth: 256}]}
innerStyles={[isOverlayVisible && styles.buttonHoveredBG, {maxWidth: 256}, innerStyles]}
onPress={calculatePopoverPositionAndToggleOverlay}
// eslint-disable-next-line react/jsx-props-no-spreading
{...(medium ? {medium: true} : {small: true})}
>
<CaretWrapper style={[styles.flex1, styles.mw100]}>
<CaretWrapper
style={[styles.flex1, styles.mw100, carretWrapperStyle]}
caretWidth={variables.iconSizeSmall}
caretHeight={variables.iconSizeSmall}
>
<Text
numberOfLines={1}
style={[styles.textMicroBold, styles.flexShrink1]}
style={[styles.textMicroBold, styles.flexShrink1, labelStyle]}
>
{buttonText}
</Text>
Expand Down
Loading
Loading