diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5fb946b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +## 0.1.0-beta.0 + +First public beta. The API may still change before 0.1.0. + +### Added + +- `DataGrid` component: fixed-height rows virtualized with FlashList, sticky header, and columns pinned to the left. +- Sorting: tap a header to cycle ascending, descending, unsorted. Stable, natural string order, empty values last. Custom `compare` per column. +- Selection: `single` and `multiple`, with a pinned checkbox column, select-all, and controlled or uncontrolled state. +- Search and filters: `searchText` that ignores case and accents, plus `text`, `values`, `range` and `custom` column filters. The search index is built on the first search and reused until `data` or `columns` change. +- Server-driven data: `manualSorting`, `manualFiltering`, `loading`, `loadingMore`, pull-to-refresh, `onEndReached`, and `error` with retry. +- Custom cells through `renderCell`, display text through `format`. +- Light and dark themes, with every color overridable, following the device by default. +- Row-level accessibility labels in visual column order, with a selection action. +- Exported helpers: `sortRows`, `nextSort`, `filterRows`, `buildSearchIndex`, `matchesColumnFilter`, `getCellText`, `getCellValue`, `lightTheme`, `darkTheme`. + +### Known limitations + +- Every mounted row renders every column. Rendering only visible columns is planned for wide tables. +- Sorting, filtering and key generation process the whole dataset in JS. +- No column resizing or inline editing yet. +- No native horizontal scrollbar or overscroll bounce: sideways scrolling is driven by a pan gesture so the header and pinned columns stay in sync ([#3](https://github.com/ahmdshrif/react-native-datagrid/issues/3)). +- Performance has been measured on the iOS simulator and an Android emulator only. Physical low-end Android devices are not measured yet. diff --git a/README.md b/README.md index 98398a2..bdd71d8 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,56 @@ -# react-native-datagrid +

react-native-datagrid

-Fast data grid for React Native: pinned columns, sticky header, virtualized rows, sorting and selection. +

+ A data grid for React Native with pinned columns that actually stay pinned. +

