@jielga/tmdatagrid 0.2.0 → 0.5.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.
Files changed (50) hide show
  1. package/dist/index.d.ts +1337 -94
  2. package/dist/index.js +2812 -1189
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +1 -1
  5. package/package.json +2 -2
  6. package/skills/columns/SKILL.md +1 -1
  7. package/skills/features/SKILL.md +61 -11
  8. package/skills/getting-started/SKILL.md +41 -6
  9. package/skills/options/SKILL.md +12 -2
  10. package/skills/server-side/SKILL.md +1 -1
  11. package/src/tmdatagrid/TMDataGridContext.ts +26 -0
  12. package/src/tmdatagrid/components/TMDataGrid.module.css +21 -1
  13. package/src/tmdatagrid/components/TMDataGrid.tsx +29 -1
  14. package/src/tmdatagrid/components/TMDataGridColumnsPanel.tsx +12 -6
  15. package/src/tmdatagrid/components/TMDataGridDetailsColumn.module.css +33 -0
  16. package/src/tmdatagrid/components/TMDataGridDetailsColumn.tsx +160 -0
  17. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +15 -0
  18. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +192 -37
  19. package/src/tmdatagrid/components/TMDataGridFilterPills.module.css +35 -0
  20. package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +136 -0
  21. package/src/tmdatagrid/components/TMDataGridFooter.module.css +8 -0
  22. package/src/tmdatagrid/components/TMDataGridFooter.tsx +63 -43
  23. package/src/tmdatagrid/components/TMDataGridGroupColumn.module.css +48 -0
  24. package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +159 -0
  25. package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +18 -0
  26. package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +249 -44
  27. package/src/tmdatagrid/components/TMDataGridSearch.tsx +122 -0
  28. package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +103 -16
  29. package/src/tmdatagrid/components/TMDataGridTable.module.css +160 -5
  30. package/src/tmdatagrid/components/TMDataGridTable.tsx +1380 -43
  31. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +33 -7
  32. package/src/tmdatagrid/components/icons.ts +6 -0
  33. package/src/tmdatagrid/components/sticky.module.css +77 -16
  34. package/src/tmdatagrid/core/autosize.ts +125 -0
  35. package/src/tmdatagrid/core/capabilities.ts +122 -11
  36. package/src/tmdatagrid/core/cellExport.ts +321 -0
  37. package/src/tmdatagrid/core/cellNavigation.ts +103 -0
  38. package/src/tmdatagrid/core/cellRange.ts +118 -0
  39. package/src/tmdatagrid/core/columnOptions.ts +123 -0
  40. package/src/tmdatagrid/core/columnUtils.ts +18 -0
  41. package/src/tmdatagrid/core/expanding.ts +93 -0
  42. package/src/tmdatagrid/core/filterOperators.ts +198 -12
  43. package/src/tmdatagrid/core/grouping.ts +21 -0
  44. package/src/tmdatagrid/core/labels.ts +211 -0
  45. package/src/tmdatagrid/core/labelsSv.ts +108 -0
  46. package/src/tmdatagrid/core/persistence.ts +12 -0
  47. package/src/tmdatagrid/core/rowSelection.ts +202 -0
  48. package/src/tmdatagrid/core/summary.ts +35 -0
  49. package/src/tmdatagrid/index.ts +96 -2
  50. package/src/tmdatagrid/useTMDataGrid.tsx +558 -37
@@ -1,24 +1,30 @@
1
1
  import { useCreateStore } from "@tanstack/react-store";
