react-gridix 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # react-gridix
1
+ # react-gridix Docs
2
2
 
3
- A React `DataGrid` with per-column filters, in-cell editing, nested fields, themes and custom components, plus the `Checkbox` it uses.
3
+ A React `DataGrid` with row selection, server-side mode, typed per-column filters, date columns, in-cell editing, resizing and pinning, virtualization, CSV export and 8 themes, plus the `Checkbox` it uses.
4
4
 
5
5
  **Contents**
6
6
 
@@ -11,8 +11,8 @@ A React `DataGrid` with per-column filters, in-cell editing, nested fields, them
11
11
  5. [Checkbox](#5-checkbox)
12
12
  6. [DataGrid](#6-datagrid)
13
13
  7. [Demos](#7-demos)
14
- 8. [Development, building and publishing](#8-development-building-and-publishing)
15
- 9. [Troubleshooting](#9-troubleshooting)
14
+ 8. [Troubleshooting](#9-troubleshooting)
15
+ 9. [Migrating from the earlier version](#10-migrating-from-the-earlier-version)
16
16
 
17
17
  ---
18
18
 
@@ -20,10 +20,29 @@ A React `DataGrid` with per-column filters, in-cell editing, nested fields, them
20
20
 
21
21
  | Component | Purpose |
22
22
  | --- | --- |
23
- | `DataGrid` | Data table with sorting, search, per-column filters, pagination, in-cell editing, nested fields, striping, themes and custom column components |
24
- | `Checkbox` | Checkbox in three looks (button with a span, tick, native), toggles with one click. The grid uses it for boolean columns, and you can use it on its own |
23
+ | `DataGrid` | Data table with per-column sorting, search, typed per-column filters, pagination, in-cell editing, date columns, row selection, server-side mode, loading skeletons, resize / pin / hide, keyboard navigation, CSV export, virtualization, nested fields, striping, themes and custom column components |
24
+ | `Checkbox` | Checkbox in three looks (button with a span, tick, native), toggles with one click. The grid uses it for boolean columns and row selection, and you can use it on its own |
25
25
 
26
- Everything is written in TypeScript, ships its own type definitions, and uses plain CSS (no CSS-in-JS runtime). The grid is 100% of its container's width by default.
26
+ **Everything extra is opt-in.** A bare `<DataGrid columns={...} rows={...} />` is a plain read-only table. Sorting, the search box, page info, selection, resizing and the rest only appear when you switch them on:
27
+
28
+ | Feature | How you switch it on | Default |
29
+ | --- | --- | --- |
30
+ | Sorting | `sortable: true` **on a column** | off |
31
+ | Multi-column sort | `multiSort` on the grid | off |
32
+ | Global search box | `searchable` | off |
33
+ | Pagination bar (row count, page controls) | `pagination` | off |
34
+ | Column filter | `filterable: true` on a column | off |
35
+ | Editing | `editable` | off |
36
+ | Row selection | `rowSelection` | off |
37
+ | Server-side mode | `serverSide` | off |
38
+ | Resize / pin | `resizable: true` / `pinned` on a column | off |
39
+ | Show / hide columns button | `columnMenu` | off |
40
+ | Arrow-key navigation | `keyboardNavigation` | off |
41
+ | CSV export button | `exportable` | off |
42
+ | Virtualization | `virtualize` | off |
43
+ | Theme | `theme` | none (plain look) |
44
+
45
+ Everything is written in TypeScript, ships its own type definitions, and uses plain CSS (no CSS-in-JS runtime). The grid is 100% of its container's width by default. There is deliberately **no column reordering**.
27
46
 
28
47
  ---
29
48
 
@@ -47,6 +66,32 @@ The library does not inject CSS by itself. If the grid looks unstyled, this impo
47
66
 
48
67
  ## 3. Quick start
49
68
 
69
+ A read-only table, the smallest possible grid:
70
+
71
+ ```tsx
72
+ import { DataGrid, type ColumnDef } from "react-gridix";
73
+ import "react-gridix/style.css";
74
+
75
+ interface Person { id: number; name: string; city: string }
76
+
77
+ const columns: ColumnDef<Person>[] = [
78
+ { field: "id", headerName: "ID" },
79
+ { field: "name", headerName: "Name" },
80
+ { field: "city", headerName: "City" },
81
+ ];
82
+
83
+ const rows: Person[] = [
84
+ { id: 1, name: "Amina", city: "Kampala" },
85
+ { id: 2, name: "Brian", city: "Nairobi" },
86
+ ];
87
+
88
+ export default function App() {
89
+ return <DataGrid columns={columns} rows={rows} />;
90
+ }
91
+ ```
92
+
93
+ The same grid with the common features switched on:
94
+
50
95
  ```tsx
51
96
  import { useState } from "react";
52
97
  import { DataGrid, type ColumnDef } from "react-gridix";
@@ -55,20 +100,21 @@ import "react-gridix/style.css";
55
100
  interface Person {
56
101
  id: number;
57
102
  name: string;
58
- contact: { email: string; phone: string };
103
+ joined: string; // "2026-03-14"
104
+ contact: { email: string };
59
105
  }
60
106
 
61
107
  const columns: ColumnDef<Person>[] = [
62
- { field: "id", headerName: "ID", type: "number", editable: false },
63
- { field: "name", headerName: "Name", filterable: true },
108
+ { field: "id", headerName: "ID", type: "number", editable: false, sortable: true },
109
+ { field: "name", headerName: "Name", sortable: true, filterable: true },
110
+ { field: "joined", headerName: "Joined", type: "date", sortable: true, filterable: true },
64
111
  { field: "contact.email", headerName: "Email" },
65
- { field: "contact.phone", headerName: "Phone" },
66
112
  ];
67
113
 
68
114
  export default function App() {
69
115
  const [rows, setRows] = useState<Person[]>([
70
- { id: 1, name: "Amina", contact: { email: "amina@example.com", phone: "+256 700 100 200" } },
71
- { id: 2, name: "Brian", contact: { email: "brian@example.com", phone: "+256 700 100 237" } },
116
+ { id: 1, name: "Amina", joined: "2026-03-14", contact: { email: "amina@example.com" } },
117
+ { id: 2, name: "Brian", joined: "2025-11-02", contact: { email: "brian@example.com" } },
72
118
  ]);
73
119
 
74
120
  return (
@@ -78,6 +124,9 @@ export default function App() {
78
124
  getRowId={(r) => r.id}
79
125
  editable
80
126
  onRowsChange={setRows}
127
+ searchable // search box
128
+ pagination // row count and page controls
129
+ rowSelection // checkbox column
81
130
  theme="ocean"
82
131
  />
83
132
  );
@@ -102,8 +151,39 @@ Pass a theme name to the grid. **Without a `theme` prop nothing changes**: the g
102
151
  | `"midnight"` | Dark slate, indigo accent, dark popups and inputs |
103
152
  | `"sunset"` | Warm cream, orange-to-pink gradient header, serif font, rounded corners |
104
153
  | `"forest"` | Calm greens, solid dark-green header, sharper corners |
154
+ | `"lavender"` | Light purple, soft violet gradient header, rounded corners |
155
+ | `"coffee"` | Warm browns and cream, serif font, rich brown header |
156
+ | `"graphite"` | Dark neutral grey with a teal accent, flat and sharp |
157
+ | `"aurora"` | Deep night blue with a green-to-blue-to-violet gradient header and a soft glow |
158
+
159
+ A theme is only a set of CSS variables on `[data-rk-theme="name"]`, written with `:where()` (zero specificity). So you can still override any variable or any class on top of a theme. Popups (the filter popup, the column menu) are drawn in `document.body`, and the grid copies the theme attribute onto them, so they follow the theme too.
105
160
 
106
- A theme is only a set of CSS variables on `[data-rk-theme="name"]`, written with `:where()` (zero specificity). So you can still override any variable or any class on top of a theme. The filter popup is drawn in `document.body`, and the grid copies the theme attribute onto it, so the popup follows the theme too.
161
+ **Switching themes at run time** is just a state change:
162
+
163
+ ```tsx
164
+ const [theme, setTheme] = useState<GridTheme>("ocean");
165
+
166
+ <select value={theme} onChange={(e) => setTheme(e.target.value as GridTheme)}>
167
+ {["ocean", "midnight", "sunset", "forest", "lavender", "coffee", "graphite", "aurora"].map((t) => (
168
+ <option key={t}>{t}</option>
169
+ ))}
170
+ </select>
171
+ <DataGrid theme={theme} ... />
172
+ ```
173
+
174
+ **Following the system dark mode.** Choose the theme from a media query:
175
+
176
+ ```tsx
177
+ const dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
178
+ <DataGrid theme={dark ? "graphite" : "lavender"} ... />
179
+ ```
180
+
181
+ **Tweaking a built-in theme.** Because themes have zero specificity, any variable or rule you write wins over them:
182
+
183
+ ```css
184
+ /* make the aurora header a single colour */
185
+ [data-rk-theme="aurora"] { --rk-header-bg: #0f766e; }
186
+ ```
107
187
 
108
188
  **Making your own theme.** Any other string works as a theme name. Define the variables you want:
109
189
 
@@ -112,6 +192,7 @@ A theme is only a set of CSS variables on `[data-rk-theme="name"]`, written with
112
192
  --rk-header-bg: #7c3aed;
113
193
  --rk-header-text: #fff;
114
194
  --rk-row-hover: #f5f3ff;
195
+ --rk-selected-bg: #ede9fe; /* selected rows */
115
196
  --rk-primary: #7c3aed;
116
197
  }
117
198
  ```
@@ -120,6 +201,8 @@ A theme is only a set of CSS variables on `[data-rk-theme="name"]`, written with
120
201
  <DataGrid theme="brand" ... />
121
202
  ```
122
203
 
204
+ A dark custom theme should also set the colours that the light defaults assume, for example `--rk-surface`, `--rk-text`, `--rk-border`, `--rk-pinned-bg`, `--rk-skeleton-a` and `--rk-skeleton-b`. Copy the `graphite` block in `themes.css` as a starting point.
205
+
123
206
  ### 4.2 CSS variables
124
207
 
125
208
  Every variable is optional and is read with a fallback, so nothing is declared on the grid itself. Set them on `:root`, on any parent element, in your own theme, or in the grid's `style`.
@@ -139,20 +222,24 @@ Every variable is optional and is read with a fallback, so nothing is declared o
139
222
  | `--rk-grid-radius` | `10px` | Grid corner radius |
140
223
  | `--rk-grid-shadow` | `none` | Grid shadow |
141
224
  | `--rk-toolbar-bg` | `transparent` | Toolbar background |
142
- | `--rk-input-bg`, `--rk-input-border`, `--rk-input-text` | surface, border, inherit | Search box, filter box, editor, page controls |
225
+ | `--rk-input-bg`, `--rk-input-border`, `--rk-input-text` | surface, border, inherit | Search box, filter inputs, editor, page controls |
143
226
  | `--rk-header-bg` | `--rk-bg-subtle` | Header background. Any CSS background, so gradients work |
144
227
  | `--rk-header-text` | inherit | Header text colour |
145
228
  | `--rk-header-hover-bg` | `rgba(0,0,0,.06)` | Header hover |
146
229
  | `--rk-header-border` | `--rk-border` | Line under the header |
147
230
  | `--rk-cell-border` | `#eef0f3` | Lines between rows |
148
231
  | `--rk-row-hover` | `#f3f4f6` | Row hover |
232
+ | `--rk-selected-bg` | `#eef2ff` | Background of selected rows |
233
+ | `--rk-pinned-bg` | `--rk-grid-bg` | Opaque background behind pinned body cells (so scrolled cells do not show through) |
234
+ | `--rk-pinned-header-bg` | `--rk-bg-subtle` | Opaque background behind pinned header cells |
235
+ | `--rk-skeleton-a`, `--rk-skeleton-b` | `#e5e7eb`, `#f3f4f6` | The two colours of the shimmering loading bars |
149
236
  | `--rk-stripe-a`, `--rk-stripe-b` | `transparent`, `#fafafa` | Alternating row colours (see [stripes](#striped-rows)) |
150
237
  | `--rk-footer-bg`, `--rk-footer-text` | subtle, muted | Pagination bar |
151
- | `--rk-btn-hover` | `#e5e7eb` | Page button and Clear button hover |
238
+ | `--rk-btn-hover` | `#e5e7eb` | Page button, toolbar button and Clear button hover |
152
239
  | `--rk-filter-active` | `--rk-primary` | Filter dot colour while a filter is set |
153
240
  | `--rk-filter-glow` | `rgba(79,70,229,.25)` | Ring around an active filter dot |
154
- | `--rk-popup-bg`, `--rk-popup-text`, `--rk-popup-border` | white, dark, grey | Filter popup |
155
- | `--rk-popup-radius`, `--rk-popup-shadow` | `10px`, soft shadow | Filter popup shape |
241
+ | `--rk-popup-bg`, `--rk-popup-text`, `--rk-popup-border` | white, dark, grey | Filter popup and column menu |
242
+ | `--rk-popup-radius`, `--rk-popup-shadow` | `10px`, soft shadow | Popup shape |
156
243
  | `--rk-row-duration` | `0.38s` | Row entrance animation length |
157
244
  | `--rk-row-stagger` | `28ms` | Extra delay per row in the entrance animation |
158
245
  | `--rk-checkbox-size` | `20px` | Checkbox width and height |
@@ -167,9 +254,16 @@ Every variable is optional and is read with a fallback, so nothing is declared o
167
254
  :root {
168
255
  --rk-primary: #0d9488;
169
256
  --rk-row-hover: #f0fdfa;
257
+ --rk-selected-bg: #ccfbf1;
170
258
  }
171
259
  ```
172
260
 
261
+ Scoped to one grid through its `style` prop:
262
+
263
+ ```tsx
264
+ <DataGrid style={{ "--rk-header-bg": "#0f172a", "--rk-header-text": "#fff" } as React.CSSProperties} ... />
265
+ ```
266
+
173
267
  ---
174
268
 
175
269
  ## 5. Checkbox
@@ -238,8 +332,6 @@ The checkbox CSS is written with `:where()` (zero specificity), so any class you
238
332
 
239
333
  ---
240
334
 
241
- ---
242
-
243
335
  ## 6. DataGrid
244
336
 
245
337
  ### 6.1 Concepts
@@ -250,44 +342,104 @@ The checkbox CSS is written with `:where()` (zero specificity), so any class you
250
342
 
251
343
  - **`rows`** is an array of plain objects.
252
344
  - **`columns`** is an array of column definitions. Each one names a `field` (a key of the row object) and describes how to show, sort, filter and edit it.
253
- - **Row ids.** Pass `getRowId` when your rows have a stable id. Without it the grid uses each row's index in `rows`, which is fine for static data.
345
+ - **Row ids.** Pass `getRowId` when your rows have a stable id. Without it the grid uses each row's index in `rows`, which is fine for static data. Selection, server mode and virtualization all work best with real ids.
254
346
  - **`rowIndex`** in callbacks and params is always the row's index in your original `rows` array, not its position on screen.
255
- - **The grid keeps its own copy of the rows** so edits appear immediately. It re-syncs whenever the `rows` prop changes identity. See [section 6.8](#68-editing).
347
+ - **The grid keeps its own copy of the rows** so edits appear immediately. It re-syncs whenever the `rows` prop changes identity. See [section 6.9](#69-editing).
348
+ - **Controlled or uncontrolled.** Search, filters, sort, page and selection each work two ways. Leave the prop out and the grid keeps the state itself (optionally starting from a `default…` value). Pass the prop together with its `on…Change` callback and you own the state. See [6.14](#614-controlled-state).
349
+ - **Opt-in everywhere.** Features are off until you switch them on (see the table in the [overview](#1-overview)).
256
350
 
257
- The grid is generic: `DataGrid<T>` infers `T` from `rows`, and `ColumnDef<T>` checks that each `field` is a real key of `T` or a dot path into a nested object (see [nested fields](#611-nested-fields)).
351
+ The grid is generic: `DataGrid<T>` infers `T` from `rows`, and `ColumnDef<T>` checks that each `field` is a real key of `T` or a dot path into a nested object (see [nested fields](#612-nested-fields)).
258
352
 
259
353
  ### 6.2 DataGrid props
260
354
 
355
+ **Data and editing**
356
+
261
357
  | Prop | Type | Default | Description |
262
358
  | --- | --- | --- | --- |
263
359
  | `columns` | `ColumnDef<T>[]` | required | Column definitions |
264
- | `rows` | `T[]` | required | Data |
360
+ | `rows` | `T[]` | required | Data. In `serverSide` mode: the current page |
265
361
  | `getRowId` | `(row, index) => string \| number` | row index | Stable id for each row |
266
362
  | `editable` | `boolean` | `false` | Allow editing cells. Columns can override this |
267
363
  | `enterMovesDown` | `boolean` | `true` | After Enter saves a cell, edit the cell below |
268
364
  | `onCellEdit` | `(event: CellEditEvent<T>) => void` | | Called after a cell edit is saved |
269
365
  | `onRowsChange` | `(rows: T[]) => void` | | Called with the full updated array after an edit |
270
- | `sortable` | `boolean` | `true` | Click headers to sort. Columns can override |
271
- | `defaultSort` | `SortModel \| null` | `null` | Initial sort. Read once, on mount |
272
- | `onSortChange` | `(sort: SortModel \| null) => void` | | Called when the sort changes |
273
- | `searchable` | `boolean` | `true` | Show the global search box |
274
- | `searchPlaceholder` | `string` | `"Search…"` | Placeholder of the search box |
275
- | `pagination` | `boolean` | `true` | Split rows into pages |
276
- | `paginationPosition` | `"bottom" \| "top" \| "both"` | `"bottom"` | Where the row count and page controls go |
277
- | `pageSize` | `number` | `10` | Initial rows per page. Read once, on mount |
278
- | `pageSizeOptions` | `number[]` | `[5, 10, 25, 50]` | Choices in the page size menu. The current page size is added if missing |
279
366
  | `onRowClick` | `(row, rowIndex) => void` | | Called when a row is clicked |
367
+
368
+ **Row selection** (see [6.13](#613-row-selection))
369
+
370
+ | Prop | Type | Default | Description |
371
+ | --- | --- | --- | --- |
372
+ | `rowSelection` | `boolean \| "single" \| "multiple"` | `false` | Adds a checkbox column. `true` means `"multiple"` |
373
+ | `selectedRowIds` | `(string \| number)[]` | | Controlled selection |
374
+ | `defaultSelectedRowIds` | `(string \| number)[]` | `[]` | Initial selection when not controlled |
375
+ | `onSelectionChange` | `(selection: RowSelection<T>) => void` | | Called with `{ ids, rows }` |
376
+ | `isRowSelectable` | `(row) => boolean` | all rows | Return `false` to disable a row's checkbox |
377
+
378
+ **Sorting** (see [6.4](#64-sorting))
379
+
380
+ | Prop | Type | Default | Description |
381
+ | --- | --- | --- | --- |
382
+ | `multiSort` | `boolean` | `false` | Shift+click adds more sort columns |
383
+ | `sort` | `SortModel[]` | | Controlled sort |
384
+ | `defaultSort` | `SortModel \| SortModel[] \| null` | `null` | Initial sort when not controlled |
385
+ | `onSortChange` | `(sort: SortModel[]) => void` | | Called when the sort changes |
386
+
387
+ **Search, filters and paging** (see [6.5](#65-searching-and-filtering), [6.7](#67-pagination), [6.14](#614-controlled-state))
388
+
389
+ | Prop | Type | Default | Description |
390
+ | --- | --- | --- | --- |
391
+ | `searchable` | `boolean` | `false` | Show the global search box |
392
+ | `searchPlaceholder` | `string` | `"Search…"` | Placeholder of the search box |
393
+ | `search` / `onSearchChange` | `string` / `(s) => void` | | Controlled search text |
394
+ | `filters` / `onFiltersChange` | `Record<string, FilterValue>` / `(f) => void` | | Controlled column filters |
395
+ | `pagination` | `boolean` | `false` | Split rows into pages and show the bar with the row count and page controls |
396
+ | `paginationPosition` | `"bottom" \| "top" \| "both"` | `"bottom"` | Where the bar goes |
397
+ | `pageSize` | `number` | `10` | Rows per page (initial value) |
398
+ | `pageSizeOptions` | `number[]` | `[5, 10, 25, 50]` | Choices in the page size menu. The current size is added if missing |
399
+ | `page` / `onPageChange` | `number` / `(page) => void` | | Controlled page (zero-based) |
400
+ | `onPageSizeChange` | `(pageSize) => void` | | Called when the page size menu changes |
401
+
402
+ **Server-side mode, loading and errors** (see [6.15](#615-server-side-mode), [6.16](#616-loading-skeleton))
403
+
404
+ | Prop | Type | Default | Description |
405
+ | --- | --- | --- | --- |
406
+ | `serverSide` | `boolean` | `false` | Your server sorts, filters, searches and pages |
407
+ | `rowCount` | `number` | | Total rows on the server (for the page count) |
408
+ | `onQueryChange` | `(query: GridQuery) => void \| Promise<unknown>` | | Called on mount and whenever the query changes. A returned promise drives the loading state |
409
+ | `queryDebounce` | `number` | `250` | Milliseconds to wait after the last keystroke before querying |
410
+ | `loading` | `boolean` | follows the promise | Force the loading state on or off |
411
+ | `loadingRows` | `number` | page size (max 20), or 5 | How many skeleton rows to draw |
412
+ | `skeletonRow` | `ComponentType<SkeletonRowProps<T>>` | shimmer bars | Your own component for each placeholder row |
413
+ | `errorMessage` | `ReactNode` | `"Could not load the data."` | Shown with a Retry button when the promise rejects |
414
+
415
+ **Columns, keyboard, big data and export**
416
+
417
+ | Prop | Type | Default | Description |
418
+ | --- | --- | --- | --- |
419
+ | `columnMenu` | `boolean` | `false` | A "Columns" button to show and hide columns |
420
+ | `onColumnVisibilityChange` | `(hiddenFields: string[]) => void` | | Called with the fields that are hidden |
421
+ | `keyboardNavigation` | `boolean` | `false` | Arrow keys, Home, End, Page Up and Page Down move between cells |
422
+ | `virtualize` | `boolean` | `false` | Render only the visible rows |
423
+ | `rowHeight` | `number` | `40` | Row height in px when virtualizing |
424
+ | `overscan` | `number` | `6` | Extra rows drawn above and below the window |
425
+ | `exportable` | `boolean \| ExportOptions` | `false` | Show an "Export CSV" button |
426
+ | `apiRef` | `MutableRefObject<DataGridApi<T> \| null>` | | Call `exportCsv()`, `getSelectedRows()` and more from outside |
427
+
428
+ **Look and feel**
429
+
430
+ | Prop | Type | Default | Description |
431
+ | --- | --- | --- | --- |
432
+ | `theme` | `GridTheme \| string` | | Applies a theme. Nothing changes without it. See [Theming](#4-theming) |
280
433
  | `toolbar` | `ReactNode` | | Extra content on the right of the toolbar |
281
434
  | `emptyMessage` | `ReactNode` | `"No rows to show"` | Shown when no rows match |
282
435
  | `striped` | `boolean` | `false` | Alternate row shading |
283
436
  | `stripeColors` | `[string, string]` | | Two colours for the alternating rows. Implies `striped`. See [striped rows](#striped-rows) |
284
437
  | `animateRows` | `boolean` | `true` | Rows slide up and fade in when they render. See [row animation](#row-animation) |
285
- | `theme` | `"ocean" \| "midnight" \| "sunset" \| "forest" \| string` | | Applies a theme. Nothing changes without it. See [Theming](#4-theming) |
286
- | `checkboxVariant` | `"button" \| "tick" \| "native"` | `"native"` | Checkbox look for `type: "boolean"` columns. Columns can override it. See [Checkbox columns](#checkbox-columns-type-boolean) |
438
+ | `checkboxVariant` | `"button" \| "tick" \| "native"` | `"native"` | Checkbox look for boolean columns and row selection. Columns can override it |
287
439
  | `maxHeight` | `number \| string` | | Makes the body scroll, with a sticky header. Example: `400` or `"60vh"` |
288
440
  | `className` | `string` | | Classes for the root element (Tailwind works). The grid is `width: 100%` of its container by default |
289
441
  | `style` | `CSSProperties` | | Inline styles for the root element |
290
- | `classNames` | `Partial<Record<GridSlot, string>>` | | Classes for individual parts. See [6.10](#610-styling) |
442
+ | `classNames` | `Partial<Record<GridSlot, string>>` | | Classes for individual parts. See [6.11](#611-styling) |
291
443
  | `styles` | `Partial<Record<GridSlot, CSSProperties>>` | | Inline styles for individual parts |
292
444
  | `rowClassName` | `string \| (row, rowIndex) => string` | | Classes for rows |
293
445
  | `rowStyle` | `CSSProperties \| (row, rowIndex) => CSSProperties` | | Inline styles for rows |
@@ -296,8 +448,9 @@ The grid is generic: `DataGrid<T>` infers `T` from `rows`, and `ColumnDef<T>` ch
296
448
 
297
449
  ```ts
298
450
  const columns: ColumnDef<Employee>[] = [
299
- { field: "name", headerName: "Name" },
300
- { field: "salary", headerName: "Salary", type: "number", align: "right" },
451
+ { field: "name", headerName: "Name", sortable: true, filterable: true },
452
+ { field: "salary", headerName: "Salary", type: "number", align: "right", sortable: true },
453
+ { field: "hired", headerName: "Hired", type: "date", sortable: true, filterable: true },
301
454
  ];
302
455
  ```
303
456
 
@@ -305,57 +458,112 @@ const columns: ColumnDef<Employee>[] = [
305
458
  | --- | --- | --- |
306
459
  | `field` | `FieldPath<T>` | **Required.** Key of the row object this column reads and writes. Use dot notation for nested objects: `"contact.phone"` |
307
460
  | `headerName` | `string` | Header text. Defaults to the field name, except in `type: "boolean"` columns, where it defaults to an empty string |
308
- | `type` | `"text" \| "number" \| "boolean"` | `"number"` columns edit and parse as numbers. `"boolean"` columns show a checkbox. Default `"text"` |
461
+ | `type` | `"text" \| "number" \| "boolean" \| "date"` | `"number"` columns edit and parse as numbers, `"boolean"` columns show a checkbox, `"date"` columns have a date picker (see [6.6](#66-date-columns)). Default `"text"` |
309
462
  | `width` | `number \| string` | Header cell width |
310
- | `minWidth` | `number \| string` | Header cell minimum width |
463
+ | `minWidth` / `maxWidth` | `number \| string` / `number` | Smallest and largest width (`maxWidth` limits dragging when resizing) |
311
464
  | `align` | `"left" \| "center" \| "right"` | Text alignment for the header, cells and editor |
312
- | `sortable` | `boolean` | Overrides the grid-level `sortable` |
465
+ | `sortable` | `boolean` | **Opt in to sorting this column** (default `false`) |
466
+ | `resizable` | `boolean` | Show a drag handle on the header's right edge (default `false`). See [6.17](#617-resize-pin-and-hide-columns) |
467
+ | `pinned` | `"left" \| "right"` | Stick the column to an edge while scrolling sideways |
468
+ | `hidden` | `boolean` | Start hidden (shown again from the Columns menu) |
469
+ | `hideable` | `boolean` | Set `false` to keep a column out of the Columns menu (default `true`) |
313
470
  | `filterable` | `boolean` | Show a filter indicator in this column's header (default `false`). Clicking it opens the filter popup. See [6.5](#65-searching-and-filtering) |
314
- | `filterPlaceholder` | `string` | Placeholder of the default filter text box |
471
+ | `filterType` | `"text" \| "exact" \| "number" \| "date" \| "select"` | Which filter controls the popup shows. Default follows `type` |
472
+ | `filterOptions` | `(string \| FilterOption)[]` | Choices for a `"select"` filter. Without it they are built from the data |
473
+ | `filterPlaceholder` | `string` | Placeholder of the text filter box |
315
474
  | `filterIndicator` | `ComponentType<FilterIndicatorProps<T>>` | Your own component drawn instead of the default dot |
316
- | `filterIndicatorClassName` | `string` | Classes for this column's indicator button |
317
- | `filterIndicatorStyle` | `CSSProperties` | Inline styles for this column's indicator button |
318
- | `filterPopupClassName` | `string` | Classes for this column's filter popup |
319
- | `filterPopupStyle` | `CSSProperties` | Inline styles for this column's filter popup |
475
+ | `filterIndicatorClassName` / `filterIndicatorStyle` | `string` / `CSSProperties` | Classes and inline styles for this column's indicator button |
476
+ | `filterPopupClassName` / `filterPopupStyle` | `string` / `CSSProperties` | Classes and inline styles for this column's filter popup |
477
+ | `filterRenderer` | `ComponentType<FilterRendererProps<T>>` | Component that replaces the filter controls inside the popup |
320
478
  | `editable` | `boolean \| (params: CellEditableParams<T>) => boolean` | Overrides the grid-level `editable`. A function decides per cell |
321
- | `valueFormatter` | `(value, row) => string` | Text used for display, search, filtering. Does not change the stored value |
479
+ | `valueFormatter` | `(value, row) => string` | Text used for display, search and text filters. Does not change the stored value |
322
480
  | `valueParser` | `(text, row) => unknown` | Turns typed text into the value to store |
323
481
  | `validate` | `(value, row) => boolean` | Return `false` to reject an edit |
324
482
  | `comparator` | `(a, b, rowA, rowB) => number` | Custom sort order |
483
+ | `dateFormat` / `dateLocale` | `Intl.DateTimeFormatOptions` / `string` | How a `"date"` column is displayed |
484
+ | `dateMin` / `dateMax` | `string` (`"YYYY-MM-DD"`) | Earliest and latest day the date picker accepts |
485
+ | `exportValue` | `(value, row) => string \| number \| boolean \| null` | What goes in the CSV file for this column |
325
486
  | `cellRenderer` | `ComponentType<CellParams<T>>` | Component that renders the cell |
326
- | `cellEditor` | `ComponentType<CellEditorProps<T>>` | Component used instead of the text box when editing |
487
+ | `cellEditor` | `ComponentType<CellEditorProps<T>>` | Component used instead of the built-in editor |
327
488
  | `headerRenderer` | `ComponentType<HeaderRendererProps<T>>` | Component that renders the header label |
328
- | `filterRenderer` | `ComponentType<FilterRendererProps<T>>` | Component that replaces the filter text box inside the popup |
329
- | `className` | `string` | Classes for the whole column: its header cell and every body cell (Tailwind works) |
330
- | `style` | `CSSProperties` | Inline styles for the whole column: its header cell and every body cell |
331
- | `headerClassName` | `string` | Classes for this column's header cell only |
332
- | `headerStyle` | `CSSProperties` | Inline styles for this column's header cell |
333
- | `cellClassName` | `string \| (params) => string` | Classes for this column's cells |
334
- | `cellStyle` | `CSSProperties \| (params) => CSSProperties` | Inline styles for this column's cells |
489
+ | `className` / `style` | `string` / `CSSProperties` | Classes and inline styles for the whole column: header cell and every body cell (Tailwind works) |
490
+ | `headerClassName` / `headerStyle` | `string` / `CSSProperties` | Header cell only |
491
+ | `cellClassName` / `cellStyle` | `string \| (params) => string` / `CSSProperties \| (params) => CSSProperties` | Body cells only |
335
492
  | `checkbox` | `ColumnCheckboxOptions` | Look of the checkbox in a `type: "boolean"` column: `variant`, `indicator`, `className`, `style`, `classNames`, `styles` |
336
- | `headerCheckbox` | `boolean` | On a `type: "boolean"` column, draws a select-all checkbox in the header, labelled with the header name (no label when it is empty). See [Select-all header checkbox](#select-all-header-checkbox) |
493
+ | `headerCheckbox` | `boolean` | On a `type: "boolean"` column, draws a select-all checkbox in the header. See [Select-all header checkbox](#select-all-header-checkbox) |
337
494
 
338
495
  ### 6.4 Sorting
339
496
 
497
+ **Sorting is chosen per column.** A column is sortable only when its definition says `sortable: true`. There is no grid-level switch.
498
+
499
+ ```tsx
500
+ const columns: ColumnDef<Employee>[] = [
501
+ { field: "id", type: "number" }, // not sortable
502
+ { field: "name", sortable: true }, // click to sort
503
+ { field: "salary", type: "number", sortable: true },
504
+ ];
505
+ ```
506
+
340
507
  - Click a header to sort ascending, click again for descending, click a third time to clear the sort.
341
- - Only one column is sorted at a time.
342
- - Numbers sort numerically, dates by time, booleans false before true. Everything else sorts as text with natural number ordering (`"item 2"` before `"item 10"`), ignoring case.
508
+ - Numbers sort numerically, dates by day, booleans false before true. Everything else sorts as text with natural number ordering (`"item 2"` before `"item 10"`), ignoring case.
343
509
  - Empty values (`null`, `undefined`, `""`) always sort last, in both directions.
344
- - Add a `comparator` to sort any other way:
510
+ - Headers are keyboard accessible (Tab to a header, Enter or Space to sort) and expose `aria-sort`. A column that is not sortable gets no sort arrow, no pointer cursor and no focus stop.
511
+
512
+ **Custom order** with a `comparator`:
513
+
514
+ ```tsx
515
+ const order = ["low", "medium", "high", "urgent"];
516
+ { field: "priority", sortable: true, comparator: (a, b) => order.indexOf(a) - order.indexOf(b) }
517
+ ```
518
+
519
+ **Starting sorted** with `defaultSort`:
345
520
 
346
521
  ```tsx
347
- { field: "priority", comparator: (a, b) => order.indexOf(a) - order.indexOf(b) }
522
+ <DataGrid defaultSort={{ field: "salary", direction: "desc" }} ... />
523
+ ```
524
+
525
+ **Multi-column sort.** Switch on `multiSort`. A plain click still replaces the sort with that one column. **Shift+click** adds the column as the next tie-breaker (or cycles its direction if it is already in the sort). Small numbers in the headers show the priority.
526
+
527
+ ```tsx
528
+ <DataGrid
529
+ multiSort
530
+ defaultSort={[
531
+ { field: "department", direction: "asc" },
532
+ { field: "salary", direction: "desc" }, // inside each department, highest salary first
533
+ ]}
534
+ columns={columns}
535
+ rows={rows}
536
+ />
348
537
  ```
349
538
 
350
- - Set an initial sort with `defaultSort={{ field: "salary", direction: "desc" }}` and react to changes with `onSortChange`.
351
- - Headers are keyboard accessible (Tab to a header, Enter or Space to sort) and expose `aria-sort`.
352
- - Turn sorting off for the whole grid with `sortable={false}` or per column with `sortable: false`.
539
+ **Reacting to the sort** (always an array, empty when unsorted):
540
+
541
+ ```tsx
542
+ <DataGrid
543
+ onSortChange={(sort) => console.log(sort)}
544
+ // [{ field: "department", direction: "asc" }, { field: "salary", direction: "desc" }]
545
+ ...
546
+ />
547
+ ```
548
+
549
+ **Controlling the sort yourself** (also see [6.14](#614-controlled-state)):
550
+
551
+ ```tsx
552
+ const [sort, setSort] = useState<SortModel[]>([{ field: "name", direction: "asc" }]);
553
+
554
+ <button onClick={() => setSort([])}>Reset sort</button>
555
+ <DataGrid sort={sort} onSortChange={setSort} ... />
556
+ ```
353
557
 
354
558
  ### 6.5 Searching and filtering
355
559
 
356
- **Global search.** The box above the grid matches rows where any column's displayed text contains the typed text (case-insensitive). "Displayed text" means the result of `valueFormatter` if there is one.
560
+ **Global search.** Off by default. Switch it on with `searchable` and a box appears above the grid. It matches rows where any column's displayed text contains the typed text (case-insensitive). "Displayed text" means the result of `valueFormatter` if there is one.
561
+
562
+ ```tsx
563
+ <DataGrid searchable searchPlaceholder="Search people…" ... />
564
+ ```
357
565
 
358
- **Column filters.** Filters are set per column, in the column definition. Only columns with `filterable: true` get one:
566
+ **Column filters.** Filters are set per column. Only columns with `filterable: true` get one:
359
567
 
360
568
  ```tsx
361
569
  const columns: ColumnDef<Person>[] = [
@@ -366,13 +574,84 @@ const columns: ColumnDef<Person>[] = [
366
574
  ```
367
575
 
368
576
  - A filterable column shows a small **indicator** in its header, next to the label. By default it is a dot. It is dim while no filter is set, and lit (with a glow) while one is active.
369
- - **Clicking the indicator** opens a popup right below it with a text box. The popup is not there until you click. It closes on Escape, on a click outside, or on Enter in the text box. Clicking the indicator does not sort the column.
577
+ - **Clicking the indicator** opens a popup right below it. The popup is not there until you click. It closes on Escape, on a click outside, or on Enter in a text box. Clicking the indicator does not sort the column.
370
578
  - The popup shows a **Clear** button while a filter is set.
371
- - Filters work as a case-insensitive "contains" on the displayed text, and combine with each other and with the global search.
579
+ - All filters and the global search combine (a row must pass every one).
372
580
  - The popup is drawn in `document.body` and positioned next to the indicator, so a scrolling grid never clips it. It follows scrolling and resizing, and stays inside the window.
373
- - There is no grid-level filter prop: a column without `filterable: true` has no filter, and a filter value is ignored if its column stops being filterable.
581
+ - There is no grid-level filter prop to turn filters on: a column without `filterable: true` has no filter, and a filter value is ignored if its column stops being filterable.
374
582
 
375
- **Styling each indicator separately.** Every column has its own options:
583
+ #### Filter types
584
+
585
+ Each column's filter has a kind. By default it follows the column `type`; set `filterType` to choose another.
586
+
587
+ | `filterType` | Default for | Popup shows | Rows pass when |
588
+ | --- | --- | --- | --- |
589
+ | `"text"` | `type: "text"` | One text box | The displayed text **contains** the typed text (case-insensitive) |
590
+ | `"exact"` | | One text box | The displayed text **equals** the typed text (case-insensitive) |
591
+ | `"number"` | `type: "number"` | Min and Max boxes | The number is between Min and Max (either may be left empty). Rows without a number never match |
592
+ | `"date"` | `type: "date"` | From and To date pickers | The day is between From and To (either may be left empty) |
593
+ | `"select"` | `type: "boolean"` | A checkbox per value | The raw value is one of the ticked values |
594
+
595
+ ```tsx
596
+ const columns: ColumnDef<Task>[] = [
597
+ // "contains": typing "log" finds "Fix login" and "Catalogue"
598
+ { field: "title", filterable: true },
599
+
600
+ // "exact": typing "active" finds "active" but NOT "inactive"
601
+ { field: "status", filterable: true, filterType: "exact" },
602
+
603
+ // number range: type: "number" gives a Min / Max popup by default
604
+ { field: "budget", type: "number", filterable: true },
605
+
606
+ // date range: type: "date" gives From / To date pickers by default
607
+ { field: "due", type: "date", filterable: true },
608
+
609
+ // choose from a list: values are collected from the data...
610
+ { field: "owner", filterable: true, filterType: "select" },
611
+
612
+ // ...or you decide the list and the labels
613
+ {
614
+ field: "priority",
615
+ filterable: true,
616
+ filterType: "select",
617
+ filterOptions: [
618
+ { value: "p1", label: "Urgent" },
619
+ { value: "p2", label: "Normal" },
620
+ "p3", // a plain string is its own label
621
+ ],
622
+ },
623
+ ];
624
+ ```
625
+
626
+ Notes on `select`: when `filterOptions` is left out, the choices are the distinct values in the data, sorted. A boolean column gets "Yes" and "No". Ticking several values combines them with OR.
627
+
628
+ #### Reading and setting filters
629
+
630
+ The shape of a filter value depends on its kind:
631
+
632
+ ```ts
633
+ // text / exact: "sales"
634
+ // number: { min: 1000, max: 5000 } (either key may be missing)
635
+ // date: { from: "2026-01-01", to: "2026-03-31" }
636
+ // select: ["active", "pending"]
637
+ ```
638
+
639
+ Listen with `onFiltersChange`, or control the filters completely:
640
+
641
+ ```tsx
642
+ const [filters, setFilters] = useState<Record<string, FilterValue>>({
643
+ status: ["open", "blocked"], // select
644
+ budget: { min: 1000 }, // number
645
+ due: { from: "2026-06-01" }, // date
646
+ });
647
+
648
+ <button onClick={() => setFilters({})}>Clear all filters</button>
649
+ <DataGrid filters={filters} onFiltersChange={setFilters} ... />
650
+ ```
651
+
652
+ #### Styling each indicator
653
+
654
+ Every column has its own options:
376
655
 
377
656
  ```tsx
378
657
  {
@@ -386,7 +665,9 @@ const columns: ColumnDef<Person>[] = [
386
665
 
387
666
  The dot takes its colour from the button's text colour, so `color` (or `text-*`) restyles it. The colour while a filter is active comes from `--rk-filter-active`. To style all indicators or popups at once, use `classNames.filterIndicator`, `classNames.filterPopup`, `classNames.filterInput` (or the matching `styles`).
388
667
 
389
- **A custom indicator component.** Replace the dot with any component. It receives `{ column, active, open }`:
668
+ #### A custom indicator component
669
+
670
+ Replace the dot with any component. It receives `{ column, active, open }`:
390
671
 
391
672
  ```tsx
392
673
  function FunnelIndicator({ active }: FilterIndicatorProps<Employee>) {
@@ -402,49 +683,123 @@ function FunnelIndicator({ active }: FilterIndicatorProps<Employee>) {
402
683
 
403
684
  The component is drawn inside the indicator `<button>`, so do not put another button in it.
404
685
 
405
- **A custom filter control.** `filterRenderer` replaces the text box inside the popup (see [filterRenderer](#filterrenderer-custom-filter-control)).
686
+ #### A custom filter control
687
+
688
+ `filterRenderer` replaces **all** the built-in controls inside the popup, whatever the filter type (see [filterRenderer](#filterrenderer-custom-filter-control)). A custom control produces a text filter, so it works with the "contains" matching.
406
689
 
407
- Notes:
690
+ #### Notes
408
691
 
409
- - Searching and filtering use the displayed text, so a salary shown as `$52,000` is found by `52,000` and not by `52000` unless your formatter says so.
410
- - Filtering is a substring match. Filtering a status column for `active` also matches `inactive`. For exact choices, use a `filterRenderer` and a distinct display text, or format the values so they don't overlap.
692
+ - Searching and text filters use the displayed text, so a salary shown as `$52,000` is found by `52,000` and not by `52000` unless your formatter says so. Number, date and select filters look at the raw value instead, so formatting never gets in their way.
411
693
  - Changing the search, a filter, the sort or the page size sends the grid back to page 1.
412
- - Turn off the search box with `searchable={false}`. Leave out `filterable` (or set it to `false`) to give a column no filter.
694
+ - In [server-side mode](#615-server-side-mode) the filters are not applied by the grid: they are handed to your server in the query.
413
695
 
414
- ### 6.6 Pagination
696
+ ### 6.6 Date columns
697
+
698
+ `type: "date"` is a built-in column type with formatting, a date picker editor, chronological sorting and a From / To filter.
415
699
 
416
700
  ```tsx
417
- <DataGrid pageSize={20} pageSizeOptions={[10, 20, 50, 100]} paginationPosition="both" />
701
+ interface Task { id: number; title: string; due: string; created: Date }
702
+
703
+ const columns: ColumnDef<Task>[] = [
704
+ { field: "title", editable: true },
705
+ {
706
+ field: "due",
707
+ type: "date",
708
+ sortable: true,
709
+ filterable: true,
710
+ editable: true,
711
+ dateMin: "2026-01-01", // the picker will not accept earlier days
712
+ dateMax: "2027-12-31",
713
+ },
714
+ ];
715
+
716
+ <DataGrid columns={columns} rows={tasks} editable onRowsChange={setTasks} />
418
717
  ```
419
718
 
420
- The pagination bar shows the row range ("11–20 of 200"), a rows-per-page menu, first/previous/next/last buttons and "Page x of y".
719
+ **What a row may hold.** The cell understands `"2026-11-05"`, a full ISO string (`"2026-11-05T10:30:00Z"`, whose date part is used as written), a `Date` object, a timestamp number, or any string `Date.parse` understands. Empty values show blank and sort last. Text that is not a date is shown as it is.
720
+
721
+ **Editing.** Double-click a date cell (or press Enter / F2) to open the browser's date picker. Typing characters does not start an editor on a date cell, because the picker is its own control. After picking or typing a valid date, Enter or a click elsewhere saves it. An empty picker saves `null`. Escape cancels. A half-typed date is ignored when you click away.
722
+
723
+ **The saved value keeps the shape the row already had.**
724
+
725
+ | Row held | Saved value |
726
+ | --- | --- |
727
+ | `"2026-03-14"` (text) | `"2026-03-14"` |
728
+ | a `Date` object | a `Date` at local midnight |
729
+ | a timestamp number | a timestamp at local midnight |
730
+ | empty | the same shape as the other rows in the column (the ISO text when there are none) |
731
+
732
+ Dates are handled in the **user's local time zone**, so typing 5 November never turns into 4 November because of UTC.
733
+
734
+ **Display.** The default is the browser's medium date format (`Nov 5, 2026`). Change it with `dateLocale` and `dateFormat` (any `Intl.DateTimeFormat` options):
735
+
736
+ ```tsx
737
+ { field: "created", type: "date", dateLocale: "en-GB", dateFormat: { dateStyle: "long" } } // 5 November 2026
738
+ { field: "created", type: "date", dateLocale: "de-DE", dateFormat: { day: "2-digit", month: "2-digit", year: "numeric" } } // 05.11.2026
739
+ ```
740
+
741
+ A `valueFormatter` still wins over these two if you set one.
742
+
743
+ **Sorting and filtering.** Dates sort by day (earliest first, empties last). The filter popup has From and To pickers; either may be empty for an open-ended range:
744
+
745
+ ```tsx
746
+ <DataGrid filters={{ due: { from: "2026-03-01", to: "2026-06-30" } }} onFiltersChange={setFilters} ... />
747
+ ```
748
+
749
+ **CSV export** writes dates as `"YYYY-MM-DD"` unless you give `exportValue`.
750
+
751
+ ### 6.7 Pagination
752
+
753
+ Pagination is off by default. Switch it on to split the rows into pages and show the bar with the row range ("11–20 of 200"), a rows-per-page menu, first / previous / next / last buttons and "Page x of y". **The bar (and its page information) only appears when `pagination` is passed.**
754
+
755
+ ```tsx
756
+ <DataGrid pagination pageSize={20} pageSizeOptions={[10, 20, 50, 100]} paginationPosition="both" ... />
757
+ ```
421
758
 
422
759
  | `paginationPosition` | Result |
423
760
  | --- | --- |
424
761
  | `"bottom"` | Bar under the table (default) |
425
- | `"top"` | Bar above the table, below the search box |
762
+ | `"top"` | Bar above the table, below the toolbar |
426
763
  | `"both"` | Both bars, kept in sync |
427
764
 
428
- With `pagination={false}` every row is rendered and only the row count shows, at the bottom. Combine it with `maxHeight` to get a scrolling grid with a sticky header:
765
+ Without `pagination` every row is rendered and there is no footer at all. Combine it with `maxHeight` for a scrolling grid with a sticky header:
429
766
 
430
767
  ```tsx
431
- <DataGrid pagination={false} maxHeight={400} />
768
+ <DataGrid maxHeight={400} ... />
432
769
  ```
433
770
 
434
- `pageSize` is only read when the grid first mounts. The user's choice in the menu is then kept inside the grid.
771
+ `pageSize` is only read when the grid first mounts. The user's choice in the menu is then kept inside the grid. To own the page, see [6.14](#614-controlled-state).
772
+
773
+ **Reacting to paging:**
774
+
775
+ ```tsx
776
+ <DataGrid
777
+ pagination
778
+ onPageChange={(page) => console.log("page", page)} // zero-based
779
+ onPageSizeChange={(size) => localStorage.setItem("size", String(size))}
780
+ pageSize={Number(localStorage.getItem("size")) || 10}
781
+ ...
782
+ />
783
+ ```
435
784
 
436
- ### 6.7 Row click, toolbar and empty state
785
+ ### 6.8 Row click, toolbar and empty state
437
786
 
438
787
  ```tsx
439
788
  <DataGrid
440
789
  onRowClick={(row, rowIndex) => navigate(`/people/${row.id}`)}
441
- toolbar={<button onClick={exportCsv}>Export</button>}
790
+ toolbar={<button onClick={openNewPersonDialog}>Add person</button>}
442
791
  emptyMessage="Nothing matches your search"
443
792
  striped
444
793
  />
445
794
  ```
446
795
 
447
- ### 6.8 Editing
796
+ The toolbar row is drawn when at least one of `searchable`, `columnMenu`, `exportable` or `toolbar` is used. Your `toolbar` content sits on the right, next to the built-in buttons. To style a toolbar button the same way as the built-in ones, give it the class `rk-grid__tool-btn`:
797
+
798
+ ```tsx
799
+ toolbar={<button className="rk-grid__tool-btn" onClick={refetch}>Reload</button>}
800
+ ```
801
+
802
+ ### 6.9 Editing
448
803
 
449
804
  Turn editing on with `editable` on the grid, then lock or unlock columns:
450
805
 
@@ -531,7 +886,7 @@ Important: the grid copies `rows` into its own state and re-syncs whenever the `
531
886
  />
532
887
  ```
533
888
 
534
- ### 6.9 Custom components per column
889
+ ### 6.10 Custom components per column
535
890
 
536
891
  Four hooks let you replace parts of a column with your own React components. They are rendered as real components, so they can use hooks and state.
537
892
 
@@ -638,7 +993,7 @@ function DepartmentFilter({ value, onChange }: FilterRendererProps<Employee>) {
638
993
 
639
994
  #### Checkbox columns (`type: "boolean"`)
640
995
 
641
- Set `type: "boolean"` on a column and each cell shows a [Checkbox](#checkbox). One click saves the new `true` / `false` value.
996
+ Set `type: "boolean"` on a column and each cell shows a [Checkbox](#5-checkbox). One click saves the new `true` / `false` value.
642
997
 
643
998
  ```tsx
644
999
  const columns: ColumnDef<Task>[] = [
@@ -727,7 +1082,7 @@ Add `headerCheckbox: true` to a boolean column. The header label becomes a check
727
1082
  - **Sorting:** clicking the checkbox or its label does not sort. Click the sort arrow beside it (or the empty part of the header cell) to sort.
728
1083
  - **Custom header:** if the column also has a `headerRenderer`, that is used and `headerCheckbox` is ignored. `headerCheckbox` does nothing on columns that are not `type: "boolean"`.
729
1084
 
730
- ### 6.10 Styling
1085
+ ### 6.11 Styling
731
1086
 
732
1087
  #### Whole grid
733
1088
 
@@ -762,6 +1117,8 @@ Add `headerCheckbox: true` to a boolean column. The header label becomes a check
762
1117
  | `pageInfo` | The "Page 1 of 20" text |
763
1118
  | `pageSize` | The rows-per-page `<select>` |
764
1119
  | `pagination` | Wrapper around the page-size menu and buttons |
1120
+ | `toolButton` | Toolbar buttons (Columns, Export CSV, Retry) |
1121
+ | `skeleton` | The shimmering bars of the loading skeleton |
765
1122
  | `pageButton` | Each first/previous/next/last button |
766
1123
  | `checkbox` | Root of each checkbox in a `type: "boolean"` column |
767
1124
  | `checkboxControl` | The checkbox's button or input |
@@ -859,12 +1216,22 @@ If you prefer writing CSS, these class names are stable.
859
1216
  | `.rk-grid__td`, `.rk-grid__td--editable`, `.rk-grid__td--editing` | Body cells |
860
1217
  | `.rk-grid__editor`, `.rk-grid__editor--invalid`, `.rk-grid__custom-editor` | Editors |
861
1218
  | `.rk-grid__empty` | Empty state |
862
- | `.rk-grid__checkbox-cell` | Wrapper around a checkbox in a boolean column (the checkbox's own classes start with `.rk-checkbox`, see [Checkbox](#checkbox)) |
1219
+ | `.rk-grid__checkbox-cell` | Wrapper around a checkbox in a boolean column (the checkbox's own classes start with `.rk-checkbox`, see [Checkbox](#5-checkbox)) |
863
1220
  | `.rk-grid__footer`, `.rk-grid__footer--top` | Pagination bar (`data-position="top"` or `"bottom"`) |
864
1221
  | `.rk-grid__count`, `.rk-grid__pagination`, `.rk-grid__page-size-label`, `.rk-grid__page-size`, `.rk-grid__page-btn`, `.rk-grid__page-info` | Pagination parts |
1222
+ | `.rk-grid__toolbar-left`, `.rk-grid__toolbar-actions` | The two halves of the toolbar (search and selected count / buttons) |
1223
+ | `.rk-grid__tool-btn`, `.rk-grid__selected-count` | Toolbar buttons and the "N selected" label |
1224
+ | `.rk-grid__select-cell`, `.rk-grid__row--selected` | Row-selection checkbox cell and a selected row |
1225
+ | `.rk-grid__pinned`, `.rk-grid__pinned--left`, `.rk-grid__pinned--right`, `.rk-grid__pinned--edge` | Pinned cells (the edge class marks the last left / first right one) |
1226
+ | `.rk-grid__resizer` | The column resize handle |
1227
+ | `.rk-grid__sort-order` | The priority number next to a sort arrow in multi-column sort |
1228
+ | `.rk-grid--virtual`, `.rk-grid__spacer` | Virtualized grid and its spacer rows |
1229
+ | `.rk-grid__skeleton`, `.rk-grid__skeleton-row`, `.rk-grid__skeleton-cell` | Loading placeholder bar, row and (custom) cell |
1230
+ | `.rk-grid__error` | The error message cell (with the Retry button) |
1231
+ | `.rk-grid__filter-fields`, `.rk-grid__filter-field`, `.rk-grid__filter-options`, `.rk-grid__filter-empty` | Controls inside the filter popup (number, date, select) |
865
1232
 
866
1233
 
867
- ### 6.11 Nested fields
1234
+ ### 6.12 Nested fields
868
1235
 
869
1236
  A column's `field` can point into nested objects with dot notation:
870
1237
 
@@ -892,54 +1259,560 @@ const columns: ColumnDef<Person>[] = [
892
1259
  - `__proto__`, `prototype` and `constructor` are never read or written through a path.
893
1260
  - `getRowId`, `onRowClick` and other callbacks work as before.
894
1261
 
895
- ### 6.12 Keyboard reference
1262
+ ### 6.13 Row selection
1263
+
1264
+ Off by default. `rowSelection` adds a checkbox column on the left.
1265
+
1266
+ ```tsx
1267
+ <DataGrid columns={columns} rows={rows} getRowId={(r) => r.id} rowSelection />
1268
+ ```
1269
+
1270
+ | Value | Behaviour |
1271
+ | --- | --- |
1272
+ | `true` or `"multiple"` | Many rows. The header has a select-all checkbox. Shift+click selects a range |
1273
+ | `"single"` | One row at a time (selecting another replaces it). No select-all checkbox |
1274
+
1275
+ - Selected rows get the class `rk-grid__row--selected`, the colour `--rk-selected-bg` and `aria-selected="true"`.
1276
+ - Selection is kept **by row id**, so it survives paging, sorting, searching and filtering. Pass `getRowId` so ids are stable.
1277
+ - The **select-all checkbox** in the header acts on every selectable row that matches the current search and filters, **across all pages**. It shows a dash while only some of them are selected.
1278
+ - **Shift+click** on a row's checkbox selects (or clears) everything between it and the last row you clicked, as shown on screen. Disabled rows in between are skipped.
1279
+ - A "N selected" count appears in the toolbar (when a toolbar is shown) and in the pagination bar.
1280
+
1281
+ **Reading the selection** (`onSelectionChange` receives the ids and the row objects):
1282
+
1283
+ ```tsx
1284
+ function Page() {
1285
+ const [selection, setSelection] = useState<RowSelection<Person>>({ ids: [], rows: [] });
1286
+
1287
+ return (
1288
+ <>
1289
+ <button disabled={!selection.ids.length} onClick={() => deletePeople(selection.ids)}>
1290
+ Delete {selection.ids.length} people
1291
+ </button>
1292
+ <DataGrid
1293
+ columns={columns}
1294
+ rows={rows}
1295
+ getRowId={(r) => r.id}
1296
+ rowSelection
1297
+ onSelectionChange={setSelection}
1298
+ />
1299
+ </>
1300
+ );
1301
+ }
1302
+ ```
1303
+
1304
+ **Controlled selection.** Pass `selectedRowIds` and keep it up to date from `onSelectionChange`:
1305
+
1306
+ ```tsx
1307
+ const [ids, setIds] = useState<(string | number)[]>([2, 4]);
1308
+
1309
+ <button onClick={() => setIds([])}>Clear</button>
1310
+ <DataGrid rowSelection selectedRowIds={ids} onSelectionChange={(s) => setIds(s.ids)} ... />
1311
+ ```
1312
+
1313
+ Or start with some rows selected and let the grid manage it: `defaultSelectedRowIds={[2, 4]}`.
1314
+
1315
+ **Rows that cannot be selected.** Return `false` from `isRowSelectable`. The row's checkbox is disabled, select-all skips it and Shift ranges pass over it:
1316
+
1317
+ ```tsx
1318
+ <DataGrid rowSelection isRowSelectable={(row) => row.status !== "archived"} ... />
1319
+ ```
1320
+
1321
+ **Single selection**, for a master / detail layout:
1322
+
1323
+ ```tsx
1324
+ const [current, setCurrent] = useState<Person | null>(null);
1325
+
1326
+ <DataGrid
1327
+ rowSelection="single"
1328
+ getRowId={(r) => r.id}
1329
+ onSelectionChange={(s) => setCurrent(s.rows[0] ?? null)}
1330
+ ...
1331
+ />
1332
+ {current && <PersonDetails person={current} />}
1333
+ ```
1334
+
1335
+ **From outside the grid**, with `apiRef` (see [6.19](#619-csv-export-and-the-api)):
1336
+
1337
+ ```tsx
1338
+ const api = useRef<DataGridApi<Person> | null>(null);
1339
+
1340
+ <button onClick={() => api.current?.selectAll()}>Select all</button>
1341
+ <button onClick={() => api.current?.clearSelection()}>Clear</button>
1342
+ <button onClick={() => console.log(api.current?.getSelectedRows())}>Log selection</button>
1343
+ <DataGrid rowSelection apiRef={api} ... />
1344
+ ```
1345
+
1346
+ **Combined with other features.** Row selection works together with pinning (the checkbox column is pinned automatically when any column is pinned left), virtualization, keyboard navigation, and CSV export (`scope: "selected"`).
1347
+
1348
+ **In server-side mode** the grid only holds the current page, so it remembers every row it has seen. `onSelectionChange` and `getSelectedRows()` return those remembered rows, including ones selected on pages you have left. Select-all selects the rows of the page that is loaded.
1349
+
1350
+ ### 6.14 Controlled state
1351
+
1352
+ Search, filters, sort, page and selection can each be **uncontrolled** (the grid keeps the state; the default) or **controlled** (you pass the value and the change callback).
1353
+
1354
+ | State | Value prop | Change callback | Initial value (uncontrolled) |
1355
+ | --- | --- | --- | --- |
1356
+ | Search text | `search` | `onSearchChange(text)` | |
1357
+ | Column filters | `filters` | `onFiltersChange(filters)` | |
1358
+ | Sort | `sort` | `onSortChange(sort)` | `defaultSort` |
1359
+ | Page (zero-based) | `page` | `onPageChange(page)` | page 0 |
1360
+ | Page size | | `onPageSizeChange(size)` | `pageSize` |
1361
+ | Selection | `selectedRowIds` | `onSelectionChange({ ids, rows })` | `defaultSelectedRowIds` |
1362
+
1363
+ **Rules.**
1364
+
1365
+ - Pass the value **and** its callback. A value without a callback makes that piece of state read-only.
1366
+ - You may control some pieces and leave the rest to the grid.
1367
+ - The page goes back to 0 automatically whenever the search, a filter, the sort or the page size changes. In a controlled grid you receive that through `onPageChange(0)`.
1368
+
1369
+ **Example: keep the grid in sync with the URL** so a page can be bookmarked and the Back button works:
1370
+
1371
+ ```tsx
1372
+ import { useSearchParams } from "react-router-dom";
1373
+
1374
+ function PeopleGrid() {
1375
+ const [params, setParams] = useSearchParams();
1376
+ const page = Number(params.get("page") ?? 0);
1377
+ const search = params.get("q") ?? "";
1378
+
1379
+ const update = (changes: Record<string, string | null>) =>
1380
+ setParams((p) => {
1381
+ for (const [k, v] of Object.entries(changes)) v === null ? p.delete(k) : p.set(k, v);
1382
+ return p;
1383
+ });
1384
+
1385
+ return (
1386
+ <DataGrid
1387
+ columns={columns}
1388
+ rows={rows}
1389
+ searchable
1390
+ pagination
1391
+ page={page}
1392
+ onPageChange={(p) => update({ page: String(p) })}
1393
+ search={search}
1394
+ onSearchChange={(q) => update({ q: q || null })}
1395
+ />
1396
+ );
1397
+ }
1398
+ ```
1399
+
1400
+ **Example: buttons outside the grid**
1401
+
1402
+ ```tsx
1403
+ const [page, setPage] = useState(0);
1404
+ const [search, setSearch] = useState("");
1405
+
1406
+ <button onClick={() => setPage(2)}>Go to page 3</button>
1407
+ <input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search outside the grid" />
1408
+ <DataGrid searchable pagination pageSize={5} page={page} onPageChange={setPage} search={search} onSearchChange={setSearch} ... />
1409
+ ```
1410
+
1411
+ **Example: saved views.** Remember a user's filters and sort between visits:
1412
+
1413
+ ```tsx
1414
+ const [filters, setFilters] = useState<Record<string, FilterValue>>(() => JSON.parse(localStorage.getItem("filters") ?? "{}"));
1415
+ const [sort, setSort] = useState<SortModel[]>(() => JSON.parse(localStorage.getItem("sort") ?? "[]"));
1416
+
1417
+ useEffect(() => localStorage.setItem("filters", JSON.stringify(filters)), [filters]);
1418
+ useEffect(() => localStorage.setItem("sort", JSON.stringify(sort)), [sort]);
1419
+
1420
+ <DataGrid filters={filters} onFiltersChange={setFilters} sort={sort} onSortChange={setSort} ... />
1421
+ ```
1422
+
1423
+ ### 6.15 Server-side mode
1424
+
1425
+ When your data lives on a server, switch on `serverSide`. The grid then **does not sort, filter, search or page the rows itself**. It shows the `rows` you give it (the current page) and tells you what to fetch.
1426
+
1427
+ ```tsx
1428
+ <DataGrid
1429
+ serverSide
1430
+ columns={columns}
1431
+ rows={rows} // only the current page
1432
+ rowCount={total} // total rows on the server, for "1–10 of 237" and the page count
1433
+ pagination
1434
+ searchable
1435
+ onQueryChange={load}
1436
+ />
1437
+ ```
1438
+
1439
+ `onQueryChange(query)` is called **on mount and whenever the query changes**: the sort, the search text (after `queryDebounce` ms), a filter, the page or the page size. The query is:
1440
+
1441
+ ```ts
1442
+ interface GridQuery {
1443
+ sort: SortModel[]; // [{ field: "salary", direction: "desc" }]
1444
+ filters: Record<string, FilterValue>; // only active ones, by field
1445
+ search: string; // trimmed
1446
+ page: number; // zero-based
1447
+ pageSize: number;
1448
+ }
1449
+ ```
1450
+
1451
+ Filter values have the shapes from [6.5](#65-searching-and-filtering): a string for text / exact, `{ min, max }` for number, `{ from, to }` for date, an array for select. Only columns marked `sortable` appear in `sort`.
1452
+
1453
+ **Loading is caught from a function.** If `onQueryChange` returns a promise, the grid shows the [loading skeleton](#616-loading-skeleton) until the promise settles. If it rejects, the grid shows `errorMessage` with a **Retry** button that runs the same query again. You do not manage any `loading` state.
1454
+
1455
+ ```tsx
1456
+ function PeopleGrid() {
1457
+ const [rows, setRows] = useState<Person[]>([]);
1458
+ const [total, setTotal] = useState(0);
1459
+
1460
+ const load = useCallback(async (q: GridQuery) => {
1461
+ const res = await fetch("/api/people?" + new URLSearchParams({
1462
+ page: String(q.page),
1463
+ pageSize: String(q.pageSize),
1464
+ search: q.search,
1465
+ sort: JSON.stringify(q.sort),
1466
+ filters: JSON.stringify(q.filters),
1467
+ }));
1468
+ if (!res.ok) throw new Error("Request failed"); // -> error row with Retry
1469
+ const body = await res.json();
1470
+ setRows(body.rows);
1471
+ setTotal(body.total);
1472
+ }, []);
1473
+
1474
+ return (
1475
+ <DataGrid
1476
+ serverSide
1477
+ columns={columns}
1478
+ rows={rows}
1479
+ rowCount={total}
1480
+ getRowId={(r) => r.id}
1481
+ pagination
1482
+ searchable
1483
+ onQueryChange={load}
1484
+ errorMessage="Could not reach the server."
1485
+ />
1486
+ );
1487
+ }
1488
+ ```
1489
+
1490
+ **Ignoring stale answers.** The grid ignores the *loading state* of an older query when a newer one has started, but it cannot know which response **you** store. Cancel old requests with `AbortController`, or compare a counter:
1491
+
1492
+ ```tsx
1493
+ const controller = useRef<AbortController>();
1494
+
1495
+ const load = useCallback(async (q: GridQuery) => {
1496
+ controller.current?.abort(); // cancel the previous request
1497
+ controller.current = new AbortController();
1498
+ const res = await fetch(url(q), { signal: controller.current.signal });
1499
+ const body = await res.json();
1500
+ setRows(body.rows);
1501
+ setTotal(body.total);
1502
+ }, []);
1503
+ ```
1504
+
1505
+ An aborted `fetch` rejects with an `AbortError`. If you do not want that to show the error row, catch it: `catch (e) { if ((e as Error).name !== "AbortError") throw e; }`.
1506
+
1507
+ **Using a data library.** Anything that returns a promise works:
1508
+
1509
+ ```tsx
1510
+ // the grid calls your function; you call the library
1511
+ const load = (q: GridQuery) => queryClient.fetchQuery({ queryKey: ["people", q], queryFn: () => api.people(q) })
1512
+ .then((res) => { setRows(res.rows); setTotal(res.total); });
1513
+ ```
1514
+
1515
+ **Forcing the loading state.** If your loading flag lives somewhere else (a hook, a store), pass `loading` and it replaces the promise-based state:
1516
+
1517
+ ```tsx
1518
+ const { data, isFetching } = useQuery(...);
1519
+ <DataGrid serverSide rows={data?.rows ?? []} rowCount={data?.total} loading={isFetching} onQueryChange={setQuery} />
1520
+ ```
1521
+
1522
+ **Refreshing** after your data changed (for example after deleting a row) runs the last query again:
1523
+
1524
+ ```tsx
1525
+ api.current?.refresh();
1526
+ ```
1527
+
1528
+ **Search debounce.** Typing in the search box waits `queryDebounce` milliseconds (250 by default) after the last keystroke, so fast typing makes one request, not one per letter. Set `queryDebounce={0}` for instant queries.
1529
+
1530
+ **Server mode and the other features.**
1531
+
1532
+ - Selection, [CSV export of selected rows](#619-csv-export-and-the-api) and keyboard navigation work on the loaded page.
1533
+ - With `scope: "filtered"` the CSV contains the rows currently loaded (the current page), because the grid does not have the rest.
1534
+ - `defaultSort` is sent in the first query. `multiSort` lets users send several sort columns.
1535
+ - `onQueryChange` is the only place the grid asks for data. Without `serverSide`, the grid never calls it.
1536
+
1537
+ ### 6.16 Loading skeleton
1538
+
1539
+ While loading, the body shows **placeholder rows** with shimmering bars instead of data. The header, toolbar and footer stay in place, and the grid root gets `aria-busy="true"`. When the promise from `onQueryChange` settles (or `loading` turns `false`), the real rows appear.
1540
+
1541
+ ```tsx
1542
+ <DataGrid serverSide onQueryChange={load} loadingRows={8} ... />
1543
+ ```
1544
+
1545
+ - `loadingRows` is how many placeholder rows to draw. Default: the page size (at most 20), or 5 without pagination.
1546
+ - With `rowSelection` the checkbox column shows a small bar too.
1547
+ - Bar widths vary slightly from row to row so the placeholder does not look like identical stripes.
1548
+ - Colours come from `--rk-skeleton-a` and `--rk-skeleton-b`. Each theme sets them. The shimmer stops when the user has "reduce motion" turned on.
1549
+ - Style the bars with `classNames.skeleton` / `styles.skeleton` (they are `<span class="rk-grid__skeleton">`).
1550
+
1551
+ **A custom skeleton for each row.** Pass a component as `skeletonRow`. It is drawn once per placeholder row, inside a single cell that spans the whole row, and receives `{ rowIndex, columns }`:
1552
+
1553
+ ```tsx
1554
+ function PersonSkeleton({ rowIndex }: SkeletonRowProps<Person>) {
1555
+ return (
1556
+ <div style={{ display: "flex", gap: 12, alignItems: "center", padding: "4px 0" }}>
1557
+ <span className="avatar-skeleton" />
1558
+ <span style={{ display: "grid", gap: 6, flex: 1 }}>
1559
+ <span className="line-skeleton" style={{ width: `${40 + ((rowIndex * 17) % 30)}%` }} />
1560
+ <span className="line-skeleton" style={{ width: "25%" }} />
1561
+ </span>
1562
+ </div>
1563
+ );
1564
+ }
1565
+
1566
+ <DataGrid serverSide onQueryChange={load} skeletonRow={PersonSkeleton} loadingRows={6} ... />
1567
+ ```
1568
+
1569
+ You can reuse the built-in shimmer in your own placeholder by giving your elements the class `rk-grid__skeleton`:
1570
+
1571
+ ```tsx
1572
+ function RowSkeleton({ columns }: SkeletonRowProps<Person>) {
1573
+ return (
1574
+ <div style={{ display: "grid", gridTemplateColumns: `repeat(${columns.length}, 1fr)`, gap: 16 }}>
1575
+ {columns.map((c) => <span key={c.field} className="rk-grid__skeleton" style={{ width: "70%" }} />)}
1576
+ </div>
1577
+ );
1578
+ }
1579
+ ```
1580
+
1581
+ Define the skeleton component at module level (not inline in render), like every other custom component.
1582
+
1583
+ **Showing it without a server.** `loading` works in a normal grid too, for example while a parent fetches `rows`:
1584
+
1585
+ ```tsx
1586
+ <DataGrid columns={columns} rows={rows} loading={isLoading} />
1587
+ ```
1588
+
1589
+ **The error state.** If the promise from `onQueryChange` rejects, the body shows `errorMessage` (default "Could not load the data.") and a Retry button. Style it with `.rk-grid__error`:
1590
+
1591
+ ```tsx
1592
+ <DataGrid serverSide onQueryChange={load} errorMessage={<strong>The server is not answering.</strong>} ... />
1593
+ ```
1594
+
1595
+ ### 6.17 Resize, pin and hide columns
1596
+
1597
+ All three are opt-in. **There is no column reordering.**
1598
+
1599
+ **Resizing.** Add `resizable: true` to a column. A drag handle appears on its right edge. Drag it, or use a touch or pen: it uses pointer events.
1600
+
1601
+ ```tsx
1602
+ const columns: ColumnDef<Employee>[] = [
1603
+ { field: "name", resizable: true, minWidth: 100 },
1604
+ { field: "email", resizable: true, minWidth: 140, maxWidth: 480 },
1605
+ { field: "id", width: 70 }, // no handle: fixed
1606
+ ];
1607
+ ```
1608
+
1609
+ - On the first drag the grid fixes every column at the width it has at that moment (so nothing jumps) and switches the table to `table-layout: fixed`.
1610
+ - `minWidth` (at least 40px) and `maxWidth` limit the drag.
1611
+ - Widths live inside the grid and are not saved. To remember them, store the `width` you want per column yourself.
1612
+
1613
+ **Pinning.** Add `pinned: "left"` or `"right"` to keep a column visible while the rest scrolls sideways. Pinned columns are moved to the edges (left-pinned first, right-pinned last), keep their order among themselves, and have an opaque background.
1614
+
1615
+ ```tsx
1616
+ const columns: ColumnDef<Employee>[] = [
1617
+ { field: "id", width: 70, pinned: "left" },
1618
+ { field: "name", width: 160, pinned: "left" }, // sits next to ID
1619
+ { field: "email", width: 280 },
1620
+ { field: "department", width: 200 },
1621
+ { field: "salary", width: 160 },
1622
+ { field: "status", width: 120, pinned: "right" }, // always at the right edge
1623
+ ];
1624
+
1625
+ <DataGrid columns={columns} rows={rows} /> // scroll sideways: ID, Name and Status stay put
1626
+ ```
1627
+
1628
+ The row-selection checkbox column is pinned too when any column is pinned left. A thin shadow line (`.rk-grid__pinned--edge`) marks where pinned columns end. Give pinned columns an explicit `width` for the cleanest look. Pinning needs the grid to be narrower than its columns, which happens when the columns' widths add up to more than the container.
1629
+
1630
+ Colours: `--rk-pinned-bg` and `--rk-pinned-header-bg`. Stripes, hover and selection are layered on top.
1631
+
1632
+ **Showing and hiding.** Switch on `columnMenu` for a "Columns" button with a checkbox per column:
1633
+
1634
+ ```tsx
1635
+ <DataGrid
1636
+ columnMenu
1637
+ onColumnVisibilityChange={(hiddenFields) => localStorage.setItem("hidden", JSON.stringify(hiddenFields))}
1638
+ columns={[
1639
+ { field: "id", hideable: false }, // always visible, not listed
1640
+ { field: "name" },
1641
+ { field: "email" },
1642
+ { field: "notes", hidden: true }, // starts hidden
1643
+ ]}
1644
+ rows={rows}
1645
+ />
1646
+ ```
1647
+
1648
+ - `hidden: true` starts a column hidden. The menu shows it unchecked.
1649
+ - `hideable: false` keeps a column out of the menu, so it cannot be hidden.
1650
+ - The last visible column cannot be hidden.
1651
+ - Hidden columns are skipped by search, keyboard navigation and CSV export (unless `includeHidden`).
1652
+
1653
+ ### 6.18 Keyboard
1654
+
1655
+ Switch on `keyboardNavigation` to move between cells with the keyboard. The grid has **one tab stop**: Tab enters the body at the last cell you used (or the first one), and the arrow keys move from there. Tab again leaves the grid.
1656
+
1657
+ | Key (on a cell) | Action |
1658
+ | --- | --- |
1659
+ | Arrow keys | Move one cell |
1660
+ | Home / End | First / last column of the row |
1661
+ | Ctrl+Home / Ctrl+End | First cell of the first row / last cell of the last row |
1662
+ | Page Up / Page Down | Move 10 rows |
1663
+ | Enter / F2 | Start editing (editable cells) |
1664
+ | Any character | Start editing and replace the value (text and number cells) |
1665
+
1666
+ Other keys, always available:
896
1667
 
897
1668
  | Where | Key | Action |
898
1669
  | --- | --- | --- |
899
- | Header | Enter / Space | Sort by this column |
900
- | Selected cell | Enter / F2 | Start editing |
901
- | Selected cell | Any character | Start editing, replacing the value |
902
- | Editor | Enter | Save, edit cell below |
903
- | Editor | Shift+Enter | Save, edit cell above |
904
- | Editor | Tab / Shift+Tab | Save, edit next / previous cell |
1670
+ | Header | Enter / Space | Sort by this column (sortable columns) |
1671
+ | Editor | Enter | Save, edit the cell below |
1672
+ | Editor | Shift+Enter | Save, edit the cell above |
1673
+ | Editor | Tab / Shift+Tab | Save, edit the next / previous cell |
905
1674
  | Editor | Esc | Cancel |
1675
+ | Filter popup | Esc | Close |
1676
+ | Text filter box | Enter | Close the popup |
1677
+
1678
+ With `virtualize`, moving past the visible window scrolls the target row into view before focusing it, so you can arrow through 20,000 rows. Combined with `maxHeight`, the focused cell is scrolled into view.
1679
+
1680
+ ```tsx
1681
+ <DataGrid keyboardNavigation editable maxHeight={420} columns={columns} rows={rows} />
1682
+ ```
906
1683
 
907
- Arrow-key navigation between cells is not implemented. Use Tab, or the mouse, to select a cell.
1684
+ Without `keyboardNavigation`, cells are reachable with Tab (editable cells) or the mouse, as before.
908
1685
 
909
- ### 6.13 Accessibility
1686
+ ### 6.19 CSV export and the API
910
1687
 
911
- - The table has `role="grid"`. Sortable headers are focusable, expose `aria-sort`, and work with Enter and Space.
912
- - Editable cells are focusable (`tabindex="0"`).
913
- - The search box, filter indicators (`aria-haspopup="dialog"`, `aria-expanded`), filter popups (`role="dialog"`), editors, page buttons and page-size menu have `aria-label`s. Checkboxes in columns with an empty header are named after the field.
1688
+ **The export button.** Switch on `exportable` for an "Export CSV" button in the toolbar. It downloads what the user sees: the visible columns, in order, for every row matching the search and filters (across all pages).
1689
+
1690
+ ```tsx
1691
+ <DataGrid exportable ... /> // downloads export.csv
1692
+ <DataGrid exportable={{ filename: "employees" }} ... /> // employees.csv
1693
+ <DataGrid exportable={{ filename: "page-1", scope: "page" }} ... /> // only the current page
1694
+ <DataGrid exportable={{ scope: "selected" }} rowSelection ... /> // only the selected rows
1695
+ ```
1696
+
1697
+ | `ExportOptions` | Default | Meaning |
1698
+ | --- | --- | --- |
1699
+ | `filename` | `"export"` | File name without `.csv` |
1700
+ | `scope` | `"filtered"` | `"filtered"` all rows matching search and filters, `"page"` the current page, `"selected"` the selected rows |
1701
+ | `includeHidden` | `false` | Also write hidden columns |
1702
+ | `sanitize` | `true` | Put an apostrophe in front of text starting with `=`, `+`, `-` or `@`, so Excel does not run it as a formula |
1703
+
1704
+ **What goes in the file.** The header row uses the column header names. Cell values are the displayed text (the result of `valueFormatter`, `Yes` / `No` for booleans), except that dates are written as `YYYY-MM-DD`. Numbers therefore export formatted (`$52,000`) unless you give an `exportValue`. To decide a column's value yourself, add `exportValue`:
1705
+
1706
+ ```tsx
1707
+ {
1708
+ field: "status",
1709
+ exportValue: (value) => String(value).toUpperCase(), // ACTIVE
1710
+ }
1711
+ {
1712
+ field: "salary",
1713
+ valueFormatter: (v) => `$${v.toLocaleString()}`, // shown as $52,000
1714
+ exportValue: (v) => v, // exported as 52000, so a spreadsheet can add it up
1715
+ }
1716
+ ```
1717
+
1718
+ The file starts with a byte-order mark so Excel reads accents and non-Latin text correctly. Commas, quotes and line breaks inside values are quoted.
1719
+
1720
+ **The API.** Pass a ref as `apiRef` to call methods from outside the grid:
1721
+
1722
+ ```tsx
1723
+ const api = useRef<DataGridApi<Employee> | null>(null);
1724
+
1725
+ <DataGrid apiRef={api} rowSelection ... />
1726
+
1727
+ api.current?.exportCsv({ filename: "selected", scope: "selected" }); // download
1728
+ const text = api.current?.getCsv(); // the CSV as a string
1729
+ ```
1730
+
1731
+ | Method | Does |
1732
+ | --- | --- |
1733
+ | `getCsv(options?)` | Returns the CSV text without downloading it |
1734
+ | `exportCsv(options?)` | Downloads a CSV file |
1735
+ | `getSelectedRows()` | The selected row objects |
1736
+ | `getSelectedRowIds()` | The selected ids |
1737
+ | `selectAll()` | Selects every selectable row matching the search and filters |
1738
+ | `clearSelection()` | Clears the selection |
1739
+ | `refresh()` | Runs the last query again (`serverSide` only) |
1740
+
1741
+ **Sending the CSV somewhere else** (upload, email) instead of downloading:
1742
+
1743
+ ```tsx
1744
+ const csv = api.current!.getCsv({ scope: "selected" });
1745
+ await fetch("/api/reports", { method: "POST", headers: { "Content-Type": "text/csv" }, body: csv });
1746
+ ```
1747
+
1748
+ **Your own button** next to the built-in ones:
1749
+
1750
+ ```tsx
1751
+ <DataGrid
1752
+ apiRef={api}
1753
+ toolbar={<button className="rk-grid__tool-btn" onClick={() => api.current?.exportCsv({ filename: "all", includeHidden: true })}>Export everything</button>}
1754
+ ...
1755
+ />
1756
+ ```
1757
+
1758
+ ### 6.20 Virtualization
1759
+
1760
+ For tens of thousands of rows, switch on `virtualize`. Only the rows in (and just around) the scroll window are in the DOM; empty spacer rows stand in for the rest, so the scroll bar still reflects the full data.
1761
+
1762
+ ```tsx
1763
+ <DataGrid
1764
+ virtualize
1765
+ rowHeight={40} // every row must be exactly this tall
1766
+ maxHeight={480} // the grid scrolls inside this height (400 if you give none)
1767
+ overscan={8} // extra rows drawn above and below
1768
+ columns={columns}
1769
+ rows={twentyThousandRows}
1770
+ getRowId={(r) => r.id}
1771
+ searchable
1772
+ />
1773
+ ```
1774
+
1775
+ - **Fixed row height.** Rows are laid out as `rowHeight` pixels each and cell text does not wrap (long text is cut off). Set `rowHeight` to fit your content.
1776
+ - Use it without `pagination` so everything scrolls in one list. Sorting, search and filters still work on the whole data set.
1777
+ - Row animation is skipped while virtualizing, because rows come and go as you scroll.
1778
+ - Keyboard navigation, selection and editing all work. Moving focus to a row outside the window scrolls it into view first.
1779
+ - Choose a `rowHeight` that suits your padding: 40 is right for the default cell padding.
1780
+
1781
+ ```tsx
1782
+ // 50,000 rows, searchable, selectable, keyboard friendly
1783
+ <DataGrid virtualize rowHeight={36} maxHeight="70vh" searchable rowSelection keyboardNavigation theme="graphite" ... />
1784
+ ```
1785
+
1786
+ ### 6.21 Accessibility
1787
+
1788
+ - The table has `role="grid"`. Sortable headers are focusable, expose `aria-sort`, and work with Enter and Space. Columns that are not sortable are not focus stops.
1789
+ - Editable cells are focusable (`tabindex="0"`). With `keyboardNavigation` the body has a single tab stop and arrow keys move between cells ([6.18](#618-keyboard)).
1790
+ - The search box, filter indicators (`aria-haspopup="dialog"`, `aria-expanded`), filter popups (`role="dialog"`), the Columns menu, editors, page buttons and page-size menu have `aria-label`s. Filter inputs are named after their column ("Minimum Budget", "Due from"). Checkboxes in columns with an empty header are named after the field.
1791
+ - Selected rows have `aria-selected="true"`. Row checkboxes are named "Select row N", and the header one "Select all rows".
1792
+ - The "N selected" count is a status region (`role="status"`).
1793
+ - While loading, the grid has `aria-busy="true"` and the placeholder rows are hidden from assistive technology. The error message has `role="alert"`.
914
1794
  - Invalid editors set `aria-invalid="true"`.
915
- - Custom components you supply are responsible for their own accessibility.
1795
+ - The shimmer and row animations stop when the system has "reduce motion" turned on.
1796
+ - Custom components you supply (renderers, editors, skeleton rows, indicators) are responsible for their own accessibility.
916
1797
 
917
- ### 6.14 TypeScript
1798
+ ### 6.22 TypeScript
918
1799
 
919
1800
  All types are exported:
920
1801
 
921
1802
  ```ts
922
1803
  import type {
923
- ColumnDef,
924
- DataGridProps,
925
- CellParams,
926
- CellEditEvent,
927
- CellEditorProps,
928
- HeaderRendererProps,
929
- FilterRendererProps,
930
- GridSlot,
931
- SortModel,
932
- SortDirection,
933
- MoveDirection,
934
- PaginationPosition,
935
- CellEditableParams,
936
- ColumnCheckboxOptions,
937
- FilterIndicatorProps,
938
- FieldPath,
939
- GridTheme,
940
- CheckboxProps,
941
- CheckboxVariant,
942
- CheckboxSlot,
1804
+ // grid and columns
1805
+ DataGridProps, ColumnDef, DataGridApi, GridTheme, GridSlot, FieldPath,
1806
+ // cells, editors, headers
1807
+ CellParams, CellEditEvent, CellEditorProps, CellEditableParams, HeaderRendererProps, MoveDirection,
1808
+ // sorting, paging
1809
+ SortModel, SortDirection, PaginationPosition,
1810
+ // filters
1811
+ FilterType, FilterValue, FilterOption, NumberRangeFilter, DateRangeFilter, FilterRendererProps, FilterIndicatorProps,
1812
+ // selection, server, export, loading
1813
+ RowSelection, GridQuery, ExportOptions, SkeletonRowProps,
1814
+ // checkbox
1815
+ ColumnCheckboxOptions, CheckboxProps, CheckboxVariant, CheckboxSlot,
943
1816
  } from "react-gridix";
944
1817
  ```
945
1818
 
@@ -948,14 +1821,45 @@ Typing tips:
948
1821
  ```tsx
949
1822
  const columns: ColumnDef<Employee>[] = [ ... ]; // field is checked against Employee (dot paths too)
950
1823
  function Cell({ row }: CellParams<Employee>) { ... } // row is typed as Employee
1824
+
1825
+ const api = useRef<DataGridApi<Employee> | null>(null); // typed apiRef
1826
+ const [sel, setSel] = useState<RowSelection<Employee>>({ ids: [], rows: [] });
1827
+ const [filters, setFilters] = useState<Record<string, FilterValue>>({});
1828
+
1829
+ // narrowing a filter value yourself (for example when building a server query)
1830
+ function toParams(f: Record<string, FilterValue>) {
1831
+ return Object.entries(f).map(([field, v]) => {
1832
+ if (typeof v === "string") return `${field}~${v}`; // text / exact
1833
+ if (Array.isArray(v)) return `${field}=${v.join(",")}`; // select
1834
+ if ("min" in v || "max" in v) return `${field}:${(v as NumberRangeFilter).min ?? ""}..${(v as NumberRangeFilter).max ?? ""}`;
1835
+ return `${field}:${(v as DateRangeFilter).from ?? ""}..${(v as DateRangeFilter).to ?? ""}`; // date
1836
+ });
1837
+ }
951
1838
  ```
952
1839
 
953
- ### 6.15 Recipes
1840
+ ### 6.23 Recipes
954
1841
 
955
1842
  **Read-only grid**
956
1843
 
957
1844
  ```tsx
958
- <DataGrid columns={columns} rows={rows} /> // editable defaults to false
1845
+ <DataGrid columns={columns} rows={rows} /> // editable, sortable, searchable... all default to off
1846
+ ```
1847
+
1848
+ **A typical admin table**
1849
+
1850
+ ```tsx
1851
+ <DataGrid
1852
+ columns={columns} // sortable / filterable chosen per column
1853
+ rows={rows}
1854
+ getRowId={(r) => r.id}
1855
+ searchable
1856
+ pagination
1857
+ rowSelection
1858
+ columnMenu
1859
+ exportable={{ filename: "users" }}
1860
+ striped
1861
+ theme="lavender"
1862
+ />
959
1863
  ```
960
1864
 
961
1865
  **Currency column that accepts "$1,200" when typed**
@@ -965,6 +1869,7 @@ function Cell({ row }: CellParams<Employee>) { ... } // row is typed as Employ
965
1869
  field: "salary",
966
1870
  type: "number",
967
1871
  align: "right",
1872
+ sortable: true,
968
1873
  valueFormatter: (v) => (v == null ? "" : money.format(v)),
969
1874
  valueParser: (text) => Number(text.replace(/[$,]/g, "")),
970
1875
  validate: (v) => Number.isFinite(v),
@@ -988,7 +1893,7 @@ function Cell({ row }: CellParams<Employee>) { ... } // row is typed as Employ
988
1893
  **Scrolling grid with sticky header**
989
1894
 
990
1895
  ```tsx
991
- <DataGrid pagination={false} maxHeight="60vh" />
1896
+ <DataGrid maxHeight="60vh" ... />
992
1897
  ```
993
1898
 
994
1899
  **Highlight a row based on its data**
@@ -997,47 +1902,179 @@ function Cell({ row }: CellParams<Employee>) { ... } // row is typed as Employ
997
1902
  <DataGrid rowClassName={(row) => (row.overdue ? "bg-red-50" : "")} />
998
1903
  ```
999
1904
 
1000
- ### 6.16 Known limitations
1905
+ **Overdue dates in red**
1906
+
1907
+ ```tsx
1908
+ {
1909
+ field: "due",
1910
+ type: "date",
1911
+ cellStyle: ({ value }) => (value && String(value) < new Date().toISOString().slice(0, 10) ? { color: "#dc2626" } : {}),
1912
+ }
1913
+ ```
1914
+
1915
+ **Status as a coloured badge, with an exact filter**
1916
+
1917
+ ```tsx
1918
+ const Badge = ({ value }: CellParams<Task>) => <span className={`badge badge-${value}`}>{String(value)}</span>;
1919
+
1920
+ { field: "status", sortable: true, filterable: true, filterType: "select", cellRenderer: Badge }
1921
+ ```
1922
+
1923
+ **Delete the selected rows**
1924
+
1925
+ ```tsx
1926
+ const [rows, setRows] = useState(initial);
1927
+ const [ids, setIds] = useState<(string | number)[]>([]);
1928
+
1929
+ <button disabled={!ids.length} onClick={() => { setRows((r) => r.filter((x) => !ids.includes(x.id))); setIds([]); }}>
1930
+ Delete {ids.length}
1931
+ </button>
1932
+ <DataGrid rows={rows} columns={columns} getRowId={(r) => r.id} rowSelection selectedRowIds={ids} onSelectionChange={(s) => setIds(s.ids)} />
1933
+ ```
1934
+
1935
+ **Bulk edit the selected rows** (`onRowsChange` gives the whole array back):
1936
+
1937
+ ```tsx
1938
+ const markDone = () => setRows((rs) => rs.map((r) => (ids.includes(r.id) ? { ...r, status: "done" } : r)));
1939
+ ```
1940
+
1941
+ **Choose the page size once and remember it**
1942
+
1943
+ ```tsx
1944
+ <DataGrid pagination pageSize={Number(localStorage.getItem("ps")) || 25} onPageSizeChange={(n) => localStorage.setItem("ps", String(n))} />
1945
+ ```
1946
+
1947
+ **A fast, big, read-only table**
1948
+
1949
+ ```tsx
1950
+ <DataGrid virtualize rowHeight={32} maxHeight="80vh" animateRows={false} searchable columns={columns} rows={hugeArray} getRowId={(r) => r.id} />
1951
+ ```
1952
+
1953
+ **An infinite-looking server table with a dark theme**
1954
+
1955
+ ```tsx
1956
+ <DataGrid serverSide pagination searchable multiSort theme="aurora" columns={columns} rows={rows} rowCount={total} onQueryChange={load} />
1957
+ ```
1001
1958
 
1002
- Not included (yet):
1959
+ ### 6.24 Known limitations
1003
1960
 
1004
- - Column resizing, reordering, hiding or pinning
1005
- - Row selection as its own concept (a set of selected rows with `onSelectionChange`). True/false checkbox columns, including a select-all header (`headerCheckbox`), are supported, see [Checkbox columns](#checkbox-columns-type-boolean)
1006
- - Multi-column sorting
1007
- - Row virtualization (very large pages render every row, so keep `pageSize` moderate)
1008
- - Server-side sorting, filtering and paging (the grid works on the array you give it)
1009
- - Arrow-key navigation between cells
1010
- - Controlled search, filter or page state (only `defaultSort` and `pageSize` initial values)
1961
+ Not included:
1962
+
1963
+ - **Column reordering** (by design)
1964
+ - Row grouping, tree data, row detail panels and column spanning
1965
+ - Infinite scroll in server mode (paging is used instead)
1966
+ - Saving column widths or visibility between visits (use `onColumnVisibilityChange` and your own storage)
1967
+ - Variable row heights while virtualizing
1011
1968
 
1012
1969
  Behaviours to be aware of:
1013
1970
 
1014
- - Column filters match by substring, so `active` also matches `inactive`.
1015
- - Only one filter popup is open at a time (opening another closes the first by its outside click).
1971
+ - Text filters match by substring (`active` also matches `inactive`). Use `filterType: "exact"` or `"select"` for exact choices.
1972
+ - Only one filter popup is open at a time.
1016
1973
  - Stripes and the row animation restart on every page, sort, search or filter change.
1017
- - `defaultSort` and `pageSize` are read once, on mount. Changing them later has no effect.
1018
- - `rows` must keep a stable identity between renders, otherwise edits are discarded (see [6.8](#68-editing)).
1974
+ - `pageSize` is read once, on mount (use `onPageSizeChange`). `defaultSort` and `defaultSelectedRowIds` are read once too: to change them later, control the state.
1975
+ - `rows` must keep a stable identity between renders, otherwise edits are discarded (see [6.9](#69-editing)).
1019
1976
  - `onRowClick` also fires for clicks inside a cell that is being edited.
1020
1977
  - A `valueParser` that returns `NaN` stores `NaN`. Add `validate: (v) => Number.isFinite(v)` to reject it.
1021
- - Date values display with `toLocaleDateString()`. Editing a `Date` cell shows its `String()` form, so store dates as ISO strings (`"2026-11-05"`) or provide a `cellEditor`.
1978
+ - `virtualize` needs fixed-height rows. A cell with wrapped text will be cut off.
1979
+ - Pinned columns only stick when the table is wider than its container.
1980
+ - In `serverSide` mode the grid cannot export or select rows it has never loaded.
1981
+ - The browser's date picker looks different in each browser. Its language follows the browser, not `dateLocale` (which only formats the display).
1982
+
1983
+ ---
1984
+
1985
+ ## 7. Demos
1986
+
1987
+ The repository contains a playground with one page per feature:
1988
+
1989
+ | Demo (`#id`) | Shows |
1990
+ | --- | --- |
1991
+ | Basic (`#basic`) | Opt-in sorting, global search, per-column filter dots with popups, pagination, controlled `SortModel[]` |
1992
+ | Editing (`#editing`) | Enter-moves-down editing, Tab, validation, locked cells, `onCellEdit` |
1993
+ | Custom components (`#custom`) | `cellRenderer`, `cellEditor`, `headerRenderer`, a custom funnel `filterIndicator`, a per-column indicator style, a `filterRenderer` in the popup |
1994
+ | Checkboxes (`#checkboxes`) | Boolean columns with the three variants, an empty-label header, a custom `★` character, select-all headers, locking, styling from outside, and a standalone `Checkbox` |
1995
+ | Styling (`#styling`) | Tailwind classes and inline styles, column `className` / `style`, and `stripeColors` |
1996
+ | Pagination (`#pagination`) | Top, bottom and both, no pagination, the row animation toggle, and a **controlled page and search** grid driven by outside buttons |
1997
+ | Nested fields (`#nested`) | `contact.phone` style dot paths: editing, search, filter, sort, and proof the original data is not mutated |
1998
+ | Row selection (`#selection`) | Multiple and single modes, controlled ids, `isRowSelectable`, Shift+click ranges, select-all across pages, `apiRef.selectAll()`, a Delete button |
1999
+ | Server-side (`#server`) | A fake 237-row database with 700 ms latency: server sorting, multi-sort, search debounce, filters, paging, the loading skeleton (default and custom row), and a switch that makes the request fail so you can see the error and Retry |
2000
+ | Columns (`#columns`) | Resizing, pinned columns on both sides, the Columns menu, arrow-key navigation, CSV export with `exportValue`, multi-column sort |
2001
+ | Large data (`#large`) | 20,000 virtualized rows with search, sorting and a theme |
2002
+ | Dates and filters (`#dates`) | `type: "date"` with the date picker editor, `dateMin` / `dateMax`, a `Date`-object column, from/to, number range, select and exact filters |
2003
+ | Themes (`#themes`) | A switcher for none and all 8 themes |
2004
+
2005
+ Run it:
2006
+
2007
+ ```bash
2008
+ npm install
2009
+ npm run dev
2010
+ ```
2011
+
2012
+ Open the URL it prints (usually `http://localhost:5173`). Link straight to a demo with the `#id` in the table. The source for each demo is in `demo/examples/`, written as small, commented, copy-paste-able files.
2013
+
2014
+ The Styling demo loads Tailwind from its CDN, so it needs an internet connection. The rest works offline.
1022
2015
 
1023
2016
  ---
1024
2017
 
1025
- ## 7. Troubleshooting
2018
+ ## 9. Troubleshooting
1026
2019
 
1027
2020
  | Problem | Cause and fix |
1028
2021
  | --- | --- |
1029
2022
  | Everything looks unstyled | Import `react-gridix/style.css` once in your app |
1030
2023
  | `theme="ocean"` does nothing | The theme CSS ships in `style.css`: make sure it is imported. The attribute `data-rk-theme` should appear on the grid root |
1031
- | I can't find the filter input | Filters are per column: set `filterable: true` in the column definition, then click the dot in the header. The old grid-level `columnFilters` prop no longer exists |
2024
+ | Headers don't sort | Sorting is per column: add `sortable: true` to the column definition. There is no grid-level `sortable` prop any more |
2025
+ | There is no search box | Pass `searchable` |
2026
+ | There is no page info or page buttons | Pass `pagination`. Without it every row is shown and there is no footer |
2027
+ | I can't find the filter input | Filters are per column: set `filterable: true`, then click the dot in the header |
2028
+ | A date column shows `2026-11-05` instead of `Nov 5, 2026` | The column needs `type: "date"` |
2029
+ | The date picker never opens | The cell must be editable (`editable` on the grid or the column), then double-click it or press Enter / F2 |
2030
+ | The number filter popup asks for Min and Max, but I wanted text | Number columns default to a range filter. Set `filterType: "text"` |
2031
+ | Select filter lists the wrong labels | Pass `filterOptions` with `{ value, label }` pairs. `value` must equal what is stored in the row |
2032
+ | Selecting rows does nothing | Add `rowSelection`. Give the grid `getRowId` so selection survives sorting and paging |
2033
+ | The server grid asks for data twice on mount | In React StrictMode (development) effects run twice. The production build calls it once. Your handler should be safe to call repeatedly |
2034
+ | The server grid shows no skeleton | `onQueryChange` must **return** the promise (`return fetch(...)` or an `async` function). A function that starts a request and returns nothing cannot be tracked. Alternatively pass `loading` |
2035
+ | The server grid shows old rows after I changed a filter | You stored an older response last. Cancel the previous request with `AbortController` or compare a request counter |
2036
+ | Page 1 is shown after every search | By design: the page resets when search, filters, sort or page size change |
2037
+ | Resize handles do nothing | The column needs `resizable: true` |
2038
+ | Pinned columns scroll away | The columns must be wider than the container (give them `width`s), otherwise there is nothing to scroll. Give pinned columns an explicit `width` |
2039
+ | Virtualized rows look cut off or overlap | `rowHeight` is smaller than your content. Increase it, or shorten the content |
2040
+ | Virtualized grid is tiny | It scrolls inside `maxHeight` (400 if unset). Set `maxHeight` |
2041
+ | Arrow keys do nothing | Add `keyboardNavigation` and click or Tab into a cell first |
2042
+ | CSV opens with strange characters in Excel | The file has a BOM and is UTF-8. Use Excel's Data > From Text if your version ignores it |
2043
+ | CSV shows `$52,000` where I wanted `52000` | Add `exportValue: (v) => v` to the column |
1032
2044
  | Type error on `field: "contact.phone"` | The path must exist in your row type (optional objects are fine). If `rows` is typed loosely (`any`), type the columns as `ColumnDef<Person>[]` |
1033
2045
  | My checkbox column header is empty | By design: boolean columns default to no label. Set `headerName` to show one |
1034
- | No row animation | `animateRows` is off, or the system has "reduce motion" turned on |
2046
+ | No row animation | `animateRows` is off, `virtualize` is on, or the system has "reduce motion" turned on |
1035
2047
  | My Tailwind class doesn't change the grid | Put it in the right slot (`classNames.headerCell`, and so on). The library CSS has zero specificity, so a class in the right place wins |
1036
2048
  | Edits disappear on the next render | `rows` gets a new identity every render. Keep it in `useState` or `useMemo` |
1037
- | A custom editor or renderer loses focus or resets while typing | The component is defined inline inside render. Move it to module level |
2049
+ | A custom editor, renderer or skeleton loses focus or resets while typing | The component is defined inline inside render. Move it to module level |
1038
2050
  | Typing in a custom editor column doesn't start editing | By design: custom editors open with double-click, Enter or F2 |
1039
2051
  | Enter doesn't move to the next row | The cell below is locked, you are on the last row of the page, or `enterMovesDown` is `false` |
1040
- | Filtering "active" also shows "inactive" | Filters are substring matches. Use a `filterRenderer` with distinct values or different display text |
1041
- | Changing `pageSize` or `defaultSort` after mount does nothing | They are initial values only |
2052
+ | Changing `pageSize` or `defaultSort` after mount does nothing | They are initial values only. Control `page` / `sort` instead |
1042
2053
  | A checkbox in a boolean column is greyed out and won't toggle | The cell isn't editable. Set `editable` on the grid, or `editable: true` on that column |
1043
2054
  | Type error: `field` not assignable | `field` must be a key of your row type. Check the spelling, and that `ColumnDef<T>` uses the same `T` as `rows` |
2055
+
2056
+ ---
2057
+
2058
+ ## 10. Migrating from the earlier version
2059
+
2060
+ Three defaults changed so that every feature is opt-in.
2061
+
2062
+ | Before | Now | What to do |
2063
+ | --- | --- | --- |
2064
+ | Grid prop `sortable` (default `true`), column `sortable` override | **Column** `sortable` only, default `false`. The grid-level prop is gone | Add `sortable: true` to each column that should sort. Remove `sortable` / `sortable={false}` from the grid |
2065
+ | `defaultSort` / `onSortChange` used one `SortModel` or `null` | `defaultSort` takes one `SortModel` **or an array**. `onSortChange` receives an **array** (empty when unsorted) | Change `(sort) => sort?.field` to `(sort) => sort[0]?.field` |
2066
+ | `searchable` default `true` | Default `false` | Add `searchable` to grids that should show the search box |
2067
+ | `pagination` default `true` | Default `false`. With it off there is no footer and no row count | Add `pagination` to grids that should page |
2068
+ | Filters were text only | Filter kinds: text, exact, number, date, select | Nothing to change. Number columns now get a Min / Max popup, boolean columns a checkbox list |
2069
+ | `FilterRendererProps` | Unchanged (`value: string`) | |
2070
+
2071
+ A quick way to get the old behaviour everywhere:
2072
+
2073
+ ```tsx
2074
+ // before: <DataGrid columns={columns} rows={rows} />
2075
+ const withSorting = (cols: ColumnDef<Row>[]) => cols.map((c) => ({ sortable: true, ...c }));
2076
+
2077
+ <DataGrid columns={withSorting(columns)} rows={rows} searchable pagination />
2078
+ ```
2079
+
2080
+ Everything else in this document is new and optional.