From 2a6cf35a9231e6b640136f82abceeafa04146099 Mon Sep 17 00:00:00 2001 From: ahmdshrif Date: Thu, 17 Sep 2026 13:26:45 +0300 Subject: [PATCH 1/2] feat: add search and column filters - searchText prop matches shown cell text, ignoring case and accents, with a lazily built search index reused until data or columns change - columnFilters prop with text, values, range and custom filters - Range filters reject NaN, invalid dates and non-numeric cells, and ignore invalid bounds - Recompute row keys when keyExtractor changes while keeping identical key arrays stable - Label perf monitor counts as long frame intervals and qualify README performance claims - Example app: search box and status/amount filter chips --- README.md | 59 +++++++-- example/src/App.tsx | 148 +++++++++++++++++++++- example/src/PerfMonitor.tsx | 11 +- example/src/data.ts | 2 +- src/DataGrid.tsx | 64 ++++++++-- src/GridRow.tsx | 10 +- src/__tests__/filter.test.ts | 232 +++++++++++++++++++++++++++++++++++ src/cell.ts | 13 ++ src/filter.ts | 120 ++++++++++++++++++ src/index.tsx | 11 +- src/sort.ts | 6 +- src/types.ts | 32 ++++- 12 files changed, 667 insertions(+), 41 deletions(-) create mode 100644 src/__tests__/filter.test.ts create mode 100644 src/cell.ts create mode 100644 src/filter.ts diff --git a/README.md b/README.md index 472f3f0..66c8d57 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,9 @@ Fast data grid for React Native: pinned columns, sticky header, virtualized rows, sorting and selection. - **Pinned columns and sticky header** that follow sideways scrolling on the UI thread, so they never lag behind the body -- **Virtualized rows** with [FlashList](https://shopify.github.io/flash-list/), so 10,000 rows cost about the same as 30 +- **Virtualized rows** with [FlashList](https://shopify.github.io/flash-list/): only rows on screen are mounted. Sorting, filtering and key generation still process the whole dataset in JS - **Sorting**: tap a header to cycle ascending, descending, unsorted. Stable, natural string order, empty values last +- **Search and filters**: quick search that ignores case and accents, plus text, value, range and custom column filters - **Selection**: single or multiple, with a pinned checkbox column and select-all - **Custom cells** through `renderCell`, or plain text through `format` - **Light and dark themes**, with every color overridable @@ -77,6 +78,8 @@ The grid fills its parent (`flex: 1`), so give the parent a height. | `compare` | `(a, b) => number` | Custom ascending comparator. | | `format` | `(value, row) => string` | Display text. Ignored when `renderCell` is set. | | `renderCell` | `({ row, rowIndex, value, column, selected }) => ReactNode` | Custom cell content. | +| `searchable` | `boolean` | Include in `searchText` matching. Default `true`. | +| `getSearchText` | `(row) => string` | Text used for search. Defaults to the shown text. | ## Props @@ -84,13 +87,16 @@ The grid fills its parent (`flex: 1`), so give the parent a height. | --- | --- | --- | --- | | `data` | `readonly T[]` | required | Rows. | | `columns` | `DataGridColumn[]` | required | Column definitions. Memoize them to avoid re-rendering rows. | -| `keyExtractor` | `(row, index) => string` | required | Stable unique key per row. | +| `keyExtractor` | `(row, index) => string` | required | Unique key per row. Use a stable ID from the row, not the index. | | `rowHeight` | `number` | `44` | Fixed row height. | | `headerHeight` | `number` | `40` | Header height. | | `striped` | `boolean` | `true` | Alternate row backgrounds. | | `sort` | `SortState \| null` | | Controlled sort. | | `defaultSort` | `SortState \| null` | `null` | Initial sort when uncontrolled. | | `onSortChange` | `(sort) => void` | | Called when a header is tapped. | +| `searchText` | `string` | | Show rows whose searchable columns contain this text. | +| `columnFilters` | `Record` | | Show rows matching every filter, by column key. | +| `onFilteredCountChange` | `(count) => void` | | Number of rows left after filtering. | | `selectionMode` | `'none' \| 'single' \| 'multiple'` | `'none'` | `multiple` adds a pinned checkbox column. | | `selectedKeys` | `string[]` | | Controlled selection. | | `defaultSelectedKeys` | `string[]` | `[]` | Initial selection when uncontrolled. | @@ -101,22 +107,57 @@ The grid fills its parent (`flex: 1`), so give the parent a height. | `emptyText` | `string` | `'No rows'` | Shown when `data` is empty. | | `style` | `ViewStyle` | | Container style. | -`sortRows`, `nextSort`, `lightTheme` and `darkTheme` are exported too, for sorting on your own or building a custom theme. +`sortRows`, `nextSort`, `filterRows`, `lightTheme` and `darkTheme` are exported too, for sorting on your own or building a custom theme. + +## Search and filters + +```tsx +const [search, setSearch] = useState(''); +const [statuses, setStatuses] = useState([]); + +const columnFilters = useMemo>( + () => ({ + status: statuses.length ? { type: 'values', values: statuses } : null, + amount: { type: 'range', min: 1000 }, + }), + [statuses] +); + + row.id} + searchText={search} + columnFilters={columnFilters} +/>; +``` + +| Filter | Matches | +| --- | --- | +| `{ type: 'text', value }` | Shown cell text contains `value`, ignoring case and accents. | +| `{ type: 'values', values }` | Cell value equals one of `values` (dates compare by time). An empty list matches everything. | +| `{ type: 'range', min?, max? }` | Number or date between `min` and `max`, inclusive. Cells that aren't valid numbers or dates never match; invalid bounds are ignored. | +| `{ type: 'custom', test }` | `test(row)` returns true. | + +The grid has no built-in filter controls yet, so you render your own search box and chips (see the example app). `filterRows`, `buildSearchIndex` and `matchesColumnFilter` are exported for filtering outside the grid. ## Performance -Measured on the example app (Release build, 10,000 rows × 13 columns, checkbox column plus 2 pinned columns) during about 12 seconds of fast flings and sideways swipes: +Early measurements on the example app (Release build, 10,000 rows × 13 columns, checkbox column plus 2 pinned columns, about 12 seconds of fast flings and sideways swipes). The numbers are frame callback intervals longer than 25 ms, a rough jank signal rather than a dropped-frame count: -| Platform | UI thread dropped frames | JS thread dropped frames | +| Platform | UI thread long intervals | JS thread long intervals | | --- | --- | --- | -| iOS 26 simulator | 0 | 0 | -| Android emulator (API 35) | 1 | 21 | +| iOS 26 simulator (Mac) | 0 | 0 | +| Android emulator, API 35 (Mac) | 1 | 21 | + +**Not yet measured on physical devices.** Emulator runs varied a lot with host load, so these results say nothing reliable about low-end Android phones. Device benchmarks with platform profiling are planned before a stable release. Background on the approach is in [docs/spike-results.md](docs/spike-results.md). -Android emulator runs varied a lot between repeats (host load on the Mac), so treat the Android row as a rough best case. Real low-end Android devices have not been measured yet. The header and pinned columns stay in sync in every run, because they move on the UI thread. Background on the approach is in [docs/spike-results.md](docs/spike-results.md). +What scales with the full dataset: sorting (once per sort change), key generation, and the search index (built on the first search, reused until `data` or `columns` change). Pass stable `data`, `columns` and `keyExtractor` references to avoid repeating that work. ## Roadmap -- v0.2: column resizing, inline cell editing, filtering +- Server-driven data: manual sorting and filtering, loading, refresh and load-more +- Beta feedback decides what comes next (column resizing and inline editing are candidates) - Measure on real low-end Android phones and cut JS work per row - Later: rendering only visible columns for very wide tables, right-pinned columns, row grouping diff --git a/example/src/App.tsx b/example/src/App.tsx index 7c928b4..cc1c596 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -3,14 +3,19 @@ import { Pressable, StyleSheet, Text, + TextInput, View, useColorScheme, } from 'react-native'; import { StatusBar } from 'expo-status-bar'; import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; import { DataGrid, darkTheme, lightTheme } from 'react-native-datagrid'; -import type { DataGridColumn, DataGridTheme } from 'react-native-datagrid'; -import { makeWorkOrders } from './data'; +import type { + ColumnFilters, + DataGridColumn, + DataGridTheme, +} from 'react-native-datagrid'; +import { STATUSES, makeWorkOrders } from './data'; import type { WorkOrder, WorkOrderStatus } from './data'; import { PerfMonitor } from './PerfMonitor'; @@ -123,6 +128,26 @@ export default function App() { const [selected, setSelected] = useState([]); const [showPerf, setShowPerf] = useState(true); + const [search, setSearch] = useState(''); + const [statuses, setStatuses] = useState([]); + const [overThousand, setOverThousand] = useState(false); + const [visibleCount, setVisibleCount] = useState(0); + const columnFilters = useMemo>( + () => ({ + status: statuses.length ? { type: 'values', values: statuses } : null, + amount: overThousand ? { type: 'range', min: 1000 } : null, + }), + [statuses, overThousand] + ); + const toggleStatus = (status: WorkOrderStatus) => + setStatuses((current) => + current.includes(status) + ? current.filter((s) => s !== status) + : [...current, status] + ); + const filtersActive = + search.length > 0 || statuses.length > 0 || overThousand; + return ( - {data.length.toLocaleString()} rows · {selected.length} selected + {visibleCount.toLocaleString()} of {data.length.toLocaleString()}{' '} + rows · {selected.length} selected setShowPerf(key === 'on')} /> + + + + + + {STATUSES.map((status) => ( + toggleStatus(status)} + /> + ))} + setOverThousand((v) => !v)} + /> + {filtersActive && ( + { + setSearch(''); + setStatuses([]); + setOverThousand(false); + }} + style={styles.clear} + accessibilityRole="button" + > + + Clear filters + + + )} + + {showPerf && } @@ -190,6 +278,42 @@ export default function App() { ); } +type ChipProps = { + theme: DataGridTheme; + label: string; + on: boolean; + onPress: () => void; +}; + +function Chip({ theme, label, on, onPress }: ChipProps) { + return ( + + + {label} + + + ); +} + type SegmentedProps = { theme: DataGridTheme; label: string; @@ -255,6 +379,24 @@ const styles = StyleSheet.create({ }, segment: { paddingHorizontal: 10, paddingVertical: 6 }, segmentText: { fontSize: 12, fontWeight: '600' }, + filters: { paddingHorizontal: 12, paddingBottom: 8, gap: 8 }, + search: { borderWidth: 1, borderRadius: 10, paddingHorizontal: 12 }, + searchInput: { height: 38, fontSize: 15 }, + chips: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + alignItems: 'center', + }, + chip: { + borderWidth: 1, + borderRadius: 999, + paddingHorizontal: 10, + paddingVertical: 5, + }, + chipText: { fontSize: 12, fontWeight: '600' }, + clear: { paddingHorizontal: 6, paddingVertical: 5 }, + clearText: { fontSize: 12, fontWeight: '600' }, pill: { alignSelf: 'flex-start', paddingHorizontal: 8, diff --git a/example/src/PerfMonitor.tsx b/example/src/PerfMonitor.tsx index 3dee0bb..c0524c5 100644 --- a/example/src/PerfMonitor.tsx +++ b/example/src/PerfMonitor.tsx @@ -2,7 +2,9 @@ import { useEffect, useRef, useState } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { useFrameCallback, useSharedValue } from 'react-native-reanimated'; -// A frame counts as dropped when it takes longer than 1.5x a 60Hz frame. +// Counts frame callback intervals longer than 25 ms (1.5x a 60 Hz frame). +// One long interval can hide several missed display frames, so this is a rough +// jank signal, not a dropped-frame count. Use platform profilers for real numbers. const JANK_MS = 25; type Stats = { fps: number; jank: number; worst: number }; @@ -63,7 +65,7 @@ export function PerfMonitor() { setUi(nextUi); setJs(nextJs); console.log( - `[perf] ui=${nextUi.fps}fps uiDropped=${nextUi.jank} uiWorst=${nextUi.worst}ms js=${nextJs.fps}fps jsDropped=${nextJs.jank} jsWorst=${nextJs.worst}ms` + `[perf] ui=${nextUi.fps}fps uiLong=${nextUi.jank} uiWorst=${nextUi.worst}ms js=${nextJs.fps}fps jsLong=${nextJs.jank} jsWorst=${nextJs.worst}ms` ); }, 1000); return () => clearInterval(id); @@ -80,10 +82,11 @@ export function PerfMonitor() { return ( - UI {ui.fps}fps · dropped {ui.jank} · worst {ui.worst}ms + UI {ui.fps}fps · long intervals {ui.jank} · worst {ui.worst}ms - JS {jsStats.fps}fps · dropped {jsStats.jank} · worst {jsStats.worst}ms + JS {jsStats.fps}fps · long intervals {jsStats.jank} · worst{' '} + {jsStats.worst}ms ({ sort: sortProp, defaultSort = null, onSortChange, + searchText, + columnFilters, + onFilteredCountChange, selectionMode = 'none', selectedKeys: selectedKeysProp, defaultSelectedKeys = EMPTY_KEYS, @@ -52,17 +56,59 @@ export function DataGrid({ // Sorting: controlled when `sort` is passed (null counts as controlled). const [innerSort, setInnerSort] = useState(defaultSort); const sort = sortProp !== undefined ? sortProp : innerSort; + + // Search index is built lazily on the first search, then reused until data or columns change. + const searchCache = useRef<{ + data: readonly T[]; + columns: readonly unknown[]; + index: string[]; + } | null>(null); + const hasSearch = !!searchText && searchText.trim().length > 0; + const filtered = useMemo(() => { + let searchIndex: string[] | undefined; + if (hasSearch) { + const cache = searchCache.current; + if (cache && cache.data === data && cache.columns === columns) { + searchIndex = cache.index; + } else { + searchIndex = buildSearchIndex(data, columns); + searchCache.current = { data, columns, index: searchIndex }; + } + } + return filterRows(data, columns, { + searchText: hasSearch ? searchText : undefined, + columnFilters, + searchIndex, + }); + }, [data, columns, hasSearch, searchText, columnFilters]); + + const filteredCount = filtered.length; + const onFilteredCountChangeRef = useRef(onFilteredCountChange); + onFilteredCountChangeRef.current = onFilteredCountChange; + useEffect(() => { + onFilteredCountChangeRef.current?.(filteredCount); + }, [filteredCount]); + const rows = useMemo( - () => sortRows(data, columns, sort), - [data, columns, sort] + () => sortRows(filtered, columns, sort), + [filtered, columns, sort] ); - const keys = useMemo( - () => rows.map((row, index) => keyExtractor(row, index)), - // keyExtractor is usually an inline function; keys only change with rows. - // eslint-disable-next-line react-hooks/exhaustive-deps - [rows] - ); + // Recompute keys whenever rows or keyExtractor change, but keep the previous array when the + // keys are identical, so an inline keyExtractor doesn't re-render visible rows. + const previousKeys = useRef(EMPTY_KEYS); + const keys = useMemo(() => { + const next = rows.map((row, index) => keyExtractor(row, index)); + const prev = previousKeys.current; + if ( + prev.length === next.length && + next.every((key, index) => key === prev[index]) + ) { + return prev; + } + return next; + }, [rows, keyExtractor]); + previousKeys.current = keys; // Selection: controlled when `selectedKeys` is passed. const [innerSelected, setInnerSelected] = useState>( diff --git a/src/GridRow.tsx b/src/GridRow.tsx index 9613e54..6d2b911 100644 --- a/src/GridRow.tsx +++ b/src/GridRow.tsx @@ -6,7 +6,7 @@ import type { StyleProp, ViewStyle } from 'react-native'; import { Checkbox } from './Checkbox'; import { CHECKBOX_COLUMN_WIDTH } from './layout'; import type { GridLayout } from './layout'; -import { getCellValue } from './sort'; +import { getCellText, getCellValue } from './cell'; import type { DataGridTheme } from './theme'; import type { CellAlign, DataGridColumn } from './types'; @@ -71,7 +71,7 @@ function GridRowImpl({ const texts: Record = {}; const labelParts: string[] = []; for (const column of [...layout.pinned, ...layout.scrolling]) { - const text = cellText(row, column); + const text = getCellText(row, column); texts[column.key] = text; labelParts.push(`${column.title}: ${text}`); } @@ -181,12 +181,6 @@ function GridRowImpl({ ); } -function cellText(row: T, column: DataGridColumn): string { - const value = getCellValue(row, column); - if (column.format) return column.format(value, row); - return value == null ? '' : String(value); -} - const SELECT_ACTIONS = [{ name: 'toggleSelection', label: 'Toggle selection' }]; export function alignStyle(align: CellAlign | undefined) { diff --git a/src/__tests__/filter.test.ts b/src/__tests__/filter.test.ts new file mode 100644 index 0000000..18795e3 --- /dev/null +++ b/src/__tests__/filter.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from '@jest/globals'; +import { + buildSearchIndex, + filterRows, + matchesColumnFilter, + normalizeSearchText, +} from '../filter'; +import type { DataGridColumn } from '../types'; + +type Row = { + id: string; + name: string; + status: 'Open' | 'Done'; + amount: number | null; + due: Date; + secret: string; +}; + +const columns: DataGridColumn[] = [ + { key: 'id', title: 'ID', width: 80 }, + { key: 'name', title: 'Name', width: 120 }, + { key: 'status', title: 'Status', width: 80 }, + { + key: 'amount', + title: 'Amount', + width: 80, + format: (v) => (v == null ? '' : `$${(v as number).toFixed(2)}`), + }, + { + key: 'due', + title: 'Due', + width: 80, + format: (v) => (v as Date).toISOString().slice(0, 10), + }, + { key: 'secret', title: 'Secret', width: 80, searchable: false }, +]; + +const rows: Row[] = [ + { + id: 'r1', + name: 'Café Luna', + status: 'Open', + amount: 1200, + due: new Date('2026-01-10'), + secret: 'zebra', + }, + { + id: 'r2', + name: 'Harbor Dental', + status: 'Done', + amount: 45.5, + due: new Date('2026-02-01'), + secret: 'lion', + }, + { + id: 'r3', + name: 'Atlas Freight', + status: 'Open', + amount: null, + due: new Date('2026-03-15'), + secret: 'tiger', + }, +]; + +const ids = (list: readonly Row[]) => list.map((r) => r.id); +const column = (key: string) => columns.find((c) => c.key === key)!; + +describe('normalizeSearchText', () => { + it('lowercases and strips accents', () => { + expect(normalizeSearchText('CAFÉ Ñandú')).toBe('cafe nandu'); + }); +}); + +describe('filterRows', () => { + it('returns the same array when nothing filters', () => { + expect(filterRows(rows, columns, {})).toBe(rows); + expect(filterRows(rows, columns, { searchText: ' ' })).toBe(rows); + expect(filterRows(rows, columns, { columnFilters: { name: null } })).toBe( + rows + ); + }); + + it('searches shown text across columns, ignoring case and accents', () => { + expect(ids(filterRows(rows, columns, { searchText: 'cafe' }))).toEqual([ + 'r1', + ]); + expect(ids(filterRows(rows, columns, { searchText: 'OPEN' }))).toEqual([ + 'r1', + 'r3', + ]); + expect(ids(filterRows(rows, columns, { searchText: '$45.50' }))).toEqual([ + 'r2', + ]); + expect(ids(filterRows(rows, columns, { searchText: '2026-03' }))).toEqual([ + 'r3', + ]); + }); + + it('skips columns marked searchable: false', () => { + expect(ids(filterRows(rows, columns, { searchText: 'zebra' }))).toEqual([]); + }); + + it('uses a prebuilt search index', () => { + const searchIndex = buildSearchIndex(rows, columns); + expect( + ids(filterRows(rows, columns, { searchText: 'atlas', searchIndex })) + ).toEqual(['r3']); + }); + + it('combines search and column filters', () => { + expect( + ids( + filterRows(rows, columns, { + searchText: 'a', + columnFilters: { status: { type: 'values', values: ['Open'] } }, + }) + ) + ).toEqual(['r1', 'r3']); + }); + + it('ignores filters for unknown columns', () => { + expect( + filterRows(rows, columns, { + columnFilters: { missing: { type: 'text', value: 'x' } }, + }) + ).toBe(rows); + }); +}); + +describe('matchesColumnFilter', () => { + it('text filter matches shown text', () => { + const f = { type: 'text' as const, value: 'harbor' }; + expect(matchesColumnFilter(rows[1]!, column('name'), f)).toBe(true); + expect(matchesColumnFilter(rows[0]!, column('name'), f)).toBe(false); + expect( + matchesColumnFilter(rows[0]!, column('name'), { type: 'text', value: '' }) + ).toBe(true); + }); + + it('values filter matches any listed value, including dates', () => { + expect( + matchesColumnFilter(rows[1]!, column('status'), { + type: 'values', + values: ['Done'], + }) + ).toBe(true); + expect( + matchesColumnFilter(rows[0]!, column('due'), { + type: 'values', + values: [new Date('2026-01-10')], + }) + ).toBe(true); + expect( + matchesColumnFilter(rows[0]!, column('status'), { + type: 'values', + values: [], + }) + ).toBe(true); + }); + + it('range filter is inclusive and skips empty cells', () => { + const amount = column('amount'); + expect( + matchesColumnFilter(rows[0]!, amount, { type: 'range', min: 1200 }) + ).toBe(true); + expect( + matchesColumnFilter(rows[1]!, amount, { type: 'range', min: 100 }) + ).toBe(false); + expect( + matchesColumnFilter(rows[2]!, amount, { type: 'range', max: 100 }) + ).toBe(false); + expect( + matchesColumnFilter(rows[1]!, column('due'), { + type: 'range', + min: new Date('2026-01-15'), + max: new Date('2026-02-01'), + }) + ).toBe(true); + }); + + it('range filter rejects cells that are not valid numbers or dates', () => { + const amount: DataGridColumn<{ v: unknown }> = { + key: 'v', + title: 'V', + width: 50, + }; + const f = { type: 'range' as const, min: 0, max: 10 }; + expect(matchesColumnFilter({ v: 5 }, amount, f)).toBe(true); + expect(matchesColumnFilter({ v: NaN }, amount, f)).toBe(false); + expect(matchesColumnFilter({ v: '5' }, amount, f)).toBe(false); + expect(matchesColumnFilter({ v: new Date('nope') }, amount, f)).toBe(false); + expect(matchesColumnFilter({ v: Infinity }, amount, f)).toBe(false); + }); + + it('range filter ignores invalid bounds', () => { + const amount: DataGridColumn<{ v: unknown }> = { + key: 'v', + title: 'V', + width: 50, + }; + expect( + matchesColumnFilter({ v: 5 }, amount, { + type: 'range', + min: NaN, + max: 10, + }) + ).toBe(true); + expect( + matchesColumnFilter({ v: 50 }, amount, { + type: 'range', + min: NaN, + max: 10, + }) + ).toBe(false); + expect( + matchesColumnFilter({ v: null }, amount, { + type: 'range', + min: new Date('nope'), + max: null, + }) + ).toBe(true); + }); + + it('custom filter calls the test', () => { + expect( + matchesColumnFilter(rows[2]!, column('id'), { + type: 'custom', + test: (r) => r.amount == null, + }) + ).toBe(true); + }); +}); diff --git a/src/cell.ts b/src/cell.ts new file mode 100644 index 0000000..06211bf --- /dev/null +++ b/src/cell.ts @@ -0,0 +1,13 @@ +import type { DataGridColumn } from './types'; + +export function getCellValue(row: T, column: DataGridColumn): unknown { + if (column.getValue) return column.getValue(row); + return (row as Record)[column.key]; +} + +/** The text a cell shows: `format` when set, otherwise the value as a string. */ +export function getCellText(row: T, column: DataGridColumn): string { + const value = getCellValue(row, column); + if (column.format) return column.format(value, row); + return value == null ? '' : String(value); +} diff --git a/src/filter.ts b/src/filter.ts new file mode 100644 index 0000000..1621301 --- /dev/null +++ b/src/filter.ts @@ -0,0 +1,120 @@ +import { getCellText, getCellValue } from './cell'; +import type { ColumnFilter, ColumnFilters, DataGridColumn } from './types'; + +const hasNormalize = typeof ''.normalize === 'function'; + +/** Lowercase and strip accents, so "cafe" matches "Café". */ +export function normalizeSearchText(text: string): string { + const lower = text.toLowerCase(); + if (!hasNormalize) return lower; + return lower.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); +} + +/** + * One searchable string per row, built from what each column shows. + * Build it once per `data`/`columns` change; searching then only scans strings. + */ +export function buildSearchIndex( + data: readonly T[], + columns: readonly DataGridColumn[] +): string[] { + const searchable = columns.filter((c) => c.searchable !== false); + return data.map((row) => { + let text = ''; + for (const column of searchable) { + const part = column.getSearchText + ? column.getSearchText(row) + : getCellText(row, column); + if (part) text += (text ? '\n' : '') + part; + } + return normalizeSearchText(text); + }); +} + +function toComparable(value: unknown): unknown { + return value instanceof Date ? value.getTime() : value; +} + +/** A finite number for range checks, or undefined for anything else (NaN, invalid dates, strings). */ +function toRangeNumber(value: unknown): number | undefined { + const comparable = toComparable(value); + return typeof comparable === 'number' && Number.isFinite(comparable) + ? comparable + : undefined; +} + +export function matchesColumnFilter( + row: T, + column: DataGridColumn, + filter: ColumnFilter +): boolean { + switch (filter.type) { + case 'text': { + const query = normalizeSearchText(filter.value.trim()); + if (!query) return true; + return normalizeSearchText(getCellText(row, column)).includes(query); + } + case 'values': { + if (filter.values.length === 0) return true; + const value = toComparable(getCellValue(row, column)); + return filter.values.some((v) => Object.is(toComparable(v), value)); + } + case 'range': { + // Bounds that are not valid numbers or dates are ignored. + // Cells that are not valid numbers or dates never match. + const min = toRangeNumber(filter.min); + const max = toRangeNumber(filter.max); + if (min === undefined && max === undefined) return true; + const value = toRangeNumber(getCellValue(row, column)); + if (value === undefined) return false; + if (min !== undefined && value < min) return false; + if (max !== undefined && value > max) return false; + return true; + } + case 'custom': + return filter.test(row); + } +} + +export type FilterOptions = { + searchText?: string; + columnFilters?: ColumnFilters; + /** Result of `buildSearchIndex` for the same data and columns. Built on demand when omitted. */ + searchIndex?: readonly string[]; +}; + +/** + * Keeps rows that match the search text and every column filter. + * Returns `data` itself when nothing filters. + */ +export function filterRows( + data: readonly T[], + columns: readonly DataGridColumn[], + { searchText, columnFilters, searchIndex }: FilterOptions +): readonly T[] { + const query = searchText ? normalizeSearchText(searchText.trim()) : ''; + const active: [DataGridColumn, ColumnFilter][] = []; + if (columnFilters) { + for (const column of columns) { + const filter = columnFilters[column.key]; + if (filter) active.push([column, filter]); + } + } + if (!query && active.length === 0) return data; + + const index = query ? (searchIndex ?? buildSearchIndex(data, columns)) : null; + const result: T[] = []; + for (let i = 0; i < data.length; i++) { + const row = data[i] as T; + if (index && !(index[i] ?? '').includes(query)) continue; + let keep = true; + for (const [column, filter] of active) { + if (!matchesColumnFilter(row, column, filter)) { + keep = false; + break; + } + } + if (keep) result.push(row); + } + return result; +} diff --git a/src/index.tsx b/src/index.tsx index 5ed0ad2..d4f021c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,10 +1,19 @@ export { DataGrid } from './DataGrid'; -export { compareValues, getCellValue, nextSort, sortRows } from './sort'; +export { getCellText, getCellValue } from './cell'; +export { + buildSearchIndex, + filterRows, + matchesColumnFilter, + normalizeSearchText, +} from './filter'; +export { compareValues, nextSort, sortRows } from './sort'; export { darkTheme, lightTheme } from './theme'; export type { DataGridTheme } from './theme'; export type { CellAlign, CellRenderInfo, + ColumnFilter, + ColumnFilters, DataGridColumn, DataGridProps, SelectionMode, diff --git a/src/sort.ts b/src/sort.ts index f5bb73f..22c2f2c 100644 --- a/src/sort.ts +++ b/src/sort.ts @@ -1,10 +1,6 @@ +import { getCellValue } from './cell'; import type { DataGridColumn, SortState } from './types'; -export function getCellValue(row: T, column: DataGridColumn): unknown { - if (column.getValue) return column.getValue(row); - return (row as Record)[column.key]; -} - const collator = typeof Intl !== 'undefined' && typeof Intl.Collator === 'function' ? new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }) diff --git a/src/types.ts b/src/types.ts index 937249f..2626f6c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,25 @@ export type SelectionMode = 'none' | 'single' | 'multiple'; export type CellAlign = 'left' | 'center' | 'right'; +/** Filter for one column. Rows must match every active column filter. */ +export type ColumnFilter = + /** Cell text contains the value (ignores case and accents). */ + | { type: 'text'; value: string } + /** Cell value equals one of the values. An empty list matches everything. */ + | { type: 'values'; values: readonly unknown[] } + /** + * Numeric or date cell value between min and max, inclusive. + * Empty, NaN, invalid-date and non-numeric cells never match. Invalid bounds are ignored. + */ + | { type: 'range'; min?: number | Date | null; max?: number | Date | null } + /** Your own test. */ + | { type: 'custom'; test: (row: T) => boolean }; + +/** Column filters by column key. `null` or `undefined` means no filter for that column. */ +export type ColumnFilters = Readonly< + Record | null | undefined> +>; + export type CellRenderInfo = { row: T; rowIndex: number; @@ -41,6 +60,10 @@ export type DataGridColumn = { format?: (value: unknown, row: T) => string; /** Render custom cell content. */ renderCell?: (info: CellRenderInfo) => ReactNode; + /** Include this column in `searchText` matching. Default true. */ + searchable?: boolean; + /** Text used for search. Defaults to the shown text (`format` or the value). */ + getSearchText?: (row: T) => string; }; export type DataGridProps = { @@ -62,6 +85,13 @@ export type DataGridProps = { defaultSort?: SortState | null; onSortChange?: (sort: SortState | null) => void; + /** Show only rows whose searchable columns contain this text (ignores case and accents). */ + searchText?: string; + /** Show only rows matching every filter, by column key. */ + columnFilters?: ColumnFilters; + /** Called after filtering with the number of visible rows. */ + onFilteredCountChange?: (count: number) => void; + /** `multiple` adds a pinned checkbox column. Default `none`. */ selectionMode?: SelectionMode; /** Controlled selection. */ @@ -77,7 +107,7 @@ export type DataGridProps = { /** Override individual theme tokens. */ theme?: Partial; - /** Shown when `data` is empty. */ + /** Shown when there are no rows to show (including when filters hide every row). */ emptyText?: string; style?: StyleProp; testID?: string; From de586166cf8345baa93fe609f27b9b912b73c04a Mon Sep 17 00:00:00 2001 From: ahmdshrif Date: Thu, 17 Sep 2026 13:40:53 +0300 Subject: [PATCH 2/2] feat: add server-driven data mode - manualSorting keeps the supplied row order while the header still shows sort and taps still call onSortChange - manualFiltering shows supplied rows without local search or filters - loading shows a spinner only while there are no rows, so existing rows stay visible during a new query - loadingMore footer, pull-to-refresh (refreshing/onRefresh), onEndReached, and error with optional Retry in the empty state or footer - getVisibleRows helper with tests for manual modes - Example app: Local and Server tabs; the Server tab pages a fake API with latency, server-side sort/search/status filter, refresh, load more and a fail-next-request switch --- README.md | 37 ++++ example/src/App.tsx | 419 ++++--------------------------------- example/src/LocalDemo.tsx | 121 +++++++++++ example/src/ServerDemo.tsx | 181 ++++++++++++++++ example/src/columns.tsx | 108 ++++++++++ example/src/controls.tsx | 172 +++++++++++++++ example/src/fakeServer.ts | 63 ++++++ src/DataGrid.tsx | 150 +++++++++++-- src/__tests__/rows.test.ts | 58 +++++ src/rows.ts | 33 +++ src/types.ts | 30 +++ 11 files changed, 978 insertions(+), 394 deletions(-) create mode 100644 example/src/LocalDemo.tsx create mode 100644 example/src/ServerDemo.tsx create mode 100644 example/src/columns.tsx create mode 100644 example/src/controls.tsx create mode 100644 example/src/fakeServer.ts create mode 100644 src/__tests__/rows.test.ts create mode 100644 src/rows.ts diff --git a/README.md b/README.md index 66c8d57..7e2ae05 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Fast data grid for React Native: pinned columns, sticky header, virtualized rows - **Pinned columns and sticky header** that follow sideways scrolling on the UI thread, so they never lag behind the body - **Virtualized rows** with [FlashList](https://shopify.github.io/flash-list/): only rows on screen are mounted. Sorting, filtering and key generation still process the whole dataset in JS - **Sorting**: tap a header to cycle ascending, descending, unsorted. Stable, natural string order, empty values last +- **Server-driven data**: manual sorting and filtering, loading, pull-to-refresh, load more, and error states with retry - **Search and filters**: quick search that ignores case and accents, plus text, value, range and custom column filters - **Selection**: single or multiple, with a pinned checkbox column and select-all - **Custom cells** through `renderCell`, or plain text through `format` @@ -97,6 +98,17 @@ The grid fills its parent (`flex: 1`), so give the parent a height. | `searchText` | `string` | | Show rows whose searchable columns contain this text. | | `columnFilters` | `Record` | | Show rows matching every filter, by column key. | | `onFilteredCountChange` | `(count) => void` | | Number of rows left after filtering. | +| `manualSorting` | `boolean` | `false` | Keep the order of `data`. The header still shows `sort` and taps still call `onSortChange`. | +| `manualFiltering` | `boolean` | `false` | Show `data` without applying `searchText` or `columnFilters`. | +| `loading` | `boolean` | `false` | First load. Shows a spinner only while there are no rows. | +| `loadingText` | `string` | `'Loading'` | Text under the first-load spinner. | +| `loadingMore` | `boolean` | `false` | Shows a spinner below the last row. | +| `refreshing` | `boolean` | | Pull-to-refresh state. | +| `onRefresh` | `() => void` | | Enables pull-to-refresh. | +| `onEndReached` | `() => void` | | Called near the end of the list, to load the next page. | +| `onEndReachedThreshold` | `number` | `0.5` | How far from the end `onEndReached` fires, in visible list lengths. | +| `error` | `string \| null` | | Error message. Replaces the empty state when there are no rows, otherwise shows below the last row. | +| `onRetry` | `() => void` | | Shows a Retry button next to `error`. | | `selectionMode` | `'none' \| 'single' \| 'multiple'` | `'none'` | `multiple` adds a pinned checkbox column. | | `selectedKeys` | `string[]` | | Controlled selection. | | `defaultSelectedKeys` | `string[]` | `[]` | Initial selection when uncontrolled. | @@ -141,6 +153,31 @@ const columnFilters = useMemo>( The grid has no built-in filter controls yet, so you render your own search box and chips (see the example app). `filterRows`, `buildSearchIndex` and `matchesColumnFilter` are exported for filtering outside the grid. +## Server-driven data + +When a server sorts, filters or pages the data, turn off local processing and let the grid report what the user asked for. Your app owns fetching, pagination, retries and request cancellation; the grid only shows the states. + +```tsx + row.id} + sort={sort} + onSortChange={setSort} // refetch page 1 with the new sort + manualSorting + manualFiltering + loading={isFirstLoad} // spinner only while there are no rows + loadingMore={isLoadingNextPage} // footer spinner, rows stay visible + refreshing={isRefreshing} + onRefresh={refetchFirstPage} + onEndReached={loadNextPage} + error={errorMessage} + onRetry={retryLastRequest} +/> +``` + +When a new sort or filter is loading and rows are already on screen, those rows stay visible until the new page arrives. The example app's **Server** tab shows a complete flow with a fake paged API, including a switch that makes the next request fail. + ## Performance Early measurements on the example app (Release build, 10,000 rows × 13 columns, checkbox column plus 2 pinned columns, about 12 seconds of fast flings and sideways swipes). The numbers are frame callback intervals longer than 25 ms, a rough jank signal rather than a dropped-frame count: diff --git a/example/src/App.tsx b/example/src/App.tsx index cc1c596..e2b3f07 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,152 +1,23 @@ -import { useMemo, useState } from 'react'; -import { - Pressable, - StyleSheet, - Text, - TextInput, - View, - useColorScheme, -} from 'react-native'; +import { useState } from 'react'; +import { StyleSheet, Text, View, useColorScheme } from 'react-native'; import { StatusBar } from 'expo-status-bar'; import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; -import { DataGrid, darkTheme, lightTheme } from 'react-native-datagrid'; -import type { - ColumnFilters, - DataGridColumn, - DataGridTheme, -} from 'react-native-datagrid'; -import { STATUSES, makeWorkOrders } from './data'; -import type { WorkOrder, WorkOrderStatus } from './data'; +import { darkTheme, lightTheme } from 'react-native-datagrid'; +import type { DataGridTheme } from 'react-native-datagrid'; +import { Segmented } from './controls'; +import { LocalDemo } from './LocalDemo'; import { PerfMonitor } from './PerfMonitor'; +import { ServerDemo } from './ServerDemo'; -const ROW_COUNTS = [100, 10000] as const; - -const money = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', -}); -const shortDate = new Intl.DateTimeFormat('en-US', { - month: 'short', - day: 'numeric', - timeZone: 'UTC', -}); - -const STATUS_COLORS: Record< - WorkOrderStatus, - { light: [string, string]; dark: [string, string] } -> = { - 'Scheduled': { light: ['#DBE7F7', '#1C5AAB'], dark: ['#162941', '#8AB7F6'] }, - 'In progress': { - light: ['#FAEACD', '#8F5400'], - dark: ['#33260F', '#F1BA5E'], - }, - 'Done': { light: ['#DCF1E2', '#1C7439'], dark: ['#142E1D', '#6FD592'] }, - 'Blocked': { light: ['#FBE0DC', '#B42318'], dark: ['#381916', '#FF8C7F'] }, - 'Cancelled': { light: ['#E6EBE8', '#5F6B66'], dark: ['#222A26', '#A1ADA7'] }, -}; - -function makeColumns(dark: boolean): DataGridColumn[] { - return [ - { key: 'id', title: 'Order', width: 96, pinned: 'left', sortable: true }, - { - key: 'customer', - title: 'Customer', - width: 170, - pinned: 'left', - sortable: true, - }, - { - key: 'status', - title: 'Status', - width: 120, - sortable: true, - renderCell: ({ value }) => { - const status = value as WorkOrderStatus; - const [bg, fg] = STATUS_COLORS[status][dark ? 'dark' : 'light']; - return ( - - {status} - - ); - }, - }, - { key: 'priority', title: 'Priority', width: 90, sortable: true }, - { key: 'region', title: 'Region', width: 96, sortable: true }, - { key: 'technician', title: 'Technician', width: 140, sortable: true }, - { - key: 'scheduled', - title: 'Scheduled', - width: 100, - sortable: true, - format: (v) => shortDate.format(v as Date), - }, - { - key: 'hours', - title: 'Hours', - width: 72, - align: 'right', - sortable: true, - format: (v) => (v as number).toFixed(1), - }, - { - key: 'amount', - title: 'Amount', - width: 110, - align: 'right', - sortable: true, - format: (v) => money.format(v as number), - }, - { key: 'site', title: 'Site', width: 80 }, - { - key: 'visits', - title: 'Visits', - width: 72, - align: 'right', - sortable: true, - }, - { - key: 'rating', - title: 'Rating', - width: 76, - align: 'right', - sortable: true, - format: (v) => (v == null ? '—' : (v as number).toFixed(1)), - }, - { key: 'notes', title: 'Notes', width: 230 }, - ]; -} +type Mode = 'local' | 'server'; export default function App() { const systemScheme = useColorScheme(); const [darkOverride, setDarkOverride] = useState(null); const dark = darkOverride ?? systemScheme === 'dark'; const theme: DataGridTheme = dark ? darkTheme : lightTheme; - - const [rowCount, setRowCount] = useState<(typeof ROW_COUNTS)[number]>(10000); - const data = useMemo(() => makeWorkOrders(rowCount), [rowCount]); - const columns = useMemo(() => makeColumns(dark), [dark]); - const [selected, setSelected] = useState([]); - const [showPerf, setShowPerf] = useState(true); - - const [search, setSearch] = useState(''); - const [statuses, setStatuses] = useState([]); - const [overThousand, setOverThousand] = useState(false); - const [visibleCount, setVisibleCount] = useState(0); - const columnFilters = useMemo>( - () => ({ - status: statuses.length ? { type: 'values', values: statuses } : null, - amount: overThousand ? { type: 'range', min: 1000 } : null, - }), - [statuses, overThousand] - ); - const toggleStatus = (status: WorkOrderStatus) => - setStatuses((current) => - current.includes(status) - ? current.filter((s) => s !== status) - : [...current, status] - ); - const filtersActive = - search.length > 0 || statuses.length > 0 || overThousand; + const [mode, setMode] = useState('local'); + const [showPerf, setShowPerf] = useState(false); return ( @@ -156,252 +27,54 @@ export default function App() { > - - - Work orders - - - {visibleCount.toLocaleString()} of {data.length.toLocaleString()}{' '} - rows · {selected.length} selected - - - ({ - key: String(n), - label: n >= 1000 ? `${n / 1000}k` : String(n), - }))} - value={String(rowCount)} - onChange={(key) => { - setRowCount(Number(key) as (typeof ROW_COUNTS)[number]); - setSelected([]); - }} - /> - setDarkOverride(key === 'dark')} - /> - setShowPerf(key === 'on')} - /> - - - - Work orders + + setMode(key as Mode)} /> - - - {STATUSES.map((status) => ( - toggleStatus(status)} - /> - ))} - setDarkOverride(key === 'dark')} + /> + setOverThousand((v) => !v)} + label="Perf" + options={[ + { key: 'on', label: 'FPS' }, + { key: 'off', label: 'Off' }, + ]} + value={showPerf ? 'on' : 'off'} + onChange={(key) => setShowPerf(key === 'on')} /> - {filtersActive && ( - { - setSearch(''); - setStatuses([]); - setOverThousand(false); - }} - style={styles.clear} - accessibilityRole="button" - > - - Clear filters - - - )} {showPerf && } - row.id} - selectionMode="multiple" - selectedKeys={selected} - onSelectionChange={setSelected} - defaultSort={{ columnKey: 'scheduled', direction: 'asc' }} - searchText={search} - columnFilters={columnFilters} - onFilteredCountChange={setVisibleCount} - emptyText="No work orders match these filters" - - colorScheme={dark ? 'dark' : 'light'} - testID="work-orders-grid" - /> + {mode === 'local' ? ( + + ) : ( + + )} ); } -type ChipProps = { - theme: DataGridTheme; - label: string; - on: boolean; - onPress: () => void; -}; - -function Chip({ theme, label, on, onPress }: ChipProps) { - return ( - - - {label} - - - ); -} - -type SegmentedProps = { - theme: DataGridTheme; - label: string; - options: { key: string; label: string }[]; - value: string; - onChange: (key: string) => void; -}; - -function Segmented({ theme, label, options, value, onChange }: SegmentedProps) { - return ( - - {options.map((option) => { - const on = option.key === value; - return ( - onChange(option.key)} - style={[ - styles.segment, - { backgroundColor: on ? theme.accent : theme.background }, - ]} - accessibilityRole="radio" - accessibilityState={{ checked: on }} - accessibilityLabel={`${label} ${option.label}`} - > - - {option.label} - - - ); - })} - - ); -} - const styles = StyleSheet.create({ flex: { flex: 1 }, - toolbar: { - flexDirection: 'row', - flexWrap: 'wrap', - alignItems: 'center', - gap: 8, - paddingHorizontal: 12, - paddingVertical: 8, - }, - titleBlock: { flexBasis: '100%' }, + toolbar: { paddingHorizontal: 12, paddingVertical: 8, gap: 8 }, title: { fontSize: 18, fontWeight: '700' }, - subtitle: { fontSize: 12, fontVariant: ['tabular-nums'] }, - segmented: { - flexDirection: 'row', - borderWidth: 1, - borderRadius: 8, - overflow: 'hidden', - }, - segment: { paddingHorizontal: 10, paddingVertical: 6 }, - segmentText: { fontSize: 12, fontWeight: '600' }, - filters: { paddingHorizontal: 12, paddingBottom: 8, gap: 8 }, - search: { borderWidth: 1, borderRadius: 10, paddingHorizontal: 12 }, - searchInput: { height: 38, fontSize: 15 }, - chips: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 6, - alignItems: 'center', - }, - chip: { - borderWidth: 1, - borderRadius: 999, - paddingHorizontal: 10, - paddingVertical: 5, - }, - chipText: { fontSize: 12, fontWeight: '600' }, - clear: { paddingHorizontal: 6, paddingVertical: 5 }, - clearText: { fontSize: 12, fontWeight: '600' }, - pill: { - alignSelf: 'flex-start', - paddingHorizontal: 8, - paddingVertical: 3, - borderRadius: 999, - }, - pillText: { fontSize: 12, fontWeight: '600' }, + controls: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, }); diff --git a/example/src/LocalDemo.tsx b/example/src/LocalDemo.tsx new file mode 100644 index 0000000..bcfdc32 --- /dev/null +++ b/example/src/LocalDemo.tsx @@ -0,0 +1,121 @@ +import { useMemo, useState } from 'react'; +import { Text, View } from 'react-native'; +import { DataGrid } from 'react-native-datagrid'; +import type { ColumnFilters, DataGridTheme } from 'react-native-datagrid'; +import { makeColumns } from './columns'; +import { + Chip, + LinkButton, + SearchBox, + Segmented, + controlStyles, +} from './controls'; +import { STATUSES, makeWorkOrders } from './data'; +import type { WorkOrder, WorkOrderStatus } from './data'; + +const ROW_COUNTS = [100, 10000] as const; + +type Props = { theme: DataGridTheme; dark: boolean }; + +/** All rows in memory; the grid sorts, searches and filters locally. */ +export function LocalDemo({ theme, dark }: Props) { + const [rowCount, setRowCount] = useState<(typeof ROW_COUNTS)[number]>(10000); + const data = useMemo(() => makeWorkOrders(rowCount), [rowCount]); + const columns = useMemo(() => makeColumns(dark), [dark]); + const [selected, setSelected] = useState([]); + + const [search, setSearch] = useState(''); + const [statuses, setStatuses] = useState([]); + const [overThousand, setOverThousand] = useState(false); + const [visibleCount, setVisibleCount] = useState(0); + const columnFilters = useMemo>( + () => ({ + status: statuses.length ? { type: 'values', values: statuses } : null, + amount: overThousand ? { type: 'range', min: 1000 } : null, + }), + [statuses, overThousand] + ); + const toggleStatus = (status: WorkOrderStatus) => + setStatuses((current) => + current.includes(status) + ? current.filter((s) => s !== status) + : [...current, status] + ); + const filtersActive = + search.length > 0 || statuses.length > 0 || overThousand; + + return ( + <> + + + + {visibleCount.toLocaleString()} of {data.length.toLocaleString()}{' '} + rows · {selected.length} selected + + ({ + key: String(n), + label: n >= 1000 ? `${n / 1000}k` : String(n), + }))} + value={String(rowCount)} + onChange={(key) => { + setRowCount(Number(key) as (typeof ROW_COUNTS)[number]); + setSelected([]); + }} + /> + + + + {STATUSES.map((status) => ( + toggleStatus(status)} + /> + ))} + setOverThousand((v) => !v)} + /> + {filtersActive && ( + { + setSearch(''); + setStatuses([]); + setOverThousand(false); + }} + /> + )} + + + row.id} + selectionMode="multiple" + selectedKeys={selected} + onSelectionChange={setSelected} + defaultSort={{ columnKey: 'scheduled', direction: 'asc' }} + searchText={search} + columnFilters={columnFilters} + onFilteredCountChange={setVisibleCount} + emptyText="No work orders match these filters" + colorScheme={dark ? 'dark' : 'light'} + testID="work-orders-grid" + /> + + ); +} diff --git a/example/src/ServerDemo.tsx b/example/src/ServerDemo.tsx new file mode 100644 index 0000000..a10abd6 --- /dev/null +++ b/example/src/ServerDemo.tsx @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Text, View } from 'react-native'; +import { DataGrid } from 'react-native-datagrid'; +import type { DataGridTheme, SortState } from 'react-native-datagrid'; +import { makeColumns } from './columns'; +import { Chip, LinkButton, SearchBox, controlStyles } from './controls'; +import { STATUSES } from './data'; +import type { WorkOrder, WorkOrderStatus } from './data'; +import { fetchOrders } from './fakeServer'; + +const PAGE_SIZE = 50; + +type RequestKind = 'first' | 'more' | 'refresh'; +type Props = { theme: DataGridTheme; dark: boolean }; + +function useDebounced(value: V, ms: number): V { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), ms); + return () => clearTimeout(timer); + }, [value, ms]); + return debounced; +} + +/** + * Rows come from a (fake) server one page at a time. The grid runs with + * manualSorting and manualFiltering, so it only reports what the user asked for. + */ +export function ServerDemo({ theme, dark }: Props) { + const columns = useMemo(() => makeColumns(dark), [dark]); + const [sort, setSort] = useState({ + columnKey: 'scheduled', + direction: 'asc', + }); + const [searchInput, setSearchInput] = useState(''); + const search = useDebounced(searchInput, 300); + const [statuses, setStatuses] = useState([]); + const [failNext, setFailNext] = useState(false); + const failNextRef = useRef(failNext); + failNextRef.current = failNext; + + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [nextPage, setNextPage] = useState(0); + const [kind, setKind] = useState(null); + const [error, setError] = useState(null); + const controller = useRef(null); + const lastRequest = useRef<{ page: number; kind: RequestKind }>({ + page: 0, + kind: 'first', + }); + + const request = useCallback( + async (page: number, requestKind: RequestKind) => { + controller.current?.abort(); + const current = new AbortController(); + controller.current = current; + lastRequest.current = { page, kind: requestKind }; + setKind(requestKind); + setError(null); + + const fail = failNextRef.current; + if (fail) setFailNext(false); + try { + const result = await fetchOrders({ + page, + pageSize: PAGE_SIZE, + sort, + search, + statuses, + fail, + signal: current.signal, + }); + setRows((prev) => + page === 0 ? result.rows : [...prev, ...result.rows] + ); + setTotal(result.total); + setNextPage(result.nextPage); + } catch (e) { + if (current.signal.aborted) return; + setError(e instanceof Error ? e.message : 'Request failed'); + } finally { + if (controller.current === current) setKind(null); + } + }, + [sort, search, statuses] + ); + + // New sort, search or status filter: fetch the first page again, keeping current rows visible. + useEffect(() => { + request(0, 'first'); + }, [request]); + useEffect(() => () => controller.current?.abort(), []); + + const onEndReached = () => { + if (kind === null && !error && nextPage !== null && rows.length > 0) { + request(nextPage, 'more'); + } + }; + const onRetry = () => { + const { page, kind: lastKind } = lastRequest.current; + request(page, lastKind === 'refresh' ? 'first' : lastKind); + }; + + const toggleStatus = (status: WorkOrderStatus) => + setStatuses((current) => + current.includes(status) + ? current.filter((s) => s !== status) + : [...current, status] + ); + + const status = + kind === 'first' && rows.length > 0 + ? 'Updating…' + : `${rows.length.toLocaleString()} of ${total.toLocaleString()} loaded`; + + return ( + <> + + + + {status} · page size {PAGE_SIZE} · 700 ms latency + + setFailNext((v) => !v)} + /> + + + + {STATUSES.map((s) => ( + toggleStatus(s)} + /> + ))} + {(searchInput.length > 0 || statuses.length > 0) && ( + { + setSearchInput(''); + setStatuses([]); + }} + /> + )} + + + row.id} + sort={sort} + onSortChange={setSort} + manualSorting + manualFiltering + loading={kind === 'first'} + loadingText="Loading work orders" + loadingMore={kind === 'more'} + refreshing={kind === 'refresh'} + onRefresh={() => request(0, 'refresh')} + onEndReached={onEndReached} + error={error} + onRetry={onRetry} + emptyText="No work orders match these filters" + colorScheme={dark ? 'dark' : 'light'} + testID="server-grid" + /> + + ); +} diff --git a/example/src/columns.tsx b/example/src/columns.tsx new file mode 100644 index 0000000..a7b814c --- /dev/null +++ b/example/src/columns.tsx @@ -0,0 +1,108 @@ +import { StyleSheet, Text, View } from 'react-native'; +import type { DataGridColumn } from 'react-native-datagrid'; +import type { WorkOrder, WorkOrderStatus } from './data'; + +const money = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', +}); +const shortDate = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + timeZone: 'UTC', +}); + +const STATUS_COLORS: Record< + WorkOrderStatus, + { light: [string, string]; dark: [string, string] } +> = { + 'Scheduled': { light: ['#DBE7F7', '#1C5AAB'], dark: ['#162941', '#8AB7F6'] }, + 'In progress': { + light: ['#FAEACD', '#8F5400'], + dark: ['#33260F', '#F1BA5E'], + }, + 'Done': { light: ['#DCF1E2', '#1C7439'], dark: ['#142E1D', '#6FD592'] }, + 'Blocked': { light: ['#FBE0DC', '#B42318'], dark: ['#381916', '#FF8C7F'] }, + 'Cancelled': { light: ['#E6EBE8', '#5F6B66'], dark: ['#222A26', '#A1ADA7'] }, +}; + +export function makeColumns(dark: boolean): DataGridColumn[] { + return [ + { key: 'id', title: 'Order', width: 96, pinned: 'left', sortable: true }, + { + key: 'customer', + title: 'Customer', + width: 170, + pinned: 'left', + sortable: true, + }, + { + key: 'status', + title: 'Status', + width: 120, + sortable: true, + renderCell: ({ value }) => { + const status = value as WorkOrderStatus; + const [bg, fg] = STATUS_COLORS[status][dark ? 'dark' : 'light']; + return ( + + {status} + + ); + }, + }, + { key: 'priority', title: 'Priority', width: 90, sortable: true }, + { key: 'region', title: 'Region', width: 96, sortable: true }, + { key: 'technician', title: 'Technician', width: 140, sortable: true }, + { + key: 'scheduled', + title: 'Scheduled', + width: 100, + sortable: true, + format: (v) => shortDate.format(v as Date), + }, + { + key: 'hours', + title: 'Hours', + width: 72, + align: 'right', + sortable: true, + format: (v) => (v as number).toFixed(1), + }, + { + key: 'amount', + title: 'Amount', + width: 110, + align: 'right', + sortable: true, + format: (v) => money.format(v as number), + }, + { key: 'site', title: 'Site', width: 80 }, + { + key: 'visits', + title: 'Visits', + width: 72, + align: 'right', + sortable: true, + }, + { + key: 'rating', + title: 'Rating', + width: 76, + align: 'right', + sortable: true, + format: (v) => (v == null ? '—' : (v as number).toFixed(1)), + }, + { key: 'notes', title: 'Notes', width: 230 }, + ]; +} + +const styles = StyleSheet.create({ + pill: { + alignSelf: 'flex-start', + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 999, + }, + pillText: { fontSize: 12, fontWeight: '600' }, +}); diff --git a/example/src/controls.tsx b/example/src/controls.tsx new file mode 100644 index 0000000..fdaa1b5 --- /dev/null +++ b/example/src/controls.tsx @@ -0,0 +1,172 @@ +import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import type { DataGridTheme } from 'react-native-datagrid'; + +type ChipProps = { + theme: DataGridTheme; + label: string; + on: boolean; + onPress: () => void; +}; + +export function Chip({ theme, label, on, onPress }: ChipProps) { + return ( + + + {label} + + + ); +} + +type SegmentedProps = { + theme: DataGridTheme; + label: string; + options: { key: string; label: string }[]; + value: string; + onChange: (key: string) => void; +}; + +export function Segmented({ + theme, + label, + options, + value, + onChange, +}: SegmentedProps) { + return ( + + {options.map((option) => { + const on = option.key === value; + return ( + onChange(option.key)} + style={[ + styles.segment, + { backgroundColor: on ? theme.accent : theme.background }, + ]} + accessibilityRole="radio" + accessibilityState={{ checked: on }} + accessibilityLabel={`${label} ${option.label}`} + > + + {option.label} + + + ); + })} + + ); +} + +type SearchBoxProps = { + theme: DataGridTheme; + value: string; + onChange: (text: string) => void; + placeholder: string; +}; + +export function SearchBox({ + theme, + value, + onChange, + placeholder, +}: SearchBoxProps) { + return ( + + + + ); +} + +type LinkButtonProps = { + theme: DataGridTheme; + label: string; + onPress: () => void; +}; + +export function LinkButton({ theme, label, onPress }: LinkButtonProps) { + return ( + + {label} + + ); +} + +export const controlStyles = StyleSheet.create({ + filters: { paddingHorizontal: 12, paddingBottom: 8, gap: 8 }, + chips: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + alignItems: 'center', + }, + subtitle: { fontSize: 12, fontVariant: ['tabular-nums'] }, +}); + +const styles = StyleSheet.create({ + segmented: { + flexDirection: 'row', + borderWidth: 1, + borderRadius: 8, + overflow: 'hidden', + }, + segment: { paddingHorizontal: 10, paddingVertical: 6 }, + segmentText: { fontSize: 12, fontWeight: '600' }, + search: { borderWidth: 1, borderRadius: 10, paddingHorizontal: 12 }, + searchInput: { height: 38, fontSize: 15 }, + chip: { + borderWidth: 1, + borderRadius: 999, + paddingHorizontal: 10, + paddingVertical: 5, + }, + chipText: { fontSize: 12, fontWeight: '600' }, + link: { paddingHorizontal: 6, paddingVertical: 5 }, + linkText: { fontSize: 12, fontWeight: '600' }, +}); diff --git a/example/src/fakeServer.ts b/example/src/fakeServer.ts new file mode 100644 index 0000000..d7700c5 --- /dev/null +++ b/example/src/fakeServer.ts @@ -0,0 +1,63 @@ +import { buildSearchIndex, filterRows, sortRows } from 'react-native-datagrid'; +import type { SortState } from 'react-native-datagrid'; +import { makeColumns } from './columns'; +import { makeWorkOrders } from './data'; +import type { WorkOrder, WorkOrderStatus } from './data'; + +// Stand-in for a real backend: sorts, searches and pages 5,000 orders after a delay. +const ALL = makeWorkOrders(5000); +const COLUMNS = makeColumns(false); +const SEARCH_INDEX = buildSearchIndex(ALL, COLUMNS); +const LATENCY_MS = 700; + +export type OrdersQuery = { + page: number; + pageSize: number; + sort: SortState | null; + search: string; + statuses: WorkOrderStatus[]; + /** Make this request fail, to show error handling. */ + fail?: boolean; + signal?: AbortSignal; +}; + +export type OrdersPage = { + rows: WorkOrder[]; + total: number; + nextPage: number | null; +}; + +function wait(ms: number, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener('abort', () => { + clearTimeout(timer); + reject(new Error('Request cancelled')); + }); + }); +} + +export async function fetchOrders(query: OrdersQuery): Promise { + await wait(LATENCY_MS, query.signal); + if (query.fail) { + throw new Error('Could not load work orders. Check your connection.'); + } + const filtered = filterRows(ALL, COLUMNS, { + searchText: query.search, + searchIndex: SEARCH_INDEX, + columnFilters: { + status: query.statuses.length + ? { type: 'values', values: query.statuses } + : null, + }, + }); + const sorted = sortRows(filtered, COLUMNS, query.sort); + const start = query.page * query.pageSize; + const rows = sorted.slice(start, start + query.pageSize); + const hasMore = start + query.pageSize < sorted.length; + return { + rows, + total: sorted.length, + nextPage: hasMore ? query.page + 1 : null, + }; +} diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index 5de9f25..fa588d4 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -1,5 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { StyleSheet, Text, View, useColorScheme } from 'react-native'; +import { + ActivityIndicator, + Pressable, + StyleSheet, + Text, + View, + useColorScheme, +} from 'react-native'; import type { LayoutChangeEvent } from 'react-native'; import { FlashList } from '@shopify/flash-list'; import type { ListRenderItemInfo } from '@shopify/flash-list'; @@ -10,10 +17,11 @@ import Animated, { } from 'react-native-reanimated'; import { GridHeader } from './GridHeader'; import { GridRow } from './GridRow'; -import { buildSearchIndex, filterRows } from './filter'; +import { buildSearchIndex } from './filter'; import { computeLayout } from './layout'; import { getSelectAllState, toggleAll, toggleSelection } from './selection'; -import { nextSort, sortRows } from './sort'; +import { getVisibleRows } from './rows'; +import { nextSort } from './sort'; import { darkTheme, lightTheme } from './theme'; import type { DataGridProps, SortState } from './types'; @@ -29,9 +37,20 @@ export function DataGrid({ sort: sortProp, defaultSort = null, onSortChange, + manualSorting = false, searchText, columnFilters, onFilteredCountChange, + manualFiltering = false, + loading = false, + loadingMore = false, + refreshing, + onRefresh, + onEndReached, + onEndReachedThreshold = 0.5, + error, + onRetry, + loadingText = 'Loading', selectionMode = 'none', selectedKeys: selectedKeysProp, defaultSelectedKeys = EMPTY_KEYS, @@ -63,8 +82,9 @@ export function DataGrid({ columns: readonly unknown[]; index: string[]; } | null>(null); - const hasSearch = !!searchText && searchText.trim().length > 0; - const filtered = useMemo(() => { + const hasSearch = + !manualFiltering && !!searchText && searchText.trim().length > 0; + const rows = useMemo(() => { let searchIndex: string[] | undefined; if (hasSearch) { const cache = searchCache.current; @@ -75,24 +95,31 @@ export function DataGrid({ searchCache.current = { data, columns, index: searchIndex }; } } - return filterRows(data, columns, { + return getVisibleRows(data, columns, { + sort, + manualSorting, searchText: hasSearch ? searchText : undefined, columnFilters, + manualFiltering, searchIndex, }); - }, [data, columns, hasSearch, searchText, columnFilters]); + }, [ + data, + columns, + sort, + manualSorting, + hasSearch, + searchText, + columnFilters, + manualFiltering, + ]); - const filteredCount = filtered.length; + const rowCount = rows.length; const onFilteredCountChangeRef = useRef(onFilteredCountChange); onFilteredCountChangeRef.current = onFilteredCountChange; useEffect(() => { - onFilteredCountChangeRef.current?.(filteredCount); - }, [filteredCount]); - - const rows = useMemo( - () => sortRows(filtered, columns, sort), - [filtered, columns, sort] - ); + onFilteredCountChangeRef.current?.(rowCount); + }, [rowCount]); // Recompute keys whenever rows or keyExtractor change, but keep the previous array when the // keys are identical, so an inline keyExtractor doesn't re-render visible rows. @@ -210,10 +237,50 @@ export function DataGrid({ return { transform: [{ translateX: -scrollX.value }] }; }); - const [bodyHeight, setBodyHeight] = useState(0); + const [bodySize, setBodySize] = useState({ width: 0, height: 0 }); const onBodyLayout = useCallback((event: LayoutChangeEvent) => { - setBodyHeight(event.nativeEvent.layout.height); + const { width, height } = event.nativeEvent.layout; + setBodySize((prev) => + prev.width === width && prev.height === height ? prev : { width, height } + ); }, []); + const bodyHeight = bodySize.height; + + const retryButton = onRetry ? ( + + Retry + + ) : null; + + // Footer spans the visible width and follows horizontal scroll, so it stays on screen. + const footer = + loadingMore || error ? ( + + {loadingMore ? ( + + ) : ( + <> + + {error} + + {retryButton} + + )} + + ) : null; const renderItem = useCallback( ({ item, index }: ListRenderItemInfo) => { @@ -270,10 +337,28 @@ export function DataGrid({ /> {rows.length === 0 ? ( - - - {emptyText} - + + {loading ? ( + <> + + + {loadingText} + + + ) : error ? ( + <> + + {error} + + {retryButton} + + ) : ( + + {emptyText} + + )} ) : ( bodyHeight > 0 && ( @@ -291,6 +376,11 @@ export function DataGrid({ keyExtractor={listKeyExtractor} extraData={selected} drawDistance={rowHeight * 6} + ListFooterComponent={footer} + onEndReached={onEndReached} + onEndReachedThreshold={onEndReachedThreshold} + refreshing={onRefresh ? (refreshing ?? false) : undefined} + onRefresh={onRefresh} testID={testID ? `${testID}-list` : undefined} /> @@ -310,5 +400,23 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', padding: 24, + gap: 12, + }, + stateText: { fontSize: 14, textAlign: 'center' }, + footer: { + minHeight: 56, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 16, + gap: 12, + }, + footerText: { fontSize: 13, flexShrink: 1 }, + retry: { + borderWidth: 1, + borderRadius: 8, + paddingHorizontal: 14, + paddingVertical: 6, }, + retryText: { fontSize: 13, fontWeight: '600' }, }); diff --git a/src/__tests__/rows.test.ts b/src/__tests__/rows.test.ts new file mode 100644 index 0000000..cbaad85 --- /dev/null +++ b/src/__tests__/rows.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from '@jest/globals'; +import { getVisibleRows } from '../rows'; +import type { DataGridColumn } from '../types'; + +type Row = { id: string; name: string; amount: number }; + +const columns: DataGridColumn[] = [ + { key: 'name', title: 'Name', width: 100, sortable: true }, + { key: 'amount', title: 'Amount', width: 80, sortable: true }, +]; +const data: Row[] = [ + { id: 'a', name: 'Zed', amount: 5 }, + { id: 'b', name: 'Amy', amount: 50 }, + { id: 'c', name: 'Max', amount: 500 }, +]; +const ids = (rows: readonly Row[]) => rows.map((r) => r.id); +const sort = { columnKey: 'name', direction: 'asc' as const }; +const columnFilters = { amount: { type: 'range' as const, min: 10 } }; + +describe('getVisibleRows', () => { + it('filters then sorts locally by default', () => { + expect( + ids( + getVisibleRows(data, columns, { sort, searchText: 'm', columnFilters }) + ) + ).toEqual(['b', 'c']); + }); + + it('keeps supplied order with manualSorting', () => { + expect( + ids(getVisibleRows(data, columns, { sort, manualSorting: true })) + ).toEqual(['a', 'b', 'c']); + }); + + it('shows supplied rows with manualFiltering but still sorts locally', () => { + expect( + ids( + getVisibleRows(data, columns, { + sort, + searchText: 'zzz', + columnFilters, + manualFiltering: true, + }) + ) + ).toEqual(['b', 'c', 'a']); + }); + + it('returns data untouched when both are manual', () => { + expect( + getVisibleRows(data, columns, { + sort, + manualSorting: true, + searchText: 'm', + manualFiltering: true, + }) + ).toBe(data); + }); +}); diff --git a/src/rows.ts b/src/rows.ts new file mode 100644 index 0000000..6d43c00 --- /dev/null +++ b/src/rows.ts @@ -0,0 +1,33 @@ +import { filterRows } from './filter'; +import { sortRows } from './sort'; +import type { ColumnFilters, DataGridColumn, SortState } from './types'; + +export type VisibleRowsOptions = { + sort?: SortState | null; + /** Keep `data` order; the app sorts (for example on a server). */ + manualSorting?: boolean; + searchText?: string; + columnFilters?: ColumnFilters; + /** Show `data` as is; the app filters (for example on a server). */ + manualFiltering?: boolean; + searchIndex?: readonly string[]; +}; + +/** Rows the grid shows: filtered, then sorted, unless the app handles either step. */ +export function getVisibleRows( + data: readonly T[], + columns: readonly DataGridColumn[], + { + sort, + manualSorting = false, + searchText, + columnFilters, + manualFiltering = false, + searchIndex, + }: VisibleRowsOptions +): readonly T[] { + const filtered = manualFiltering + ? data + : filterRows(data, columns, { searchText, columnFilters, searchIndex }); + return manualSorting ? filtered : sortRows(filtered, columns, sort); +} diff --git a/src/types.ts b/src/types.ts index 2626f6c..979d349 100644 --- a/src/types.ts +++ b/src/types.ts @@ -85,12 +85,42 @@ export type DataGridProps = { defaultSort?: SortState | null; onSortChange?: (sort: SortState | null) => void; + /** + * Keep the order of `data`. The header still shows `sort` and taps still call `onSortChange`, + * so the app can sort (for example on a server) and pass new data. + */ + manualSorting?: boolean; + /** Show only rows whose searchable columns contain this text (ignores case and accents). */ searchText?: string; /** Show only rows matching every filter, by column key. */ columnFilters?: ColumnFilters; /** Called after filtering with the number of visible rows. */ onFilteredCountChange?: (count: number) => void; + /** + * Show `data` without applying `searchText` or `columnFilters` locally. + * Use when the app filters (for example on a server). + */ + manualFiltering?: boolean; + + /** First load. Shows a loading state only while there are no rows; existing rows stay visible. */ + loading?: boolean; + /** Loading more rows at the end of the list. Shows a footer spinner. */ + loadingMore?: boolean; + /** Pull-to-refresh state. */ + refreshing?: boolean; + /** Enables pull-to-refresh. */ + onRefresh?: () => void; + /** Called when scrolling near the end, to load the next page. */ + onEndReached?: () => void; + /** How close to the end `onEndReached` fires, in visible list lengths. Default 0.5. */ + onEndReachedThreshold?: number; + /** Error message. Replaces the empty state when there are no rows, otherwise shows in the footer. */ + error?: string | null; + /** Shows a Retry button next to `error`. */ + onRetry?: () => void; + /** Text under the loading spinner. Default "Loading". */ + loadingText?: string; /** `multiple` adds a pinned checkbox column. Default `none`. */ selectionMode?: SelectionMode;