@jielga/tmdatagrid 2.0.0-beta.2 → 2.0.0-beta.21

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 (150) hide show
  1. package/README.md +5 -212
  2. package/dist/index.d.ts +1664 -632
  3. package/dist/index.js +5226 -3223
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles.css +1 -1
  6. package/docs/anatomy.md +102 -0
  7. package/docs/cell-selection.md +154 -0
  8. package/docs/column-layout.md +204 -0
  9. package/docs/columns.md +262 -0
  10. package/docs/components.md +304 -0
  11. package/docs/editing.md +603 -0
  12. package/docs/editors.md +250 -0
  13. package/docs/export.md +326 -0
  14. package/docs/filtering.md +358 -0
  15. package/docs/getting-started.md +123 -0
  16. package/docs/grouping.md +165 -0
  17. package/docs/loading-and-empty.md +92 -0
  18. package/docs/localization.md +79 -0
  19. package/docs/menu.md +143 -0
  20. package/docs/pagination.md +144 -0
  21. package/docs/persistence.md +111 -0
  22. package/docs/portfolio-rebalancer.md +94 -0
  23. package/docs/query-builder.md +175 -0
  24. package/docs/quick-search.md +83 -0
  25. package/docs/row-details.md +113 -0
  26. package/docs/row-interaction.md +148 -0
  27. package/docs/row-pinning.md +132 -0
  28. package/docs/row-selection.md +134 -0
  29. package/docs/row-styling.md +133 -0
  30. package/docs/scrolling.md +111 -0
  31. package/docs/server-query.md +246 -0
  32. package/docs/server-side.md +206 -0
  33. package/docs/sorting.md +101 -0
  34. package/docs/styling.md +126 -0
  35. package/docs/summary-row.md +76 -0
  36. package/docs/testing.md +309 -0
  37. package/docs/toolbar.md +161 -0
  38. package/docs/use-tm-data-grid.md +361 -0
  39. package/package.json +21 -45
  40. package/skills/appearance/SKILL.md +70 -17
  41. package/skills/cell-selection/SKILL.md +70 -76
  42. package/skills/columns/SKILL.md +131 -32
  43. package/skills/data/SKILL.md +100 -23
  44. package/skills/editing/SKILL.md +217 -96
  45. package/skills/editing/references/common-mistakes.md +111 -24
  46. package/skills/editing/references/editing-api.md +63 -39
  47. package/skills/editing/references/editors-and-validation.md +77 -19
  48. package/skills/filtering/SKILL.md +148 -40
  49. package/skills/getting-started/SKILL.md +18 -16
  50. package/skills/grouping/SKILL.md +32 -15
  51. package/skills/options/SKILL.md +39 -9
  52. package/skills/rows/SKILL.md +22 -18
  53. package/skills/server-side/SKILL.md +170 -17
  54. package/skills/testing/SKILL.md +10 -7
  55. package/src/{tmdatagrid/TMDataGridContext.ts → TMDataGridContext.ts} +7 -19
  56. package/src/{tmdatagrid/components → components}/TMDataGrid.module.css +7 -1
  57. package/src/{tmdatagrid/components → components}/TMDataGrid.tsx +39 -23
  58. package/src/{tmdatagrid/components → components}/TMDataGridCellEditor.tsx +106 -38
  59. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.module.css +5 -1
  60. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.tsx +47 -55
  61. package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.tsx +4 -4
  62. package/src/components/TMDataGridDraftActions.tsx +307 -0
  63. package/src/{tmdatagrid/components → components}/TMDataGridEditColumn.tsx +58 -50
  64. package/src/{tmdatagrid/components → components}/TMDataGridEntryRows.tsx +150 -115
  65. package/src/components/TMDataGridExportPicker.module.css +77 -0
  66. package/src/components/TMDataGridExportPicker.tsx +234 -0
  67. package/src/components/TMDataGridFilterPanel.module.css +54 -0
  68. package/src/components/TMDataGridFilterPanel.tsx +348 -0
  69. package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.tsx +7 -5
  70. package/src/components/TMDataGridFilterSurface.module.css +54 -0
  71. package/src/components/TMDataGridFilterSurface.tsx +167 -0
  72. package/src/{tmdatagrid/components → components}/TMDataGridFooter.tsx +53 -13
  73. package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.tsx +4 -3
  74. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.module.css +10 -0
  75. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.tsx +100 -28
  76. package/src/components/TMDataGridHeaderFilterRow.module.css +51 -0
  77. package/src/components/TMDataGridHeaderFilterRow.tsx +301 -0
  78. package/src/components/TMDataGridMenu.tsx +354 -0
  79. package/src/{tmdatagrid/components → components}/TMDataGridSelectColumn.tsx +12 -7
  80. package/src/{tmdatagrid/components → components}/TMDataGridTable.module.css +90 -67
  81. package/src/{tmdatagrid/components → components}/TMDataGridTable.tsx +678 -156
  82. package/src/components/TMDataGridToolbar.module.css +21 -0
  83. package/src/components/TMDataGridToolbar.tsx +181 -0
  84. package/src/{tmdatagrid/components → components}/editors/TMDataGridBooleanEditor.tsx +3 -3
  85. package/src/{tmdatagrid/components → components}/editors/TMDataGridDateEditor.tsx +3 -3
  86. package/src/{tmdatagrid/components → components}/editors/TMDataGridMultiSelectEditor.tsx +3 -3
  87. package/src/components/editors/TMDataGridNumberEditor.tsx +70 -0
  88. package/src/{tmdatagrid/components → components}/editors/TMDataGridSelectEditor.tsx +3 -3
  89. package/src/{tmdatagrid/components → components}/editors/TMDataGridStringEditor.tsx +3 -3
  90. package/src/{tmdatagrid/components → components}/editors/editorShared.ts +17 -31
  91. package/src/{tmdatagrid/components → components}/filters/DgAutocompleteFilter.tsx +5 -4
  92. package/src/{tmdatagrid/components → components}/filters/DgDateRangeFilter.tsx +22 -5
  93. package/src/{tmdatagrid/components → components}/filters/DgRangeSliderFilter.tsx +5 -1
  94. package/src/{tmdatagrid/components → components}/filters/DgTriStateFilter.tsx +5 -1
  95. package/src/{tmdatagrid/components → components}/filters/TMDataGridFilterValueInput.tsx +44 -28
  96. package/src/components/filters/controlLayout.ts +32 -0
  97. package/src/components/filters/filterControlFor.ts +65 -0
  98. package/src/{tmdatagrid/components → components}/icons.ts +1 -0
  99. package/src/{tmdatagrid/components → components}/sticky.module.css +44 -0
  100. package/src/components/useHideableColumns.ts +52 -0
  101. package/src/{tmdatagrid/core → core}/autosize.ts +5 -2
  102. package/src/{tmdatagrid/core → core}/capabilities.ts +14 -6
  103. package/src/{tmdatagrid/core → core}/columnOptions.ts +46 -0
  104. package/src/{tmdatagrid/core → core}/columnOrdering.ts +33 -14
  105. package/src/{tmdatagrid/core → core}/columnUtils.ts +44 -5
  106. package/src/core/controlledState.ts +179 -0
  107. package/src/core/controlledStateSync.ts +108 -0
  108. package/src/core/deletedRows.ts +34 -0
  109. package/src/core/dom.ts +74 -0
  110. package/src/core/editEngine.ts +2476 -0
  111. package/src/{tmdatagrid/core → core}/editorFocus.ts +8 -4
  112. package/src/core/export.ts +843 -0
  113. package/src/{tmdatagrid/core → core}/filterControls.ts +38 -1
  114. package/src/{tmdatagrid/core → core}/filterOperators.ts +64 -1
  115. package/src/core/filterSurface.ts +99 -0
  116. package/src/{tmdatagrid/core → core}/labels.ts +66 -8
  117. package/src/{tmdatagrid/core → core}/labelsSv.ts +26 -3
  118. package/src/core/pageReset.ts +120 -0
  119. package/src/{tmdatagrid/core → core}/persistence.ts +22 -5
  120. package/src/core/resizePreview.ts +141 -0
  121. package/src/core/summary.ts +59 -0
  122. package/src/core/useSettledTableState.ts +36 -0
  123. package/src/{tmdatagrid/index.ts → index.ts} +75 -12
  124. package/src/{tmdatagrid/useTMDataGrid.tsx → useTMDataGrid.tsx} +734 -135
  125. package/src/useTMDataGridExport.ts +78 -0
  126. package/src/tmdatagrid/components/TMDataGridEditActions.tsx +0 -162
  127. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +0 -35
  128. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +0 -354
  129. package/src/tmdatagrid/components/TMDataGridToolbar.module.css +0 -12
  130. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +0 -162
  131. package/src/tmdatagrid/components/editors/TMDataGridNumberEditor.tsx +0 -40
  132. package/src/tmdatagrid/core/cellExport.ts +0 -320
  133. package/src/tmdatagrid/core/editEngine.ts +0 -1006
  134. package/src/tmdatagrid/core/summary.ts +0 -35
  135. /package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.module.css +0 -0
  136. /package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.module.css +0 -0
  137. /package/src/{tmdatagrid/components → components}/TMDataGridFooter.module.css +0 -0
  138. /package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.module.css +0 -0
  139. /package/src/{tmdatagrid/components → components}/TMDataGridRowNumberColumn.tsx +0 -0
  140. /package/src/{tmdatagrid/components → components}/TMDataGridSearch.tsx +0 -0
  141. /package/src/{tmdatagrid/core → core}/cellNavigation.ts +0 -0
  142. /package/src/{tmdatagrid/core → core}/cellRange.ts +0 -0
  143. /package/src/{tmdatagrid/core → core}/draftCellContext.ts +0 -0
  144. /package/src/{tmdatagrid/core → core}/expanding.ts +0 -0
  145. /package/src/{tmdatagrid/core → core}/grouping.ts +0 -0
  146. /package/src/{tmdatagrid/core → core}/matchHighlight.ts +0 -0
  147. /package/src/{tmdatagrid/core → core}/quickSearch.ts +0 -0
  148. /package/src/{tmdatagrid/core → core}/rowPinning.ts +0 -0
  149. /package/src/{tmdatagrid/core → core}/rowSelection.ts +0 -0
  150. /package/src/{tmdatagrid/core → core}/sizes.ts +0 -0
