@jielga/tmdatagrid 2.0.0-beta.9 → 2.0.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 (154) hide show
  1. package/README.md +5 -212
  2. package/dist/index.d.ts +1281 -768
  3. package/dist/index.js +4607 -3250
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles.css +1 -1
  6. package/docs/adding-rows.md +132 -0
  7. package/docs/anatomy.md +119 -0
  8. package/docs/card-view.md +108 -0
  9. package/docs/cell-selection.md +194 -0
  10. package/docs/column-layout.md +182 -0
  11. package/docs/column-menu.md +66 -0
  12. package/docs/columns.md +268 -0
  13. package/docs/components.md +311 -0
  14. package/docs/draft-store.md +242 -0
  15. package/docs/editing.md +303 -0
  16. package/docs/editors.md +250 -0
  17. package/docs/export.md +319 -0
  18. package/docs/filtering.md +362 -0
  19. package/docs/getting-started.md +123 -0
  20. package/docs/grouping.md +165 -0
  21. package/docs/loading-and-empty.md +92 -0
  22. package/docs/localization.md +79 -0
  23. package/docs/menu.md +143 -0
  24. package/docs/migrating-to-2.md +163 -0
  25. package/docs/pagination.md +144 -0
  26. package/docs/persistence.md +114 -0
  27. package/docs/portfolio-rebalancer.md +94 -0
  28. package/docs/query-builder.md +179 -0
  29. package/docs/quick-search.md +84 -0
  30. package/docs/row-details.md +115 -0
  31. package/docs/row-interaction.md +149 -0
  32. package/docs/row-pinning.md +132 -0
  33. package/docs/row-selection.md +136 -0
  34. package/docs/row-styling.md +133 -0
  35. package/docs/scrolling.md +112 -0
  36. package/docs/server-query.md +246 -0
  37. package/docs/server-side.md +206 -0
  38. package/docs/sorting.md +101 -0
  39. package/docs/styling.md +126 -0
  40. package/docs/summary-row.md +76 -0
  41. package/docs/testing.md +744 -0
  42. package/docs/toolbar.md +161 -0
  43. package/docs/use-tm-data-grid.md +361 -0
  44. package/package.json +22 -46
  45. package/skills/appearance/SKILL.md +72 -19
  46. package/skills/cell-selection/SKILL.md +46 -47
  47. package/skills/columns/SKILL.md +90 -34
  48. package/skills/data/SKILL.md +86 -16
  49. package/skills/editing/SKILL.md +67 -40
  50. package/skills/editing/references/common-mistakes.md +77 -69
  51. package/skills/editing/references/editing-api.md +22 -19
  52. package/skills/editing/references/editors-and-validation.md +24 -17
  53. package/skills/filtering/SKILL.md +148 -40
  54. package/skills/getting-started/SKILL.md +17 -15
  55. package/skills/grouping/SKILL.md +31 -16
  56. package/skills/options/SKILL.md +7 -7
  57. package/skills/rows/SKILL.md +22 -18
  58. package/skills/server-side/SKILL.md +170 -17
  59. package/skills/testing/SKILL.md +150 -32
  60. package/skills/testing-components/SKILL.md +230 -0
  61. package/skills/testing-editing/SKILL.md +240 -0
  62. package/src/{tmdatagrid/components → components}/TMDataGrid.tsx +38 -21
  63. package/src/{tmdatagrid/components → components}/TMDataGridCellEditor.tsx +54 -8
  64. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.module.css +5 -1
  65. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.tsx +47 -55
  66. package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.tsx +5 -51
  67. package/src/{tmdatagrid/components → components}/TMDataGridDraftActions.tsx +41 -24
  68. package/src/{tmdatagrid/components → components}/TMDataGridEditColumn.tsx +12 -59
  69. package/src/{tmdatagrid/components → components}/TMDataGridEntryRows.tsx +164 -92
  70. package/src/components/TMDataGridExportPicker.module.css +77 -0
  71. package/src/components/TMDataGridExportPicker.tsx +234 -0
  72. package/src/components/TMDataGridFilterPanel.module.css +54 -0
  73. package/src/components/TMDataGridFilterPanel.tsx +348 -0
  74. package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.tsx +15 -8
  75. package/src/components/TMDataGridFilterSurface.module.css +54 -0
  76. package/src/components/TMDataGridFilterSurface.tsx +167 -0
  77. package/src/{tmdatagrid/components → components}/TMDataGridFooter.tsx +53 -90
  78. package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.tsx +5 -69
  79. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.module.css +12 -2
  80. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.tsx +58 -21
  81. package/src/components/TMDataGridHeaderFilterRow.module.css +51 -0
  82. package/src/components/TMDataGridHeaderFilterRow.tsx +301 -0
  83. package/src/components/TMDataGridMenu.tsx +357 -0
  84. package/src/{tmdatagrid/components → components}/TMDataGridSelectColumn.tsx +11 -48
  85. package/src/{tmdatagrid/components → components}/TMDataGridTable.module.css +69 -56
  86. package/src/{tmdatagrid/components → components}/TMDataGridTable.tsx +240 -138
  87. package/src/{tmdatagrid/components → components}/TMDataGridToolbar.module.css +5 -0
  88. package/src/components/TMDataGridToolbar.tsx +181 -0
  89. package/src/{tmdatagrid/components → components}/editors/TMDataGridBooleanEditor.tsx +3 -3
  90. package/src/{tmdatagrid/components → components}/editors/TMDataGridDateEditor.tsx +3 -3
  91. package/src/{tmdatagrid/components → components}/editors/TMDataGridMultiSelectEditor.tsx +3 -3
  92. package/src/{tmdatagrid/components → components}/editors/TMDataGridNumberEditor.tsx +3 -3
  93. package/src/{tmdatagrid/components → components}/editors/TMDataGridSelectEditor.tsx +3 -3
  94. package/src/{tmdatagrid/components → components}/editors/TMDataGridStringEditor.tsx +3 -3
  95. package/src/{tmdatagrid/components → components}/editors/editorShared.ts +16 -2
  96. package/src/{tmdatagrid/components → components}/filters/DgAutocompleteFilter.tsx +5 -4
  97. package/src/{tmdatagrid/components → components}/filters/DgDateRangeFilter.tsx +22 -5
  98. package/src/{tmdatagrid/components → components}/filters/DgRangeSliderFilter.tsx +5 -1
  99. package/src/{tmdatagrid/components → components}/filters/DgTriStateFilter.tsx +5 -1
  100. package/src/{tmdatagrid/components → components}/filters/TMDataGridFilterValueInput.tsx +44 -28
  101. package/src/components/filters/controlLayout.ts +32 -0
  102. package/src/components/filters/filterControlFor.ts +65 -0
  103. package/src/components/generatedColumns.tsx +187 -0
  104. package/src/{tmdatagrid/components → components}/icons.ts +1 -0
  105. package/src/{tmdatagrid/components → components}/sticky.module.css +44 -0
  106. package/src/components/useHideableColumns.ts +52 -0
  107. package/src/{tmdatagrid/core → core}/autosize.ts +5 -2
  108. package/src/{tmdatagrid/core → core}/columnOptions.ts +60 -8
  109. package/src/{tmdatagrid/core → core}/columnOrdering.ts +33 -14
  110. package/src/{tmdatagrid/core → core}/columnUtils.ts +44 -5
  111. package/src/core/controlledStateSync.ts +108 -0
  112. package/src/core/deletedRows.ts +34 -0
  113. package/src/core/dom.ts +74 -0
  114. package/src/{tmdatagrid/core → core}/editEngine.ts +1107 -460
  115. package/src/core/export.ts +704 -0
  116. package/src/{tmdatagrid/core → core}/filterControls.ts +38 -1
  117. package/src/{tmdatagrid/core → core}/filterOperators.ts +64 -1
  118. package/src/core/filterSurface.ts +99 -0
  119. package/src/{tmdatagrid/core → core}/grouping.ts +21 -0
  120. package/src/{tmdatagrid/core → core}/labels.ts +51 -6
  121. package/src/{tmdatagrid/core → core}/labelsSv.ts +27 -6
  122. package/src/core/pageReset.ts +120 -0
  123. package/src/core/pagination.ts +81 -0
  124. package/src/{tmdatagrid/core → core}/persistence.ts +22 -5
  125. package/src/{tmdatagrid/core → core}/summary.ts +20 -4
  126. package/src/{tmdatagrid/index.ts → index.ts} +69 -35
  127. package/src/{tmdatagrid/useTMDataGrid.tsx → useTMDataGrid.tsx} +428 -109
  128. package/src/useTMDataGridExport.ts +78 -0
  129. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +0 -35
  130. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +0 -354
  131. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +0 -161
  132. package/src/tmdatagrid/core/cellExport.ts +0 -320
  133. /package/src/{tmdatagrid/TMDataGridContext.ts → TMDataGridContext.ts} +0 -0
  134. /package/src/{tmdatagrid/components → components}/TMDataGrid.module.css +0 -0
  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}/capabilities.ts +0 -0
  142. /package/src/{tmdatagrid/core → core}/cellNavigation.ts +0 -0
  143. /package/src/{tmdatagrid/core → core}/cellRange.ts +0 -0
  144. /package/src/{tmdatagrid/core → core}/controlledState.ts +0 -0
  145. /package/src/{tmdatagrid/core → core}/draftCellContext.ts +0 -0
  146. /package/src/{tmdatagrid/core → core}/editorFocus.ts +0 -0
  147. /package/src/{tmdatagrid/core → core}/expanding.ts +0 -0
  148. /package/src/{tmdatagrid/core → core}/matchHighlight.ts +0 -0
  149. /package/src/{tmdatagrid/core → core}/quickSearch.ts +0 -0
  150. /package/src/{tmdatagrid/core → core}/resizePreview.ts +0 -0
  151. /package/src/{tmdatagrid/core → core}/rowPinning.ts +0 -0
  152. /package/src/{tmdatagrid/core → core}/rowSelection.ts +0 -0
  153. /package/src/{tmdatagrid/core → core}/sizes.ts +0 -0
  154. /package/src/{tmdatagrid/core → core}/useSettledTableState.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,12 +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";