2
2
  import {
3
+ aggregationFns,
3
4
  type ColumnDef,
4
5
  columnFacetingFeature,
5
6
  columnFilteringFeature,
7
+ columnGroupingFeature,
6
8
  columnOrderingFeature,
7
9
  columnPinningFeature,
8
10
  columnResizingFeature,
9
11
  columnSizingFeature,
10
12
  columnVisibilityFeature,
11
13
  createColumnHelper,
14
+ createExpandedRowModel,
12
15
  createFacetedMinMaxValues,
13
16
  createFacetedRowModel,
14
17
  createFacetedUniqueValues,
15
18
  createFilteredRowModel,
19
+ createGroupedRowModel,
16
20
  createPaginatedRowModel,
17
21
  createSortedRowModel,
18
22
  filterFns,
19
23
  globalFilteringFeature,
20
24
  metaHelper,
25
+ type Row,
21
26
  type RowData,
27
+ rowExpandingFeature,
22
28
  rowPaginationFeature,
23
29
  rowSelectionFeature,
24
30
  rowSortingFeature,
@@ -30,12 +36,18 @@ import {
30
36
  useTable,
31
37
  } from "@tanstack/react-table";
32
38
  import type { Store } from "@tanstack/store";
33
- import { useEffect, useMemo, useState } from "react";
39
+ import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
40
+ import type { TMDataGridOptionsSource } from "./core/columnOptions";
34
41
  import {
35
42
  type TMDataGridColumnType,
36
43
  tmDataGridFilterFn,
37
44
  getDefaultOperator,
38
45
  } from "./core/filterOperators";
46
+ import {
47
+ mergeLabels,
48
+ type TMDataGridLabels,
49
+ type TMDataGridLabelsOverride,
50
+ } from "./core/labels";
39
51
  import {
40
52
  hasPersistenceKeys,
41
53
  readPersistedState,
@@ -44,13 +56,27 @@ import {
44
56
  } from "./core/persistence";
45
57
  import {
46
58
  readFeatureFlags,
59
+ type TMDataGridCellSelectionMode,
47
60
  type TMDataGridFeatureFlags,
48
- type TMDataGridRowSelectionMode,
61
+ type TMDataGridSelectionMode,
49
62
  } from "./core/capabilities";
63
+ import {
64
+ isSameCell,
65
+ type TMDataGridCellPosition,
66
+ } from "./core/cellNavigation";
67
+ import type { TMDataGridCellRange } from "./core/cellRange";
50
68
  import {
51
69
  createSelectColumn,
52
70
  SELECT_COLUMN_ID,
53
71
  } from "./components/TMDataGridSelectColumn";
72
+ import {
73
+ createGroupColumn,
74
+ GROUP_COLUMN_ID,
75
+ } from "./components/TMDataGridGroupColumn";
76
+ import {
77
+ createDetailsColumn,
78
+ DETAILS_COLUMN_ID,
79
+ } from "./components/TMDataGridDetailsColumn";
54
80
 
55
81
  /**
56
82
  * How long the grid waits after the last state change before writing to
@@ -63,11 +89,28 @@ const PERSIST_DEBOUNCE_MS = 200;
63
89
  export type TMDataGridColumnMeta = {
64
90
  /** Name shown in menus and the column manager. Falls back to a string header. */
65
91
  label?: string;
66
- /** Drives which filter operators are offered. Defaults to `"string"`. */
92
+ /**
93
+ * Drives which filter operators are offered and, once editing is on, which
94
+ * editor the cell opens. Defaults to `"string"`.
95
+ */
67
96
  type?: TMDataGridColumnType;
97
+ /**
98
+ * The choices of a `select` / `multiSelect` column — one declaration feeding
99
+ * the filter panel's value control and the cell editor alike. A static
100
+ * array, `"faceted"` (the distinct values present in the data), or a
101
+ * function of the table, column and — for editors — the row. See
102
+ * {@link TMDataGridOptionsSource}.
103
+ */
104
+ options?: TMDataGridOptionsSource;
68
105
  /** Share of the leftover width this column claims. Defaults to `1`. */
69
106
  flex?: number;
70
107
  align?: "left" | "right" | "center";
108
+ /**
109
+ * Size the column to its widest mounted content once, after the first rows
110
+ * render — unless a persisted or user-set width already covers it. The same
111
+ * measurement as double-clicking the resize divider; see `autosizeColumn`.
112
+ */
113
+ autoSize?: boolean;
71
114
  /**
72
115
  * `false` keeps the column where it is: no header dragging, no move items.
73
116
  * Column ordering is the one feature TanStack defines no column option for,
@@ -105,9 +148,16 @@ export const tmDataGridFeatures = tableFeatures({
105
148
  columnSizingFeature,
106
149
  columnResizingFeature,
107
150
  columnFacetingFeature,
151
+ columnGroupingFeature,
152
+ // Registered for grouping's sake rather than for tree data: the grouped row
153
+ // model builds the parent rows, and this is what flattens the expanded ones
154
+ // back into the flat list the body virtualizes.
155
+ rowExpandingFeature,
108
156
 
109
157
  filteredRowModel: createFilteredRowModel(),
158
+ groupedRowModel: createGroupedRowModel(),
110
159
  sortedRowModel: createSortedRowModel(),
160
+ expandedRowModel: createExpandedRowModel(),
111
161
  paginatedRowModel: createPaginatedRowModel(),
112
162
  facetedRowModel: createFacetedRowModel(),
113
163
  facetedMinMaxValues: createFacetedMinMaxValues(),
@@ -115,6 +165,10 @@ export const tmDataGridFeatures = tableFeatures({
115
165
 
116
166
  filterFns: { ...filterFns, tmDataGrid: tmDataGridFilterFn },
117
167
  sortFns,
168
+ // The names `columnDef.aggregationFn` accepts. Only consulted for columns
169
+ // that ask for one — a column with no `aggregationFn` reports `undefined` on
170
+ // a group row, which is what keeps a plain "group by" free of aggregates.
171
+ aggregationFns,
118
172
 
119
173
  tableMeta: metaHelper<TMDataGridTableMeta>(),
120
174
  columnMeta: metaHelper<TMDataGridColumnMeta>(),
@@ -131,6 +185,26 @@ export function createTMDataGridColumnHelper<TData extends RowData>() {
131
185
  return createColumnHelper<TMDataGridFeatures, TData>();
132
186
  }
133
187
 
188
+ /** What the `renderDetails` render prop is handed for an expanded row. */
189
+ export type TMDataGridDetailsArgs<TData extends RowData> = {
190
+ row: Row<TMDataGridFeatures, TData>;
191
+ table: TMDataGridTable<TData>;
192
+ };
193
+
194
+ /** Builds the panel shown underneath an expanded row. See `renderDetails`. */
195
+ export type TMDataGridDetailsRenderer<TData extends RowData> = (
196
+ args: TMDataGridDetailsArgs<TData>,
197
+ ) => ReactNode;
198
+
199
+ /**
200
+ * What the virtualizer assumes a detail panel it has not measured yet is worth.
201
+ * Only a seed — every mounted row is measured, so the real height replaces it.
202
+ */
203
+ const DEFAULT_DETAILS_EST_HEIGHT = 160;
204
+
205
+ /** Rows kept mounted on each side of the viewport. See `overscan`. */
206
+ const DEFAULT_OVERSCAN = 6;
207
+
134
208
  /**
135
209
  * Chrome state that is *not* table state: which panels are open, and which
136
210
  * column opened the filter panel. Kept in a TanStack Store so consumers can
@@ -146,6 +220,39 @@ export type TMDataGridUiState = {
146
220
  * `dataTransfer`, which browsers keep unreadable until the drop.
147
221
  */
148
222
  draggedColumnId: string | null;
223
+ /**
224
+ * The single highlighted row — the one a detail panel would be showing. Its
225
+ * own concept, not a slice of `rowSelection`: under
226
+ * `selectionMode: "checkboxAndHighlight"` the two coexist, and TanStack's one
227
+ * selection map cannot hold both.
228
+ *
229
+ * Not pruned when the row is filtered out, paged away or dropped from `data`,
230
+ * matching how TanStack treats `rowSelection` — nothing there resets it
231
+ * either. The row simply renders unhighlighted, and highlights again if it
232
+ * comes back.
233
+ */
234
+ highlightedRowId: string | null;
235
+ /** The pivot a shift-click extends from. See resolveRowSelectionClick. */
236
+ selectionAnchorRowId: string | null;
237
+ /**
238
+ * The cell the keyboard is on, under `enableCellSelection`. `null` until the
239
+ * grid is first entered — and again whenever a consumer clears it.
240
+ *
241
+ * The state is the source of truth and DOM focus follows it, not the other
242
+ * way around: under virtualization the cell it names is often not mounted,
243
+ * which is exactly what a coordinate has to survive. Held as ids for the
244
+ * same reason — see {@link TMDataGridCellPosition}.
245
+ */
246
+ focusedCell: TMDataGridCellPosition | null;
247
+ /**
248
+ * The selected rectangle, under `cellSelection: "range"`. Held as the two
249
+ * cells that span it — see {@link TMDataGridCellRange}.
250
+ *
251
+ * Always covers the focused cell: every gesture that moves the focus either
252
+ * extends the rectangle or collapses it onto the new cell, so the two never
253
+ * describe different places.
254
+ */
255
+ cellRange: TMDataGridCellRange | null;
149
256
  };
150
257
 
151
258
  export type TMDataGridUiActions = {
@@ -155,6 +262,25 @@ export type TMDataGridUiActions = {
155
262
  toggleColumnsPanel: () => void;
156
263
  startColumnDrag: (columnId: string) => void;
157
264
  endColumnDrag: () => void;
265
+ /**
266
+ * Moves the active row, or clears it with `null` — which is how a consumer
267
+ * closing its detail panel puts the grid back in step.
268
+ */
269
+ setHighlightedRow: (rowId: string | null) => void;
270
+ setSelectionAnchor: (rowId: string | null) => void;
271
+ /**
272
+ * Moves the focused cell, or clears it with `null`. DOM focus follows while
273
+ * the grid holds it — so this both moves the keyboard and, when the row is
274
+ * off screen, scrolls it into view. Called for every arrow key, and available
275
+ * for a consumer putting the keyboard somewhere itself.
276
+ */
277
+ setFocusedCell: (cell: TMDataGridCellPosition | null) => void;
278
+ /**
279
+ * Sets the selected rectangle, or clears it with `null`. Does not move the
280
+ * focused cell — the two are set together by the gestures that change both,
281
+ * which is what keeps "extend the selection" and "move the cursor" separable.
282
+ */
283
+ setCellRange: (range: TMDataGridCellRange | null) => void;
158
284
  };
159
285
 
160
286
  export type TMDataGridUiStore = Store<TMDataGridUiState, TMDataGridUiActions>;
@@ -165,6 +291,14 @@ export type TMDataGridApi<TData extends RowData> = {
165
291
  ui: TMDataGridUiStore;
166
292
  /** Table-level feature switches, re-read from options on every render. */
167
293
  features: TMDataGridFeatureFlags;
294
+ /** Every string the chrome renders, `labels` merged over the English defaults. */
295
+ labels: TMDataGridLabels;
296
+ /** The detail renderer, when row details are on. See `renderDetails`. */
297
+ renderDetails?: TMDataGridDetailsRenderer<TData>;
298
+ /** Detail estimate in px: the option, or {@link DEFAULT_DETAILS_EST_HEIGHT}. */
299
+ renderDetailsEstHeight: number;
300
+ /** Virtualizer overscan: the option, or {@link DEFAULT_OVERSCAN}. */
301
+ overscan: number;
168
302
  };
169
303
 
170
304
  export type UseTMDataGridOptions<TData extends RowData> = Omit<
@@ -179,6 +313,20 @@ export type UseTMDataGridOptions<TData extends RowData> = Omit<
179
313
  * dependency of the subscription that writes back.
180
314
  */
181
315
  persist?: TMDataGridPersistence;
316
+ /**
317
+ * Overrides for the grid's strings — menu items, panels, the pager, and
318
+ * every `aria-label`. Any subset, merged over the English defaults; a full
319
+ * Swedish dictionary ships as `TMDATAGRID_LABELS_SV`.
320
+ *
321
+ * ```tsx
322
+ * useTMDataGrid({ data, columns, labels: TMDATAGRID_LABELS_SV });
323
+ * useTMDataGrid({ data, columns, labels: { noResults: "Inga rader" } });
324
+ * ```
325
+ *
326
+ * Keep the object referentially stable (module scope or `useMemo`) — the
327
+ * chrome re-renders when its identity changes.
328
+ */
329
+ labels?: TMDataGridLabelsOverride;
182
330
  /**
183
331
  * Header drag-and-drop and the move items in the column menu. Defaults to
184
332
  * `true`.
@@ -201,18 +349,29 @@ export type UseTMDataGridOptions<TData extends RowData> = Omit<
201
349
  /**
202
350
  * How rows are selected. Defaults to `"checkbox"`.
203
351
  *
204
- * - `"checkbox"` — the generated checkbox column selects; clicking a row
205
- * elsewhere does not.
206
- * - `"row"` — no checkbox column; clicking a row toggles it. Other rows keep
207
- * their state, so a click never clears the rest of the selection.
352
+ * | Mode | Checkbox column | Row click |
353
+ * | ---- | --------------- | --------- |
354
+ * | `"checkbox"` | yes, multi-select | nothing |
355
+ * | `"row"` | no | multi-selects, Ctrl/Shift modifiers |
356
+ * | `"checkboxAndHighlight"` | yes, multi-select | highlights one row |
357
+ * | `"highlight"` | no | highlights one row, no selection at all |
208
358
  *
209
- * Ignored when `enableRowSelection` is `false`. Both modes write to the same
210
- * `rowSelection` state.
359
+ * One option rather than two, so the combination that cannot work — a click
360
+ * that both toggles a multi-selection and moves the highlight — is not
361
+ * expressible.
362
+ *
363
+ * The first two write to TanStack's `rowSelection`. The highlight is separate
364
+ * state, which is what lets `"checkboxAndHighlight"` run both at once: tick
365
+ * rows for a bulk action, click one to open its detail panel. See
366
+ * `defaultHighlightedRowId` / `onHighlightedRowChange`.
367
+ *
368
+ * `enableRowSelection` still gates the selection half, predicate form
369
+ * included; under `"highlight"` there is nothing for it to gate.
211
370
  */
212
- rowSelectionMode?: TMDataGridRowSelectionMode;
371
+ selectionMode?: TMDataGridSelectionMode;
213
372
  /**
214
373
  * Give selected rows the highlight background. Defaults to `true` under
215
- * `rowSelectionMode: "row"`, where the highlight is the only feedback a click
374
+ * `selectionMode: "row"`, where the highlight is the only feedback a click
216
375
  * gives, and `false` under `"checkbox"`, where the box already shows it.
217
376
  *
218
377
  * The colour is the `--dg-row-selected-bg` CSS variable, so it can be changed
@@ -225,7 +384,138 @@ export type UseTMDataGridOptions<TData extends RowData> = Omit<
225
384
  * />
226
385
  * ```
227
386
  */
228
- highlightSelectedRows?: boolean;
387
+ showSelectedBackground?: boolean;
388
+ /**
389
+ * The row that starts out highlighted, under a `selectionMode` that has a
390
+ * highlight. Read once on mount, like `initialState`.
391
+ *
392
+ * The grid never persists the highlighted row. Pair this with
393
+ * {@link onHighlightedRowChange} and keep it wherever it belongs — for a
394
+ * detail panel that is usually the route, which gets you a shareable link and
395
+ * a working back button as well as surviving a reload:
396
+ *
397
+ * ```tsx
398
+ * const { rowId } = useParams();
399
+ * useTMDataGrid({
400
+ * selectionMode: "highlight",
401
+ * defaultHighlightedRowId: rowId,
402
+ * onHighlightedRowChange: (id) => navigate(id ? `/employees/${id}` : "/employees"),
403
+ * });
404
+ * ```
405
+ */
406
+ defaultHighlightedRowId?: string | null;
407
+ /**
408
+ * Called with the newly highlighted row id, or `null` when it is cleared.
409
+ * Fires for `ui.actions.setHighlightedRow` too, not only for clicks.
410
+ */
411
+ onHighlightedRowChange?: (rowId: string | null) => void;
412
+ /**
413
+ * How cells are selected. Defaults to `"none"`.
414
+ *
415
+ * | Mode | What it gives |
416
+ * | ---- | ------------- |
417
+ * | `"none"` | nothing; the body's tab stop stays on the row |
418
+ * | `"single"` | one focused cell, moved with the arrow keys |
419
+ * | `"range"` | as `"single"`, plus a rectangle of cells |
420
+ *
421
+ * Under either live mode:
422
+ *
423
+ * | Key | Moves to |
424
+ * | --- | --- |
425
+ * | Arrows | the neighbouring cell, clamped at the edges |
426
+ * | PageUp / PageDown | one viewport of rows |
427
+ * | Home / End | first / last cell of the row |
428
+ * | Ctrl+Home / Ctrl+End | first / last cell of the grid |
429
+ * | Enter or F2 | into the cell — the checkbox, link or button it holds |
430
+ * | Escape | back out to the cell |
431
+ * | Space | selects the row, as it does in row-selection mode |
432
+ *
433
+ * And under `"range"`, additionally:
434
+ *
435
+ * | Gesture | Does |
436
+ * | ------- | ---- |
437
+ * | Drag across cells | selects the rectangle they span |
438
+ * | Shift+click, Shift+arrows | extends the rectangle from its anchor |
439
+ * | Ctrl+C | copies it as tab-separated text — paste lands in Excel's cells |
440
+ * | Right-click inside it | offers the CSV export, headers optional |
441
+ *
442
+ * Off, nothing about the body changes. On, three things do: the body's tab
443
+ * stop moves from the row to a cell, so the whole grid is one Tab stop; the
444
+ * grid reports itself as a `grid` of `gridcell`s rather than a `table` of
445
+ * `cell`s, which is what tells a screen reader the arrow keys are live; and
446
+ * the cells take `data-focused` / `data-selected` for the ring and the tint.
447
+ *
448
+ * The state is `ui.state.focusedCell` and `ui.state.cellRange`, and moving it
449
+ * is `ui.actions.setFocusedCell` / `setCellRange` — so a consumer can put the
450
+ * keyboard on a cell, or follow it:
451
+ *
452
+ * ```tsx
453
+ * const grid = useTMDataGrid({
454
+ * data,
455
+ * columns,
456
+ * cellSelection: "range",
457
+ * onFocusedCellChange: (cell) => setStatus(cell?.columnId ?? ""),
458
+ * });
459
+ * ```
460
+ *
461
+ * Ids rather than a row/column index pair, so sorting, filtering and column
462
+ * reordering move the selection with the cells instead of leaving it over
463
+ * whatever slid into those positions.
464
+ */
465
+ cellSelection?: TMDataGridCellSelectionMode;
466
+ /**
467
+ * Called with the newly focused cell, or `null` when it is cleared. Fires for
468
+ * `ui.actions.setFocusedCell` too, not only for keys and clicks.
469
+ */
470
+ onFocusedCellChange?: (cell: TMDataGridCellPosition | null) => void;
471
+ /**
472
+ * Renders a panel underneath an expanded row, spanning every column. Setting
473
+ * it is what turns row details on.
474
+ *
475
+ * Which rows are open is TanStack's own `expanded` state, so opening one is
476
+ * `row.toggleExpanded()` from wherever suits — a chevron in a cell, a button
477
+ * in the context menu, a double-click:
478
+ *
479
+ * ```tsx
480
+ * const grid = useTMDataGrid({
481
+ * data,
482
+ * columns,
483
+ * renderDetails: ({ row }) => <EmployeeCard employee={row.original} />,
484
+ * });
485
+ * ```
486
+ *
487
+ * The panel is as tall as what it renders — see {@link renderDetailsEstHeight}
488
+ * for what the virtualizer assumes before it has measured one.
489
+ *
490
+ * Group rows are left out: expanding one opens its children, and a panel there
491
+ * would be about an arbitrary one of them. The same reason they sit out
492
+ * `onRowClick`.
493
+ *
494
+ * Row details and `selectionMode: "highlight"` are two answers to the same
495
+ * question. Details keep the record in place and in context, which suits a few
496
+ * fields or an action strip; a highlight-driven side panel has room for more
497
+ * and survives scrolling. Nothing stops a grid from doing both.
498
+ */
499
+ renderDetails?: TMDataGridDetailsRenderer<TData>;
500
+ /**
501
+ * What the virtualizer assumes an unmeasured detail panel is worth, in px.
502
+ * Defaults to 160.
503
+ *
504
+ * An estimate, not a height: every mounted row is measured, so the real one
505
+ * takes over as soon as the panel is on screen. It keeps the scrollbar honest
506
+ * for panels that open off screen — restored `expanded` state, say — and being
507
+ * roughly right is enough.
508
+ */
509
+ renderDetailsEstHeight?: number;
510
+ /**
511
+ * Rows the virtualizer keeps mounted above and below the viewport. Defaults
512
+ * to 6.
513
+ *
514
+ * Raise it to trade memory for a scroll that stays painted — fast wheel or
515
+ * touch flings can outrun the virtualizer and flash blank rows, and a larger
516
+ * buffer covers the gap. Lower it when rows are expensive to render.
517
+ */
518
+ overscan?: number;
229
519
  };
230
520
 
231
521
  type TMDataGridColumnDef<TData extends RowData> = ColumnDef<
@@ -235,8 +525,17 @@ type TMDataGridColumnDef<TData extends RowData> = ColumnDef<
235
525
  >;
236
526
 
237
527
  /**
238
- * Point every column at the operator-dispatching filter function unless the
239
- * column opted into its own.
528
+ * Point every column at the operator-dispatching filter function, and take
529
+ * grouping's aggregation defaults back off, unless the column opted into its
530
+ * own. Anything the consumer set wins — it is spread over these.
531
+ *
532
+ * The aggregation pair needs explaining. TanStack's grouping feature hands
533
+ * every column `aggregationFn: "auto"` and an `aggregatedCell` that stringifies
534
+ * whatever comes out, so merely registering the feature would have every
535
+ * numeric column silently sum itself and every date column show a range the
536
+ * moment anything is grouped. That is a summary table, and "group by" is not a
537
+ * request for one. Cleared here, so a grouped grid is a tree until a column
538
+ * says otherwise — `aggregationFn: "sum"` on the column that wants it.
240
539
  */
241
540
  function withTMDataGridDefaults<TData extends RowData>(
242
541
  columns: ReadonlyArray<TMDataGridColumnDef<TData>>,
@@ -248,7 +547,12 @@ function withTMDataGridDefaults<TData extends RowData>(
248
547
  columns: withTMDataGridDefaults<TData>(column.columns),
249
548
  };
250
549
  }
251
- return { filterFn: "tmDataGrid", ...column } as TMDataGridColumnDef<TData>;
550
+ return {
551
+ filterFn: "tmDataGrid",
552
+ aggregationFn: undefined,
553
+ aggregatedCell: undefined,
554
+ ...column,
555
+ } as TMDataGridColumnDef<TData>;
252
556
  });
