@jielga/tmdatagrid 0.3.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 (43) hide show
  1. package/dist/index.d.ts +947 -64
  2. package/dist/index.js +2474 -1423
  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 +1 -1
  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 +10 -0
  13. package/src/tmdatagrid/components/TMDataGrid.tsx +23 -1
  14. package/src/tmdatagrid/components/TMDataGridColumnsPanel.tsx +5 -5
  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.tsx +111 -33
  18. package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +5 -4
  19. package/src/tmdatagrid/components/TMDataGridFooter.tsx +11 -19
  20. package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +23 -11
  21. package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +15 -3
  22. package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +181 -52
  23. package/src/tmdatagrid/components/TMDataGridSearch.tsx +122 -0
  24. package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +23 -13
  25. package/src/tmdatagrid/components/TMDataGridTable.module.css +127 -6
  26. package/src/tmdatagrid/components/TMDataGridTable.tsx +1003 -30
  27. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +30 -7
  28. package/src/tmdatagrid/components/icons.ts +1 -0
  29. package/src/tmdatagrid/components/sticky.module.css +77 -16
  30. package/src/tmdatagrid/core/autosize.ts +125 -0
  31. package/src/tmdatagrid/core/capabilities.ts +41 -0
  32. package/src/tmdatagrid/core/cellExport.ts +321 -0
  33. package/src/tmdatagrid/core/cellNavigation.ts +103 -0
  34. package/src/tmdatagrid/core/cellRange.ts +118 -0
  35. package/src/tmdatagrid/core/columnOptions.ts +123 -0
  36. package/src/tmdatagrid/core/columnUtils.ts +18 -0
  37. package/src/tmdatagrid/core/expanding.ts +93 -0
  38. package/src/tmdatagrid/core/filterOperators.ts +176 -15
  39. package/src/tmdatagrid/core/labels.ts +211 -0
  40. package/src/tmdatagrid/core/labelsSv.ts +108 -0
  41. package/src/tmdatagrid/core/summary.ts +35 -0
  42. package/src/tmdatagrid/index.ts +70 -0
  43. package/src/tmdatagrid/useTMDataGrid.tsx +328 -12
@@ -0,0 +1,35 @@
1
+ import { aggregationFns, type RowData } from "@tanstack/react-table";
2
+ import type { TMDataGridRowData } from "../TMDataGridContext";
3
+ import type { TMDataGridTable } from "../useTMDataGrid";
4
+
5
+ /** The registered aggregation names — the same set `aggregationFn` accepts. */
6
+ export type TMDataGridAggregationName = keyof typeof aggregationFns;
7
+
8
+ /**
9
+ * One column aggregated over every filtered row — the number a summary-row
10
+ * `footer` wants:
11
+ *
12
+ * ```tsx
13
+ * columnHelper.accessor("salary", {
14
+ * footer: ({ table }) => sek(Number(aggregateColumn({ table, columnId: "salary" }))),
15
+ * });
16
+ * ```
17
+ *
18
+ * Filtered rather than paginated rows, so the total covers everything the
19
+ * filters left — all pages — and follows the filters as they change. Reuses
20
+ * TanStack's registered aggregation functions; `fn` defaults to `"sum"`.
21
+ */
22
+ export function aggregateColumn<TData extends RowData>({
23
+ table,
24
+ columnId,
25
+ fn = "sum",
26
+ }: {
27
+ table: TMDataGridTable<TData>;
28
+ columnId: string;
29
+ fn?: TMDataGridAggregationName;
30
+ }): unknown {
31
+ const erased = table as unknown as TMDataGridTable<TMDataGridRowData>;
32
+ const rows = erased.getFilteredRowModel().rows;
33
+ // Leaf and child rows are the same set here: the filtered model is flat.
34
+ return aggregationFns[fn](columnId, rows, rows);
35
+ }
@@ -1,10 +1,12 @@
1
1
  export { TMDataGrid, type TMDataGridProps } from "./components/TMDataGrid";