-- **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` -- **Light and dark themes**, with every color overridable -- Pure JS on top of FlashList, Reanimated and Gesture Handler: works with Expo, no native code of its own +

+ Quick start · + API · + Server data · + Theming · + Limitations +

-> Status: early development (v0.1). The API may still change. +

+ npm + CI + types + license +

-## Installation +

+ Scrolling a 10,000-row grid sideways while the first columns and the header stay in place, then sorting, selecting rows, switching to dark mode, and paging from a server +

+ +> **Beta.** The API may still change before `0.1.0`. What lands next is decided by feedback from real apps — [open an issue](https://github.com/ahmdshrif/react-native-datagrid/issues). + +## Why this grid + +**⚡ Pinned columns that don't drift.** Sideways scrolling is driven by one Reanimated value, so rows, header and pinned columns move in the same frame. Feeding a `ScrollView`'s offset into a transform is a frame late on Android, and pinned columns visibly flicker ([#3](https://github.com/ahmdshrif/react-native-datagrid/issues/3)). + +**🚀 Built on FlashList.** Only the rows on screen are mounted, so 10,000 rows scroll like 30. + +**🎛 The parts business apps need.** Sorting, single and multiple selection with select-all, search and column filters, and a server-driven mode for sorting, filtering and paging on the backend. + +**🎨 Yours to style.** Light and dark themes that follow the device, every colour overridable, and `renderCell` for custom cells. Screen readers read each row as one sentence in column order. + +## Quick start ```sh -npm install react-native-datagrid @shopify/flash-list react-native-reanimated react-native-worklets react-native-gesture-handler +npx expo install react-native-datagrid@beta @shopify/flash-list react-native-reanimated react-native-worklets react-native-gesture-handler ``` -With Expo: +
+Without Expo ```sh -npx expo install react-native-datagrid @shopify/flash-list react-native-reanimated react-native-worklets react-native-gesture-handler +npm install react-native-datagrid@beta @shopify/flash-list react-native-reanimated react-native-worklets react-native-gesture-handler ``` -Requires the New Architecture, FlashList 2, Reanimated 4 and Gesture Handler 2. If you don't use Expo, follow the [Reanimated setup guide](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/getting-started) to add the worklets Babel plugin. +Follow the [Reanimated setup guide](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/getting-started) to add the worklets Babel plugin. On Expo, install the peers with `expo install` so they match your SDK; installing this library first can pull a Reanimated version that needs a newer React Native than your SDK ships. + +
Wrap your app in `GestureHandlerRootView` once, near the root: @@ -38,9 +62,7 @@ export default function App() { } ``` -Sideways scrolling uses a pan gesture rather than a `ScrollView`, so the header and pinned columns move in the same frame as the rows. A `ScrollView` reports its offset a frame late on Android, which makes pinned columns flicker. - -## Usage +Then render a grid. It fills its parent, so give the parent a height. ```tsx import { DataGrid } from 'react-native-datagrid'; @@ -75,147 +97,111 @@ export function Orders({ orders }: { orders: Order[] }) { } ``` -The grid fills its parent (`flex: 1`), so give the parent a height. +Memoize `columns`, or every row re-renders when the parent does. ## Columns -| Option | Type | Description | -| --- | --- | --- | -| `key` | `string` | Unique id. Also reads `row[key]` when `getValue` is not set. | -| `title` | `string` | Header text. | -| `width` | `number` | Width in points. | -| `pinned` | `'left'` | Keep the column visible while scrolling sideways. Pinned columns move to the front. | -| `align` | `'left' \| 'center' \| 'right'` | Cell alignment. Default `left`. | -| `sortable` | `boolean` | Let users sort by tapping the header. | -| `getValue` | `(row) => unknown` | Read the value used for display and sorting. | -| `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 - -| Prop | Type | Default | Description | -| --- | --- | --- | --- | -| `data` | `readonly T[]` | required | Rows. | -| `columns` | `DataGridColumn[]` | required | Column definitions. Memoize them to avoid re-rendering rows. | -| `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. | -| `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. | -| `onSelectionChange` | `(keys) => void` | | Called with all selected keys. | -| `onRowPress` | `(row, index) => void` | | Row tap. In `multiple` mode, setting this makes row taps call it instead of toggling selection. | -| `colorScheme` | `'auto' \| 'light' \| 'dark'` | `'auto'` | `auto` follows the device. | -| `theme` | `Partial` | | Override theme tokens. | -| `emptyText` | `string` | `'No rows'` | Shown when `data` is empty. | -| `style` | `ViewStyle` | | Container style. | - -`sortRows`, `nextSort`, `filterRows`, `lightTheme` and `darkTheme` are exported too, for sorting on your own or building a custom theme. - -## Search and filters +A column is data plus presentation: `width` and `pinned` place it, `format` or `renderCell` draw it, `sortable` and `compare` order it. ```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} -/>; +const columns: DataGridColumn[] = [ + // Stays visible while scrolling sideways + { key: 'id', title: 'Order', width: 96, pinned: 'left', sortable: true }, + + // Custom cell content + { + key: 'status', + title: 'Status', + width: 120, + renderCell: ({ value }) => , + }, + + // A value that isn't a plain field, with its own sort order + { + key: 'technician', + title: 'Technician', + width: 160, + getValue: (row) => row.assignee?.fullName ?? '', + compare: (a, b) => a.assignee.rank - b.assignee.rank, + sortable: true, + }, +]; ``` -| Filter | Matches | +Every option is in the [API reference](docs/api.md). + +## Features + +| | | | --- | --- | -| `{ 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. | +| **Sorting** | Tap a header to cycle ascending → descending → off. Stable, natural string order (`Item 2` before `Item 10`), empty values last. [Docs](docs/api.md#sorting) | +| **Selection** | `single` or `multiple`, with a pinned checkbox column, select-all, and controlled or uncontrolled state. [Docs](docs/api.md) | +| **Search & filters** | `searchText` ignores case and accents; per-column `text`, `values`, `range` and `custom` filters. [Docs](docs/api.md#filters) | +| **Server data** | `manualSorting`, `manualFiltering`, loading, pull-to-refresh, load more, errors with retry. [Docs](docs/server-data.md) | +| **Theming** | Light and dark, device-aware, every token overridable. [Docs](docs/theming.md) | -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 -## Server-driven data +Release builds, 10,000 rows × 13 columns, a checkbox column plus 2 pinned columns, over about 12 seconds of fast flings and sideways swipes. The numbers count frame intervals longer than 25 ms, a rough jank signal rather than a dropped-frame count: -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. +| Platform | UI thread | JS thread | +| --- | --- | --- | +| iOS 26 simulator (Mac) | 0 long intervals | 0 | +| Android emulator, API 35 (Mac) | 1 | 21 | -```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} -/> -``` +**Not measured on physical devices yet.** Emulator results move with host load, so they say nothing reliable about low-end Android phones. Device benchmarks are the gate before `0.1.0`. -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. +What scales with the whole dataset, not just the visible rows: sorting, key generation, and the search index (built on the first search, then reused until `data` or `columns` change). Pass stable `data`, `columns` and `keyExtractor` references. -## Performance +Reproduce it yourself with `yarn example ios` or `yarn example android`, then switch on the FPS toggle in the example app. -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: +## Compatibility -| Platform | UI thread long intervals | JS thread long intervals | +| Package | Tested with | Peer range | | --- | --- | --- | -| iOS 26 simulator (Mac) | 0 | 0 | -| Android emulator, API 35 (Mac) | 1 | 21 | +| react-native | 0.83.10 | `*`, New Architecture required | +| react | 19.2.0 | `*` | +| expo | SDK 55 | not required | +| @shopify/flash-list | 2.0.2 | `>=2.0.0` | +| react-native-reanimated | 4.2.1 | `>=4.0.0` | +| react-native-worklets | 0.7.4 | `>=0.5.0` | +| react-native-gesture-handler | 2.30.0 | `>=2.20.0` | + +Checked on the iOS 26 simulator and an Android API 35 emulator in Release builds, plus a clean install into a fresh Expo app. Web is untested. + +## How it compares + +| | react-native-datagrid | Plain `FlatList` | [TanStack Table](https://tanstack.com/table) | +| --- | --- | --- | --- | +| Pinned columns + sticky header | Built in | You build it | You build it | +| Row virtualization | FlashList | FlatList | Bring your own list | +| Sorting and filtering logic | Built in | You build it | Built in, headless | +| Server paging states | Built in | You build it | You build it | +| Ready-made mobile UI | Yes | — | No, headless by design | + +There is also [`@bestcoder/react-native-data-table`](https://www.npmjs.com/package/@bestcoder/react-native-data-table), which covers FlashList virtualization, fixed left and right columns, sorting and selection — including right-pinned columns, which this library does not have yet. I have not benchmarked it; compare both before choosing. -**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). +## Limitations -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. +- Fixed row height; rows do not auto-size. +- Every mounted row renders every column. Rendering only visible columns is planned for wide tables. +- No column resizing, reordering or inline editing yet. +- Left-pinned columns only. +- No native horizontal scrollbar or sideways overscroll bounce: that is the trade for keeping pinned columns in sync. +- Not measured on physical low-end Android devices. ## Roadmap -- 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 +- Physical-device benchmarks, then `0.1.0` +- Scroll reset when a new sort or filter is applied +- Column virtualization for wide tables +- Right-pinned columns +- Column resizing and inline editing, if beta users ask for them ## Contributing -- [Development workflow](CONTRIBUTING.md#development-workflow) -- [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request) -- [Code of conduct](CODE_OF_CONDUCT.md) +Issues and pull requests are welcome, especially reports from real apps with real tables. See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow and [RELEASING.md](RELEASING.md) for how releases are cut. ## License -MIT +MIT © [ahmdshrif](https://github.com/ahmdshrif) diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..4cbd646 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,23 @@ +# Releasing + +## Beta + +1. `npm login` (once per machine). +2. Make sure `main` is green in CI and your working tree is clean. +3. Bump the version in `package.json` if needed, for example `0.1.0-beta.1`, and add a `CHANGELOG.md` entry. +4. `yarn prepare` — builds `lib/` with bob. +5. `npm publish` — `publishConfig.tag` is `beta`, so it will **not** become `latest`. +6. `git tag v0.1.0-beta.0 && git push --tags`. + +Install instructions for testers: `npm install react-native-datagrid@beta`. + +## Stable + +Same steps, with `npm publish --tag latest`, after the beta has been confirmed on physical devices. + +## Checklist before publishing + +- `yarn lint`, `yarn typecheck`, `yarn test` pass. +- `npm pack --dry-run` lists only `src/`, `lib/`, `README.md`, `LICENSE` and `package.json`. +- The example app builds and runs in Release on both platforms. +- A clean install into a fresh app renders the grid (see `docs/spike-results.md` for the method). diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..8f9a46c --- /dev/null +++ b/docs/api.md @@ -0,0 +1,104 @@ +# API + +## `` + +The grid fills its parent (`flex: 1`), so give the parent a height. + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `data` | `readonly T[]` | required | Rows. | +| `columns` | `DataGridColumn[]` | required | Column definitions. Memoize them, or every row re-renders. | +| `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. | +| `manualSorting` | `boolean` | `false` | Keep the order of `data`; the header still shows `sort` and taps still call `onSortChange`. | +| `searchText` | `string` | | Show rows whose searchable columns contain this text. | +| `columnFilters` | `ColumnFilters` | | Show rows matching every filter, by column key. | +| `manualFiltering` | `boolean` | `false` | Show `data` without applying `searchText` or `columnFilters`. | +| `onFilteredCountChange` | `(count) => void` | | Number of rows left after filtering. | +| `selectionMode` | `'none' \| 'single' \| 'multiple'` | `'none'` | `multiple` adds a pinned checkbox column. | +| `selectedKeys` | `readonly string[]` | | Controlled selection. | +| `defaultSelectedKeys` | `readonly string[]` | `[]` | Initial selection when uncontrolled. | +| `onSelectionChange` | `(keys: string[]) => void` | | Called with all selected keys. | +| `onRowPress` | `(row, index) => void` | | Row tap. In `multiple` mode, setting this makes row taps call it instead of toggling selection. | +| `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`. | +| `colorScheme` | `'auto' \| 'light' \| 'dark'` | `'auto'` | `auto` follows the device. | +| `theme` | `Partial` | | Override theme tokens. See [theming](theming.md). | +| `emptyText` | `string` | `'No rows'` | Shown when there are no rows to show. | +| `style` | `StyleProp` | | Container style. | +| `testID` | `string` | | Also sets `${testID}-list` and `${testID}-horizontal`. | + +## `DataGridColumn` + +| Option | Type | Description | +| --- | --- | --- | +| `key` | `string` | Unique id. Also reads `row[key]` when `getValue` is not set. | +| `title` | `string` | Header text. | +| `width` | `number` | Width in points. | +| `pinned` | `'left'` | Keep the column visible while scrolling sideways. Pinned columns move to the front. | +| `align` | `'left' \| 'center' \| 'right'` | Cell alignment. Default `left`. Right-aligned cells use tabular figures. | +| `sortable` | `boolean` | Let users sort by tapping the header. | +| `getValue` | `(row) => unknown` | Read the value used for display and sorting. | +| `compare` | `(a, b) => number` | Custom ascending comparator. Reversed automatically for descending. | +| `format` | `(value, row) => string` | Display text. Ignored when `renderCell` is set. | +| `renderCell` | `(info) => ReactNode` | Custom cell content. `info` is `{ row, rowIndex, value, column, selected }`. | +| `searchable` | `boolean` | Include in `searchText` matching. Default `true`. | +| `getSearchText` | `(row) => string` | Text used for search. Defaults to the shown text. | + +## Sorting + +Tapping a sortable header cycles ascending → descending → unsorted. + +Sorting is stable, and empty values (`null`, `undefined`) always go last, in both directions. Numbers compare numerically, dates by time, booleans false-first, and strings with `Intl.Collator` using natural number order and no case or accent sensitivity, so `Item 2` comes before `Item 10`. + +```tsx +const [sort, setSort] = useState({ + columnKey: 'amount', + direction: 'desc', +}); + +; +``` + +## Filters + +| 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. | + +`searchText` matches against every column with `searchable !== false`, using what the cell shows (`format` output), so `$1,114.00` matches a search for `1,114`. + +The grid has no built-in filter controls; render your own search box and chips, as the example app does. + +## Exported helpers + +These are plain functions, usable without the component: + +| Function | Purpose | +| --- | --- | +| `sortRows(data, columns, sort)` | Returns a sorted copy, or `data` itself when nothing sorts. | +| `nextSort(current, columnKey)` | The header-tap cycle: ascending → descending → `null`. | +| `filterRows(data, columns, options)` | Applies `searchText` and `columnFilters`. | +| `buildSearchIndex(data, columns)` | One searchable string per row; pass it back to `filterRows` to skip rebuilding. | +| `matchesColumnFilter(row, column, filter)` | Tests one filter against one row. | +| `getCellValue(row, column)` / `getCellText(row, column)` | The value and the shown text for a cell. | +| `lightTheme` / `darkTheme` | The built-in theme token sets. | + +## Types + +`DataGridProps`, `DataGridColumn`, `CellRenderInfo`, `CellAlign`, `SortState`, `SortDirection`, `SelectionMode`, `ColumnFilter`, `ColumnFilters`, `DataGridTheme`. diff --git a/docs/media/demo.gif b/docs/media/demo.gif new file mode 100644 index 0000000..d8fdc22 Binary files /dev/null and b/docs/media/demo.gif differ diff --git a/docs/server-data.md b/docs/server-data.md new file mode 100644 index 0000000..8e40fd5 --- /dev/null +++ b/docs/server-data.md @@ -0,0 +1,66 @@ +# 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 cancellation; the grid owns the states it shows. + +```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} +/> +``` + +## What each flag means + +| Prop | Behaviour | +| --- | --- | +| `manualSorting` | The grid keeps the order of `data`. The header still shows the sort arrow and taps still call `onSortChange`, so you can refetch. | +| `manualFiltering` | The grid shows `data` as it is. `searchText` and `columnFilters` are ignored locally; use them to build your request. | +| `loading` | Shown **only when there are no rows**. When rows are already on screen, they stay visible while the next result loads. | +| `loadingMore` | A spinner below the last row. | +| `error` | Replaces the empty state when there are no rows; otherwise appears below the last row so the loaded rows stay usable. | + +## States to handle + +- **First load:** `loading` true, no rows. The grid shows a spinner and `loadingText`. +- **Changing sort or filters with rows on screen:** keep the old rows, set `loading` true. The grid keeps showing them, so the screen doesn't flash empty. +- **Loading a page:** `loadingMore` true. Do nothing else; the rows stay put. +- **Failure:** set `error`, and `onRetry` to repeat the last request. Keep the rows you already have. +- **Empty result:** clear `loading` and `error` and pass an empty array; the grid shows `emptyText`. + +## Cancelling in-flight requests + +Sorting and typing produce overlapping requests. Cancel the previous one so a slow response can't overwrite a newer one: + +```tsx +const controller = useRef(null); + +const load = useCallback(async (page: number) => { + controller.current?.abort(); + const current = new AbortController(); + controller.current = current; + try { + const result = await fetchRows({ page, sort, search, signal: current.signal }); + setRows((prev) => (page === 0 ? result.rows : [...prev, ...result.rows])); + } catch (e) { + if (!current.signal.aborted) setError(String(e)); + } +}, [sort, search]); +``` + +## Known rough edge + +After a new sort or filter, the list keeps its scroll position, so a deep scroll can call `onEndReached` immediately. A scroll-reset option is on the roadmap. Until then, you can remount the grid with a `key` that changes with the query. + +A complete working version of all of this, with a fake paged API and a switch that makes the next request fail, is in the example app's **Server** tab: [`example/src/ServerDemo.tsx`](../example/src/ServerDemo.tsx). diff --git a/docs/theming.md b/docs/theming.md new file mode 100644 index 0000000..fec8f7d --- /dev/null +++ b/docs/theming.md @@ -0,0 +1,67 @@ +# Theming + +The grid ships light and dark themes and follows the device by default. Override any token, or force a scheme. + +```tsx + +``` + +## Tokens + +| Token | Used for | +| --- | --- | +| `background` | Row and grid background. | +| `rowAlternateBackground` | Every second row when `striped`. | +| `rowSelectedBackground` | Selected rows. | +| `headerBackground` | Header row, including its pinned part. | +| `headerText` | Header labels. | +| `text` | Cell text and the active sorted header. | +| `mutedText` | Empty state, footer messages, unchecked checkbox border. | +| `border` | Row separators and header cell dividers. | +| `pinnedEdge` | The line marking the pinned column edge, and the header's bottom border. | +| `accent` | Sort arrow, checkboxes, spinners, Retry button. | +| `onAccent` | The checkmark inside a checked checkbox. | +| `fontSize` | Cell text size. Default `14`. | +| `headerFontSize` | Header label size. Default `11`. | +| `cellPaddingHorizontal` | Left and right padding inside every cell. Default `10`. | + +## Building on the defaults + +`lightTheme` and `darkTheme` are exported, so you can extend one instead of listing every token: + +```tsx +import { darkTheme } from 'react-native-datagrid'; + +const brandDark = { ...darkTheme, accent: '#FFB300', onAccent: '#1A1A1A' }; + +; +``` + +## Matching your app's scheme + +Pass `colorScheme` when your app has its own theme switch, so the grid follows your setting instead of the device: + +```tsx +const { isDark } = useAppTheme(); + +; +``` + +## Cell-level styling + +Tokens cover the frame: backgrounds, borders, header and default cell text. For anything richer — status pills, coloured amounts, avatars — use `renderCell` and style it yourself: + +```tsx +{ + key: 'status', + title: 'Status', + width: 120, + renderCell: ({ value }) => , +} +``` + +Memoize the `columns` array, including any closures over your theme, or every row re-renders when the parent does. diff --git a/package.json b/package.json index dccf717..f8a26bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-datagrid", - "version": "0.1.0", + "version": "0.1.0-beta.0", "description": "Fast data grid for React Native: pinned columns, sticky header, virtualized rows, sorting and selection", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -65,7 +65,9 @@ }, "homepage": "https://github.com/ahmdshrif/react-native-datagrid#readme", "publishConfig": { - "registry": "https://registry.npmjs.org/" + "registry": "https://registry.npmjs.org/", + "tag": "beta", + "access": "public" }, "devDependencies": { "@commitlint/config-conventional": "^21.2.0",