@@ -18,13 +18,19 @@ import {
18
18
  useCallback,
19
19
  useEffect,
20
20
  useLayoutEffect,
21
+ useMemo,
21
22
  useRef,
22
23
  useState,
23
24
  } from "react";
24
25
  import classes from "./TMDataGridTable.module.css";
25
26
  import sticky from "./sticky.module.css";
26
27
  import { type TMDataGridRowData, useTMDataGridContext } from "../TMDataGridContext";
27
- import { TMDataGridFilterPanel } from "./TMDataGridFilterPanel";
28
+ import {
29
+ TMDataGridFilterPopup,
30
+ TMDataGridFilterSidebar,
31
+ } from "./TMDataGridFilterSurface";
32
+ import { TMDataGridHeaderFilterRow } from "./TMDataGridHeaderFilterRow";
33
+ import { getGridCapabilities } from "../core/capabilities";
28
34
  import {
29
35
  TMDataGridHeaderCell,
30
36
  type TMDataGridColumnMenuItemsRenderer,
@@ -34,7 +40,17 @@ import {
34
40
  type TMDataGridCellEditorClose,
35
41
  } from "./TMDataGridCellEditor";
36
42
  import { TMDataGridEntryRows } from "./TMDataGridEntryRows";
37
- import { getColumnAlign, isControlColumn } from "../core/columnUtils";
43
+ import {
44
+ getColumnAlign,
45
+ isControlColumn,
46
+ isGeneratedColumn,
47
+ } from "../core/columnUtils";
48
+ import {
49
+ resizePreview,
50
+ type TMDataGridColumnTrack,
51
+ } from "../core/resizePreview";
52
+ import { useSettledTableState } from "../core/useSettledTableState";
53
+ import { isInputElement } from "../core/dom";
38
54
  import { draftCellContext } from "../core/draftCellContext";
39
55
  import {
40
56
  getEditFieldName,
@@ -53,14 +69,14 @@ import {
53
69
  resolveRangeBounds,
54
70
  } from "../core/cellRange";
55
71
  import {
56
- buildCellMatrix,
57
- DEFAULT_CELL_EXPORT_OPTIONS,
58
- downloadTextFile,
72
+ buildExportData,
73
+ fromCellExportOptions,
74
+ resolveExportOptions,
59
75
  toClipboardText,
60
- toExcelCsv,
61
76
  writeClipboardText,
77
+ writeExportFile,
62
78
  type TMDataGridCellExportOptions,
63
- } from "../core/cellExport";
79
+ } from "../core/export";
64
80
  import { autosizeColumn, hasMountedCells } from "../core/autosize";
65
81
  import {
66
82
  getDisplayedRows,
@@ -281,10 +297,22 @@ function TMDataGridBodyCell({
281
297
  // The whole draft, not one field: a computed or custom renderer may read any
282
298
  // field off the row, so every data cell of a drafted row repaints together
283
299
  // when its draft moves - and only then, `values` being reference-stable
284
- // across meta-only form events. `undefined` everywhere else.
285
- const draftValues = useSelector(edit.store, (state) =>
286
- isControl ? undefined : state.rows[cellRowId]?.values,
287
- );
300
+ // across meta-only form events. `undefined` everywhere else - including a
301
+ // committed row, whose draft already *is* the row: the hook feeds the draft
302
+ // store's values to the table as `data`, so `row.original` holds them.
303
+ // Only an open form's undecided values need the overlay.
304
+ const draftValues = useSelector(edit.store, (state) => {
305
+ if (isControl) return undefined;
306
+ if (state.committedRowIds.includes(cellRowId)) return undefined;
307
+ if (
308
+ state.newRows.some(
309
+ (newRow) => newRow.tempId === cellRowId && newRow.committed,
310
+ )
311
+ ) {
312
+ return undefined;
313
+ }
314
+ return state.rows[cellRowId]?.values;
315
+ });
288
316
  return (
289
317
  <div
290
318
  // `gridcell` rather than `cell` is what tells a screen reader the arrow
@@ -344,19 +372,22 @@ function TMDataGridBodyCell({
344
372
  left: layout.pinnedAt === "left" ? layout.offset : undefined,
345
373
  right: layout.pinnedAt === "right" ? layout.offset : undefined,
346
374
  // The focus ring is drawn inside the cell's own box, so the cell has to
347
- // be able to stack above its neighbours - a plain one to lift the ring
348
- // over the pinned lane it sits beside, a pinned one to keep the ring
349
- // whole while the rest of the row slides under it. Values from the
350
- // stacking ladder in TMDataGrid.module.css.
375
+ // stack above the cells it shares an edge with - but not above the
376
+ // pinned lanes, or the ring rides over them once the row scrolls
377
+ // sideways. A focused cell that is itself pinned takes the slot above
378
+ // them, keeping its ring whole while the rest of the row slides under
379
+ // it. Values from the stacking ladder in TMDataGrid.module.css.
351
380
  position: layout.pinnedAt
352
381
  ? "sticky"
353
382
  : nav?.focused
354
383
  ? "relative"
355
384
  : undefined,
356
- zIndex: nav?.focused
357
- ? "var(--dg-z-focused-cell, 3)"
358
- : layout.pinnedAt
359
- ? "var(--dg-z-pinned-cell, 2)"
385
+ zIndex: layout.pinnedAt
386
+ ? nav?.focused
387
+ ? "var(--dg-z-pinned-focused-cell, 3)"
388
+ : "var(--dg-z-pinned-cell, 2)"
389
+ : nav?.focused
390
+ ? "var(--dg-z-focused-cell, 1)"
360
391
  : undefined,
361
392
  }}
362
393
  >
@@ -364,9 +395,10 @@ function TMDataGridBodyCell({
364
395
  <span className={classes.cellContent}>
365
396
  {(() => {
366
397
  // A row with a live form shows its draft, not `data`: the column's
367
- // own renderer over a context reading the form's values. Parked
368
- // drafts - draft mode, cellConfirm's kept-on-blur - are the point;
369
- // group cells are excluded because no group row ever has a form.
398
+ // own renderer over a context reading the form's values. The
399
+ // drafts that outlive their editor - parked ones, cellConfirm's
400
+ // kept ones - are the point; group cells are excluded because no
401
+ // group row ever has a form.
370
402
  const draftContext =
371
403
  draftValues !== undefined &&
372
404
  contentOverride === undefined &&
@@ -649,9 +681,10 @@ export type TMDataGridTableProps<TData extends RowData> = {
649
681
  */
650
682
  rowContextMenuProps?: Omit<MenuProps, "opened" | "onChange" | "children">;
651
683
  /**
652
- * How Ctrl+C and the export item write values, under
653
- * `cellSelection: "range"`. Defaults to the Nordic Excel conventions - see
654
- * {@link TMDataGridCellExportOptions}.
684
+ * @deprecated Set `exportOptions` on `useTMDataGrid` instead; it covers the
685
+ * cell-range menu and every other export alike. Until it goes, this is
686
+ * converted (`separator` and `decimalComma` become a `csvExcelFormat`) and
687
+ * merged over `exportOptions` for the cell-range menu only.
655
688
  */
656
689
  cellExport?: TMDataGridCellExportOptions;
657
690
  /**
@@ -716,6 +749,8 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
716
749
  ui,
717
750
  edit,
718
751
  features,
752
+ filters,
753
+ exportOptions: gridExportOptions,
719
754
  labels,
720
755
  rowHeight,
721
756
  controlSize,
@@ -725,9 +760,10 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
725
760
  scrollerRef,
726
761
  } = useTMDataGridContext();
727
762
 
728
- // The body depends on every state slice (sorting, filters, paging, sizing,
729
- // visibility, selection), so it subscribes to the whole table store.
730
- useSelector(table.store);
763
+ // The body depends on every settled state slice (sorting, filters, paging,
764
+ // sizing, visibility, selection). Not on the transient resize deltas: those
765
+ // publish on every pointer move and the drag paints itself, see below.
766
+ useSettledTableState(table.store);
731
767
  // The active row lives in the chrome store, so it needs its own subscription.
732
768
  const highlightedRowId = useSelector(ui, (state) => state.highlightedRowId);
733
769
  // As does the focused cell. Selected separately from the row above so a grid
@@ -740,7 +776,10 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
740
776
  // Row mode opens whole rows and any number of them at once, so which rows
741
777
  // are showing editors is the set of open forms, not the single active slot.
742
778
  const editOpenRowIds = useSelector(edit.store, (state) => state.openRowIds);
743
- const newRowCount = useSelector(edit.store, (state) => state.newRows.length);
779
+ // Entry rows, for the block's mount and for `data-new` on the committed
780
+ // ones that have joined the body. Identity-stable across typing.
781
+ const newRows = useSelector(edit.store, (state) => state.newRows);
782
+ const newRowCount = newRows.length;
744
783
  const deletedRowIds = useSelector(edit.store, (state) => state.deletedRowIds);
745
784
  // Rows carrying a dirty draft, for the row-level marker. Derived from the
746
785
  // same projections the cells subscribe to; the array's identity churns with
@@ -750,6 +789,32 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
750
789
  (id) => (state.rows[id]?.dirtyFields.length ?? 0) > 0,
751
790
  ),
752
791
  );
792
+ // Rows parked in the draft store, waiting for Save. A separate marker from
793
+ // `data-dirty`, which covers any row being typed into: after a partial save
794
+ // these are what is left, so one selector highlights everything still
795
+ // pending.
796
+ const editDraftRowIds = useSelector(
797
+ edit.store,
798
+ (state) => state.committedRowIds,
799
+ );
800
+ // The same lists by id: the body asks them once per rendered row, and an
801
+ // import runs each of them to thousands.
802
+ const editOpenRowIdSet = useMemo(
803
+ () => new Set(editOpenRowIds),
804
+ [editOpenRowIds],
805
+ );
806
+ const editDirtyRowIdSet = useMemo(
807
+ () => new Set(editDirtyRowIds),
808
+ [editDirtyRowIds],
809
+ );
810
+ const editDraftRowIdSet = useMemo(
811
+ () => new Set(editDraftRowIds),
812
+ [editDraftRowIds],
813
+ );
814
+ const newRowIdSet = useMemo(
815
+ () => new Set(newRows.map((newRow) => newRow.tempId)),
816
+ [newRows],
817
+ );
753
818
 
754
819
  const { loading, noResultsLabel = labels.noResults } =
755
820
  table.options.meta ?? {};
@@ -843,29 +908,99 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
843
908
  });
844
909
 
845
910
  /**
846
- * The header's height, published as `--dg-header-height` for the entry
847
- * block to stick under - never measured otherwise (`top: 0` needs no
848
- * number). A ResizeObserver, mounted only while the block is in use: the
849
- * same discipline `renderDetails` applies to `measureElement`.
911
+ * Everything the body rows start after, and how much of it stays on screen
912
+ * once they scroll: the header rows, the entry block and the pinned top
913
+ * block all sit in the same scroll content, above the first virtual row.
914
+ *
915
+ * Three readers, one measurement.
916
+ *
917
+ * `--dg-header-height` is the header total alone, published for the entry
918
+ * block to stick under and only while a block is in use - the stylesheet's
919
+ * per-`size` value is what the header cells are laid out against otherwise,
920
+ * and it is not the sum of a stacked group's rows.
921
+ *
922
+ * `bodyOffsetTop` is the whole stack, and it is the virtualizer's
923
+ * `scrollMargin`: item offsets start at zero, so without it every one of
924
+ * them is short by the height of what the rows begin after. `stickyCoverTop`
925
+ * leaves out the entry rows that flow with the body (`editNewRowsSticky`
926
+ * off), and is its `scrollPaddingStart` - the part of the stack that stays
927
+ * put and would otherwise be covering the row `align: "auto"` just scrolled
928
+ * to.
929
+ *
930
+ * The observer is mounted whatever the grid shows, unlike the block
931
+ * measurements below it: the header is there in every case, and the
932
+ * virtualizer needs its height from the first paint.
850
933
  */
851
934
  const gridElementRef = useRef<HTMLDivElement>(null);
935
+ /**
936
+ * Header filters only render where something can be filtered - the row would
937
+ * otherwise be a band of empty cells on a grid with `enableColumnFilters:
938
+ * false`. Derived up here because the header measurement below depends on
939
+ * whether the row is in the DOM.
940
+ */
941
+ const hasHeaderFilterRow =
942
+ filters.inHeader && getGridCapabilities(table, features).canFilterAny;
943
+ /**
944
+ * How many rows deep the header is. In the measurement's dependencies
945
+ * because the rows are found once, by query: a `columns` change that adds or
946
+ * drops a group level changes which elements have to be measured and given
947
+ * their sticky inset, and nothing else in the list moves when it does.
948
+ */
949
+ const headerDepth = table.getCenterHeaderGroups().length;
950
+ const tableWrapperRef = useRef<HTMLDivElement>(null);
951
+ const [bodyOffsetTop, setBodyOffsetTop] = useState(0);
952
+ const [stickyCoverTop, setStickyCoverTop] = useState(0);
953
+ const [stickyCoverBottom, setStickyCoverBottom] = useState(0);
852
954
  useEffect(() => {
853
- if (newRowCount === 0 && pinnedTopRows.length === 0) return;
854
955
  const grid = gridElementRef.current;
855
956
  if (grid === null) return;
856
957
  const headerRows = grid.querySelectorAll<HTMLElement>(
857
958
  "[data-dg-header-row]",
858
959
  );
960
+ const stickyBlocks = grid.querySelectorAll<HTMLElement>(
961
+ '[data-dg-entry-block], [data-dg-part="pinned-top"]',
962
+ );
963
+ const flowBlocks = grid.querySelectorAll<HTMLElement>(
964
+ "[data-dg-entry-flow-block]",
965
+ );
859
966
  const measure = () => {
860
- let total = 0;
861
- for (const headerRow of headerRows) total += headerRow.offsetHeight;
862
- grid.style.setProperty("--dg-header-height", `${total}px`);
967
+ let header = 0;
968
+ for (const headerRow of headerRows) {
969
+ // Every header row is sticky, so each one needs the height of the rows
970
+ // above it as its own inset - otherwise a stacked group and the filter
971
+ // row all pin to the top edge and paint over each other. Written on the
972
+ // element because only JS knows where the row before it ended; the
973
+ // stylesheet's `top` is the pre-measurement fallback.
974
+ headerRow.style.top = `${header}px`;
975
+ header += headerRow.offsetHeight;
976
+ }
977
+ if (newRowCount > 0 || pinnedTopRows.length > 0) {
978
+ grid.style.setProperty("--dg-header-height", `${header}px`);
979
+ }
980
+ // The whole header's height, on the wrapper rather than on the grid,
981
+ // because the popup is a sibling of the scroll container and never sees
982
+ // a property set inside it. A second name rather than reusing
983
+ // `--dg-header-height`: the header cells read that one for their own
984
+ // `min-height`, so publishing a stacked total there would feed a
985
+ // two-row header back in as the height of each of its rows.
986
+ tableWrapperRef.current?.style.setProperty(
987
+ "--dg-header-stack-height",
988
+ `${header}px`,
989
+ );
990
+ let cover = header;
991
+ for (const block of stickyBlocks) cover += block.offsetHeight;
992
+ let offset = cover;
993
+ for (const block of flowBlocks) offset += block.offsetHeight;
994
+ setStickyCoverTop(cover);
995
+ setBodyOffsetTop(offset);
863
996
  };
864
997
  measure();
865
998
  const observer = new ResizeObserver(measure);
866
- for (const headerRow of headerRows) observer.observe(headerRow);
999
+ for (const element of [...headerRows, ...stickyBlocks, ...flowBlocks]) {
1000
+ observer.observe(element);
1001
+ }
867
1002
  return () => observer.disconnect();
868
- }, [newRowCount, pinnedTopRows.length]);
1003
+ }, [newRowCount, pinnedTopRows.length, hasHeaderFilterRow, headerDepth]);
869
1004
 
870
1005
  /**
871
1006
  * The entry block's height, published as `--dg-entry-height` so the pinned
@@ -889,32 +1024,6 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
889
1024
  };
890
1025
  }, [newRowCount, pinnedTopRows.length]);
891
1026
 
892
- /**
893
- * The summary row's height, published as `--dg-summary-height` so the
894
- * pinned bottom block sticks above it rather than underneath it.
895
- */
896
- useEffect(() => {
897
- if (pinnedBottomRows.length === 0) return;
898
- const grid = gridElementRef.current;
899
- if (grid === null) return;
900
- const summaryRow = grid.querySelector<HTMLElement>(
901
- '[data-dg-part="summary-row"]',
902
- );
903
- if (summaryRow === null) return;
904
- const measure = () =>
905
- grid.style.setProperty(
906
- "--dg-summary-height",
907
- `${summaryRow.offsetHeight}px`,
908
- );
909
- measure();
910
- const observer = new ResizeObserver(measure);
911
- observer.observe(summaryRow);
912
- return () => {
913
- observer.disconnect();
914
- grid.style.removeProperty("--dg-summary-height");
915
- };
916
- }, [pinnedBottomRows.length]);
917
-
918
1027
  // A persisted pageIndex can outlive the data that produced it; TanStack only
919
1028
  // auto-resets on live filter/sort/data changes, not on restored state. The
920
1029
  // guard makes the dependency-free effect idempotent.
@@ -959,12 +1068,44 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
959
1068
  getItemKey: useCallback((index: number) => rows[index]?.id ?? index, [rows]),
960
1069
  overscan,
961
1070
  initialRect: { height: 600, width: 1200 },
1071
+ /**
1072
+ * The rows are not the first thing in the scroll container - the header,
1073
+ * the entry block and the pinned top block come before them. The margin is
1074
+ * what puts an item's offset in the container's own scroll coordinates;
1075
+ * the two paddings are the sticky blocks at either end, which cover the
1076
+ * viewport without shrinking it, so `align: "auto"` has to scroll past
1077
+ * them rather than stopping underneath.
1078
+ */
1079
+ scrollMargin: bodyOffsetTop,
1080
+ scrollPaddingStart: stickyCoverTop,
1081
+ scrollPaddingEnd: stickyCoverBottom,
962
1082
  });
963
1083
 
1084
+ /**
1085
+ * A live `size` (or `meta.rowHeight`) change moves what `estimateSize`
1086
+ * answers, but the virtualizer's measurement cache does not list that option
1087
+ * among its dependencies - by its own docs, changing the estimate means
1088
+ * calling `measure()`. Without this the spacers keep the old height until
1089
+ * something else invalidates the cache, so the scroll range and the window
1090
+ * disagree with the rows actually painted.
1091
+ */
1092
+ const prevRowHeightRef = useRef(rowHeight);
1093
+ useEffect(() => {
1094
+ if (prevRowHeightRef.current === rowHeight) return;
1095
+ prevRowHeightRef.current = rowHeight;
1096
+ virtualizer.measure();
1097
+ }, [rowHeight, virtualizer]);
1098
+
1099
+ // The spacers stand in for the rows that are not mounted, so they measure
1100
+ // from the first row rather than from the top of the scroll container:
1101
+ // `scrollMargin` is included in every item's `start` and `end` and excluded
1102
+ // from `getTotalSize()`, and taking it back off here is what keeps a spacer
1103
+ // the height of the rows it replaces.
964
1104
  const virtualItems = virtualizer.getVirtualItems();
965
- const paddingTop = virtualItems[0]?.start ?? 0;
1105
+ const paddingTop = (virtualItems[0]?.start ?? bodyOffsetTop) - bodyOffsetTop;
966
1106
  const paddingBottom =
967
- virtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0);
1107
+ virtualizer.getTotalSize() -
1108
+ ((virtualItems.at(-1)?.end ?? bodyOffsetTop) - bodyOffsetTop);
968
1109
 
969
1110
  /**
970
1111
  * `api.scrollToRow`, implemented where the virtualizer is.
@@ -1021,14 +1162,14 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1021
1162
  onReachEnd();
1022
1163
  });
1023
1164
 
1024
- // The *Visible* variants throughout: `getLeftLeafColumns()` and friends
1165
+ // The *Visible* variants throughout: `getStartLeafColumns()` and friends
1025
1166
  // include hidden columns, while the cells below come from
1026
- // `row.getLeftVisibleCells()`, which does not. Mixing the two would lay down
1167
+ // `row.getStartVisibleCells()`, which does not. Mixing the two would lay down
1027
1168
  // a grid track for a column that renders no cell, shifting every column after
1028
1169
  // it out of its header. The tree column is hidden exactly this way while
1029
1170
  // nothing is grouped.
1030
- const leftLeafColumns = table.getLeftVisibleLeafColumns();
1031
- const rightLeafColumns = table.getRightVisibleLeafColumns();
1171
+ const leftLeafColumns = table.getStartVisibleLeafColumns();
1172
+ const rightLeafColumns = table.getEndVisibleLeafColumns();
1032
1173
  const centerLeafColumns = table.getCenterVisibleLeafColumns();
1033
1174
 
1034
1175
  const orderedColumns = [
@@ -1040,8 +1181,8 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1040
1181
  // Headers and cells must follow the same left → center → right order as the
1041
1182
  // column tracks, otherwise pinning a column would shuffle it out of its lane.
1042
1183
  const centerHeaderGroups = table.getCenterHeaderGroups();
1043
- const leftHeaderGroups = table.getLeftHeaderGroups();
1044
- const rightHeaderGroups = table.getRightHeaderGroups();
1184
+ const leftHeaderGroups = table.getStartHeaderGroups();
1185
+ const rightHeaderGroups = table.getEndHeaderGroups();
1045
1186
  const headerGroups = centerHeaderGroups.map((group, index) => ({
1046
1187
  id: group.id,
1047
1188
  headers: [
@@ -1058,19 +1199,26 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1058
1199
  // Pinned columns are always exact: their sticky offsets come from
1059
1200
  // `getStart()` / `getAfter()`, which sum `getSize()` and cannot see an `fr`.
1060
1201
  const columnSizing = table.store.state.columnSizing;
1061
- const columnTracks = orderedColumns.map((column) => {
1062
- const isFixed = column.columnDef.minSize === column.columnDef.maxSize;
1063
- const isResized = column.id in columnSizing;
1064
- const isPinned = column.getIsPinned() !== false;
1065
- const minWidth = column.columnDef.minSize ?? 80;
1066
- if (isFixed || isResized || isPinned) {
1067
- return { track: `${column.getSize()}px`, minWidth: column.getSize() };
1068
- }
1069
- return {
1070
- track: `minmax(${minWidth}px, ${column.columnDef.meta?.flex ?? 1}fr)`,
1071
- minWidth,
1072
- };
1073
- });
1202
+ const columnTracks: Array<TMDataGridColumnTrack> = orderedColumns.map(
1203
+ (column) => {
1204
+ const isFixed = column.columnDef.minSize === column.columnDef.maxSize;
1205
+ const isResized = column.id in columnSizing;
1206
+ const isPinned = column.getIsPinned() !== false;
1207
+ const minWidth = column.columnDef.minSize ?? 80;
1208
+ if (isFixed || isResized || isPinned) {
1209
+ return {
1210
+ id: column.id,
1211
+ track: `${column.getSize()}px`,
1212
+ minWidth: column.getSize(),
1213
+ };
1214
+ }
1215
+ return {
1216
+ id: column.id,
1217
+ track: `minmax(${minWidth}px, ${column.columnDef.meta?.flex ?? 1}fr)`,
1218
+ minWidth,
1219
+ };
1220
+ },
1221
+ );
1074
1222
  const gridTemplateColumns = columnTracks.map((c) => c.track).join(" ");
1075
1223
  const gridMinWidth = columnTracks.reduce((sum, c) => sum + c.minWidth, 0);
1076
1224
 
@@ -1118,6 +1266,65 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1118
1266
  const layoutFor = (columnId: string) =>
1119
1267
  columnLayout.get(columnId) ?? UNPINNED_LAYOUT;
1120
1268
 
1269
+ /**
1270
+ * A running resize drag paints itself, straight onto the grid element.
1271
+ *
1272
+ * Every row sits on the grid's own column tracks through `subgrid`, so the
1273
+ * whole drag is one style write per frame. Letting the drag render instead -
1274
+ * `columnResizeMode: "onChange"`, which publishes a width on every pointer
1275
+ * move - costs a React pass over every mounted cell for each of those moves,
1276
+ * and the gesture turns choppy on a grid wide enough to be worth resizing.
1277
+ *
1278
+ * TanStack still owns the gesture and still commits the width into
1279
+ * `columnSizing` when the pointer is released; the render that follows lands
1280
+ * on the same numbers the last frame painted.
1281
+ */
1282
+ const previewRef = useRef<{
1283
+ tracks: Array<TMDataGridColumnTrack>;
1284
+ leftLane: Array<string>;
1285
+ rightLane: Array<string>;
1286
+ }>({ tracks: columnTracks, leftLane: [], rightLane: [] });
1287
+ useEffect(() => {
1288
+ previewRef.current = {
1289
+ tracks: columnTracks,
1290
+ leftLane: leftLeafColumns.map((column) => column.id),
1291
+ // Outermost last, the order the offsets above accumulate in.
1292
+ rightLane: [...rightLeafColumns].reverse().map((column) => column.id),
1293
+ };
1294
+ });
1295
+ useEffect(() => {
1296
+ const element = gridElementRef.current;
1297
+ if (!element) return;
1298
+
1299
+ // Painted straight from the store's publish rather than on a frame of its
1300
+ // own: the browser already coalesces pointer moves to one per frame, and a
1301
+ // `requestAnimationFrame` stops firing altogether while the window is
1302
+ // occluded - which would leave the drag frozen under the pointer.
1303
+ let painted = "";
1304
+ const subscription = table.store.subscribe((state) => {
1305
+ const preview = resizePreview({
1306
+ progress: state.columnResizing,
1307
+ boundsOf: (columnId) => table.getColumn(columnId)?.columnDef ?? {},
1308
+ ...previewRef.current,
1309
+ });
1310
+ if (!preview || preview.gridTemplateColumns === painted) return;
1311
+ painted = preview.gridTemplateColumns;
1312
+ element.style.gridTemplateColumns = preview.gridTemplateColumns;
1313
+ element.style.minWidth = `${preview.minWidth}px`;
1314
+ // Only the pinned lanes, and only while the drag is resizing one of
1315
+ // them: their sticky offsets are the one measurement that does not
1316
+ // follow from the track list alone.
1317
+ for (const { columnId, side, offset } of preview.offsets) {
1318
+ for (const cell of element.querySelectorAll<HTMLElement>(
1319
+ `[data-column-id="${CSS.escape(columnId)}"]`,
1320
+ )) {
1321
+ cell.style[side] = `${offset}px`;
1322
+ }
1323
+ }
1324
+ });
1325
+ return () => subscription.unsubscribe();
1326
+ }, [table]);
1327
+
1121
1328
  // Pinned rows count as content: with every visible row pinned to an edge,
1122
1329
  // an empty-state message beside them would contradict what is on screen.
1123
1330
  const isEmpty = rows.length === 0 && pinnedRowCount === 0;
@@ -1141,6 +1348,68 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1141
1348
  const hasSummaryRow = orderedColumns.some(
1142
1349
  (column) => column.columnDef.footer !== undefined,
1143
1350
  );
1351
+ /**
1352
+ * Rows above the first body row, which is what every `aria-rowindex` in the
1353
+ * body counts from and what `aria-rowcount` adds up. The header groups plus
1354
+ * the filter row, when there is one.
1355
+ */
1356
+ const headerRowCount = headerGroups.length + (hasHeaderFilterRow ? 1 : 0);
1357
+
1358
+ /**
1359
+ * The summary row's height, published as `--dg-summary-height`, which two
1360
+ * things read: the pinned bottom block sticks above the summary row rather
1361
+ * than underneath it, and the summary row's own `top` inset holds it on the
1362
+ * bottom edge of a body too short to scroll. A layout effect, so the first
1363
+ * paint already has the measurement - `top` would otherwise resolve to 100%
1364
+ * for a frame and take an overflowing grid's summary row out of view. Down
1365
+ * here rather than beside the other measurements because `hasSummaryRow` is
1366
+ * only known this far into the render.
1367
+ */
1368
+ useLayoutEffect(() => {
1369
+ if (!hasSummaryRow) return;
1370
+ const grid = gridElementRef.current;
1371
+ if (grid === null) return;
1372
+ const summaryRow = grid.querySelector<HTMLElement>(
1373
+ '[data-dg-part="summary-row"]',
1374
+ );
1375
+ if (summaryRow === null) return;
1376
+ const measure = () =>
1377
+ grid.style.setProperty(
1378
+ "--dg-summary-height",
1379
+ `${summaryRow.offsetHeight}px`,
1380
+ );
1381
+ measure();
1382
+ const observer = new ResizeObserver(measure);
1383
+ observer.observe(summaryRow);
1384
+ return () => {
1385
+ observer.disconnect();
1386
+ grid.style.removeProperty("--dg-summary-height");
1387
+ };
1388
+ }, [hasSummaryRow]);
1389
+
1390
+ /**
1391
+ * The other end of the same story as `stickyCoverTop`: the pinned bottom
1392
+ * block and the summary row stick to the container's bottom edge, so the
1393
+ * virtualizer takes their height as `scrollPaddingEnd` and stops scrolling a
1394
+ * row to just underneath them. Down here for the same reason as the
1395
+ * measurement above.
1396
+ */
1397
+ useEffect(() => {
1398
+ const grid = gridElementRef.current;
1399
+ if (grid === null) return;
1400
+ const blocks = grid.querySelectorAll<HTMLElement>(
1401
+ '[data-dg-part="pinned-bottom"], [data-dg-part="summary-row"]',
1402
+ );
1403
+ const measure = () => {
1404
+ let total = 0;
1405
+ for (const block of blocks) total += block.offsetHeight;
1406
+ setStickyCoverBottom(total);
1407
+ };
1408
+ measure();
1409
+ const observer = new ResizeObserver(measure);
1410
+ for (const block of blocks) observer.observe(block);
1411
+ return () => observer.disconnect();
1412
+ }, [pinnedBottomRows.length, hasSummaryRow]);
1144
1413
 
1145
1414
  // "row" mode: the row itself is the selection control, with the modifier
1146
1415
  // conventions of any desktop list - see resolveRowSelectionClick.
@@ -1241,6 +1510,23 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1241
1510
  /** The last position the effect acted on, so it can tell a move from a re-render. */
1242
1511
  const appliedCellRef = useRef<TMDataGridCellPosition | null>(null);
1243
1512
 
1513
+ /**
1514
+ * The two focusable elements that bracket the body, and the flag that says
1515
+ * which way focus is passing through one.
1516
+ *
1517
+ * They are what makes the body one tab stop without asking anything of the
1518
+ * cell renderers. Tab inside the body parks the focus on the guard past the
1519
+ * edge being left, and the browser's default Tab then continues from there -
1520
+ * so the next stop is whatever follows the grid, however many tabbable
1521
+ * controls the mounted rows happen to hold. Focus arriving at a guard any
1522
+ * other way came from outside the body, and is forwarded to the cursor cell.
1523
+ * `guardHopRef` is what tells the two apart; the grid raises it just before
1524
+ * it moves the focus itself.
1525
+ */
1526
+ const leadingGuardRef = useRef<HTMLDivElement>(null);
1527
+ const trailingGuardRef = useRef<HTMLDivElement>(null);
1528
+ const guardHopRef = useRef(false);
1529
+
1244
1530
  // O(n) per keystroke and per render while a cell is focused. Fine at any row
1245
1531
  // count a grid actually holds, and the alternative - an id→index map rebuilt
1246
1532
  // whenever sorting, filtering or grouping changes - costs more than it saves.
@@ -1257,13 +1543,14 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1257
1543
  virtualItems.some((item) => item.index === focusedRowIndex);
1258
1544
 
1259
1545
  /**
1260
- * The one cell in the body that Tab can reach.
1546
+ * The cell the tab guards hand the focus to when Tab enters the body.
1261
1547
  *
1262
1548
  * The focused cell when it is on screen. When it is not - the grid has never
1263
1549
  * been entered, or the user scrolled the focus away with the wheel - the
1264
- * first mounted cell stands in, so the body always has exactly one tab stop.
1265
- * Without the fallback, scrolling the focused row out of the DOM would take
1266
- * the grid out of the tab order entirely.
1550
+ * first mounted cell stands in, so entering the body always lands somewhere.
1551
+ * No body cell is itself in the tab order: the guards are the body's only
1552
+ * tab stops, which is what keeps a cell from turning up between two of a
1553
+ * row's controls when Tab walks them.
1267
1554
  */
1268
1555
  const firstMountedRow = rows[virtualItems[0]?.index ?? -1];
1269
1556
  const tabStopCell: TMDataGridCellPosition | null = !cellSelection
@@ -1274,6 +1561,35 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1274
1561
  ? { rowId: firstMountedRow.id, columnId: orderedColumns[0].id }
1275
1562
  : null;
1276
1563
 
1564
+ /**
1565
+ * Focus landing on either guard.
1566
+ *
1567
+ * A raised hop flag means the grid put it there on its way out, so the
1568
+ * browser's default Tab is about to carry on from the guard - nothing to do
1569
+ * but lower the flag. Anything else is Tab off the last header control onto
1570
+ * the leading guard, or Shift+Tab from below the grid onto the trailing one:
1571
+ * both mean "enter the body", and the body is entered at the cursor cell.
1572
+ *
1573
+ * The state is moved as well as the DOM focus, because the cell may not be
1574
+ * the one the ring is on; the direct focus is so it happens in this event
1575
+ * rather than a frame later.
1576
+ *
1577
+ * With no rows there is no cell to hand it to. The guard keeps the focus and
1578
+ * the next Tab leaves the grid the way it came in.
1579
+ */
1580
+ const handleGuardFocus = () => {
1581
+ if (guardHopRef.current) {
1582
+ guardHopRef.current = false;
1583
+ return;
1584
+ }
1585
+ if (tabStopCell === null) return;
1586
+ wantsCellFocusRef.current = true;
1587
+ ui.actions.setFocusedCell(tabStopCell);
1588
+ const container = scrollContainerRef.current;
1589
+ if (container === null) return;
1590
+ findCellElement(container, tabStopCell)?.focus({ preventScroll: true });
1591
+ };
1592
+
1277
1593
  const isCellAt = (
1278
1594
  position: TMDataGridCellPosition | null,
1279
1595
  rowId: string,
@@ -1340,24 +1656,26 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1340
1656
  ? 0
1341
1657
  : orderedColumns
1342
1658
  .slice(selectionBounds.left, selectionBounds.right + 1)
1343
- .filter((column) => !isControlColumn(column.id)).length;
1344
-
1345
- const exportOptions = { ...DEFAULT_CELL_EXPORT_OPTIONS, ...cellExport };
1659
+ .filter(
1660
+ (column) =>
1661
+ !isGeneratedColumn(column.id) &&
1662
+ column.columnDef.meta?.enableExport !== false,
1663
+ ).length;
1664
+
1665
+ const exportOptions = resolveExportOptions(
1666
+ gridExportOptions,
1667
+ cellExport && fromCellExportOptions(cellExport),
1668
+ );
1346
1669
  // Sticky across menus, the way a checkbox in a dialog is: a user who exports
1347
1670
  // with headers once almost always wants them the next time too.
1348
1671
  const [exportIncludesHeaders, setExportIncludesHeaders] = useState(
1349
1672
  exportOptions.includeHeaders,
1350
1673
  );
1351
- const buildSelectionMatrix = (includeHeaders: boolean) =>
1674
+ // The rectangle's values, over the displayed rows the bounds index into.
1675
+ const buildSelectionData = () =>
1352
1676
  selectionBounds === null
1353
- ? []
1354
- : buildCellMatrix({
1355
- rows,
1356
- columns: orderedColumns,
1357
- bounds: selectionBounds,
1358
- includeHeaders,
1359
- decimalComma: exportOptions.decimalComma,
1360
- });
1677
+ ? null
1678
+ : buildExportData({ table, rows, bounds: selectionBounds });
1361
1679
 
1362
1680
  /**
1363
1681
  * Ctrl+C. Values only, no header row - Excel's own copy does not include one
@@ -1366,9 +1684,13 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1366
1684
  * because a file is a document and a clipboard is a fragment.
1367
1685
  */
1368
1686
  const copySelection = async () => {
1369
- const matrix = buildSelectionMatrix(false);
1370
- if (matrix.length === 0) return;
1371
- await writeClipboardText(toClipboardText(matrix));
1687
+ const data = buildSelectionData();
1688
+ if (data === null || data.columnIds.length === 0) return;
1689
+ await writeClipboardText(
1690
+ toClipboardText(data, {
1691
+ decimalComma: exportOptions.format.decimalComma,
1692
+ }),
1693
+ );
1372
1694
  };
1373
1695
 
1374
1696
  /**
@@ -1382,12 +1704,15 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1382
1704
 
1383
1705
  // The drag ends wherever the button comes up, which is routinely outside the
1384
1706
  // grid - over the scrollbar, or past the window edge. Only the window hears
1385
- // that, so only the window can end it.
1707
+ // that, so only the window can end it. The grid's own window, not the
1708
+ // global one: rendered through a portal into a window opened with
1709
+ // `window.open`, the global is the opener and never hears the release.
1386
1710
  useEffect(() => {
1387
1711
  if (!isDraggingRange) return;
1388
1712
  const stop = () => setIsDraggingRange(false);
1389
- window.addEventListener("mouseup", stop);
1390
- return () => window.removeEventListener("mouseup", stop);
1713
+ const view = gridElementRef.current?.ownerDocument.defaultView ?? window;
1714
+ view.addEventListener("mouseup", stop);
1715
+ return () => view.removeEventListener("mouseup", stop);
1391
1716
  }, [isDraggingRange]);
1392
1717
 
1393
1718
  const startRangeDrag = (
@@ -1401,12 +1726,13 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1401
1726
  if (!extend) setIsDraggingRange(true);
1402
1727
  };
1403
1728
 
1404
- const exportSelectionCsv = (includeHeaders: boolean) => {
1405
- const matrix = buildSelectionMatrix(includeHeaders);
1406
- if (matrix.length === 0) return;
1407
- downloadTextFile({
1408
- fileName: `${exportOptions.fileName}.csv`,
1409
- text: toExcelCsv(matrix, { separator: exportOptions.separator }),
1729
+ /** The export item: the rectangle, in the grid's export format. */
1730
+ const exportSelection = () => {
1731
+ const data = buildSelectionData();
1732
+ if (data === null || data.columnIds.length === 0) return;
1733
+ void writeExportFile(data, {
1734
+ ...exportOptions,
1735
+ includeHeaders: exportIncludesHeaders,
1410
1736
  });
1411
1737
  };
1412
1738
 
@@ -1456,9 +1782,9 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1456
1782
  const row = rows.find((candidate) => candidate.id === rowId);
1457
1783
  if (row === undefined) return null;
1458
1784
  const cells = [
1459
- ...row.getLeftVisibleCells(),
1785
+ ...row.getStartVisibleCells(),
1460
1786
  ...row.getCenterVisibleCells(),
1461
- ...row.getRightVisibleCells(),
1787
+ ...row.getEndVisibleCells(),
1462
1788
  ];
1463
1789
  return (
1464
1790
  cells.find((cell) => edit.canEditCell(row, cell.column))?.column.id ??
@@ -1531,7 +1857,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1531
1857
  close: TMDataGridCellEditorClose,
1532
1858
  ) => {
1533
1859
  // Where the keyboard goes: Enter moves down, Tab to the next editable
1534
- // cell (the deferring draft variants move the same way, draft in tow) -
1860
+ // cell (cellConfirm's deferring variants move the same way, draft kept) -
1535
1861
  // everything else, and a saved row, goes back to where the edit was.
1536
1862
  const move =
1537
1863
  close.via === "enter"
@@ -1623,14 +1949,109 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1623
1949
  const handleGridKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
1624
1950
  const target = event.target as HTMLElement;
1625
1951
 
1626
- // Focus is on something inside a cell - a checkbox, a link, an editor. Its
1627
- // keys are its own; the one the grid still answers is the way back out.
1952
+ // The body cell the key was pressed in, whether the focus is on the cell
1953
+ // itself or on a control inside it. `null` for a header control or a tab
1954
+ // guard, neither of which is part of cell navigation.
1955
+ const keyCellElement =
1956
+ target.dataset.cell === "true"
1957
+ ? target
1958
+ : target.closest<HTMLElement>('[data-cell="true"]');
1959
+ if (keyCellElement === null) return;
1960
+
1961
+ if (event.key === "Tab") {
1962
+ // Inside a row's controls the row is a form, and Tab walks it the way
1963
+ // the browser walks any form: through the row's open editors, the
1964
+ // buttons in its cells and, on an open row, the lane's save and cancel.
1965
+ // That part is left to the browser. What the grid decides is the two
1966
+ // ends: past the row's last control the cursor moves to the next row's
1967
+ // first cell, and before its first control to the previous row's last -
1968
+ // the cell is put on the cursor and left alone, whatever it holds. A cell
1969
+ // editor never gets here, it claims Tab itself to commit and move on.
1970
+ if (target.dataset.cell !== "true") {
1971
+ const rowElement = keyCellElement.closest<HTMLElement>(
1972
+ '[data-dg-part="row"]',
1973
+ );
1974
+ const tabbables =
1975
+ rowElement === null
1976
+ ? []
1977
+ : Array.from(
1978
+ rowElement.querySelectorAll<HTMLElement>(FOCUSABLE_IN_CELL),
1979
+ ).filter((element) => element.tabIndex >= 0);
1980
+ const index = tabbables.indexOf(target);
1981
+ // A control the walk cannot place is left to the browser rather than
1982
+ // guessed about.
1983
+ if (index < 0) return;
1984
+ // The step itself is taken here rather than left to the browser: a
1985
+ // select editor keeps its option list in the row, and the browser's
1986
+ // Tab lands on that scrollable list, which the editor then closes
1987
+ // under the focus. Walking the controls, and only the controls, is
1988
+ // what the browser would do if the list were not there.
1989
+ const nextControl = tabbables[index + (event.shiftKey ? -1 : 1)];
1990
+ if (nextControl !== undefined) {
1991
+ event.preventDefault();
1992
+ nextControl.focus();
1993
+ // What Tab does on a text field, and what the caret otherwise loses.
1994
+ if (isInputElement(nextControl)) nextControl.select();
1995
+ return;
1996
+ }
1997
+ const rowIndex = rows.findIndex(
1998
+ (row) => row.id === keyCellElement.dataset.rowId,
1999
+ );
2000
+ const nextRowIndex = rowIndex + (event.shiftKey ? -1 : 1);
2001
+ const nextRow = rowIndex < 0 ? undefined : rows[nextRowIndex];
2002
+ const nextColumn = event.shiftKey
2003
+ ? orderedColumns[orderedColumns.length - 1]
2004
+ : orderedColumns[0];
2005
+ if (nextRow !== undefined && nextColumn !== undefined) {
2006
+ event.preventDefault();
2007
+ moveFocusedCell({
2008
+ rowIndex: nextRowIndex,
2009
+ columnIndex: event.shiftKey ? orderedColumns.length - 1 : 0,
2010
+ });
2011
+ // The focus-following effect stands down while the focus sits in an
2012
+ // editor, which is exactly where it sits now: move it by hand. The
2013
+ // adjacent row is mounted whenever there is any overscan; without
2014
+ // one, letting go of the editor is what lets the effect take over.
2015
+ const container = scrollContainerRef.current;
2016
+ const nextCell =
2017
+ container === null
2018
+ ? null
2019
+ : findCellElement(container, {
2020
+ rowId: nextRow.id,
2021
+ columnId: nextColumn.id,
2022
+ });
2023
+ if (nextCell !== null) nextCell.focus({ preventScroll: true });
2024
+ else target.blur();
2025
+ return;
2026
+ }
2027
+ // No row past this one: the body is left, below.
2028
+ }
2029
+
2030
+ // Tab leaves the body in one hop, from a cell or from the last control
2031
+ // of the last row. Not prevented: the focus is moved onto the guard past
2032
+ // the edge being left and the browser's own Tab then runs from there,
2033
+ // which is what puts it on the next thing after the grid - or, going
2034
+ // back, on the last header control, since the leading guard sits below
2035
+ // them.
2036
+ const guard = event.shiftKey
2037
+ ? leadingGuardRef.current
2038
+ : trailingGuardRef.current;
2039
+ if (guard === null) return;
2040
+ guardHopRef.current = true;
2041
+ guard.focus({ preventScroll: true });
2042
+ return;
2043
+ }
2044
+
2045
+ // An open editor owns the rest of its keys; the cell editor stops the
2046
+ // ones it acts on before they get here, a row-shaped one lets them pass.
2047
+ if (target.closest('[data-dg-part="editor"]') !== null) return;
2048
+
2049
+ // Focus is on something inside a cell - a checkbox, a link. Its keys are
2050
+ // its own; the one the grid still answers is the way back out.
1628
2051
  if (target.dataset.cell !== "true") {
1629
2052
  if (event.key !== "Escape") return;
1630
- const cellElement = target.closest<HTMLElement>('[data-cell="true"]');
1631
- if (cellElement === null) return;
1632
2053
  event.preventDefault();
1633
- cellElement.focus();
2054
+ keyCellElement.focus();
1634
2055
  return;
1635
2056
  }
1636
2057
 
@@ -1725,9 +2146,9 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1725
2146
  // Space selects the row, from any of its cells.
1726
2147
  //
1727
2148
  // Not routed through `handleRowActivate`, which is the *click* path and only
1728
- // selects in `"row"` mode: with the cell cursor on, the checkbox is no
1729
- // longer a tab stop (see useCellControlTabIndex), so this is the keyboard's
1730
- // way to a selection under every mode that has one - checkbox included.
2149
+ // selects in `"row"` mode: the checkbox is not in the page's tab order, so
2150
+ // this is the keyboard's way to a selection under every mode that has one -
2151
+ // checkbox included.
1731
2152
  if (event.key === " ") {
1732
2153
  event.preventDefault();
1733
2154
  const row = rows[rowIndex];
@@ -1785,12 +2206,27 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1785
2206
  return;
1786
2207
  }
1787
2208
 
2209
+ // The grid's own document, not the global one: in a window opened with
2210
+ // `window.open` the global document is the opener's, and its focus is
2211
+ // never in this grid.
2212
+ const activeElement = container.ownerDocument.activeElement;
2213
+
1788
2214
  // Never take the focus from elsewhere on the page. A consumer moving the
1789
2215
  // cell while the user is typing in a form somewhere means "move the ring",
1790
2216
  // not "move the caret" - the scroll above already did the visible part.
2217
+ if (!wantsCellFocusRef.current && !container.contains(activeElement)) {
2218
+ return;
2219
+ }
2220
+ // Mid-hop: Tab parked the focus on a guard and the browser is about to
2221
+ // carry it out of the grid. This effect can run in between - a discrete
2222
+ // event flushes its renders before the default action - and pulling the
2223
+ // focus back onto the cell here would send the browser's Tab off from
2224
+ // the cell instead. A guard the user *arrived* on has already asked for
2225
+ // the cell, and that intent is honoured.
1791
2226
  if (
1792
2227
  !wantsCellFocusRef.current &&
1793
- !container.contains(document.activeElement)
2228
+ activeElement !== null &&
2229
+ activeElement.closest('[data-dg-part="tab-guard"]') !== null
1794
2230
  ) {
1795
2231
  return;
1796
2232
  }
@@ -1801,7 +2237,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1801
2237
  wantsCellFocusRef.current = false;
1802
2238
  // Focus is already inside - on the control Enter stepped into. The cell is
1803
2239
  // where the ring belongs, so there is nothing to move.
1804
- if (cellElement.contains(document.activeElement)) return;
2240
+ if (cellElement.contains(activeElement)) return;
1805
2241
  // An open editor outranks the ring, wherever it is. Row mode opens a whole
1806
2242
  // row from one gesture, so the caret legitimately sits in a cell other
1807
2243
  // than the one that gesture landed the ring on - the lane's pencil is the
@@ -1809,8 +2245,8 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1809
2245
  // the caret back onto that cell would leave the row open with the caret in
1810
2246
  // none of its editors, and the user with nothing to type into.
1811
2247
  if (
1812
- document.activeElement instanceof Element &&
1813
- document.activeElement.closest('[data-dg-part="editor"]') !== null
2248
+ activeElement !== null &&
2249
+ activeElement.closest('[data-dg-part="editor"]') !== null
1814
2250
  ) {
1815
2251
  return;
1816
2252
  }
@@ -1909,9 +2345,9 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1909
2345
  </Menu.Item>
1910
2346
  <Menu.Item
1911
2347
  disabled={selectedDataColumnCount === 0}
1912
- onClick={() => exportSelectionCsv(exportIncludesHeaders)}
2348
+ onClick={exportSelection}
1913
2349
  >
1914
- {labels.exportCsv}
2350
+ {labels.exportCells}
1915
2351
  </Menu.Item>
1916
2352
  {/* Stays open on click: it is the setting the item above reads, and
1917
2353
  reopening the menu to change your mind about headers is a worse
@@ -2032,7 +2468,11 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2032
2468
  : undefined;
2033
2469
 
2034
2470
  return (
2035
- <div className={classes.tableWrapper}>
2471
+ <div
2472
+ ref={tableWrapperRef}
2473
+ className={classes.tableWrapper}
2474
+ data-dg-filter-sidebar={filters.surface === "sidebar" || undefined}
2475
+ >
2036
2476
  <div
2037
2477
  ref={scrollContainerRef}
2038
2478
  className={classes.scrollContainer}
@@ -2070,7 +2510,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2070
2510
  aria-rowcount={
2071
2511
  rows.length +
2072
2512
  pinnedRowCount +
2073
- headerGroups.length +
2513
+ headerRowCount +
2074
2514
  (hasSummaryRow ? 1 : 0)
2075
2515
  }
2076
2516
  aria-colcount={orderedColumns.length}
@@ -2090,11 +2530,16 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2090
2530
  data-dg-header-row
2091
2531
  // The scrolled-under shadow belongs to the boundary between
2092
2532
  // header and body - the last header row, not every stacked
2093
- // group row above it. See the stylesheet.
2094
- data-dg-header-last={
2095
- groupIndex === headerGroups.length - 1 || undefined
2096
- }
2097
- className={classes.headerRow}
2533
+ // group row above it, and not this one at all when the filter
2534
+ // row is below it. See sticky.module.css.
2535
+ className={[
2536
+ classes.headerRow,
2537
+ !hasHeaderFilterRow && groupIndex === headerGroups.length - 1
2538
+ ? sticky.headerBoundary
2539
+ : "",
2540
+ ]
2541
+ .filter(Boolean)
2542
+ .join(" ")}
2098
2543
  >
2099
2544
  {headerGroup.headers.map((header) => (
2100
2545
  <TMDataGridHeaderCell
@@ -2106,8 +2551,34 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2106
2551
  ))}
2107
2552
  </div>
2108
2553
  ))}
2554
+
2555
+ {hasHeaderFilterRow && (
2556
+ <TMDataGridHeaderFilterRow
2557
+ leafHeaders={leafHeaders}
2558
+ layoutFor={layoutFor}
2559
+ ariaRowIndex={headerRowCount}
2560
+ />
2561
+ )}
2109
2562
  </div>
2110
2563
 
2564
+ {/* The leading tab guard, below the header controls and above the
2565
+ first body row - see `handleGuardFocus`. Not `aria-hidden`: it
2566
+ is focusable, and a focusable node hidden from the tree is what
2567
+ screen readers have no way to describe.
2568
+ Out of the tab order while there is no cell to hand focus to,
2569
+ so an empty body is not two dead stops. */}
2570
+ {cellSelection && (
2571
+ <div
2572
+ ref={leadingGuardRef}
2573
+ tabIndex={tabStopCell === null ? -1 : 0}
2574
+ role="presentation"
2575
+ data-dg-part="tab-guard"
2576
+ data-guard="leading"
2577
+ className={classes.tabGuard}
2578
+ onFocus={handleGuardFocus}
2579
+ />
2580
+ )}
2581
+
2111
2582
  {/* The entry block - new rows being typed, stuck under the header.
2112
2583
  Present only while `edit.addRow()` has open entries. */}
2113
2584
  {features.editing && newRowCount > 0 && (
@@ -2146,17 +2617,27 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2146
2617
  const isGroupRow = row.getIsGrouped();
2147
2618
  const isInteractive = rowGesturesFor(row);
2148
2619
  const rowCells = [
2149
- ...row.getLeftVisibleCells(),
2620
+ ...row.getStartVisibleCells(),
2150
2621
  ...row.getCenterVisibleCells(),
2151
- ...row.getRightVisibleCells(),
2622
+ ...row.getEndVisibleCells(),
2152
2623
  ];
2153
2624
  // Row mode opens every editable cell of the row at once, and
2154
2625
  // rows accumulate: a second row opening leaves the first one
2155
2626
  // open, so every row holding a form shows its editors. Which of
2156
2627
  // them holds the caret is not decided here - see
2157
2628
  // placeEditCaret, which places it once per gesture.
2629
+ //
2630
+ // A parked row is not one of them. It keeps its form, so it is
2631
+ // still "open", but it has been decided: it renders its draft
2632
+ // through the column's own renderer until `begin` reopens it.
2633
+ // A committed entry row in the body has a form too - every
2634
+ // entry row does - but it is a value row until reopened, and
2635
+ // reopening takes it back to the entry block.
2158
2636
  const rowEditing =
2159
- features.editMode === "row" && editOpenRowIds.includes(row.id);
2637
+ features.editMode === "row" &&
2638
+ editOpenRowIdSet.has(row.id) &&
2639
+ !editDraftRowIdSet.has(row.id) &&
2640
+ !newRowIdSet.has(row.id);
2160
2641
  // Cell selection takes the body's tab stop off the row and puts
2161
2642
  // it on a cell - two stops per row would make Tab a way of
2162
2643
  // walking the grid, which is what the arrow keys are for. Space
@@ -2229,10 +2710,16 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2229
2710
  }
2230
2711
  // Carrying a dirty draft - the row-level face of the cells'
2231
2712
  // own data-dirty markers, for row-scoped styling.
2232
- data-dirty={
2233
- editDirtyRowIds.length > 0 &&
2234
- editDirtyRowIds.includes(row.id)
2713
+ data-dirty={editDirtyRowIdSet.has(row.id)}
2714
+ // Committed into the draft store, waiting for Save. A
2715
+ // committed entry row is one too - it is in the body
2716
+ // because it is committed - and carries `data-new` besides.
2717
+ data-draft={
2718
+ editDraftRowIdSet.has(row.id) || newRowIdSet.has(row.id)
2235
2719
  }
2720
+ // An entered row not yet in `data`, committed into the draft
2721
+ // store and sorted, filtered and grouped with the rest.
2722
+ data-new={newRowIdSet.has(row.id)}
2236
2723
  // The menu is anchored to the rowgroup, so Mantine's own
2237
2724
  // `data-expanded` lands there rather than on a row. This is
2238
2725
  // what says which row the open menu is about.
@@ -2385,13 +2872,10 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2385
2872
  row.id,
2386
2873
  cell.column.id,
2387
2874
  ),
2388
- tabIndex: isCellAt(
2389
- tabStopCell,
2390
- row.id,
2391
- cell.column.id,
2392
- )
2393
- ? 0
2394
- : -1,
2875
+ // Focusable by click and by the grid, never by
2876
+ // Tab - the guards are the way in. See
2877
+ // `tabStopCell`.
2878
+ tabIndex: -1,
2395
2879
  // "Selected" means "in the block Ctrl+C would
2396
2880
  // take" - which under `"single"` is the one cell
2397
2881
  // the user is standing on.
@@ -2417,6 +2901,24 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2417
2901
  // Right-click is the menu's; the middle button is
2418
2902
  // the browser's.
2419
2903
  if (event.button !== 0) return;
2904
+ // Pressing a control in the cell - a checkbox, a
2905
+ // chevron, a button a renderer put there - is not
2906
+ // a selection gesture, and a block the user built
2907
+ // should not pay for it. The focus still follows
2908
+ // the press, through the bubbling `onFocus`, so
2909
+ // the cursor ends up on the row acted on; only
2910
+ // the range survives.
2911
+ const pressed = event.target as HTMLElement;
2912
+ const control =
2913
+ pressed === event.currentTarget
2914
+ ? null
2915
+ : pressed.closest(FOCUSABLE_IN_CELL);
2916
+ if (
2917
+ control !== null &&
2918
+ event.currentTarget.contains(control)
2919
+ ) {
2920
+ return;
2921
+ }
2420
2922
  // Shift+click would otherwise smear the browser's
2421
2923
  // own text selection across everything between
2422
2924
  // the two cells.
@@ -2504,7 +3006,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2504
3006
  renderBodyRow(
2505
3007
  row,
2506
3008
  -1,
2507
- headerGroups.length + index + 1,
3009
+ headerRowCount + index + 1,
2508
3010
  "top",
2509
3011
  ),
2510
3012
  )}
@@ -2525,7 +3027,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2525
3027
  row,
2526
3028
  virtualItem.index,
2527
3029
  virtualItem.index +
2528
- headerGroups.length +
3030
+ headerRowCount +
2529
3031
  pinnedTopRows.length +
2530
3032
  1,
2531
3033
  );
@@ -2548,7 +3050,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2548
3050
  renderBodyRow(
2549
3051
  row,
2550
3052
  -1,
2551
- headerGroups.length +
3053
+ headerRowCount +
2552
3054
  pinnedTopRows.length +
2553
3055
  rows.length +
2554
3056
  index +
@@ -2604,7 +3106,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2604
3106
  <div
2605
3107
  role="row"
2606
3108
  aria-rowindex={
2607
- rows.length + pinnedRowCount + headerGroups.length + 1
3109
+ rows.length + pinnedRowCount + headerRowCount + 1
2608
3110
  }
2609
3111
  // Also what the height measurement above looks for. One
2610
3112
  // attribute serves both now that the part name *is* the
@@ -2657,10 +3159,30 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2657
3159
  })}
2658
3160
  </div>
2659
3161
  )}
3162
+
3163
+ {/* The trailing tab guard, last child of the grid so that Shift+Tab
3164
+ from below the grid meets it before any cell. */}
3165
+ {cellSelection && (
3166
+ <div
3167
+ ref={trailingGuardRef}
3168
+ tabIndex={tabStopCell === null ? -1 : 0}
3169
+ role="presentation"
3170
+ data-dg-part="tab-guard"
3171
+ data-guard="trailing"
3172
+ className={classes.tabGuard}
3173
+ onFocus={handleGuardFocus}
3174
+ />
3175
+ )}
2660
3176
  </div>
2661
3177
  </div>
2662
3178
 
2663
- <TMDataGridFilterPanel />
3179
+ {/* The automatic filter surfaces. The popup floats over the first body
3180
+ rows, anchored to the wrapper; the sidebar is a second column of it,
3181
+ which is why the wrapper turns into a flex row for one. Under
3182
+ `filters.surface: "none"` neither renders and the consumer's own
3183
+ `TMDataGrid.FilterPanel` is the only panel on the page. */}
3184
+ {filters.surface === "popup" && <TMDataGridFilterPopup />}
3185
+ {filters.surface === "sidebar" && <TMDataGridFilterSidebar />}
2664
3186
  </div>
2665
3187
  );
2666
3188
  }