38
48
  import {
39
49
  resizePreview,
40
50
  type TMDataGridColumnTrack,
41
51
  } from "../core/resizePreview";
42
52
  import { useSettledTableState } from "../core/useSettledTableState";
53
+ import { isInputElement } from "../core/dom";
43
54
  import { draftCellContext } from "../core/draftCellContext";
44
55
  import {
45
56
  getEditFieldName,
@@ -58,14 +69,12 @@ import {
58
69
  resolveRangeBounds,
59
70
  } from "../core/cellRange";
60
71
  import {
61
- buildCellMatrix,
62
- DEFAULT_CELL_EXPORT_OPTIONS,
63
- downloadTextFile,
72
+ buildExportData,
73
+ resolveExportOptions,
64
74
  toClipboardText,
65
- toExcelCsv,
66
75
  writeClipboardText,
67
- type TMDataGridCellExportOptions,
68
- } from "../core/cellExport";
76
+ writeExportFile,
77
+ } from "../core/export";
69
78
  import { autosizeColumn, hasMountedCells } from "../core/autosize";
70
79
  import {
71
80
  getDisplayedRows,
@@ -321,16 +330,16 @@ function TMDataGridBodyCell({
321
330
  // selectors also require `data-cell`, so they are unaffected.
322
331
  data-row-id={cell.row.id}
323
332
  data-column-id={cell.column.id}
324
- data-focused={nav?.focused}
333
+ data-focused={nav?.focused || undefined}
325
334
  aria-selected={nav?.selected}
326
- data-selected={nav?.selected}
335
+ data-selected={nav?.selected || undefined}
327
336
  // One attribute per side rather than a class per combination: the
328
337
  // stylesheet draws the outline edge by edge, and sixteen classes for the
329
338
  // sixteen corners of a rectangle is not a stylesheet anyone can read.
330
- data-edge-top={nav?.edges?.top}
331
- data-edge-bottom={nav?.edges?.bottom}
332
- data-edge-left={nav?.edges?.left}
333
- data-edge-right={nav?.edges?.right}
339
+ data-edge-top={nav?.edges?.top || undefined}
340
+ data-edge-bottom={nav?.edges?.bottom || undefined}
341
+ data-edge-left={nav?.edges?.left || undefined}
342
+ data-edge-right={nav?.edges?.right || undefined}
334
343
  // Bubbles, so focus landing on a control inside the cell counts as
335
344
  // landing on the cell - the ring follows the user into the checkbox
336
345
  // rather than being left behind on whichever cell they came from.
@@ -345,7 +354,7 @@ function TMDataGridBodyCell({
345
354
  data-align={getColumnAlign(cell.column)}
346
355
  // A control lane is a fixed track, so it cannot take the cell padding the
347
356
  // scale grows for text. See isControlColumn.
348
- data-control-column={isControl}
357
+ data-control-column={isControl || undefined}
349
358
  onContextMenu={onContextMenu}
350
359
  className={[
351
360
  classes.bodyCell,
@@ -669,12 +678,6 @@ export type TMDataGridTableProps<TData extends RowData> = {
669
678
  * `position`, `transitionProps` and the rest. Its open state is the grid's.
670
679
  */
671
680
  rowContextMenuProps?: Omit<MenuProps, "opened" | "onChange" | "children">;
672
- /**
673
- * How Ctrl+C and the export item write values, under
674
- * `cellSelection: "range"`. Defaults to the Nordic Excel conventions - see
675
- * {@link TMDataGridCellExportOptions}.
676
- */
677
- cellExport?: TMDataGridCellExportOptions;
678
681
  /**
679
682
  * Called when the scroll approaches the last row - the infinite-scroll
680
683
  * hook-in. Append the next page to `data` and the virtualizer keeps its
@@ -726,7 +729,6 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
726
729
  renderRowContextMenu,
727
730
  renderColumnMenuItems,
728
731
  rowContextMenuProps,
729
- cellExport,
730
732
  onReachEnd,
731
733
  reachEndThreshold = 10,
732
734
  "aria-label": ariaLabel,
@@ -737,6 +739,8 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
737
739
  ui,
738
740
  edit,
739
741
  features,
742
+ filters,
743
+ exportOptions: gridExportOptions,
740
744
  labels,
741
745
  rowHeight,
742
746
  controlSize,
@@ -783,6 +787,24 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
783
787
  edit.store,
784
788
  (state) => state.committedRowIds,
785
789
  );
790
+ // The same lists by id: the body asks them once per rendered row, and an
791
+ // import runs each of them to thousands.
792
+ const editOpenRowIdSet = useMemo(
793
+ () => new Set(editOpenRowIds),
794
+ [editOpenRowIds],
795
+ );
796
+ const editDirtyRowIdSet = useMemo(
797
+ () => new Set(editDirtyRowIds),
798
+ [editDirtyRowIds],
799
+ );
800
+ const editDraftRowIdSet = useMemo(
801
+ () => new Set(editDraftRowIds),
802
+ [editDraftRowIds],
803
+ );
804
+ const newRowIdSet = useMemo(
805
+ () => new Set(newRows.map((newRow) => newRow.tempId)),
806
+ [newRows],
807
+ );
786
808
 
787
809
  const { loading, noResultsLabel = labels.noResults } =
788
810
  table.options.meta ?? {};
@@ -900,6 +922,22 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
900
922
  * virtualizer needs its height from the first paint.
901
923
  */
902
924
  const gridElementRef = useRef<HTMLDivElement>(null);
925
+ /**
926
+ * Header filters only render where something can be filtered - the row would
927
+ * otherwise be a band of empty cells on a grid with `enableColumnFilters:
928
+ * false`. Derived up here because the header measurement below depends on
929
+ * whether the row is in the DOM.
930
+ */
931
+ const hasHeaderFilterRow =
932
+ filters.inHeader && getGridCapabilities(table, features).canFilterAny;
933
+ /**
934
+ * How many rows deep the header is. In the measurement's dependencies
935
+ * because the rows are found once, by query: a `columns` change that adds or
936
+ * drops a group level changes which elements have to be measured and given
937
+ * their sticky inset, and nothing else in the list moves when it does.
938
+ */
939
+ const headerDepth = table.getCenterHeaderGroups().length;
940
+ const tableWrapperRef = useRef<HTMLDivElement>(null);
903
941
  const [bodyOffsetTop, setBodyOffsetTop] = useState(0);
904
942
  const [stickyCoverTop, setStickyCoverTop] = useState(0);
905
943
  const [stickyCoverBottom, setStickyCoverBottom] = useState(0);
@@ -917,10 +955,28 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
917
955
  );
918
956
  const measure = () => {
919
957
  let header = 0;
920
- for (const headerRow of headerRows) header += headerRow.offsetHeight;
958
+ for (const headerRow of headerRows) {
959
+ // Every header row is sticky, so each one needs the height of the rows
960
+ // above it as its own inset - otherwise a stacked group and the filter
961
+ // row all pin to the top edge and paint over each other. Written on the
962
+ // element because only JS knows where the row before it ended; the
963
+ // stylesheet's `top` is the pre-measurement fallback.
964
+ headerRow.style.top = `${header}px`;
965
+ header += headerRow.offsetHeight;
966
+ }
921
967
  if (newRowCount > 0 || pinnedTopRows.length > 0) {
922
968
  grid.style.setProperty("--dg-header-height", `${header}px`);
923
969
  }
970
+ // The whole header's height, on the wrapper rather than on the grid,
971
+ // because the popup is a sibling of the scroll container and never sees
972
+ // a property set inside it. A second name rather than reusing
973
+ // `--dg-header-height`: the header cells read that one for their own
974
+ // `min-height`, so publishing a stacked total there would feed a
975
+ // two-row header back in as the height of each of its rows.
976
+ tableWrapperRef.current?.style.setProperty(
977
+ "--dg-header-stack-height",
978
+ `${header}px`,
979
+ );
924
980
  let cover = header;
925
981
  for (const block of stickyBlocks) cover += block.offsetHeight;
926
982
  let offset = cover;
@@ -934,7 +990,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
934
990
  observer.observe(element);
935
991
  }
936
992
  return () => observer.disconnect();
937
- }, [newRowCount, pinnedTopRows.length]);
993
+ }, [newRowCount, pinnedTopRows.length, hasHeaderFilterRow, headerDepth]);
938
994
 
939
995
  /**
940
996
  * The entry block's height, published as `--dg-entry-height` so the pinned
@@ -958,32 +1014,6 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
958
1014
  };
959
1015
  }, [newRowCount, pinnedTopRows.length]);
960
1016
 
961
- /**
962
- * The summary row's height, published as `--dg-summary-height` so the
963
- * pinned bottom block sticks above it rather than underneath it.
964
- */
965
- useEffect(() => {
966
- if (pinnedBottomRows.length === 0) return;
967
- const grid = gridElementRef.current;
968
- if (grid === null) return;
969
- const summaryRow = grid.querySelector<HTMLElement>(
970
- '[data-dg-part="summary-row"]',
971
- );
972
- if (summaryRow === null) return;
973
- const measure = () =>
974
- grid.style.setProperty(
975
- "--dg-summary-height",
976
- `${summaryRow.offsetHeight}px`,
977
- );
978
- measure();
979
- const observer = new ResizeObserver(measure);
980
- observer.observe(summaryRow);
981
- return () => {
982
- observer.disconnect();
983
- grid.style.removeProperty("--dg-summary-height");
984
- };
985
- }, [pinnedBottomRows.length]);
986
-
987
1017
  // A persisted pageIndex can outlive the data that produced it; TanStack only
988
1018
  // auto-resets on live filter/sort/data changes, not on restored state. The
989
1019
  // guard makes the dependency-free effect idempotent.
@@ -1122,14 +1152,14 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1122
1152
  onReachEnd();
1123
1153
  });
1124
1154
 
1125
- // The *Visible* variants throughout: `getLeftLeafColumns()` and friends
1155
+ // The *Visible* variants throughout: `getStartLeafColumns()` and friends
1126
1156
  // include hidden columns, while the cells below come from
1127
- // `row.getLeftVisibleCells()`, which does not. Mixing the two would lay down
1157
+ // `row.getStartVisibleCells()`, which does not. Mixing the two would lay down
1128
1158
  // a grid track for a column that renders no cell, shifting every column after
1129
1159
  // it out of its header. The tree column is hidden exactly this way while
1130
1160
  // nothing is grouped.
1131
- const leftLeafColumns = table.getLeftVisibleLeafColumns();
1132
- const rightLeafColumns = table.getRightVisibleLeafColumns();
1161
+ const leftLeafColumns = table.getStartVisibleLeafColumns();
1162
+ const rightLeafColumns = table.getEndVisibleLeafColumns();
1133
1163
  const centerLeafColumns = table.getCenterVisibleLeafColumns();
1134
1164
 
1135
1165
  const orderedColumns = [
@@ -1141,8 +1171,8 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1141
1171
  // Headers and cells must follow the same left → center → right order as the
1142
1172
  // column tracks, otherwise pinning a column would shuffle it out of its lane.
1143
1173
  const centerHeaderGroups = table.getCenterHeaderGroups();
1144
- const leftHeaderGroups = table.getLeftHeaderGroups();
1145
- const rightHeaderGroups = table.getRightHeaderGroups();
1174
+ const leftHeaderGroups = table.getStartHeaderGroups();
1175
+ const rightHeaderGroups = table.getEndHeaderGroups();
1146
1176
  const headerGroups = centerHeaderGroups.map((group, index) => ({
1147
1177
  id: group.id,
1148
1178
  headers: [
@@ -1308,14 +1338,51 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1308
1338
  const hasSummaryRow = orderedColumns.some(
1309
1339
  (column) => column.columnDef.footer !== undefined,
1310
1340
  );
1341
+ /**
1342
+ * Rows above the first body row, which is what every `aria-rowindex` in the
1343
+ * body counts from and what `aria-rowcount` adds up. The header groups plus
1344
+ * the filter row, when there is one.
1345
+ */
1346
+ const headerRowCount = headerGroups.length + (hasHeaderFilterRow ? 1 : 0);
1347
+
1348
+ /**
1349
+ * The summary row's height, published as `--dg-summary-height`, which two
1350
+ * things read: the pinned bottom block sticks above the summary row rather
1351
+ * than underneath it, and the summary row's own `top` inset holds it on the
1352
+ * bottom edge of a body too short to scroll. A layout effect, so the first
1353
+ * paint already has the measurement - `top` would otherwise resolve to 100%
1354
+ * for a frame and take an overflowing grid's summary row out of view. Down
1355
+ * here rather than beside the other measurements because `hasSummaryRow` is
1356
+ * only known this far into the render.
1357
+ */
1358
+ useLayoutEffect(() => {
1359
+ if (!hasSummaryRow) return;
1360
+ const grid = gridElementRef.current;
1361
+ if (grid === null) return;
1362
+ const summaryRow = grid.querySelector<HTMLElement>(
1363
+ '[data-dg-part="summary-row"]',
1364
+ );
1365
+ if (summaryRow === null) return;
1366
+ const measure = () =>
1367
+ grid.style.setProperty(
1368
+ "--dg-summary-height",
1369
+ `${summaryRow.offsetHeight}px`,
1370
+ );
1371
+ measure();
1372
+ const observer = new ResizeObserver(measure);
1373
+ observer.observe(summaryRow);
1374
+ return () => {
1375
+ observer.disconnect();
1376
+ grid.style.removeProperty("--dg-summary-height");
1377
+ };
1378
+ }, [hasSummaryRow]);
1311
1379
 
1312
1380
  /**
1313
1381
  * The other end of the same story as `stickyCoverTop`: the pinned bottom
1314
1382
  * block and the summary row stick to the container's bottom edge, so the
1315
1383
  * virtualizer takes their height as `scrollPaddingEnd` and stops scrolling a
1316
- * row to just underneath them. Down here rather than beside the other
1317
- * measurements because `hasSummaryRow` is only known this far into the
1318
- * render.
1384
+ * row to just underneath them. Down here for the same reason as the
1385
+ * measurement above.
1319
1386
  */
1320
1387
  useEffect(() => {
1321
1388
  const grid = gridElementRef.current;
@@ -1579,24 +1646,23 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1579
1646
  ? 0
1580
1647
  : orderedColumns
1581
1648
  .slice(selectionBounds.left, selectionBounds.right + 1)
1582
- .filter((column) => !isControlColumn(column.id)).length;
1649
+ .filter(
1650
+ (column) =>
1651
+ !isGeneratedColumn(column.id) &&
1652
+ column.columnDef.meta?.enableExport !== false,
1653
+ ).length;
1583
1654
 
1584
- const exportOptions = { ...DEFAULT_CELL_EXPORT_OPTIONS, ...cellExport };
1655
+ const exportOptions = resolveExportOptions(gridExportOptions);
1585
1656
  // Sticky across menus, the way a checkbox in a dialog is: a user who exports
1586
1657
  // with headers once almost always wants them the next time too.
1587
1658
  const [exportIncludesHeaders, setExportIncludesHeaders] = useState(
1588
1659
  exportOptions.includeHeaders,
1589
1660
  );
1590
- const buildSelectionMatrix = (includeHeaders: boolean) =>
1661
+ // The rectangle's values, over the displayed rows the bounds index into.
1662
+ const buildSelectionData = () =>
1591
1663
  selectionBounds === null
1592
- ? []
1593
- : buildCellMatrix({
1594
- rows,
1595
- columns: orderedColumns,
1596
- bounds: selectionBounds,
1597
- includeHeaders,
1598
- decimalComma: exportOptions.decimalComma,
1599
- });
1664
+ ? null
1665
+ : buildExportData({ table, rows, bounds: selectionBounds });
1600
1666
 
1601
1667
  /**
1602
1668
  * Ctrl+C. Values only, no header row - Excel's own copy does not include one
@@ -1605,9 +1671,13 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1605
1671
  * because a file is a document and a clipboard is a fragment.
1606
1672
  */
1607
1673
  const copySelection = async () => {
1608
- const matrix = buildSelectionMatrix(false);
1609
- if (matrix.length === 0) return;
1610
- await writeClipboardText(toClipboardText(matrix));
1674
+ const data = buildSelectionData();
1675
+ if (data === null || data.columnIds.length === 0) return;
1676
+ await writeClipboardText(
1677
+ toClipboardText(data, {
1678
+ decimalComma: exportOptions.format.decimalComma,
1679
+ }),
1680
+ );
1611
1681
  };
1612
1682
 
1613
1683
  /**
@@ -1621,12 +1691,15 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1621
1691
 
1622
1692
  // The drag ends wherever the button comes up, which is routinely outside the
1623
1693
  // grid - over the scrollbar, or past the window edge. Only the window hears
1624
- // that, so only the window can end it.
1694
+ // that, so only the window can end it. The grid's own window, not the
1695
+ // global one: rendered through a portal into a window opened with
1696
+ // `window.open`, the global is the opener and never hears the release.
1625
1697
  useEffect(() => {
1626
1698
  if (!isDraggingRange) return;
1627
1699
  const stop = () => setIsDraggingRange(false);
1628
- window.addEventListener("mouseup", stop);
1629
- return () => window.removeEventListener("mouseup", stop);
1700
+ const view = gridElementRef.current?.ownerDocument.defaultView ?? window;
1701
+ view.addEventListener("mouseup", stop);
1702
+ return () => view.removeEventListener("mouseup", stop);
1630
1703
  }, [isDraggingRange]);
1631
1704
 
1632
1705
  const startRangeDrag = (
@@ -1640,12 +1713,13 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1640
1713
  if (!extend) setIsDraggingRange(true);
1641
1714
  };
1642
1715
 
1643
- const exportSelectionCsv = (includeHeaders: boolean) => {
1644
- const matrix = buildSelectionMatrix(includeHeaders);
1645
- if (matrix.length === 0) return;
1646
- downloadTextFile({
1647
- fileName: `${exportOptions.fileName}.csv`,
1648
- text: toExcelCsv(matrix, { separator: exportOptions.separator }),
1716
+ /** The export item: the rectangle, in the grid's export format. */
1717
+ const exportSelection = () => {
1718
+ const data = buildSelectionData();
1719
+ if (data === null || data.columnIds.length === 0) return;
1720
+ void writeExportFile(data, {
1721
+ ...exportOptions,
1722
+ includeHeaders: exportIncludesHeaders,
1649
1723
  });
1650
1724
  };
1651
1725
 
@@ -1695,9 +1769,9 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1695
1769
  const row = rows.find((candidate) => candidate.id === rowId);
1696
1770
  if (row === undefined) return null;
1697
1771
  const cells = [
1698
- ...row.getLeftVisibleCells(),
1772
+ ...row.getStartVisibleCells(),
1699
1773
  ...row.getCenterVisibleCells(),
1700
- ...row.getRightVisibleCells(),
1774
+ ...row.getEndVisibleCells(),
1701
1775
  ];
1702
1776
  return (
1703
1777
  cells.find((cell) => edit.canEditCell(row, cell.column))?.column.id ??
@@ -1904,7 +1978,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1904
1978
  event.preventDefault();
1905
1979
  nextControl.focus();
1906
1980
  // What Tab does on a text field, and what the caret otherwise loses.
1907
- if (nextControl instanceof HTMLInputElement) nextControl.select();
1981
+ if (isInputElement(nextControl)) nextControl.select();
1908
1982
  return;
1909
1983
  }
1910
1984
  const rowIndex = rows.findIndex(
@@ -2119,13 +2193,15 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2119
2193
  return;
2120
2194
  }
2121
2195
 
2196
+ // The grid's own document, not the global one: in a window opened with
2197
+ // `window.open` the global document is the opener's, and its focus is
2198
+ // never in this grid.
2199
+ const activeElement = container.ownerDocument.activeElement;
2200
+
2122
2201
  // Never take the focus from elsewhere on the page. A consumer moving the
2123
2202
  // cell while the user is typing in a form somewhere means "move the ring",
2124
2203
  // not "move the caret" - the scroll above already did the visible part.
2125
- if (
2126
- !wantsCellFocusRef.current &&
2127
- !container.contains(document.activeElement)
2128
- ) {
2204
+ if (!wantsCellFocusRef.current && !container.contains(activeElement)) {
2129
2205
  return;
2130
2206
  }
2131
2207
  // Mid-hop: Tab parked the focus on a guard and the browser is about to
@@ -2136,8 +2212,8 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2136
2212
  // the cell, and that intent is honoured.
2137
2213
  if (
2138
2214
  !wantsCellFocusRef.current &&
2139
- document.activeElement instanceof Element &&
2140
- document.activeElement.closest('[data-dg-part="tab-guard"]') !== null
2215
+ activeElement !== null &&
2216
+ activeElement.closest('[data-dg-part="tab-guard"]') !== null
2141
2217
  ) {
2142
2218
  return;
2143
2219
  }
@@ -2148,7 +2224,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2148
2224
  wantsCellFocusRef.current = false;
2149
2225
  // Focus is already inside - on the control Enter stepped into. The cell is
2150
2226
  // where the ring belongs, so there is nothing to move.
2151
- if (cellElement.contains(document.activeElement)) return;
2227
+ if (cellElement.contains(activeElement)) return;
2152
2228
  // An open editor outranks the ring, wherever it is. Row mode opens a whole
2153
2229
  // row from one gesture, so the caret legitimately sits in a cell other
2154
2230
  // than the one that gesture landed the ring on - the lane's pencil is the
@@ -2156,8 +2232,8 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2156
2232
  // the caret back onto that cell would leave the row open with the caret in
2157
2233
  // none of its editors, and the user with nothing to type into.
2158
2234
  if (
2159
- document.activeElement instanceof Element &&
2160
- document.activeElement.closest('[data-dg-part="editor"]') !== null
2235
+ activeElement !== null &&
2236
+ activeElement.closest('[data-dg-part="editor"]') !== null
2161
2237
  ) {
2162
2238
  return;
2163
2239
  }
@@ -2256,9 +2332,9 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2256
2332
  </Menu.Item>
2257
2333
  <Menu.Item
2258
2334
  disabled={selectedDataColumnCount === 0}
2259
- onClick={() => exportSelectionCsv(exportIncludesHeaders)}
2335
+ onClick={exportSelection}
2260
2336
  >
2261
- {labels.exportCsv}
2337
+ {labels.exportCells}
2262
2338
  </Menu.Item>
2263
2339
  {/* Stays open on click: it is the setting the item above reads, and
2264
2340
  reopening the menu to change your mind about headers is a worse
@@ -2379,7 +2455,11 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2379
2455
  : undefined;
2380
2456
 
2381
2457
  return (
2382
- <div className={classes.tableWrapper}>
2458
+ <div
2459
+ ref={tableWrapperRef}
2460
+ className={classes.tableWrapper}
2461
+ data-dg-filter-sidebar={filters.surface === "sidebar" || undefined}
2462
+ >
2383
2463
  <div
2384
2464
  ref={scrollContainerRef}
2385
2465
  className={classes.scrollContainer}
@@ -2417,7 +2497,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2417
2497
  aria-rowcount={
2418
2498
  rows.length +
2419
2499
  pinnedRowCount +
2420
- headerGroups.length +
2500
+ headerRowCount +
2421
2501
  (hasSummaryRow ? 1 : 0)
2422
2502
  }
2423
2503
  aria-colcount={orderedColumns.length}
@@ -2437,11 +2517,16 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2437
2517
  data-dg-header-row
2438
2518
  // The scrolled-under shadow belongs to the boundary between
2439
2519
  // header and body - the last header row, not every stacked
2440
- // group row above it. See the stylesheet.
2441
- data-dg-header-last={
2442
- groupIndex === headerGroups.length - 1 || undefined
2443
- }
2444
- className={classes.headerRow}
2520
+ // group row above it, and not this one at all when the filter
2521
+ // row is below it. See sticky.module.css.
2522
+ className={[
2523
+ classes.headerRow,
2524
+ !hasHeaderFilterRow && groupIndex === headerGroups.length - 1
2525
+ ? sticky.headerBoundary
2526
+ : "",
2527
+ ]
2528
+ .filter(Boolean)
2529
+ .join(" ")}
2445
2530
  >
2446
2531
  {headerGroup.headers.map((header) => (
2447
2532
  <TMDataGridHeaderCell
@@ -2453,6 +2538,14 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2453
2538
  ))}
2454
2539
  </div>
2455
2540
  ))}
2541
+
2542
+ {hasHeaderFilterRow && (
2543
+ <TMDataGridHeaderFilterRow
2544
+ leafHeaders={leafHeaders}
2545
+ layoutFor={layoutFor}
2546
+ ariaRowIndex={headerRowCount}
2547
+ />
2548
+ )}
2456
2549
  </div>
2457
2550
 
2458
2551
  {/* The leading tab guard, below the header controls and above the
@@ -2511,9 +2604,9 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2511
2604
  const isGroupRow = row.getIsGrouped();
2512
2605
  const isInteractive = rowGesturesFor(row);
2513
2606
  const rowCells = [
2514
- ...row.getLeftVisibleCells(),
2607
+ ...row.getStartVisibleCells(),
2515
2608
  ...row.getCenterVisibleCells(),
2516
- ...row.getRightVisibleCells(),
2609
+ ...row.getEndVisibleCells(),
2517
2610
  ];
2518
2611
  // Row mode opens every editable cell of the row at once, and
2519
2612
  // rows accumulate: a second row opening leaves the first one
@@ -2529,9 +2622,9 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2529
2622
  // reopening takes it back to the entry block.
2530
2623
  const rowEditing =
2531
2624
  features.editMode === "row" &&
2532
- editOpenRowIds.includes(row.id) &&
2533
- !editDraftRowIds.includes(row.id) &&
2534
- !newRows.some((newRow) => newRow.tempId === row.id);
2625
+ editOpenRowIdSet.has(row.id) &&
2626
+ !editDraftRowIdSet.has(row.id) &&
2627
+ !newRowIdSet.has(row.id);
2535
2628
  // Cell selection takes the body's tab stop off the row and puts
2536
2629
  // it on a cell - two stops per row would make Tab a way of
2537
2630
  // walking the grid, which is what the arrow keys are for. Space
@@ -2568,7 +2661,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2568
2661
  // The tree's own rows. `data-grouped` rather than a class, to
2569
2662
  // match how the rest of the row's state is published - and so
2570
2663
  // a consumer can restyle them without reaching into modules.
2571
- data-grouped={isGroupRow}
2664
+ data-grouped={isGroupRow || undefined}
2572
2665
  data-depth={row.depth}
2573
2666
  // Which edge block the row sits in, for styling hooks.
2574
2667
  data-pinned={pinnedAt}
@@ -2577,13 +2670,16 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2577
2670
  // still hold ids from before the mode changed - TanStack never
2578
2671
  // prunes it - and a row must not report itself selected in a
2579
2672
  // mode where selecting is not a thing.
2580
- data-selected={features.rowSelection && row.getIsSelected()}
2673
+ data-selected={
2674
+ (features.rowSelection && row.getIsSelected()) || undefined
2675
+ }
2581
2676
  // Being selected is state; painting it is a display choice, so
2582
2677
  // the two are separate attributes.
2583
2678
  data-selected-bg={
2584
- features.rowSelection &&
2585
- features.showSelectedBackground &&
2586
- row.getIsSelected()
2679
+ (features.rowSelection &&
2680
+ features.showSelectedBackground &&
2681
+ row.getIsSelected()) ||
2682
+ undefined
2587
2683
  }
2588
2684
  // The highlighted row is its own concept, so its own attribute
2589
2685
  // pair - `data-highlighted` / `aria-current` against
@@ -2595,42 +2691,40 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2595
2691
  // so a consumer styling or querying rows by it would cast far
2596
2692
  // wider than they meant to.
2597
2693
  data-highlighted={
2598
- features.highlightRow && row.id === highlightedRowId
2694
+ (features.highlightRow && row.id === highlightedRowId) ||
2695
+ undefined
2599
2696
  }
2600
2697
  // Marked deleted under draft: struck through and inert
2601
- // until submitAll reports it, or the mark is toggled back.
2698
+ // until saveDrafts reports it, or the mark is toggled back.
2602
2699
  data-deleted={
2603
- deletedRowIds.length > 0 && deletedRowIds.includes(row.id)
2700
+ (deletedRowIds.length > 0 && deletedRowIds.includes(row.id)) ||
2701
+ undefined
2604
2702
  }
2605
2703
  // Carrying a dirty draft - the row-level face of the cells'
2606
2704
  // own data-dirty markers, for row-scoped styling.
2607
- data-dirty={
2608
- editDirtyRowIds.length > 0 &&
2609
- editDirtyRowIds.includes(row.id)
2610
- }
2705
+ data-dirty={editDirtyRowIdSet.has(row.id) || undefined}
2611
2706
  // Committed into the draft store, waiting for Save. A
2612
2707
  // committed entry row is one too - it is in the body
2613
2708
  // because it is committed - and carries `data-new` besides.
2614
2709
  data-draft={
2615
- (editDraftRowIds.length > 0 &&
2616
- editDraftRowIds.includes(row.id)) ||
2617
- (newRowCount > 0 &&
2618
- newRows.some((newRow) => newRow.tempId === row.id))
2710
+ editDraftRowIdSet.has(row.id) ||
2711
+ newRowIdSet.has(row.id) ||
2712
+ undefined
2619
2713
  }
2620
2714
  // An entered row not yet in `data`, committed into the draft
2621
2715
  // store and sorted, filtered and grouped with the rest.
2622
- data-new={
2623
- newRowCount > 0 &&
2624
- newRows.some((newRow) => newRow.tempId === row.id)
2625
- }
2716
+ data-new={newRowIdSet.has(row.id) || undefined}
2626
2717
  // The menu is anchored to the rowgroup, so Mantine's own
2627
2718
  // `data-expanded` lands there rather than on a row. This is
2628
2719
  // what says which row the open menu is about.
2629
2720
  data-context-menu={
2630
- contextMenuContent !== null &&
2631
- contextMenuTarget?.rowId === row.id
2721
+ (contextMenuContent !== null &&
2722
+ contextMenuTarget?.rowId === row.id) ||
2723
+ undefined
2724
+ }
2725
+ data-selects-on-click={
2726
+ (selectsOnRowClick && !isGroupRow) || undefined
2632
2727
  }
2633
- data-selects-on-click={selectsOnRowClick && !isGroupRow}
2634
2728
  aria-selected={
2635
2729
  features.rowSelection ? row.getIsSelected() : undefined
2636
2730
  }
@@ -2642,7 +2736,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2642
2736
  // From the row's position in the whole view, so the stripes
2643
2737
  // survive the virtualizer's moving window - see the prop.
2644
2738
  data-striped={
2645
- striped && viewIndex >= 0 ? viewIndex % 2 === 1 : undefined
2739
+ (striped && viewIndex >= 0 && viewIndex % 2 === 1) || undefined
2646
2740
  }
2647
2741
  className={[classes.bodyRow, rowClassNameFor(row)]
2648
2742
  .filter(Boolean)
@@ -2909,7 +3003,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2909
3003
  renderBodyRow(
2910
3004
  row,
2911
3005
  -1,
2912
- headerGroups.length + index + 1,
3006
+ headerRowCount + index + 1,
2913
3007
  "top",
2914
3008
  ),
2915
3009
  )}
@@ -2930,7 +3024,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2930
3024
  row,
2931
3025
  virtualItem.index,
2932
3026
  virtualItem.index +
2933
- headerGroups.length +
3027
+ headerRowCount +
2934
3028
  pinnedTopRows.length +
2935
3029
  1,
2936
3030
  );
@@ -2953,7 +3047,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2953
3047
  renderBodyRow(
2954
3048
  row,
2955
3049
  -1,
2956
- headerGroups.length +
3050
+ headerRowCount +
2957
3051
  pinnedTopRows.length +
2958
3052
  rows.length +
2959
3053
  index +
@@ -3009,7 +3103,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
3009
3103
  <div
3010
3104
  role="row"
3011
3105
  aria-rowindex={
3012
- rows.length + pinnedRowCount + headerGroups.length + 1
3106
+ rows.length + pinnedRowCount + headerRowCount + 1
3013
3107
  }
3014
3108
  // Also what the height measurement above looks for. One
3015
3109
  // attribute serves both now that the part name *is* the
@@ -3025,7 +3119,9 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
3025
3119
  role="cell"
3026
3120
  data-column-id={header.column.id}
3027
3121
  data-align={getColumnAlign(header.column)}
3028
- data-control-column={isControlColumn(header.column.id)}
3122
+ data-control-column={
3123
+ isControlColumn(header.column.id) || undefined
3124
+ }
3029
3125
  className={[
3030
3126
  classes.summaryCell,
3031
3127
  layout.isBoundary && layout.pinnedAt === "left"
@@ -3079,7 +3175,13 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
3079
3175
  </div>
3080
3176
  </div>
3081
3177
 
3082
- <TMDataGridFilterPanel />
3178
+ {/* The automatic filter surfaces. The popup floats over the first body
3179
+ rows, anchored to the wrapper; the sidebar is a second column of it,
3180
+ which is why the wrapper turns into a flex row for one. Under
3181
+ `filters.surface: "none"` neither renders and the consumer's own
3182
+ `TMDataGrid.FilterPanel` is the only panel on the page. */}
3183
+ {filters.surface === "popup" && <TMDataGridFilterPopup />}
3184
+ {filters.surface === "sidebar" && <TMDataGridFilterSidebar />}
3083
3185
  </div>
3084
3186
  );
3085
3187
  }