253
557
  }
254
558
 
@@ -266,50 +570,146 @@ function withTMDataGridDefaults<TData extends RowData>(
266
570
  export function useTMDataGrid<TData extends RowData>({
267
571
  persist,
268
572
  // Not TanStack options, so they are kept out of what `useTable` receives.
573
+ labels: labelsOverride,
269
574
  enableColumnOrdering,
270
575
  enablePagination,
271
- rowSelectionMode,
272
- highlightSelectedRows,
576
+ selectionMode,
577
+ showSelectedBackground,
578
+ defaultHighlightedRowId,
579
+ onHighlightedRowChange,
580
+ cellSelection,
581
+ onFocusedCellChange,
582
+ renderDetails,
583
+ renderDetailsEstHeight = DEFAULT_DETAILS_EST_HEIGHT,
584
+ overscan = DEFAULT_OVERSCAN,
273
585
  ...options
274
586
  }: UseTMDataGridOptions<TData>): TMDataGridApi<TData> {
275
- const selectionEnabled = options.enableRowSelection !== false;
587
+ // Derived up here, rather than just before the return, because the rest of the
588
+ // hook needs `selectColumn` — one place decides what each mode means.
589
+ //
590
+ // Deliberately not memoized on `table`: the flags must re-derive whenever the
591
+ // caller passes different options, and `table` keeps the same identity when
592
+ // they do. See readFeatureFlags.
593
+ const features = readFeatureFlags({
594
+ ...options,
595
+ enableColumnOrdering,
596
+ enablePagination,
597
+ selectionMode,
598
+ showSelectedBackground,
599
+ cellSelection,
600
+ });
601
+
602
+ // Resolved on the override's identity, so a module-scope dictionary costs one
603
+ // merge for the lifetime of the grid.
604
+ const labels = useMemo(() => mergeLabels(labelsOverride), [labelsOverride]);
605
+
276
606
  const pinningEnabled = options.enableColumnPinning !== false;
277
- // Only "checkbox" mode owns a column; "row" mode selects from the row itself.
278
- const selectColumnEnabled =
279
- selectionEnabled && rowSelectionMode !== "row";
607
+ const selectColumnEnabled = features.selectColumn;
608
+ const groupColumnEnabled = features.grouping;
609
+ // The lane that opens the panels. Nothing to switch on: a grid with no
610
+ // `renderDetails` has nothing for it to open.
611
+ const detailsColumnEnabled = renderDetails !== undefined;
612
+
613
+ // The generated lanes bake `meta.label` into their definitions, so the memo
614
+ // depends on the three strings rather than on the labels object — a fresh
615
+ // inline `labels` must not rebuild the table's columns.
616
+ const selectColumnLabel = labels.selectColumnLabel;
617
+ const groupColumnLabel = labels.groupColumnLabel;
618
+ const detailsColumnLabel = labels.detailsColumnLabel;
280
619
 
281
620
  const columns = useMemo(() => {
282
621
  const base = withTMDataGridDefaults<TData>(
283
622
  options.columns as ReadonlyArray<TMDataGridColumnDef<TData>>,
284
623
  );
285
- return selectColumnEnabled ? [createSelectColumn<TData>(), ...base] : base;
286
- }, [options.columns, selectColumnEnabled]);
624
+ // Every generated column is present whenever its feature is on, and the
625
+ // tree column hides itself while nothing is grouped. Adding it to the array
626
+ // only once a column is grouped would make the column list depend on table
627
+ // state, which is the one thing that cannot be a `useMemo` dependency here
628
+ // — the table is built from these columns.
629
+ //
630
+ // The order is the order they are pinned in, and it follows what each one
631
+ // is about: tick a row, find it in the tree the user grouped it into, then
632
+ // open it. The details chevron sits last because it acts on the record the
633
+ // lanes to its left have narrowed down to.
634
+ return [
635
+ ...(selectColumnEnabled
636
+ ? [createSelectColumn<TData>(selectColumnLabel)]
637
+ : []),
638
+ ...(groupColumnEnabled ? [createGroupColumn<TData>(groupColumnLabel)] : []),
639
+ ...(detailsColumnEnabled
640
+ ? [createDetailsColumn<TData>(detailsColumnLabel)]
641
+ : []),
642
+ ...base,
643
+ ];
644
+ }, [
645
+ options.columns,
646
+ selectColumnEnabled,
647
+ detailsColumnEnabled,
648
+ groupColumnEnabled,
649
+ selectColumnLabel,
650
+ groupColumnLabel,
651
+ detailsColumnLabel,
652
+ ]);
287
653
 
