diff --git a/contributingGuides/TABLE.md b/contributingGuides/TABLE.md new file mode 100644 index 000000000000..d0bb6d938dc4 --- /dev/null +++ b/contributingGuides/TABLE.md @@ -0,0 +1,249 @@ +# Table Component + +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> = [ + { key: 'name', label: 'Name' }, + { key: 'status', label: 'Status' }, +]; + +function MyTable() { + return ( + + data={items} + columns={columns} + renderItem={({ item }) => } + keyExtractor={(item) => item.id} + > + + + + ); +} +``` + +## Compositional Pattern + +The Table uses a **compound component pattern** where the parent `` manages all state and child components render specific UI parts: + +| Component | Purpose | +|-----------|---------| +| `
` | Parent container that manages state and provides context | +| `` | Renders sortable column headers | +| `` | Renders data rows using FlashList | +| `` | Search input that filters data | +| `` | Dropdown filter buttons | + +### Flexible Composition + +You only include the components you need: + +```tsx +// Minimal: just data rows +
+ +
+ +// With search + + + +
+ +// Full featured + + + + + +
+``` + +## Features + +### Sorting + +Enable by providing `compareItems`: + +```tsx +const compareItems: CompareItemsCallback = (a, b, { columnKey, order }) => { + const multiplier = order === 'asc' ? 1 : -1; + return a[columnKey].localeCompare(b[columnKey]) * multiplier; +}; + + + {/* Clicking headers toggles sort */} + +
+``` + +Header click behavior: `ascending → descending → reset` + +### Searching + +Enable by providing `isItemInSearch`: + +```tsx +const isItemInSearch = (item: Item, searchString: string) => + item.name.toLowerCase().includes(searchString.toLowerCase()); + + + + +
+``` + +### 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, filterValues) => { + if (filterValues.includes('all')) return true; + return filterValues.includes(item.status); +}; + + + + +
+``` + +## Programmatic Control + +Access table methods via ref: + +```tsx +import type { TableHandle } from '@components/Table'; + +const tableRef = useRef>(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 }); + + + +
+``` + +## 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() { + const { processedData, activeSorting, updateSorting } = useTableContext(); + // Build custom UI using context data... +} +``` + +## Column Configuration + +```tsx +type TableColumn = { + key: ColumnKey; // Unique identifier + label: string; // Display text + styling?: { + flex?: number; // Column width ratio + containerStyles?: StyleProp; + labelStyles?: StyleProp; + }; +}; +``` + +## Props Reference + +### Table Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `data` | `T[]` | Yes | Array of items to display | +| `columns` | `TableColumn[]` | Yes | Column configuration | +| `renderItem` | FlashList's `renderItem` | Yes | Row renderer | +| `keyExtractor` | FlashList's `keyExtractor` | Yes | Unique key generator | +| `compareItems` | `CompareItemsCallback` | No | Sorting comparator | +| `isItemInSearch` | `IsItemInSearchCallback` | No | Search predicate | +| `isItemInFilter` | `IsItemInFilterCallback` | No | Filter predicate | +| `filters` | `FilterConfig` | No | Filter dropdown config | +| `ref` | `Ref>` | No | Ref for programmatic control | + +Plus all FlashList props except `data`. diff --git a/src/components/CaretWrapper.tsx b/src/components/CaretWrapper.tsx index dc29fa9c3b3a..6518f6a53449 100644 --- a/src/components/CaretWrapper.tsx +++ b/src/components/CaretWrapper.tsx @@ -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; + 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 ( {children} ); diff --git a/src/components/FeedSelector.tsx b/src/components/FeedSelector.tsx index 30a010c9db21..1fcf129582be 100644 --- a/src/components/FeedSelector.tsx +++ b/src/components/FeedSelector.tsx @@ -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'; @@ -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 ( {plaidUrl ? ( diff --git a/src/components/Search/FilterDropdowns/DropdownButton.tsx b/src/components/Search/FilterDropdowns/DropdownButton.tsx index b661c60e20cb..2b6286b20b79 100644 --- a/src/components/Search/FilterDropdowns/DropdownButton.tsx +++ b/src/components/Search/FilterDropdowns/DropdownButton.tsx @@ -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'; @@ -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'; @@ -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; + + /** Button label style */ + labelStyle?: StyleProp; + + /** Carret wrapper style */ + carretWrapperStyle?: StyleProp; + + /** Wrapper style for the outer view */ + wrapperStyle?: StyleProp; }; const PADDING_MODAL = 8; @@ -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(); @@ -108,18 +125,26 @@ function DropdownButton({label, value, viewportOffsetTop, PopoverComponent}: Dro }, [PopoverComponent, toggleOverlay]); return ( - + {/* Dropdown Trigger */}