2
2
  export {
3
+ useCellControlTabIndex,
3
4
  useTMDataGridContext,
4
5
  type TMDataGridContextValue,
5
6
  type TMDataGridRowData,
6
7
  } from "./TMDataGridContext";
7
8
  export { SELECT_COLUMN_ID } from "./components/TMDataGridSelectColumn";
9
+ export { DETAILS_COLUMN_ID } from "./components/TMDataGridDetailsColumn";
8
10
  export {
9
11
  formatGroupValue,
10
12
  GROUP_COLUMN_ID,
@@ -15,6 +17,8 @@ export {
15
17
  tmDataGridFeatures,
16
18
  type TMDataGridApi,
17
19
  type TMDataGridColumnMeta,
20
+ type TMDataGridDetailsArgs,
21
+ type TMDataGridDetailsRenderer,
18
22
  type TMDataGridFeatures,
19
23
  type TMDataGridTable,
20
24
  type TMDataGridTableMeta,
@@ -28,20 +32,84 @@ export {
28
32
  TMDataGridFilterPills,
29
33
  type TMDataGridFilterPillsProps,
30
34
  } from "./components/TMDataGridFilterPills";
35
+ export {
36
+ TMDataGridSearch,
37
+ type TMDataGridSearchProps,
38
+ } from "./components/TMDataGridSearch";
31
39
  export {
32
40
  type TMDataGridColumnType,
33
41
  type TMDataGridFilterOperator,
34
42
  type TMDataGridFilterValue,
35
43
  FILTER_OPERATOR_LABELS,
36
44
  formatFilterLabel,
45
+ getDefaultOperator,
46
+ getOperatorsForType,
37
47
  isFilterActive,
48
+ operatorNeedsValue,
49
+ operatorTakesArrayValue,
38
50
  } from "./core/filterOperators";
51
+ export {
52
+ optionsToComboboxData,
53
+ resolveColumnOptions,
54
+ type TMDataGridOption,
55
+ type TMDataGridOptionsArgs,
56
+ type TMDataGridOptionsSource,
57
+ } from "./core/columnOptions";
58
+ export {
59
+ mergeLabels,
60
+ TMDATAGRID_LABELS_EN,
61
+ type TMDataGridLabels,
62
+ type TMDataGridLabelsOverride,
63
+ } from "./core/labels";
64
+ export { TMDATAGRID_LABELS_SV } from "./core/labelsSv";
39
65
  export {
40
66
  getColumnLabel,
41
67
  getColumnType,
42
68
  isColumnReorderable,
69
+ isControlColumn,
43
70
  } from "./core/columnUtils";
44
71
  export { getGroupDataRows } from "./core/grouping";
72
+ export {
73
+ aggregateColumn,
74
+ type TMDataGridAggregationName,
75
+ } from "./core/summary";
76
+ export { autosizeColumn, measureColumnContentWidth } from "./core/autosize";
77
+ export {
78
+ isSameCell,
79
+ resolveCellMove,
80
+ type ResolveCellMoveArgs,
81
+ type TMDataGridCellCoords,
82
+ type TMDataGridCellPosition,
83
+ } from "./core/cellNavigation";
84
+ export {
85
+ boundsCellCount,
86
+ boundsEdges,
87
+ isWithinBounds,
88
+ resolveRangeBounds,
89
+ type ResolveRangeBoundsArgs,
90
+ type TMDataGridCellRange,
91
+ type TMDataGridRangeBounds,
92
+ } from "./core/cellRange";
93
+ export {
94
+ buildCellMatrix,
95
+ buildGridCellMatrix,
96
+ DEFAULT_CELL_EXPORT_OPTIONS,
97
+ downloadTextFile,
98
+ exportGridToCsv,
99
+ formatExportValue,
100
+ toClipboardText,
101
+ toExcelCsv,
102
+ writeClipboardText,
103
+ type BuildCellMatrixArgs,
104
+ type TMDataGridCellExportOptions,
105
+ type TMDataGridCellMatrix,
106
+ } from "./core/cellExport";
107
+ export {
108
+ areAllRowsExpanded,
109
+ resolveExpandAll,
110
+ type TMDataGridExpandAllArgs,
111
+ type TMDataGridExpandTarget,
112
+ } from "./core/expanding";
45
113
  export {
46
114
  getColumnRegion,
47
115
  getStepTargetColumn,
@@ -56,6 +124,7 @@ export {
56
124
  getColumnCapabilities,
57
125
  getGridCapabilities,
58
126
  readFeatureFlags,
127
+ type TMDataGridCellSelectionMode,
59
128
  type TMDataGridCapabilities,
60
129
  type TMDataGridColumnCapabilities,
61
130
  type TMDataGridFeatureFlags,
@@ -86,6 +155,7 @@ export {
86
155
  type TMDataGridStorageMode,
87
156
  } from "./core/persistence";
88
157
  export type {
158
+ TMDataGridCellNav,
89
159
  TMDataGridColumnLayout,
90
160
  TMDataGridRowContextMenu,
91
161
  TMDataGridRowContextMenuArgs,
@@ -22,6 +22,7 @@ import {
22
22
  filterFns,
23
23
  globalFilteringFeature,
24
24
  metaHelper,
25
+ type Row,
25
26
  type RowData,
26
27
  rowExpandingFeature,
27
28
  rowPaginationFeature,
@@ -35,12 +36,18 @@ import {
35
36
  useTable,
36
37
  } from "@tanstack/react-table";
37
38
  import type { Store } from "@tanstack/store";
38
- import { useEffect, useMemo, useRef, useState } from "react";
39
+ import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
40
+ import type { TMDataGridOptionsSource } from "./core/columnOptions";
39
41
  import {
40
42
  type TMDataGridColumnType,
41
43
  tmDataGridFilterFn,
42
44
  getDefaultOperator,
43
45
  } from "./core/filterOperators";
46
+ import {
47
+ mergeLabels,
48
+ type TMDataGridLabels,
49
+ type TMDataGridLabelsOverride,
50
+ } from "./core/labels";
44
51
  import {
45
52
  hasPersistenceKeys,
46
53
  readPersistedState,
@@ -49,9 +56,15 @@ import {
49
56
  } from "./core/persistence";
50
57
  import {
51
58
  readFeatureFlags,
59
+ type TMDataGridCellSelectionMode,
52
60
  type TMDataGridFeatureFlags,
53
61
  type TMDataGridSelectionMode,
54
62
  } from "./core/capabilities";
63
+ import {
64
+ isSameCell,
65
+ type TMDataGridCellPosition,
66
+ } from "./core/cellNavigation";
67
+ import type { TMDataGridCellRange } from "./core/cellRange";
55
68
  import {
56
69
  createSelectColumn,
57
70
  SELECT_COLUMN_ID,
@@ -60,6 +73,10 @@ import {
60
73
  createGroupColumn,
61
74
  GROUP_COLUMN_ID,
62
75
  } from "./components/TMDataGridGroupColumn";
76
+ import {
77
+ createDetailsColumn,
78
+ DETAILS_COLUMN_ID,
79
+ } from "./components/TMDataGridDetailsColumn";
63
80
 
64
81
  /**
65
82
  * How long the grid waits after the last state change before writing to
@@ -72,11 +89,28 @@ const PERSIST_DEBOUNCE_MS = 200;
72
89
  export type TMDataGridColumnMeta = {
73
90
  /** Name shown in menus and the column manager. Falls back to a string header. */
74
91
  label?: string;
75
- /** 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
+ */
76
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;
77
105
  /** Share of the leftover width this column claims. Defaults to `1`. */
78
106
  flex?: number;
79
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;
80
114
  /**
81
115
  * `false` keeps the column where it is: no header dragging, no move items.
82
116
  * Column ordering is the one feature TanStack defines no column option for,
@@ -151,6 +185,26 @@ export function createTMDataGridColumnHelper<TData extends RowData>() {
151
185
  return createColumnHelper<TMDataGridFeatures, TData>();
152
186
  }
153
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
+
154
208
  /**
155
209
  * Chrome state that is *not* table state: which panels are open, and which
156
210
  * column opened the filter panel. Kept in a TanStack Store so consumers can
@@ -180,6 +234,25 @@ export type TMDataGridUiState = {
180
234
  highlightedRowId: string | null;
181
235
  /** The pivot a shift-click extends from. See resolveRowSelectionClick. */
182
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;
183
256
  };
184
257
 
185
258
  export type TMDataGridUiActions = {
@@ -195,6 +268,19 @@ export type TMDataGridUiActions = {
195
268
  */
196
269
  setHighlightedRow: (rowId: string | null) => void;
197
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;
198
284
  };
199
285
 
200
286
  export type TMDataGridUiStore = Store<TMDataGridUiState, TMDataGridUiActions>;
@@ -205,6 +291,14 @@ export type TMDataGridApi<TData extends RowData> = {
205
291
  ui: TMDataGridUiStore;
206
292
  /** Table-level feature switches, re-read from options on every render. */
207
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;
208
302
  };
209
303
 
210
304
  export type UseTMDataGridOptions<TData extends RowData> = Omit<
@@ -219,6 +313,20 @@ export type UseTMDataGridOptions<TData extends RowData> = Omit<
219
313
  * dependency of the subscription that writes back.
220
314
  */
221
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;
222
330
  /**
223
331
  * Header drag-and-drop and the move items in the column menu. Defaults to
224
332
  * `true`.
@@ -301,6 +409,113 @@ export type UseTMDataGridOptions<TData extends RowData> = Omit<
301
409
  * Fires for `ui.actions.setHighlightedRow` too, not only for clicks.
302
410
  */
303
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;
304
519
  };
305
520
 
306
521
  type TMDataGridColumnDef<TData extends RowData> = ColumnDef<
@@ -355,12 +570,18 @@ function withTMDataGridDefaults<TData extends RowData>(
355
570
  export function useTMDataGrid<TData extends RowData>({
356
571
  persist,
357
572
  // Not TanStack options, so they are kept out of what `useTable` receives.
573
+ labels: labelsOverride,
358
574
  enableColumnOrdering,
359
575
  enablePagination,
360
576
  selectionMode,
361
577
  showSelectedBackground,
362
578
  defaultHighlightedRowId,
363
579
  onHighlightedRowChange,
580
+ cellSelection,
581
+ onFocusedCellChange,
582
+ renderDetails,
583
+ renderDetailsEstHeight = DEFAULT_DETAILS_EST_HEIGHT,
584
+ overscan = DEFAULT_OVERSCAN,
364
585
  ...options
365
586
  }: UseTMDataGridOptions<TData>): TMDataGridApi<TData> {
366
587
  // Derived up here, rather than just before the return, because the rest of the
@@ -375,27 +596,60 @@ export function useTMDataGrid<TData extends RowData>({
375
596
  enablePagination,
376
597
  selectionMode,
377
598
  showSelectedBackground,
599
+ cellSelection,
378
600
  });
379
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
+
380
606
  const pinningEnabled = options.enableColumnPinning !== false;
381
607
  const selectColumnEnabled = features.selectColumn;
382
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;
383
619
 
384
620
  const columns = useMemo(() => {
385
621
  const base = withTMDataGridDefaults<TData>(
386
622
  options.columns as ReadonlyArray<TMDataGridColumnDef<TData>>,
387
623
  );
388
- // Both generated columns are always present when their feature is on, and
389
- // the tree column hides itself while nothing is grouped. Adding it to the
390
- // array only once a column is grouped would make the column list depend on
391
- // table state, which is the one thing that cannot be a `useMemo` dependency
392
- // here — the table is built from these columns.
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.
393
634
  return [
394
- ...(selectColumnEnabled ? [createSelectColumn<TData>()] : []),
395
- ...(groupColumnEnabled ? [createGroupColumn<TData>()] : []),
635
+ ...(selectColumnEnabled
636
+ ? [createSelectColumn<TData>(selectColumnLabel)]
637
+ : []),
638
+ ...(groupColumnEnabled ? [createGroupColumn<TData>(groupColumnLabel)] : []),
639
+ ...(detailsColumnEnabled
640
+ ? [createDetailsColumn<TData>(detailsColumnLabel)]
641
+ : []),
396
642
  ...base,
397
643
  ];
398
- }, [options.columns, selectColumnEnabled, groupColumnEnabled]);
644
+ }, [
645
+ options.columns,
646
+ selectColumnEnabled,
647
+ detailsColumnEnabled,
648
+ groupColumnEnabled,
649
+ selectColumnLabel,
650
+ groupColumnLabel,
651
+ detailsColumnLabel,
652
+ ]);
399
653
 
400
654
  // Read once on mount: `initialState` is only consumed on the first render,
401
655
  // and re-reading later would fight the user's live edits.
@@ -417,6 +671,14 @@ export function useTMDataGrid<TData extends RowData>({
417
671
  // `"reorder"` to keep the column and have it moved to the front instead.
418
672
  groupedColumnMode: "remove",
419
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
+ : {}),
420
682
  features: tmDataGridFeatures,
421
683
  columns: columns as TableOptions<TMDataGridFeatures, TData>["columns"],
422
684
  initialState: {
@@ -437,11 +699,17 @@ export function useTMDataGrid<TData extends RowData>({
437
699
  left: [
438
700
  ...(selectColumnEnabled && pinningEnabled ? [SELECT_COLUMN_ID] : []),
439
701
  ...(groupColumnEnabled && pinningEnabled ? [GROUP_COLUMN_ID] : []),
702
+ ...(detailsColumnEnabled && pinningEnabled ? [DETAILS_COLUMN_ID] : []),
440
703
  ...(
441
704
  persistedState.columnPinning?.left ??
442
705
  options.initialState?.columnPinning?.left ??
443
706
  []
444
- ).filter((id) => id !== SELECT_COLUMN_ID && id !== GROUP_COLUMN_ID),
707
+ ).filter(
708
+ (id) =>
709
+ id !== SELECT_COLUMN_ID &&
710
+ id !== DETAILS_COLUMN_ID &&
711
+ id !== GROUP_COLUMN_ID,
712
+ ),
445
713
  ],
446
714
  right:
447
715
  persistedState.columnPinning?.right ??
@@ -557,6 +825,8 @@ export function useTMDataGrid<TData extends RowData>({
557
825
  // default rather than a value that would fight later clicks.
558
826
  highlightedRowId: defaultHighlightedRowId ?? null,
559
827
  selectionAnchorRowId: null,
828
+ focusedCell: null,
829
+ cellRange: null,
560
830
  },
561
831
  ({ setState }) => ({
562
832
  openFilterPanel: (columnId = null) =>
@@ -583,6 +853,25 @@ export function useTMDataGrid<TData extends RowData>({
583
853
  setState((prev) => ({ ...prev, highlightedRowId: rowId })),
584
854
  setSelectionAnchor: (rowId) =>
585
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
+ ),
586
875
  }),
587
876
  );
588
877
 
@@ -608,7 +897,34 @@ export function useTMDataGrid<TData extends RowData>({
608
897
  return () => subscription.unsubscribe();
609
898
  }, [ui]);
610
899
 
611
- return { table, ui, features };
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]);
918
+
919
+ return {
920
+ table,
921
+ ui,
922
+ features,
923
+ labels,
924
+ renderDetails,
925
+ renderDetailsEstHeight,
926
+ overscan,
927
+ };
612
928
  }
613
929
 
614
930
  /**