288
654
  // Read once on mount: `initialState` is only consumed on the first render,
289
655
  // and re-reading later would fight the user's live edits.
290
656
  const [persistedState] = useState(() => readPersistedState(persist));
291
657
 
658
+ // Restored grouping decides whether the tree column starts out visible, so a
659
+ // reload comes back to the tree the user left rather than to a hidden lane.
660
+ const initialGrouping =
661
+ persistedState.grouping ?? options.initialState?.grouping ?? [];
662
+
292
663
  const table = useTable({
293
664
  columnResizeMode: "onChange",
294
665
  enableSorting: true,
295
666
  enableColumnResizing: true,
296
667
  globalFilterFn: "includesString",
668
+ // Grouping by a column takes it out of the grid, the way AG Grid does it:
669
+ // its values have moved into the tree column, so leaving it in place would
670
+ // show every row the same value it was grouped under. Overridable — pass
671
+ // `"reorder"` to keep the column and have it moved to the front instead.
672
+ groupedColumnMode: "remove",
297
673
  ...options,
674
+ // Row details ride on `expanded`, the same state the tree uses — but a data
675
+ // row answers `getCanExpand()` false, since TanStack's fallback is
676
+ // `subRows.length > 0`. `() => true` is the right answer for a group row
677
+ // too, so nothing here has to tell the two apart, and a consumer passing
678
+ // their own predicate still wins.
679
+ ...(renderDetails !== undefined
680
+ ? { getRowCanExpand: options.getRowCanExpand ?? (() => true) }
681
+ : {}),
298
682
  features: tmDataGridFeatures,
299
683
  columns: columns as TableOptions<TMDataGridFeatures, TData>["columns"],
300
684
  initialState: {
301
685
  ...options.initialState,
302
686
  ...persistedState,
687
+ columnVisibility: {
688
+ ...options.initialState?.columnVisibility,
689
+ ...persistedState.columnVisibility,
690
+ // Last word, because the tree column's visibility is not a user setting
691
+ // — it tracks the grouping state. See the effect below.
692
+ ...(groupColumnEnabled
693
+ ? { [GROUP_COLUMN_ID]: initialGrouping.length > 0 }
694
+ : {}),
695
+ },
303
696
  columnPinning: {
304
- // The checkbox column is structurally pinned, so it is re-applied on
305
- // top of anything restored from storage.
697
+ // The generated columns are structurally pinned, so they are re-applied
698
+ // on top of anything restored from storage.
306
699
  left: [
307
700
  ...(selectColumnEnabled && pinningEnabled ? [SELECT_COLUMN_ID] : []),
701
+ ...(groupColumnEnabled && pinningEnabled ? [GROUP_COLUMN_ID] : []),
702
+ ...(detailsColumnEnabled && pinningEnabled ? [DETAILS_COLUMN_ID] : []),
308
703
  ...(
309
704
  persistedState.columnPinning?.left ??
310
705
  options.initialState?.columnPinning?.left ??
311
706
  []
312
- ).filter((id) => id !== SELECT_COLUMN_ID),
707
+ ).filter(
708
+ (id) =>
709
+ id !== SELECT_COLUMN_ID &&
710
+ id !== DETAILS_COLUMN_ID &&
711
+ id !== GROUP_COLUMN_ID,
712
+ ),
313
713
  ],
314
714
  right:
315
715
  persistedState.columnPinning?.right ??
@@ -325,6 +725,60 @@ export function useTMDataGrid<TData extends RowData>({
325
725
  },
326
726
  });
327
727
 
728
+ // Two things have to happen whenever `grouping` changes.
729
+ //
730
+ // One: the tree column appears with the first grouped column and goes away
731
+ // with the last, so an ungrouped grid looks exactly as it did before grouping
732
+ // existed. Driven from a subscription rather than by rebuilding the column
733
+ // array, because the array is what the table is built from — deriving it from
734
+ // table state would close the loop. Visibility is the one column property
735
+ // that can be changed after the fact without touching the definitions.
736
+ //
737
+ // Two, and this one is a workaround. In table-core 9.0.0-beta.21 the
738
+ // per-region column APIs do not list `grouping` among their memo
739
+ // dependencies, even though they all derive from `getAllLeafColumns()`, which
740
+ // does:
741
+ //
742
+ // | API | Declares |
743
+ // | --- | --- |
744
+ // | `getLeft/Center/RightVisibleLeafColumns` | columns, columnPinning, columnVisibility, columnOrder |
745
+ // | `getLeft/Center/RightHeaderGroups` | columnPinning, columnOrder |
746
+ // | `row.getLeft/Center/RightVisibleCells` | columnPinning, columnVisibility |
747
+ //
748
+ // So grouping a *second* column leaves every one of them returning the
749
+ // previous list: the column TanStack removed keeps its header and its grid
750
+ // track, and the row cells no longer line up with them. The first grouping
751
+ // appears to work only because the visibility write above happens to touch a
752
+ // dependency they share.
753
+ //
754
+ // Re-publishing `columnVisibility` and `columnOrder` — same contents, new
755
+ // identity — invalidates all three families. `columnOrder` is the only
756
+ // dependency the header groups declare, and `columnVisibility` the only one
757
+ // the cells do, so both are needed. Remove this once the deps are fixed
758
+ // upstream; the test that fails without it groups two columns and asserts the
759
+ // second one leaves the grid.
760
+ //
761
+ // Writing back into the store from its own subscriber is safe: the guard is
762
+ // on `grouping`'s identity, and neither write touches it, so the callback
763
+ // these writes trigger short-circuits.
764
+ useEffect(() => {
765
+ if (!groupColumnEnabled) return;
766
+ let previousGrouping = table.store.state.grouping;
767
+
768
+ const subscription = table.store.subscribe((state) => {
769
+ if (state.grouping === previousGrouping) return;
770
+ previousGrouping = state.grouping;
771
+
772
+ table.setColumnVisibility((old) => ({
773
+ ...old,
774
+ [GROUP_COLUMN_ID]: state.grouping.length > 0,
775
+ }));
776
+ table.setColumnOrder((old) => [...old]);
777
+ });
778
+
779
+ return () => subscription.unsubscribe();
780
+ }, [groupColumnEnabled, table]);
781
+
328
782
  // Mirror every state change back to storage. Subscribing (rather than writing
329
783
  // from an effect on a state snapshot) means nothing is missed, including
330
784
  // changes made straight through the table API by the consumer.
@@ -367,6 +821,12 @@ export function useTMDataGrid<TData extends RowData>({
367
821
  columnsPanelOpen: false,
368
822
  filterPanelColumnId: null,
369
823
  draggedColumnId: null,
824
+ // `useCreateStore` builds the store once per mount, so this is a genuine
825
+ // default rather than a value that would fight later clicks.
826
+ highlightedRowId: defaultHighlightedRowId ?? null,
827
+ selectionAnchorRowId: null,
828
+ focusedCell: null,
829
+ cellRange: null,
370
830
  },
371
831
  ({ setState }) => ({
372
832
  openFilterPanel: (columnId = null) =>
@@ -389,21 +849,82 @@ export function useTMDataGrid<TData extends RowData>({
389
849
  setState((prev) => ({ ...prev, draggedColumnId: columnId })),
390
850
  endColumnDrag: () =>
391
851
  setState((prev) => ({ ...prev, draggedColumnId: null })),
852
+ setHighlightedRow: (rowId) =>
853
+ setState((prev) => ({ ...prev, highlightedRowId: rowId })),
854
+ setSelectionAnchor: (rowId) =>
855
+ setState((prev) => ({ ...prev, selectionAnchorRowId: rowId })),
856
+ setFocusedCell: (cell) =>
857
+ setState((prev) =>
858
+ // Same cell, same object: the body re-renders on every scroll frame
859
+ // and mouse events land on the cell that already has the focus, so a
860
+ // fresh identity here would republish the store for nothing.
861
+ isSameCell(prev.focusedCell, cell)
862
+ ? prev
863
+ : { ...prev, focusedCell: cell },
864
+ ),
865
+ setCellRange: (range) =>
866
+ setState((prev) =>
867
+ // Same guard as above, and it earns more here: a drag publishes a new
868
+ // corner on every mouse move, and most of those land on the cell the
869
+ // range already ends at.
870
+ isSameCell(prev.cellRange?.anchor ?? null, range?.anchor ?? null) &&
871
+ isSameCell(prev.cellRange?.focus ?? null, range?.focus ?? null)
872
+ ? prev
873
+ : { ...prev, cellRange: range },
874
+ ),
392
875
  }),
393
876
  );
394
877
 
395
- // Deliberately not memoized on `table`: the flags must re-derive whenever the
396
- // caller passes different options, and `table` keeps the same identity when
397
- // they do. See readFeatureFlags.
398
- const features = readFeatureFlags({
399
- ...options,
400
- enableColumnOrdering,
401
- enablePagination,
402
- rowSelectionMode,
403
- highlightSelectedRows,
404
- });
878
+ // `onHighlightedRowChange` is fired from a subscription rather than from the store
879
+ // action, so it covers every route to a new active row — a row click, and a
880
+ // consumer calling `setHighlightedRow` itself. Held in a ref because the store is
881
+ // built once and its actions would otherwise close over the first render's
882
+ // callback.
883
+ const onHighlightedRowChangeRef = useRef(onHighlightedRowChange);
884
+ useEffect(() => {
885
+ onHighlightedRowChangeRef.current = onHighlightedRowChange;
886
+ }, [onHighlightedRowChange]);
887
+
888
+ useEffect(() => {
889
+ // Seeded from the current value so mounting with a `defaultHighlightedRowId`
890
+ // does not report a change that nothing made.
891
+ let previous = ui.state.highlightedRowId;
892
+ const subscription = ui.subscribe((state) => {
893
+ if (state.highlightedRowId === previous) return;
894
+ previous = state.highlightedRowId;
895
+ onHighlightedRowChangeRef.current?.(state.highlightedRowId);
896
+ });
897
+ return () => subscription.unsubscribe();
898
+ }, [ui]);
899
+
900
+ // Same shape for the focused cell, and for the same reason: arrow keys, a
901
+ // click and `setFocusedCell` all have to report through one place.
902
+ const onFocusedCellChangeRef = useRef(onFocusedCellChange);
903
+ useEffect(() => {
904
+ onFocusedCellChangeRef.current = onFocusedCellChange;
905
+ }, [onFocusedCellChange]);
906
+
907
+ useEffect(() => {
908
+ let previous = ui.state.focusedCell;
909
+ const subscription = ui.subscribe((state) => {
910
+ // Identity is enough: `setFocusedCell` keeps the previous object when the
911
+ // cell has not changed, so a new one always means a new cell.
912
+ if (state.focusedCell === previous) return;
913
+ previous = state.focusedCell;
914
+ onFocusedCellChangeRef.current?.(state.focusedCell);
915
+ });
916
+ return () => subscription.unsubscribe();
917
+ }, [ui]);
405
918
 
406
- return { table, ui, features };
919
+ return {
920
+ table,
921
+ ui,
922
+ features,
923
+ labels,
924
+ renderDetails,
925
+ renderDetailsEstHeight,
926
+ overscan,
927
+ };
407
928
  }
408
929
 
409
930
  /**