@x12i/data 1.0.0 → 1.1.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 +524 -27
- package/dist/chunk-ASP4ABDG.js +1987 -0
- package/dist/chunk-ASP4ABDG.js.map +1 -0
- package/dist/index.d.ts +375 -89
- package/dist/index.js +218 -54
- package/dist/index.js.map +1 -1
- package/dist/messages-BF20Mq97.d.ts +508 -0
- package/dist/react.d.ts +328 -0
- package/dist/react.js +3078 -0
- package/dist/react.js.map +1 -0
- package/dist/styles.css +1169 -0
- package/package.json +58 -5
package/README.md
CHANGED
|
@@ -1,38 +1,213 @@
|
|
|
1
1
|
# @x12i/data
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An accessible, virtualized data grid for the Unified UI Kit, and the headless
|
|
4
|
+
engine it is built on.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
- **`DataGrid`**: sort, filter, quick search, group and aggregate, resize,
|
|
7
|
+
reorder, hide and pin columns, row selection, row detail panels, inline
|
|
8
|
+
editing with rollback, totals, pagination, saved views, CSV export and copy.
|
|
9
|
+
Rows and columns are virtualized. Server mode loads rows in blocks.
|
|
10
|
+
- **Headless engine**: the same sorting, filtering, grouping, selection,
|
|
11
|
+
keyboard model, CSV, saved views and server block store as plain functions.
|
|
12
|
+
No React, no DOM, safe on the server.
|
|
7
13
|
|
|
8
|
-
|
|
9
|
-
|
|
14
|
+
It is styled by kit tokens only (`--kit-*`), uses logical CSS properties, and
|
|
15
|
+
adapts to density, right-to-left text, high contrast and reduced motion.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pnpm add @x12i/data @x12i/ui @x12i/themes react react-dom
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```css
|
|
24
|
+
@import "@x12i/themes/themes.css";
|
|
25
|
+
@import "@x12i/ui/styles.css";
|
|
26
|
+
@import "@x12i/data/styles.css";
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`@x12i/ui` is a peer dependency of the React entry only (the grid uses its
|
|
30
|
+
`Button`, `IconButton`, `Badge`, `Spinner` and `VisuallyHidden`). If you only
|
|
31
|
+
use the headless entry you do not need React or `@x12i/ui`. The package's own
|
|
32
|
+
dependencies (`@x12i/icons`, `@x12i/tokens`, `@tanstack/react-virtual`) install
|
|
33
|
+
with it.
|
|
34
|
+
|
|
35
|
+
## Two entry points
|
|
36
|
+
|
|
37
|
+
| Import | What it is | Needs |
|
|
38
|
+
| --- | --- | --- |
|
|
39
|
+
| `@x12i/data` | The headless engine and all the types. | Nothing. No React, no DOM. |
|
|
40
|
+
| `@x12i/data/react` | `DataGrid`, `Pagination`, `useGridState`, `useSavedViews`. | React 19, `@x12i/ui`. |
|
|
41
|
+
| `@x12i/data/styles.css` | The grid's stylesheet. | The kit's theme CSS for the token values. |
|
|
42
|
+
|
|
43
|
+
`@x12i/data/react` starts with a `"use client"` directive, so a Next.js server
|
|
44
|
+
component can import it and the grid becomes a client component. The root entry
|
|
45
|
+
has no directive and imports on the server as it is.
|
|
10
46
|
|
|
11
|
-
|
|
47
|
+
**Server rendering.** `DataGrid` renders on the server: the first paint holds a
|
|
48
|
+
window of rows and columns (about two dozen rows at the default height), not an
|
|
49
|
+
empty box, and the client takes over from there. Browser-only work (measuring
|
|
50
|
+
the row height, observing the size, clipboard, downloads) runs in effects or on
|
|
51
|
+
user action. `pnpm --filter @x12i/data run smoke:ssr` renders the built package
|
|
52
|
+
with a 500-row grid in Node, without a DOM, and checks that only a window of the
|
|
53
|
+
rows ended up in the markup.
|
|
12
54
|
|
|
13
|
-
|
|
14
|
-
{ id: "customer", header: "Customer", field: "customer", sortable: true },
|
|
15
|
-
{ id: "total", header: "Total", field: "total", cellType: "currency" },
|
|
16
|
-
]);
|
|
55
|
+
## Examples
|
|
17
56
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
57
|
+
### A grid with client-side data
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
import { DataGrid, type DataGridColumn } from "@x12i/data/react";
|
|
61
|
+
|
|
62
|
+
type Order = { id: string; customer: string; status: "open" | "paid" | "late"; total: number; placed: string };
|
|
63
|
+
|
|
64
|
+
const columns: DataGridColumn<Order>[] = [
|
|
65
|
+
{ id: "id", header: "Order", field: "id", width: 120, pinned: "start" },
|
|
66
|
+
{ id: "customer", header: "Customer", field: "customer", filterable: true },
|
|
67
|
+
{
|
|
68
|
+
id: "status",
|
|
69
|
+
header: "Status",
|
|
70
|
+
field: "status",
|
|
71
|
+
cellType: "status",
|
|
72
|
+
options: [
|
|
73
|
+
{ value: "open", label: "Open", tone: "info" },
|
|
74
|
+
{ value: "paid", label: "Paid", tone: "success" },
|
|
75
|
+
{ value: "late", label: "Late", tone: "danger" },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
{ id: "total", header: "Total", field: "total", cellType: "currency", currency: "EUR", aggregate: "sum", editable: true },
|
|
79
|
+
{ id: "placed", header: "Placed", field: "placed", cellType: "date", filterable: true },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
export function Orders({ rows }: { rows: Order[] }) {
|
|
83
|
+
return (
|
|
84
|
+
<DataGrid
|
|
85
|
+
columns={columns}
|
|
86
|
+
rows={rows}
|
|
87
|
+
getRowId={(row) => row.id}
|
|
88
|
+
aria-label="Orders"
|
|
89
|
+
selectionMode="multiple"
|
|
90
|
+
showTotals
|
|
91
|
+
height={480}
|
|
92
|
+
locale="de-DE"
|
|
93
|
+
onCellEdit={async ({ rowId, column, value }) => {
|
|
94
|
+
await saveOrderField(rowId, column.id, value); // your API call; throw to roll the edit back
|
|
95
|
+
}}
|
|
96
|
+
/>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The grid sorts, filters, groups and pages `rows` itself. It never changes
|
|
102
|
+
`rows`: an edit is shown at once, `onCellEdit` persists it, and when it throws
|
|
103
|
+
the cell goes back to the old value and says so. Give the grid new `rows` (for
|
|
104
|
+
example after a refetch) and the pending edits are dropped.
|
|
105
|
+
|
|
106
|
+
Every piece of state works uncontrolled (`defaultSort`, `defaultFilter`, ...) or
|
|
107
|
+
controlled (`sort` + `onSortChange`, ...). `useGridState({ columns })` holds all
|
|
108
|
+
of it in one hook when you want to read it, put it in the URL or reset it:
|
|
109
|
+
|
|
110
|
+
```tsx
|
|
111
|
+
const state = useGridState({ columns });
|
|
112
|
+
<DataGrid columns={columns} rows={rows} getRowId={getRowId} aria-label="Orders" {...state.props} />;
|
|
113
|
+
state.reset();
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### A server-side grid
|
|
117
|
+
|
|
118
|
+
Pass a `dataSource` instead of `rows`. The grid asks for blocks of rows as the
|
|
119
|
+
user scrolls, sends the sort, filters and quick search to the server, and shows
|
|
120
|
+
placeholder rows while a block loads.
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
import { DataGrid } from "@x12i/data/react";
|
|
124
|
+
import type { ServerDataSource } from "@x12i/data";
|
|
125
|
+
|
|
126
|
+
// Create it once (module level or useMemo): a new data source throws the loaded rows away.
|
|
127
|
+
const orders: ServerDataSource<Order> = {
|
|
128
|
+
async getRows({ startRow, endRow, sort, filter, quickFilter }, { signal } = {}) {
|
|
129
|
+
const query = new URLSearchParams({
|
|
130
|
+
start: String(startRow),
|
|
131
|
+
end: String(endRow),
|
|
132
|
+
sort: JSON.stringify(sort), // [{ field: "total", direction: "desc" }]
|
|
133
|
+
filter: JSON.stringify(filter), // [{ field: "status", operator: "in", value: ["late"] }]
|
|
134
|
+
q: quickFilter ?? "",
|
|
135
|
+
});
|
|
136
|
+
const response = await fetch(`/api/orders?${query}`, { signal });
|
|
137
|
+
if (!response.ok) throw new Error(`Orders failed: ${response.status}`);
|
|
138
|
+
const { rows, total } = await response.json();
|
|
139
|
+
return { rows, total }; // `total` sizes the scrollbar
|
|
22
140
|
},
|
|
23
141
|
};
|
|
142
|
+
|
|
143
|
+
<DataGrid columns={columns} dataSource={orders} getRowId={(row) => row.id} aria-label="Orders" blockSize={100} />;
|
|
24
144
|
```
|
|
25
145
|
|
|
26
|
-
|
|
146
|
+
What the grid does for you in server mode:
|
|
27
147
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
148
|
+
- **Blocks.** One request per `blockSize` rows, only for blocks near the visible
|
|
149
|
+
window, never twice for the same block. Rows that have not arrived show a
|
|
150
|
+
skeleton row (`aria-busy`).
|
|
151
|
+
- **Abort.** Every request gets an `AbortSignal`. A new sort or filter, a reload
|
|
152
|
+
and unmounting abort what is in flight. Pass `signal` to `fetch`; an aborted
|
|
153
|
+
request is ignored, not shown as an error.
|
|
154
|
+
- **Stale while revalidating.** After a sort or filter change the rows on screen
|
|
155
|
+
stay (dimmed, `data-stale`) until the new blocks arrive, instead of flashing empty.
|
|
156
|
+
- **Errors.** A failed block shows an alert with a retry button; blocks that did
|
|
157
|
+
load stay visible. The rows of a failed block say "Row could not be loaded"
|
|
158
|
+
and stop claiming to be busy.
|
|
159
|
+
- **Pagination.** `paginated` makes each page one block and shows the footer.
|
|
160
|
+
- **Cursor APIs.** Return `nextCursor` with an answer and the following block is
|
|
161
|
+
requested with `request.cursor` set to it (`null` for the first block). Blocks
|
|
162
|
+
then load in order, so jumping far ahead loads the blocks in between.
|
|
163
|
+
- **Totals, selection, export.** `showTotals` plus `totals` (keyed by column id)
|
|
164
|
+
shows totals you computed on the server. Select all selects "everything except
|
|
165
|
+
these" without loading every row. Export reads every row the query matches,
|
|
166
|
+
block after block.
|
|
167
|
+
- **Column names.** Requests name columns by `column.field ?? column.id`.
|
|
168
|
+
- `ref.current.reload()` drops the loaded rows and fetches again.
|
|
33
169
|
|
|
34
|
-
|
|
35
|
-
|
|
170
|
+
`createClientDataSource(rows, columns, { latency, fail })` is a data source over
|
|
171
|
+
an array that behaves like a server (it honors the abort signal), for demos and
|
|
172
|
+
tests.
|
|
173
|
+
|
|
174
|
+
### Saved views
|
|
175
|
+
|
|
176
|
+
A saved view is versioned JSON: column order, widths, visibility and pinning,
|
|
177
|
+
plus sort, filters and grouping. `useSavedViews` saves, lists and applies them
|
|
178
|
+
against any `SavedViewStore`.
|
|
179
|
+
|
|
180
|
+
```tsx
|
|
181
|
+
import { createLocalStorageSavedViewStore } from "@x12i/data";
|
|
182
|
+
import { DataGrid, useGridState, useSavedViews } from "@x12i/data/react";
|
|
183
|
+
|
|
184
|
+
const store = createLocalStorageSavedViewStore({ key: "orders-views" }); // memory-only where there is no localStorage
|
|
185
|
+
|
|
186
|
+
function Orders({ rows }: { rows: Order[] }) {
|
|
187
|
+
const state = useGridState({ columns });
|
|
188
|
+
const views = useSavedViews({ store, columns, grid: state });
|
|
189
|
+
return (
|
|
190
|
+
<>
|
|
191
|
+
<button onClick={() => void views.save("mine", "My view")}>Save view</button>
|
|
192
|
+
<select value={views.activeId ?? ""} onChange={(event) => void views.apply(event.target.value)}>
|
|
193
|
+
<option value="" disabled>Views</option>
|
|
194
|
+
{views.views.map((view) => (
|
|
195
|
+
<option key={view.id} value={view.id}>{view.name ?? view.id}</option>
|
|
196
|
+
))}
|
|
197
|
+
</select>
|
|
198
|
+
<DataGrid columns={columns} rows={rows} getRowId={(row) => row.id} aria-label="Orders" {...state.props} />
|
|
199
|
+
</>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`parseSavedView(value)` reads a view from untrusted storage and returns `null`
|
|
205
|
+
for anything this version does not understand, so a bad or newer record is
|
|
206
|
+
skipped instead of crashing the grid. A view saved against other columns still
|
|
207
|
+
applies: ids the grid does not have are ignored. Implement `SavedViewStore`
|
|
208
|
+
(`list`, `load`, `save`, `delete`) to keep views in your API.
|
|
209
|
+
`@x12i/flowix-ui` has one that keeps each view as a Flowix fact, with every save
|
|
210
|
+
kept as an immutable revision:
|
|
36
211
|
|
|
37
212
|
```ts
|
|
38
213
|
import { createFlowixSavedViewStore } from "@x12i/flowix-ui/stores";
|
|
@@ -40,13 +215,335 @@ import { createFlowixSavedViewStore } from "@x12i/flowix-ui/stores";
|
|
|
40
215
|
const views = createFlowixSavedViewStore(flowix, { namespace: "grid-views" });
|
|
41
216
|
```
|
|
42
217
|
|
|
218
|
+
### CSV export and copy
|
|
219
|
+
|
|
220
|
+
The toolbar has an Export CSV button (it downloads `export.csv`, or calls
|
|
221
|
+
`onExport(csv)` with the text if you pass it; the file starts with a byte order
|
|
222
|
+
mark so Excel reads non-ASCII text) and, while rows are selected, a Copy button.
|
|
223
|
+
`toolbar={{ export: false }}` removes one of them, `toolbar={false}` all of them.
|
|
224
|
+
The same things are available in code:
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
const ref = useRef<DataGridHandle<Order>>(null);
|
|
228
|
+
|
|
229
|
+
<DataGrid ref={ref} columns={columns} rows={rows} getRowId={getRowId} aria-label="Orders" exportFileName="orders.csv" />;
|
|
230
|
+
|
|
231
|
+
// The current view: every filtered row (all pages), in sort order, visible columns only.
|
|
232
|
+
const csv = await ref.current?.exportCsv({ values: "raw", bom: true });
|
|
233
|
+
await ref.current?.copySelection(); // selected rows (or the focused cell) as tab-separated text
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Headless, for any rows and columns, on the server too:
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
import { gridToCsv, gridToTsv } from "@x12i/data";
|
|
240
|
+
|
|
241
|
+
const csv = gridToCsv(rows, columns, { locale: "en-US", bom: true });
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
CSV follows RFC 4180 (CRLF, quoted fields, doubled quotes). Cells a spreadsheet
|
|
245
|
+
would run as a formula (a leading `=`, `@`, tab, or a `+`/`-` that is not a plain
|
|
246
|
+
number) get an apostrophe prefix, so an exported cell cannot execute. Values are
|
|
247
|
+
written as the grid shows them; `values: "raw"` writes the stored values instead.
|
|
248
|
+
`bom: true` adds the byte order mark Excel needs for non-ASCII text.
|
|
249
|
+
|
|
250
|
+
## Column definition reference
|
|
251
|
+
|
|
252
|
+
`ColumnDef<Row>` (headless) is the base; `DataGridColumn<Row>` adds the React
|
|
253
|
+
renderers. Only `id` and `header` are required.
|
|
254
|
+
|
|
255
|
+
| Property | Type | Meaning |
|
|
256
|
+
| --- | --- | --- |
|
|
257
|
+
| `id` | `string` | Stable identifier. Used by state, saved views and `columnVisibility` and friends. |
|
|
258
|
+
| `header` | `string` | Header text. Pass a translated string. |
|
|
259
|
+
| `field` | `keyof Row` | Property to read. Also what server requests call the column. |
|
|
260
|
+
| `accessor` | `(row) => unknown` | Reads the value when `field` is not enough (a nested property). |
|
|
261
|
+
| `cellType` | `text` `number` `currency` `percent` `date` `datetime` `boolean` `status` `avatar` `progress` `sparkline` `link` `tags` `actions` `custom` | How the cell renders, formats, aligns, sorts, filters and exports. |
|
|
262
|
+
| `format` | `string` | Intl-based format name by type: number `integer` `compact` `decimal:2` `unit:kilobyte`; currency an ISO code; percent `whole`; date `short` `medium` `long` `full` `iso`. |
|
|
263
|
+
| `currency` / `numberFormat` / `dateFormat` | `string` / `Intl.NumberFormatOptions` / `Intl.DateTimeFormatOptions` | Direct `Intl` control. `currency` defaults to `USD`. |
|
|
264
|
+
| `formatter` | `(value, row) => string` | Text for a value. Used by the cell, CSV, copy, quick search and screen readers. |
|
|
265
|
+
| `comparator` | `(a, b, rowA, rowB) => number` | Replaces the default comparison (numbers numerically, dates by time, text with `Intl.Collator`). |
|
|
266
|
+
| `width` / `minWidth` / `maxWidth` | `number` | Pixels. |
|
|
267
|
+
| `align` | `start` `center` `end` | Defaults to `end` for `number`, `currency` and `percent`, `center` for `boolean`, `start` otherwise. |
|
|
268
|
+
| `pinned` | `start` `end` | Start state. Logical sides, so it follows `dir`. |
|
|
269
|
+
| `hidden` | `boolean` | Hidden until the user shows it. |
|
|
270
|
+
| `hideable` / `groupable` / `searchable` | `boolean` | `false` removes the column from the visibility menu, grouping, quick search. |
|
|
271
|
+
| `sortable` / `filterable` / `resizable` | `boolean` | Sortable (except `actions` and `custom` cells) and resizable by default; filterable only when set. |
|
|
272
|
+
| `filterType` | `text` `number` `date` `enum` `boolean` | The filter UI and operators. Derived from `cellType` when absent. |
|
|
273
|
+
| `options` | `{ value, label, tone? }[]` | Choices for `status` cells, enum filters and select editors. |
|
|
274
|
+
| `aggregate` | `sum` `avg` `min` `max` `count` or `(values, rows) => unknown` | Shown in group rows and the totals row. |
|
|
275
|
+
| `editable` / `canEdit` | `boolean` / `(row) => boolean` | Inline editing, and per-row editability on top of it. |
|
|
276
|
+
| `editor` | `text` `number` `select` `boolean` `date` | Defaults from `cellType`. Boolean cells toggle in place. |
|
|
277
|
+
| `validate` | `(value, row) => string \| null` | Return a message to reject an edit. |
|
|
278
|
+
| `parseValue` | `(input, row) => unknown` | Turns the editor's text into a value. |
|
|
279
|
+
| `group` | `string` | Adjacent columns with the same `group` share a header above them. |
|
|
280
|
+
| `progressMax` | `number` | Maximum of `progress` cells. Default 100. |
|
|
281
|
+
| `meta` | `Record<string, unknown>` | Yours. The grid never reads it. |
|
|
282
|
+
| `cell` | `(context) => ReactNode` | Replaces the cell. Runs for every rendered cell, so keep it cheap. |
|
|
283
|
+
| `headerCell` | `(context) => ReactNode` | Replaces the header label. The sort, filter and menu buttons stay. |
|
|
284
|
+
| `footerCell` | `(context) => ReactNode` | Replaces the totals-row content. |
|
|
285
|
+
| `actions` | `(row) => RowAction[]` | Menu entries for `actions` cells. |
|
|
286
|
+
| `href` | `(row) => string \| undefined` | Target of `link` cells. Without it the cell's string value is the target. A value becomes an anchor only when it is `http`, `https`, `mailto`, `tel` or a relative path; a target from `href` may be anything except `javascript:`, `data:` and `vbscript:`. Otherwise the text is not linked. |
|
|
287
|
+
|
|
288
|
+
### `DataGrid` props
|
|
289
|
+
|
|
290
|
+
| Group | Props |
|
|
291
|
+
| --- | --- |
|
|
292
|
+
| Data | `columns`, `rows` (client) or `dataSource` (server), `getRowId` (required), `blockSize` (server, default 100), `totals` (server), `loading`, `error`, `onRetry` (client) |
|
|
293
|
+
| Naming | `aria-label` or `aria-labelledby` (give one), `className`, `ref` |
|
|
294
|
+
| Layout | `height`, `maxHeight` (default 640 px), `density`, `dir`, `estimateRowHeight`, `virtualize` (`"auto"`), `overscan`, `columnOverscan` |
|
|
295
|
+
| Language | `locale`, `timeZone`, `messages` |
|
|
296
|
+
| Sort, filter, group | `sort`, `filter`, `quickFilter`, `group`, `groupExpansion`, `groupsExpandedByDefault`, `multiSort`, `quickFilterDelay`; each with `default…` and `on…Change` |
|
|
297
|
+
| Columns | `columnOrder`, `columnVisibility`, `columnSizing`, `columnPinning`; each with `default…` and `on…Change` |
|
|
298
|
+
| Selection, detail | `selectionMode` (`none` `single` `multiple`), `selection`, `isRowSelectable`, `renderDetail`, `detailHeight`, `isRowExpandable`, `expanded`, `onRowClick`; with `default…` and `on…Change` for `selection` and `expanded` |
|
|
299
|
+
| Paging | `paginated`, `pagination`, `pageSizeOptions`; with `default…` and `on…Change` for `pagination` |
|
|
300
|
+
| Editing | `onCellEdit` |
|
|
301
|
+
| Chrome | `toolbar` (`false`, or `{ search, columns, export, copy }` with each defaulting to `true`), `toolbarStart`, `toolbarEnd`, `showTotals`, `slots` (`empty`, `loading`, `error`), `onExport`, `exportFileName` |
|
|
302
|
+
|
|
303
|
+
`DataGridHandle` (the `ref`): `scrollToIndex`, `scrollToRow`, `focusCell`,
|
|
304
|
+
`exportCsv`, `copySelection`, `getSelectedRows`, `reload`, `getViewState`.
|
|
305
|
+
|
|
306
|
+
`Pagination` is also exported on its own, for any list: `page` (zero-based),
|
|
307
|
+
`pageSize`, `total` (or `null` while unknown), `onPageChange`, and optionally
|
|
308
|
+
`pageSizeOptions` with `onPageSizeChange`, `messages`, `locale`, `disabled`.
|
|
309
|
+
|
|
310
|
+
```tsx
|
|
311
|
+
<Pagination page={page} pageSize={25} total={rows.length} onPageChange={setPage} locale="en-US" />
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
## Keyboard map
|
|
315
|
+
|
|
316
|
+
The grid is one tab stop (roving tabindex): Tab moves into it at the last cell
|
|
317
|
+
you were on, and the next Tab leaves it. Row 0 is the header row.
|
|
318
|
+
|
|
319
|
+
| Key | Where | What it does |
|
|
320
|
+
| --- | --- | --- |
|
|
321
|
+
| Arrow keys | Any cell | Move one cell. They stop at the edges. Left and Right swap in right-to-left. |
|
|
322
|
+
| Home / End | Any cell | First / last cell of the row. |
|
|
323
|
+
| Ctrl+Home / Ctrl+End | Any cell | First / last cell of the grid. |
|
|
324
|
+
| Page Up / Page Down | Any cell | Move by the number of visible rows. |
|
|
325
|
+
| Enter or Space | Header | Sort by the column. With Shift, add it to the sort (multi-sort). |
|
|
326
|
+
| Alt+Down, Menu key, Shift+F10 | Header | Open the column menu: sort, filter, group, pin, move, hide. Arrows, Home, End move; Enter selects; Escape closes and returns focus. |
|
|
327
|
+
| Ctrl+Shift+Left / Right | Header | Move the column earlier / later (in reading order). |
|
|
328
|
+
| Shift+Left / Right | Header | Make the column narrower / wider by 16 px. |
|
|
329
|
+
| Enter or F2 | Editable cell | Start editing. |
|
|
330
|
+
| Enter / Escape | Editor | Commit / cancel. Leaving the field commits. A value the column rejects keeps the editor open with the message on Enter; on leaving the field the edit is dropped and the message is announced. |
|
|
331
|
+
| Space | Body cell | Toggle the row's selection (Shift+Space extends the range from the last toggled row). In a selection checkbox, expander or editable boolean cell it activates that control. |
|
|
332
|
+
| Enter | Cell with a control | Activates the link, expander, checkbox or row-actions menu in the cell. |
|
|
333
|
+
| Shift+Up / Down | Body cell, `multiple` | Extend the selection by one row. |
|
|
334
|
+
| Ctrl+A (Cmd+A) | Body cell, `multiple` | Select every row. |
|
|
335
|
+
| Ctrl+C (Cmd+C) | Body cell | Copy the selected rows, or the focused cell, as tab-separated text. |
|
|
336
|
+
| Enter or Space | Group row | Expand or collapse the group. |
|
|
337
|
+
| Right / Left | Group row, first column | Expand / collapse the group (swapped in right-to-left). |
|
|
338
|
+
| Escape | Quick search | Clears the text. |
|
|
339
|
+
|
|
340
|
+
Column reordering also works by dragging a header with the pointer; the dragged
|
|
341
|
+
column lands before or after the one you drop on, inside its own pinned zone.
|
|
342
|
+
|
|
343
|
+
## Accessibility
|
|
344
|
+
|
|
345
|
+
- **Roles and counts.** `role="grid"` (a `treegrid` with `aria-level` and
|
|
346
|
+
`aria-expanded` while grouped), `aria-rowcount` and `aria-rowindex` that count
|
|
347
|
+
all rows even though only a window is in the DOM, `aria-colcount` and
|
|
348
|
+
`aria-colindex`. Column groups use `aria-colspan`. A server grid reports
|
|
349
|
+
`aria-rowcount="-1"` until the server has said how many rows there are.
|
|
350
|
+
- **Sorting.** `aria-sort` on the sorted header; a multi-sort shows each
|
|
351
|
+
column's priority number, with a text alternative ("Sort priority 2") for
|
|
352
|
+
screen readers.
|
|
353
|
+
- **Selection.** `aria-multiselectable` on the grid and `aria-selected` on rows;
|
|
354
|
+
native checkboxes with names; the select-all box is indeterminate for a
|
|
355
|
+
partial selection.
|
|
356
|
+
- **Live region.** A polite `role="status"` region announces sorting ("Sorted
|
|
357
|
+
by Total, descending. 120 rows."), filtering ("31 rows match the filters."),
|
|
358
|
+
grouping, selection counts, column moves, hides, pins and resizes, copies,
|
|
359
|
+
saved or failed edits, and edit validation messages. In server mode the
|
|
360
|
+
filter count waits for the new answer, so it never reports the old total.
|
|
361
|
+
- **Busy states.** `aria-busy` on the grid while loading and on placeholder
|
|
362
|
+
rows; the empty state, the loading state and the error (`role="alert"`) are
|
|
363
|
+
all text, not just pictures.
|
|
364
|
+
- **Focus.** Visible focus rings from tokens. Closing a menu or popover with
|
|
365
|
+
Escape or by choosing something returns focus to the header cell. A moved or
|
|
366
|
+
pinned column keeps the cursor.
|
|
367
|
+
- **Popups.** The column menu is `role="menu"` with `menuitem` and
|
|
368
|
+
`menuitemcheckbox`; the filter editor is a `role="dialog"` with labelled
|
|
369
|
+
fields. Both sit in the browser's top layer where it exists, so the grid's
|
|
370
|
+
scroll area never clips them, and close on Escape, on a press outside and
|
|
371
|
+
when focus leaves.
|
|
372
|
+
- **Resize and reorder.** The resize handle is a `role="separator"` with
|
|
373
|
+
`aria-valuenow`, `-min` and `-max`; its keys (arrows, Shift for larger steps,
|
|
374
|
+
Home and End) work once it has focus, and the header cell has Shift+Arrow.
|
|
375
|
+
Double-click resets the width. Reordering by drag has a keyboard shortcut and
|
|
376
|
+
menu items as alternatives.
|
|
377
|
+
- **Forced colors and reduced motion.** Selection, focus, pinned edges, the
|
|
378
|
+
progress bars and the drop marker use system colors under `forced-colors`;
|
|
379
|
+
skeleton pulses, the loading bar and transitions stop under
|
|
380
|
+
`prefers-reduced-motion`.
|
|
381
|
+
|
|
382
|
+
The test suite runs `vitest-axe` over the grid in its main states (plain,
|
|
383
|
+
selection with toolbar, totals and filter chips, loading, empty, error, grouped,
|
|
384
|
+
every cell type, paginated, inside a `ThemeProvider`, with a filter popover or a
|
|
385
|
+
column menu open, and in server mode while loading, loaded, with placeholder rows
|
|
386
|
+
and failed) and expects zero violations. Color contrast is not checked there
|
|
387
|
+
(jsdom has no layout or computed colors); it rests on the kit's token pairs.
|
|
388
|
+
Automated checks catch a lot, not everything: test with a screen reader in your
|
|
389
|
+
own context.
|
|
390
|
+
|
|
391
|
+
## Theming and density
|
|
392
|
+
|
|
393
|
+
The stylesheet reads kit tokens only: surface, text, border, accent, selection
|
|
394
|
+
and danger colors, spacing, radii, type sizes, motion durations, z-indexes. It
|
|
395
|
+
declares no colors or lengths of its own, wraps every selector in `:where()` (so
|
|
396
|
+
your CSS wins at any specificity) and uses logical properties
|
|
397
|
+
(`inset-inline-start`, `padding-inline`, `border-block-end`). Pinned columns are
|
|
398
|
+
`position: sticky` with `inset-inline-start` / `-end`, so they flip in RTL.
|
|
399
|
+
|
|
400
|
+
- **Theme.** Wrap the app in `ThemeProvider` from `@x12i/ui`; the grid follows
|
|
401
|
+
light, dark and high contrast.
|
|
402
|
+
- **Density.** The row height is the `--kit-grid-row-height` token, which follows
|
|
403
|
+
the kit's `data-density`. Use `DensityProvider` above the grid, or the
|
|
404
|
+
`density` prop for one grid.
|
|
405
|
+
- **Hooks for your CSS.** Everything has a `data-kit` name and state attributes:
|
|
406
|
+
|
|
407
|
+
| Attribute | On | Meaning |
|
|
408
|
+
| --- | --- | --- |
|
|
409
|
+
| `data-kit="data-grid"` | root | `data-density`, `data-loading`, `data-stale`, `data-server` |
|
|
410
|
+
| `data-kit="data-grid-scroller"` | the `role="grid"` element | `data-virtual-rows`, `data-virtual-columns` |
|
|
411
|
+
| `data-kit="data-grid-header-cell"` | header cell | `data-column` (the id), `data-align`, `data-sorted` (`asc` `desc`), `data-filtered`, `data-pinned` (`start` `end`), `data-pinned-edge`, `data-resizing`, `data-dragging`, `data-drop` (`before` `after`) |
|
|
412
|
+
| `data-kit="data-grid-row"` | row | `data-kind` (`data` `group` `detail` `placeholder`), `data-index`, `data-row-id`, `data-selected`, `data-expanded`, `data-failed` |
|
|
413
|
+
| `data-kit="data-grid-cell"` | cell | `data-type`, `data-align`, `data-pinned`, `data-pinned-edge`, `data-editable`, `data-editing`, `data-dirty`, `data-pending`, `data-error` |
|
|
414
|
+
| `data-kit="data-grid-overlay"` | loading, empty and error layers | `data-state` (`loading` `empty` `error`) |
|
|
415
|
+
| `data-grid-toolbar`, `-chips`, `-pagination`, `-header`, `-body`, `-footer`, `-live` | the other parts | |
|
|
416
|
+
|
|
417
|
+
Other `data-kit` names include `-sort`, `-resizer`, `-menu`, `-popover`,
|
|
418
|
+
`-filter`, `-progress`, `-tags`, `-skeleton` and `-detail`.
|
|
419
|
+
|
|
420
|
+
## Languages and formats
|
|
421
|
+
|
|
422
|
+
There is no English in the code that is not in `defaultMessages`. Pass `messages`
|
|
423
|
+
with only the strings you change; the rest keep their defaults.
|
|
424
|
+
|
|
425
|
+
```tsx
|
|
426
|
+
<DataGrid
|
|
427
|
+
locale="fr-FR"
|
|
428
|
+
messages={{
|
|
429
|
+
loading: "Chargement des lignes",
|
|
430
|
+
empty: "Aucune ligne",
|
|
431
|
+
announceFiltered: "{count, plural, =0 {Aucune ligne ne correspond} one {# ligne correspond} other {# lignes correspondent}}.",
|
|
432
|
+
}}
|
|
433
|
+
/* ... */
|
|
434
|
+
/>
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
Messages use a small ICU subset: `{name}`, `{n, plural, =0 {...} one {...} other {...}}`
|
|
438
|
+
with `#`, and `{x, select, ...}`, with the plural rules of `locale`. Numbers,
|
|
439
|
+
currency, percent, dates and times go through `Intl` with `locale` (and
|
|
440
|
+
`timeZone` for date-times); `YYYY-MM-DD` strings are calendar days and do not
|
|
441
|
+
shift with the time zone. Text sorts and searches with `Intl.Collator`, ignoring
|
|
442
|
+
case and accents, with numbers compared as numbers. `dir` (or the document's
|
|
443
|
+
direction) mirrors the layout; no code path uses left or right.
|
|
444
|
+
|
|
445
|
+
`Pagination` takes the same `messages` and `locale`. `resolveMessages(overrides)`
|
|
446
|
+
and `defaultMessages` are exported from both entries.
|
|
447
|
+
|
|
448
|
+
## Performance
|
|
449
|
+
|
|
450
|
+
- **Virtualization.** `virtualize="auto"` windows rows above 100 and center
|
|
451
|
+
columns above 24 (`@tanstack/react-virtual`). The rows in the DOM are the
|
|
452
|
+
visible ones plus `overscan` (8) either side, whatever the data size. The cell
|
|
453
|
+
with the cursor stays mounted so the grid keeps its tab stop. Pinned columns
|
|
454
|
+
are always rendered; column virtualization is off when column groups are used.
|
|
455
|
+
Below the thresholds nothing is windowed, so a short grid is plain markup.
|
|
456
|
+
- **Stable rendering.** Rows and header cells are memoized. Keep `columns`,
|
|
457
|
+
`messages` and `dataSource` stable (module level or `useMemo`), because a new
|
|
458
|
+
`columns` array recomputes the layout and a new `dataSource` reloads. Inline
|
|
459
|
+
`cell` renderers run per rendered cell, so keep them cheap.
|
|
460
|
+
- **The engine.** Sorting, filtering and grouping work on arrays of row indexes
|
|
461
|
+
and compile each column's comparison and predicate once. Measured on one
|
|
462
|
+
developer machine (Node, 100,000 rows, 6 columns): filter on three columns
|
|
463
|
+
38 ms; sort by one column 40 ms, by three columns 322 ms; filter then sort
|
|
464
|
+
61 ms; build the search index 384 ms (once per `rows`), then a quick search
|
|
465
|
+
13 ms; group by two columns 19 ms; CSV of the full set 392 ms. The test suite
|
|
466
|
+
guards these with generous bounds to catch accidental quadratic work.
|
|
467
|
+
- **Client mode keeps every row in memory** and recomputes the view when `rows`
|
|
468
|
+
or the sort, filters or grouping change. Past a few hundred thousand rows, or
|
|
469
|
+
whenever the rows live on a server anyway, use server mode: the grid then only
|
|
470
|
+
holds the blocks it has been asked to show.
|
|
471
|
+
- **Quick search** in client mode builds its index lazily, on the first search
|
|
472
|
+
after `rows` changes.
|
|
473
|
+
- **Row height is fixed** (from the density token), which is what makes the
|
|
474
|
+
window cheap. Only detail panels are measured.
|
|
475
|
+
|
|
476
|
+
## Headless engine
|
|
477
|
+
|
|
478
|
+
Everything the grid computes is a plain function of the root entry:
|
|
479
|
+
|
|
480
|
+
| Area | Exports |
|
|
481
|
+
| --- | --- |
|
|
482
|
+
| Columns | `defineColumns`, `getColumnValue`, `indexColumns`, `isSortable`, `isFilterable`, `isEditable`, `isResizable`, `resolveColumnAlign`, `resolveFilterType`, `resolveEditor` |
|
|
483
|
+
| Sorting | `sortRows`, `sortIndexes`, `compareValues`, `createCollator` |
|
|
484
|
+
| Filtering | `filterIndexes`, `matchesFilter`, `createValuePredicate`, `createSearchIndex`, `parseQuickFilter`, `operatorsFor`, `distinctValues` |
|
|
485
|
+
| Grouping, aggregates | `buildGroupTree`, `buildDisplayRows`, `computeViewIndexes`, `aggregateValues`, `computeAggregate`, `computeAggregates` |
|
|
486
|
+
| Formatting | `createCellFormatter`, `formatCellValue`, `formatMessage`, `defaultMessages`, `resolveMessages` |
|
|
487
|
+
| Selection | `createSelection`, `toggleRow`, `selectRange`, `selectAll`, `selectAllExceptNone`, `isSelected`, `countSelected`, `serializeSelection`, `deserializeSelection` |
|
|
488
|
+
| Keyboard | `navigate`, `clampNav`, `isNavigationKey` |
|
|
489
|
+
| View state | `createViewState`, `layoutColumns`, `moveColumn`, `moveColumnTo`, `resizeColumn`, `setPinning`, `toggleSort`, `setSort`, `setColumnFilter`, `toggleGroup` |
|
|
490
|
+
| Saved views | `captureView`, `applySavedView`, `parseSavedView`, `createMemorySavedViewStore`, `createLocalStorageSavedViewStore` |
|
|
491
|
+
| Server | `createServerRowStore`, `createClientDataSource`, `toServerQuery`, `fromServerQuery`, `createAbortError`, `isAbortError` |
|
|
492
|
+
| Export | `gridToCsv`, `gridToTsv`, `parseDelimited`, `escapeCsvField` |
|
|
493
|
+
|
|
43
494
|
`@x12i/records` turns the same column definitions into a Fieldkit `FieldMap`
|
|
44
495
|
(`fieldMapFromColumns`), so the detail view of a row uses the grid's headers.
|
|
45
496
|
|
|
46
|
-
##
|
|
497
|
+
## Design decisions
|
|
498
|
+
|
|
499
|
+
- **A small engine of our own, plus TanStack Virtual.** Sorting, filtering,
|
|
500
|
+
grouping and the row pipeline are plain functions over arrays of row indexes,
|
|
501
|
+
so they run unchanged on the server, in tests and in the component, and so the
|
|
502
|
+
keyboard model and server cache are testable without a DOM. Windowing is
|
|
503
|
+
`@tanstack/react-virtual` (MIT). TanStack Table is not used.
|
|
504
|
+
- **Controls are private.** The grid's checkbox, select, text input, menu and
|
|
505
|
+
popover are small internal components over native elements. The kit's `ui`
|
|
506
|
+
components it uses are `Button`, `IconButton`, `Badge`, `Spinner`,
|
|
507
|
+
`VisuallyHidden`, and icons come from `@x12i/icons`.
|
|
508
|
+
- **The grid does not own your rows.** Edits are optimistic overrides until your
|
|
509
|
+
`onCellEdit` settles, and they do not re-run sort or filter until you hand over
|
|
510
|
+
new `rows`; editing a value does not make the row jump away under the cursor.
|
|
511
|
+
- **Server rows are keyed by place, not id.** While stale blocks wait for fresh
|
|
512
|
+
ones the same id can be on screen twice, and the cursor follows a place anyway.
|
|
513
|
+
- **Selection is "these" or "all except these".** That is what lets select all
|
|
514
|
+
work on a server grid without loading every row. `getSelectedRows()` returns the
|
|
515
|
+
selected rows that are loaded.
|
|
516
|
+
- **Prefetch.** Server mode requests `overscan` rows beyond the rendered window,
|
|
517
|
+
so the next block usually arrives before you scroll to it.
|
|
518
|
+
|
|
519
|
+
## Limitations
|
|
47
520
|
|
|
48
|
-
|
|
49
|
-
UX, saved views and cell types are built on top, so no feature depends on a
|
|
50
|
-
commercial license.
|
|
521
|
+
Honest list of what this grid does not do:
|
|
51
522
|
|
|
52
|
-
|
|
523
|
+
- **Server mode has no grouping, no row detail panels and no value lists.** Group
|
|
524
|
+
and detail props are ignored there, and enum filters need `options` (the grid
|
|
525
|
+
cannot list distinct values it has not loaded). Server totals come from you.
|
|
526
|
+
- **Grouping with pagination groups the current page only.** Groups are built from
|
|
527
|
+
the rows on the page, not from the whole result.
|
|
528
|
+
- **Fixed row height.** Cells truncate with an ellipsis; text does not wrap, and
|
|
529
|
+
there is no auto-height. Detail panels may have any height.
|
|
530
|
+
- **No pivoting, no tree data** (rows with children from the data itself; grouping
|
|
531
|
+
by columns is supported), **no merged cells, no frozen rows, no cell-range
|
|
532
|
+
selection, no fill handle, no paste into cells, no row drag-and-drop.**
|
|
533
|
+
- **Editing is one cell at a time** with a text, number, select, date or boolean
|
|
534
|
+
editor; there is no row editor, and custom editors are not pluggable yet.
|
|
535
|
+
- **Export is CSV and TSV.** No Excel, PDF or print layout. Copy goes through
|
|
536
|
+
the async clipboard API where it is allowed and falls back to `execCommand`.
|
|
537
|
+
- **Column drag has no autoscroll** beyond what the browser does, and you can only
|
|
538
|
+
drop on a header that is rendered (with many columns, move far with the menu or
|
|
539
|
+
the keyboard).
|
|
540
|
+
- **Quick search** splits the text at spaces and keeps the rows where every term
|
|
541
|
+
appears, case- and accent-insensitively, somewhere in the formatted text of the
|
|
542
|
+
searchable columns (not the `actions`, `sparkline` and `custom` ones, nor any
|
|
543
|
+
with `searchable: false`). There are no quoted phrases, no fuzzy matching and
|
|
544
|
+
no ranking. In server mode the text goes to your data source as `quickFilter`
|
|
545
|
+
(after `quickFilterDelay`) and what it means is up to you.
|
|
546
|
+
- **Screen-reader behavior is tested through the DOM, not with real assistive
|
|
547
|
+
technology.** Grids are hard to make perfect: tell us what your readers hit.
|
|
548
|
+
- **Browser testing is jsdom only.** The layout code reads sizes from the DOM and
|
|
549
|
+
has been exercised against stubs, not in real browsers.
|