@x12i/data 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 x12i
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,549 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # @x12i/data
2
+
3
+ An accessible, virtualized data grid for the Unified UI Kit, and the headless
4
+ engine it is built on.
5
+
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.
13
+
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.
46
+
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.
54
+
55
+ ## Examples
56
+
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
140
+ },
141
+ };
142
+
143
+ <DataGrid columns={columns} dataSource={orders} getRowId={(row) => row.id} aria-label="Orders" blockSize={100} />;
144
+ ```
145
+
146
+ What the grid does for you in server mode:
147
+
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.
169
+
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:
211
+
212
+ ```ts
213
+ import { createFlowixSavedViewStore } from "@x12i/flowix-ui/stores";
214
+
215
+ const views = createFlowixSavedViewStore(flowix, { namespace: "grid-views" });
216
+ ```
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
+
494
+ `@x12i/records` turns the same column definitions into a Fieldkit `FieldMap`
495
+ (`fieldMapFromColumns`), so the detail view of a row uses the grid's headers.
496
+
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
520
+
521
+ Honest list of what this grid does not do:
522
+
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.