@jielga/tmdatagrid 2.0.0-beta.8 → 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 (155) hide show
  1. package/README.md +5 -212
  2. package/dist/index.d.ts +1323 -796
  3. package/dist/index.js +4719 -3193
  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 +69 -78
  47. package/skills/columns/SKILL.md +90 -34
  48. package/skills/data/SKILL.md +86 -16
  49. package/skills/editing/SKILL.md +83 -50
  50. package/skills/editing/references/common-mistakes.md +77 -69
  51. package/skills/editing/references/editing-api.md +31 -23
  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 +8 -8
  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/TMDataGridContext.ts → TMDataGridContext.ts} +7 -19
  63. package/src/{tmdatagrid/components → components}/TMDataGrid.module.css +7 -1
  64. package/src/{tmdatagrid/components → components}/TMDataGrid.tsx +38 -21
  65. package/src/{tmdatagrid/components → components}/TMDataGridCellEditor.tsx +73 -7
  66. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.module.css +5 -1
  67. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.tsx +47 -55
  68. package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.tsx +9 -55
  69. package/src/{tmdatagrid/components → components}/TMDataGridDraftActions.tsx +41 -24
  70. package/src/{tmdatagrid/components → components}/TMDataGridEditColumn.tsx +23 -63
  71. package/src/components/TMDataGridEntryRows.tsx +354 -0
  72. package/src/components/TMDataGridExportPicker.module.css +77 -0
  73. package/src/components/TMDataGridExportPicker.tsx +234 -0
  74. package/src/components/TMDataGridFilterPanel.module.css +54 -0
  75. package/src/components/TMDataGridFilterPanel.tsx +348 -0
  76. package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.tsx +15 -8
  77. package/src/components/TMDataGridFilterSurface.module.css +54 -0
  78. package/src/components/TMDataGridFilterSurface.tsx +167 -0
  79. package/src/{tmdatagrid/components → components}/TMDataGridFooter.tsx +53 -90
  80. package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.tsx +9 -72
  81. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.module.css +12 -2
  82. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.tsx +58 -21
  83. package/src/components/TMDataGridHeaderFilterRow.module.css +51 -0
  84. package/src/components/TMDataGridHeaderFilterRow.tsx +301 -0
  85. package/src/components/TMDataGridMenu.tsx +357 -0
  86. package/src/{tmdatagrid/components → components}/TMDataGridSelectColumn.tsx +15 -53
  87. package/src/{tmdatagrid/components → components}/TMDataGridTable.module.css +88 -65
  88. package/src/{tmdatagrid/components → components}/TMDataGridTable.tsx +579 -165
  89. package/src/{tmdatagrid/components → components}/TMDataGridToolbar.module.css +5 -0
  90. package/src/components/TMDataGridToolbar.tsx +181 -0
  91. package/src/{tmdatagrid/components → components}/editors/TMDataGridBooleanEditor.tsx +3 -3
  92. package/src/{tmdatagrid/components → components}/editors/TMDataGridDateEditor.tsx +3 -3
  93. package/src/{tmdatagrid/components → components}/editors/TMDataGridMultiSelectEditor.tsx +3 -3
  94. package/src/{tmdatagrid/components → components}/editors/TMDataGridNumberEditor.tsx +3 -3
  95. package/src/{tmdatagrid/components → components}/editors/TMDataGridSelectEditor.tsx +3 -3
  96. package/src/{tmdatagrid/components → components}/editors/TMDataGridStringEditor.tsx +3 -3
  97. package/src/{tmdatagrid/components → components}/editors/editorShared.ts +16 -2
  98. package/src/{tmdatagrid/components → components}/filters/DgAutocompleteFilter.tsx +5 -4
  99. package/src/{tmdatagrid/components → components}/filters/DgDateRangeFilter.tsx +22 -5
  100. package/src/{tmdatagrid/components → components}/filters/DgRangeSliderFilter.tsx +5 -1
  101. package/src/{tmdatagrid/components → components}/filters/DgTriStateFilter.tsx +5 -1
  102. package/src/{tmdatagrid/components → components}/filters/TMDataGridFilterValueInput.tsx +44 -28
  103. package/src/components/filters/controlLayout.ts +32 -0
  104. package/src/components/filters/filterControlFor.ts +65 -0
  105. package/src/components/generatedColumns.tsx +187 -0
  106. package/src/{tmdatagrid/components → components}/icons.ts +1 -0
  107. package/src/{tmdatagrid/components → components}/sticky.module.css +44 -0
  108. package/src/components/useHideableColumns.ts +52 -0
  109. package/src/{tmdatagrid/core → core}/autosize.ts +5 -2
  110. package/src/{tmdatagrid/core → core}/capabilities.ts +5 -5
  111. package/src/{tmdatagrid/core → core}/columnOptions.ts +60 -8
  112. package/src/{tmdatagrid/core → core}/columnOrdering.ts +33 -14
  113. package/src/{tmdatagrid/core → core}/columnUtils.ts +44 -5
  114. package/src/core/controlledStateSync.ts +108 -0
  115. package/src/core/deletedRows.ts +34 -0
  116. package/src/core/dom.ts +74 -0
  117. package/src/{tmdatagrid/core → core}/editEngine.ts +1172 -388
  118. package/src/{tmdatagrid/core → core}/editorFocus.ts +8 -4
  119. package/src/core/export.ts +704 -0
  120. package/src/{tmdatagrid/core → core}/filterControls.ts +38 -1
  121. package/src/{tmdatagrid/core → core}/filterOperators.ts +64 -1
  122. package/src/core/filterSurface.ts +99 -0
  123. package/src/{tmdatagrid/core → core}/grouping.ts +21 -0
  124. package/src/{tmdatagrid/core → core}/labels.ts +51 -6
  125. package/src/{tmdatagrid/core → core}/labelsSv.ts +27 -6
  126. package/src/core/pageReset.ts +120 -0
  127. package/src/core/pagination.ts +81 -0
  128. package/src/{tmdatagrid/core → core}/persistence.ts +22 -5
  129. package/src/{tmdatagrid/core → core}/summary.ts +20 -4
  130. package/src/{tmdatagrid/index.ts → index.ts} +70 -36
  131. package/src/{tmdatagrid/useTMDataGrid.tsx → useTMDataGrid.tsx} +534 -123
  132. package/src/useTMDataGridExport.ts +78 -0
  133. package/src/tmdatagrid/components/TMDataGridEntryRows.tsx +0 -298
  134. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +0 -35
  135. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +0 -354
  136. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +0 -161
  137. package/src/tmdatagrid/core/cellExport.ts +0 -320
  138. /package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.module.css +0 -0
  139. /package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.module.css +0 -0
  140. /package/src/{tmdatagrid/components → components}/TMDataGridFooter.module.css +0 -0
  141. /package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.module.css +0 -0
  142. /package/src/{tmdatagrid/components → components}/TMDataGridRowNumberColumn.tsx +0 -0
  143. /package/src/{tmdatagrid/components → components}/TMDataGridSearch.tsx +0 -0
  144. /package/src/{tmdatagrid/core → core}/cellNavigation.ts +0 -0
  145. /package/src/{tmdatagrid/core → core}/cellRange.ts +0 -0
  146. /package/src/{tmdatagrid/core → core}/controlledState.ts +0 -0
  147. /package/src/{tmdatagrid/core → core}/draftCellContext.ts +0 -0
  148. /package/src/{tmdatagrid/core → core}/expanding.ts +0 -0
  149. /package/src/{tmdatagrid/core → core}/matchHighlight.ts +0 -0
  150. /package/src/{tmdatagrid/core → core}/quickSearch.ts +0 -0
  151. /package/src/{tmdatagrid/core → core}/resizePreview.ts +0 -0
  152. /package/src/{tmdatagrid/core → core}/rowPinning.ts +0 -0
  153. /package/src/{tmdatagrid/core → core}/rowSelection.ts +0 -0
  154. /package/src/{tmdatagrid/core → core}/sizes.ts +0 -0
  155. /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,
@@ -286,10 +295,22 @@ function TMDataGridBodyCell({
286
295
  // The whole draft, not one field: a computed or custom renderer may read any
287
296
  // field off the row, so every data cell of a drafted row repaints together
288
297
  // when its draft moves - and only then, `values` being reference-stable
289
- // across meta-only form events. `undefined` everywhere else.
290
- const draftValues = useSelector(edit.store, (state) =>
291
- isControl ? undefined : state.rows[cellRowId]?.values,
292
- );
298
+ // across meta-only form events. `undefined` everywhere else - including a
299
+ // committed row, whose draft already *is* the row: the hook feeds the draft
300
+ // store's values to the table as `data`, so `row.original` holds them.
301
+ // Only an open form's undecided values need the overlay.
302
+ const draftValues = useSelector(edit.store, (state) => {
303
+ if (isControl) return undefined;
304
+ if (state.committedRowIds.includes(cellRowId)) return undefined;
305
+ if (
306
+ state.newRows.some(
307
+ (newRow) => newRow.tempId === cellRowId && newRow.committed,
308
+ )
309
+ ) {
310
+ return undefined;
311
+ }
312
+ return state.rows[cellRowId]?.values;
313
+ });
293
314
  return (
294
315
  <div
295
316
  // `gridcell` rather than `cell` is what tells a screen reader the arrow
@@ -309,16 +330,16 @@ function TMDataGridBodyCell({
309
330
  // selectors also require `data-cell`, so they are unaffected.
310
331
  data-row-id={cell.row.id}
311
332
  data-column-id={cell.column.id}
312
- data-focused={nav?.focused}
333
+ data-focused={nav?.focused || undefined}
313
334
  aria-selected={nav?.selected}
314
- data-selected={nav?.selected}
335
+ data-selected={nav?.selected || undefined}
315
336
  // One attribute per side rather than a class per combination: the
316
337
  // stylesheet draws the outline edge by edge, and sixteen classes for the
317
338
  // sixteen corners of a rectangle is not a stylesheet anyone can read.
318
- data-edge-top={nav?.edges?.top}
319
- data-edge-bottom={nav?.edges?.bottom}
320
- data-edge-left={nav?.edges?.left}
321
- 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}
322
343
  // Bubbles, so focus landing on a control inside the cell counts as
323
344
  // landing on the cell - the ring follows the user into the checkbox
324
345
  // rather than being left behind on whichever cell they came from.
@@ -333,7 +354,7 @@ function TMDataGridBodyCell({
333
354
  data-align={getColumnAlign(cell.column)}
334
355
  // A control lane is a fixed track, so it cannot take the cell padding the
335
356
  // scale grows for text. See isControlColumn.
336
- data-control-column={isControl}
357
+ data-control-column={isControl || undefined}
337
358
  onContextMenu={onContextMenu}
338
359
  className={[
339
360
  classes.bodyCell,
@@ -349,19 +370,22 @@ function TMDataGridBodyCell({
349
370
  left: layout.pinnedAt === "left" ? layout.offset : undefined,
350
371
  right: layout.pinnedAt === "right" ? layout.offset : undefined,
351
372
  // The focus ring is drawn inside the cell's own box, so the cell has to
352
- // be able to stack above its neighbours - a plain one to lift the ring
353
- // over the pinned lane it sits beside, a pinned one to keep the ring
354
- // whole while the rest of the row slides under it. Values from the
355
- // stacking ladder in TMDataGrid.module.css.
373
+ // stack above the cells it shares an edge with - but not above the
374
+ // pinned lanes, or the ring rides over them once the row scrolls
375
+ // sideways. A focused cell that is itself pinned takes the slot above
376
+ // them, keeping its ring whole while the rest of the row slides under
377
+ // it. Values from the stacking ladder in TMDataGrid.module.css.
356
378
  position: layout.pinnedAt
357
379
  ? "sticky"
358
380
  : nav?.focused
359
381
  ? "relative"
360
382
  : undefined,
361
- zIndex: nav?.focused
362
- ? "var(--dg-z-focused-cell, 3)"
363
- : layout.pinnedAt
364
- ? "var(--dg-z-pinned-cell, 2)"
383
+ zIndex: layout.pinnedAt
384
+ ? nav?.focused
385
+ ? "var(--dg-z-pinned-focused-cell, 3)"
386
+ : "var(--dg-z-pinned-cell, 2)"
387
+ : nav?.focused
388
+ ? "var(--dg-z-focused-cell, 1)"
365
389
  : undefined,
366
390
  }}
367
391
  >
@@ -654,12 +678,6 @@ export type TMDataGridTableProps<TData extends RowData> = {
654
678
  * `position`, `transitionProps` and the rest. Its open state is the grid's.
655
679
  */
656
680
  rowContextMenuProps?: Omit<MenuProps, "opened" | "onChange" | "children">;
657
- /**
658
- * How Ctrl+C and the export item write values, under
659
- * `cellSelection: "range"`. Defaults to the Nordic Excel conventions - see
660
- * {@link TMDataGridCellExportOptions}.
661
- */
662
- cellExport?: TMDataGridCellExportOptions;
663
681
  /**
664
682
  * Called when the scroll approaches the last row - the infinite-scroll
665
683
  * hook-in. Append the next page to `data` and the virtualizer keeps its
@@ -711,7 +729,6 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
711
729
  renderRowContextMenu,
712
730
  renderColumnMenuItems,
713
731
  rowContextMenuProps,
714
- cellExport,
715
732
  onReachEnd,
716
733
  reachEndThreshold = 10,
717
734
  "aria-label": ariaLabel,
@@ -722,6 +739,8 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
722
739
  ui,
723
740
  edit,
724
741
  features,
742
+ filters,
743
+ exportOptions: gridExportOptions,
725
744
  labels,
726
745
  rowHeight,
727
746
  controlSize,
@@ -747,7 +766,10 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
747
766
  // Row mode opens whole rows and any number of them at once, so which rows
748
767
  // are showing editors is the set of open forms, not the single active slot.
749
768
  const editOpenRowIds = useSelector(edit.store, (state) => state.openRowIds);
750
- const newRowCount = useSelector(edit.store, (state) => state.newRows.length);
769
+ // Entry rows, for the block's mount and for `data-new` on the committed
770
+ // ones that have joined the body. Identity-stable across typing.
771
+ const newRows = useSelector(edit.store, (state) => state.newRows);
772
+ const newRowCount = newRows.length;
751
773
  const deletedRowIds = useSelector(edit.store, (state) => state.deletedRowIds);
752
774
  // Rows carrying a dirty draft, for the row-level marker. Derived from the
753
775
  // same projections the cells subscribe to; the array's identity churns with
@@ -765,6 +787,24 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
765
787
  edit.store,
766
788
  (state) => state.committedRowIds,
767
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
+ );
768
808
 
769
809
  const { loading, noResultsLabel = labels.noResults } =
770
810
  table.options.meta ?? {};
@@ -858,29 +898,99 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
858
898
  });
859
899
 
860
900
  /**
861
- * The header's height, published as `--dg-header-height` for the entry
862
- * block to stick under - never measured otherwise (`top: 0` needs no
863
- * number). A ResizeObserver, mounted only while the block is in use: the
864
- * same discipline `renderDetails` applies to `measureElement`.
901
+ * Everything the body rows start after, and how much of it stays on screen
902
+ * once they scroll: the header rows, the entry block and the pinned top
903
+ * block all sit in the same scroll content, above the first virtual row.
904
+ *
905
+ * Three readers, one measurement.
906
+ *
907
+ * `--dg-header-height` is the header total alone, published for the entry
908
+ * block to stick under and only while a block is in use - the stylesheet's
909
+ * per-`size` value is what the header cells are laid out against otherwise,
910
+ * and it is not the sum of a stacked group's rows.
911
+ *
912
+ * `bodyOffsetTop` is the whole stack, and it is the virtualizer's
913
+ * `scrollMargin`: item offsets start at zero, so without it every one of
914
+ * them is short by the height of what the rows begin after. `stickyCoverTop`
915
+ * leaves out the entry rows that flow with the body (`editNewRowsSticky`
916
+ * off), and is its `scrollPaddingStart` - the part of the stack that stays
917
+ * put and would otherwise be covering the row `align: "auto"` just scrolled
918
+ * to.
919
+ *
920
+ * The observer is mounted whatever the grid shows, unlike the block
921
+ * measurements below it: the header is there in every case, and the
922
+ * virtualizer needs its height from the first paint.
865
923
  */
866
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);
941
+ const [bodyOffsetTop, setBodyOffsetTop] = useState(0);
942
+ const [stickyCoverTop, setStickyCoverTop] = useState(0);
943
+ const [stickyCoverBottom, setStickyCoverBottom] = useState(0);
867
944
  useEffect(() => {
868
- if (newRowCount === 0 && pinnedTopRows.length === 0) return;
869
945
  const grid = gridElementRef.current;
870
946
  if (grid === null) return;
871
947
  const headerRows = grid.querySelectorAll<HTMLElement>(
872
948
  "[data-dg-header-row]",
873
949
  );
950
+ const stickyBlocks = grid.querySelectorAll<HTMLElement>(
951
+ '[data-dg-entry-block], [data-dg-part="pinned-top"]',
952
+ );
953
+ const flowBlocks = grid.querySelectorAll<HTMLElement>(
954
+ "[data-dg-entry-flow-block]",
955
+ );
874
956
  const measure = () => {
875
- let total = 0;
876
- for (const headerRow of headerRows) total += headerRow.offsetHeight;
877
- grid.style.setProperty("--dg-header-height", `${total}px`);
957
+ let header = 0;
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
+ }
967
+ if (newRowCount > 0 || pinnedTopRows.length > 0) {
968
+ grid.style.setProperty("--dg-header-height", `${header}px`);
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
+ );
980
+ let cover = header;
981
+ for (const block of stickyBlocks) cover += block.offsetHeight;
982
+ let offset = cover;
983
+ for (const block of flowBlocks) offset += block.offsetHeight;
984
+ setStickyCoverTop(cover);
985
+ setBodyOffsetTop(offset);
878
986
  };
879
987
  measure();
880
988
  const observer = new ResizeObserver(measure);
881
- for (const headerRow of headerRows) observer.observe(headerRow);
989
+ for (const element of [...headerRows, ...stickyBlocks, ...flowBlocks]) {
990
+ observer.observe(element);
991
+ }
882
992
  return () => observer.disconnect();
883
- }, [newRowCount, pinnedTopRows.length]);
993
+ }, [newRowCount, pinnedTopRows.length, hasHeaderFilterRow, headerDepth]);
884
994
 
885
995
  /**
886
996
  * The entry block's height, published as `--dg-entry-height` so the pinned
@@ -904,32 +1014,6 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
904
1014
  };
905
1015
  }, [newRowCount, pinnedTopRows.length]);
906
1016
 
907
- /**
908
- * The summary row's height, published as `--dg-summary-height` so the
909
- * pinned bottom block sticks above it rather than underneath it.
910
- */
911
- useEffect(() => {
912
- if (pinnedBottomRows.length === 0) return;
913
- const grid = gridElementRef.current;
914
- if (grid === null) return;
915
- const summaryRow = grid.querySelector<HTMLElement>(
916
- '[data-dg-part="summary-row"]',
917
- );
918
- if (summaryRow === null) return;
919
- const measure = () =>
920
- grid.style.setProperty(
921
- "--dg-summary-height",
922
- `${summaryRow.offsetHeight}px`,
923
- );
924
- measure();
925
- const observer = new ResizeObserver(measure);
926
- observer.observe(summaryRow);
927
- return () => {
928
- observer.disconnect();
929
- grid.style.removeProperty("--dg-summary-height");
930
- };
931
- }, [pinnedBottomRows.length]);
932
-
933
1017
  // A persisted pageIndex can outlive the data that produced it; TanStack only
934
1018
  // auto-resets on live filter/sort/data changes, not on restored state. The
935
1019
  // guard makes the dependency-free effect idempotent.
@@ -974,6 +1058,17 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
974
1058
  getItemKey: useCallback((index: number) => rows[index]?.id ?? index, [rows]),
975
1059
  overscan,
976
1060
  initialRect: { height: 600, width: 1200 },
1061
+ /**
1062
+ * The rows are not the first thing in the scroll container - the header,
1063
+ * the entry block and the pinned top block come before them. The margin is
1064
+ * what puts an item's offset in the container's own scroll coordinates;
1065
+ * the two paddings are the sticky blocks at either end, which cover the
1066
+ * viewport without shrinking it, so `align: "auto"` has to scroll past
1067
+ * them rather than stopping underneath.
1068
+ */
1069
+ scrollMargin: bodyOffsetTop,
1070
+ scrollPaddingStart: stickyCoverTop,
1071
+ scrollPaddingEnd: stickyCoverBottom,
977
1072
  });
978
1073
 
979
1074
  /**
@@ -991,10 +1086,16 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
991
1086
  virtualizer.measure();
992
1087
  }, [rowHeight, virtualizer]);
993
1088
 
1089
+ // The spacers stand in for the rows that are not mounted, so they measure
1090
+ // from the first row rather than from the top of the scroll container:
1091
+ // `scrollMargin` is included in every item's `start` and `end` and excluded
1092
+ // from `getTotalSize()`, and taking it back off here is what keeps a spacer
1093
+ // the height of the rows it replaces.
994
1094
  const virtualItems = virtualizer.getVirtualItems();
995
- const paddingTop = virtualItems[0]?.start ?? 0;
1095
+ const paddingTop = (virtualItems[0]?.start ?? bodyOffsetTop) - bodyOffsetTop;
996
1096
  const paddingBottom =
997
- virtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0);
1097
+ virtualizer.getTotalSize() -
1098
+ ((virtualItems.at(-1)?.end ?? bodyOffsetTop) - bodyOffsetTop);
998
1099
 
999
1100
  /**
1000
1101
  * `api.scrollToRow`, implemented where the virtualizer is.
@@ -1051,14 +1152,14 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1051
1152
  onReachEnd();
1052
1153
  });
1053
1154
 
1054
- // The *Visible* variants throughout: `getLeftLeafColumns()` and friends
1155
+ // The *Visible* variants throughout: `getStartLeafColumns()` and friends
1055
1156
  // include hidden columns, while the cells below come from
1056
- // `row.getLeftVisibleCells()`, which does not. Mixing the two would lay down
1157
+ // `row.getStartVisibleCells()`, which does not. Mixing the two would lay down
1057
1158
  // a grid track for a column that renders no cell, shifting every column after
1058
1159
  // it out of its header. The tree column is hidden exactly this way while
1059
1160
  // nothing is grouped.
1060
- const leftLeafColumns = table.getLeftVisibleLeafColumns();
1061
- const rightLeafColumns = table.getRightVisibleLeafColumns();
1161
+ const leftLeafColumns = table.getStartVisibleLeafColumns();
1162
+ const rightLeafColumns = table.getEndVisibleLeafColumns();
1062
1163
  const centerLeafColumns = table.getCenterVisibleLeafColumns();
1063
1164
 
1064
1165
  const orderedColumns = [
@@ -1070,8 +1171,8 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1070
1171
  // Headers and cells must follow the same left → center → right order as the
1071
1172
  // column tracks, otherwise pinning a column would shuffle it out of its lane.
1072
1173
  const centerHeaderGroups = table.getCenterHeaderGroups();
1073
- const leftHeaderGroups = table.getLeftHeaderGroups();
1074
- const rightHeaderGroups = table.getRightHeaderGroups();
1174
+ const leftHeaderGroups = table.getStartHeaderGroups();
1175
+ const rightHeaderGroups = table.getEndHeaderGroups();
1075
1176
  const headerGroups = centerHeaderGroups.map((group, index) => ({
1076
1177
  id: group.id,
1077
1178
  headers: [
@@ -1237,6 +1338,68 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1237
1338
  const hasSummaryRow = orderedColumns.some(
1238
1339
  (column) => column.columnDef.footer !== undefined,
1239
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]);
1379
+
1380
+ /**
1381
+ * The other end of the same story as `stickyCoverTop`: the pinned bottom
1382
+ * block and the summary row stick to the container's bottom edge, so the
1383
+ * virtualizer takes their height as `scrollPaddingEnd` and stops scrolling a
1384
+ * row to just underneath them. Down here for the same reason as the
1385
+ * measurement above.
1386
+ */
1387
+ useEffect(() => {
1388
+ const grid = gridElementRef.current;
1389
+ if (grid === null) return;
1390
+ const blocks = grid.querySelectorAll<HTMLElement>(
1391
+ '[data-dg-part="pinned-bottom"], [data-dg-part="summary-row"]',
1392
+ );
1393
+ const measure = () => {
1394
+ let total = 0;
1395
+ for (const block of blocks) total += block.offsetHeight;
1396
+ setStickyCoverBottom(total);
1397
+ };
1398
+ measure();
1399
+ const observer = new ResizeObserver(measure);
1400
+ for (const block of blocks) observer.observe(block);
1401
+ return () => observer.disconnect();
1402
+ }, [pinnedBottomRows.length, hasSummaryRow]);
1240
1403
 
1241
1404
  // "row" mode: the row itself is the selection control, with the modifier
1242
1405
  // conventions of any desktop list - see resolveRowSelectionClick.
@@ -1337,6 +1500,23 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1337
1500
  /** The last position the effect acted on, so it can tell a move from a re-render. */
1338
1501
  const appliedCellRef = useRef<TMDataGridCellPosition | null>(null);
1339
1502
 
1503
+ /**
1504
+ * The two focusable elements that bracket the body, and the flag that says
1505
+ * which way focus is passing through one.
1506
+ *
1507
+ * They are what makes the body one tab stop without asking anything of the
1508
+ * cell renderers. Tab inside the body parks the focus on the guard past the
1509
+ * edge being left, and the browser's default Tab then continues from there -
1510
+ * so the next stop is whatever follows the grid, however many tabbable
1511
+ * controls the mounted rows happen to hold. Focus arriving at a guard any
1512
+ * other way came from outside the body, and is forwarded to the cursor cell.
1513
+ * `guardHopRef` is what tells the two apart; the grid raises it just before
1514
+ * it moves the focus itself.
1515
+ */
1516
+ const leadingGuardRef = useRef<HTMLDivElement>(null);
1517
+ const trailingGuardRef = useRef<HTMLDivElement>(null);
1518
+ const guardHopRef = useRef(false);
1519
+
1340
1520
  // O(n) per keystroke and per render while a cell is focused. Fine at any row
1341
1521
  // count a grid actually holds, and the alternative - an id→index map rebuilt
1342
1522
  // whenever sorting, filtering or grouping changes - costs more than it saves.
@@ -1353,13 +1533,14 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1353
1533
  virtualItems.some((item) => item.index === focusedRowIndex);
1354
1534
 
1355
1535
  /**
1356
- * The one cell in the body that Tab can reach.
1536
+ * The cell the tab guards hand the focus to when Tab enters the body.
1357
1537
  *
1358
1538
  * The focused cell when it is on screen. When it is not - the grid has never
1359
1539
  * been entered, or the user scrolled the focus away with the wheel - the
1360
- * first mounted cell stands in, so the body always has exactly one tab stop.
1361
- * Without the fallback, scrolling the focused row out of the DOM would take
1362
- * the grid out of the tab order entirely.
1540
+ * first mounted cell stands in, so entering the body always lands somewhere.
1541
+ * No body cell is itself in the tab order: the guards are the body's only
1542
+ * tab stops, which is what keeps a cell from turning up between two of a
1543
+ * row's controls when Tab walks them.
1363
1544
  */
1364
1545
  const firstMountedRow = rows[virtualItems[0]?.index ?? -1];
1365
1546
  const tabStopCell: TMDataGridCellPosition | null = !cellSelection
@@ -1370,6 +1551,35 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1370
1551
  ? { rowId: firstMountedRow.id, columnId: orderedColumns[0].id }
1371
1552
  : null;
1372
1553
 
1554
+ /**
1555
+ * Focus landing on either guard.
1556
+ *
1557
+ * A raised hop flag means the grid put it there on its way out, so the
1558
+ * browser's default Tab is about to carry on from the guard - nothing to do
1559
+ * but lower the flag. Anything else is Tab off the last header control onto
1560
+ * the leading guard, or Shift+Tab from below the grid onto the trailing one:
1561
+ * both mean "enter the body", and the body is entered at the cursor cell.
1562
+ *
1563
+ * The state is moved as well as the DOM focus, because the cell may not be
1564
+ * the one the ring is on; the direct focus is so it happens in this event
1565
+ * rather than a frame later.
1566
+ *
1567
+ * With no rows there is no cell to hand it to. The guard keeps the focus and
1568
+ * the next Tab leaves the grid the way it came in.
1569
+ */
1570
+ const handleGuardFocus = () => {
1571
+ if (guardHopRef.current) {
1572
+ guardHopRef.current = false;
1573
+ return;
1574
+ }
1575
+ if (tabStopCell === null) return;
1576
+ wantsCellFocusRef.current = true;
1577
+ ui.actions.setFocusedCell(tabStopCell);
1578
+ const container = scrollContainerRef.current;
1579
+ if (container === null) return;
1580
+ findCellElement(container, tabStopCell)?.focus({ preventScroll: true });
1581
+ };
1582
+
1373
1583
  const isCellAt = (
1374
1584
  position: TMDataGridCellPosition | null,
1375
1585
  rowId: string,
@@ -1436,24 +1646,23 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1436
1646
  ? 0
1437
1647
  : orderedColumns
1438
1648
  .slice(selectionBounds.left, selectionBounds.right + 1)
1439
- .filter((column) => !isControlColumn(column.id)).length;
1649
+ .filter(
1650
+ (column) =>
1651
+ !isGeneratedColumn(column.id) &&
1652
+ column.columnDef.meta?.enableExport !== false,
1653
+ ).length;
1440
1654
 
1441
- const exportOptions = { ...DEFAULT_CELL_EXPORT_OPTIONS, ...cellExport };
1655
+ const exportOptions = resolveExportOptions(gridExportOptions);
1442
1656
  // Sticky across menus, the way a checkbox in a dialog is: a user who exports
1443
1657
  // with headers once almost always wants them the next time too.
1444
1658
  const [exportIncludesHeaders, setExportIncludesHeaders] = useState(
1445
1659
  exportOptions.includeHeaders,
1446
1660
  );
1447
- const buildSelectionMatrix = (includeHeaders: boolean) =>
1661
+ // The rectangle's values, over the displayed rows the bounds index into.
1662
+ const buildSelectionData = () =>
1448
1663
  selectionBounds === null
1449
- ? []
1450
- : buildCellMatrix({
1451
- rows,
1452
- columns: orderedColumns,
1453
- bounds: selectionBounds,
1454
- includeHeaders,
1455
- decimalComma: exportOptions.decimalComma,
1456
- });
1664
+ ? null
1665
+ : buildExportData({ table, rows, bounds: selectionBounds });
1457
1666
 
1458
1667
  /**
1459
1668
  * Ctrl+C. Values only, no header row - Excel's own copy does not include one
@@ -1462,9 +1671,13 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1462
1671
  * because a file is a document and a clipboard is a fragment.
1463
1672
  */
1464
1673
  const copySelection = async () => {
1465
- const matrix = buildSelectionMatrix(false);
1466
- if (matrix.length === 0) return;
1467
- 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
+ );
1468
1681
  };
1469
1682
 
1470
1683
  /**
@@ -1478,12 +1691,15 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1478
1691
 
1479
1692
  // The drag ends wherever the button comes up, which is routinely outside the
1480
1693
  // grid - over the scrollbar, or past the window edge. Only the window hears
1481
- // 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.
1482
1697
  useEffect(() => {
1483
1698
  if (!isDraggingRange) return;
1484
1699
  const stop = () => setIsDraggingRange(false);
1485
- window.addEventListener("mouseup", stop);
1486
- return () => window.removeEventListener("mouseup", stop);
1700
+ const view = gridElementRef.current?.ownerDocument.defaultView ?? window;
1701
+ view.addEventListener("mouseup", stop);
1702
+ return () => view.removeEventListener("mouseup", stop);
1487
1703
  }, [isDraggingRange]);
1488
1704
 
1489
1705
  const startRangeDrag = (
@@ -1497,12 +1713,13 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1497
1713
  if (!extend) setIsDraggingRange(true);
1498
1714
  };
1499
1715
 
1500
- const exportSelectionCsv = (includeHeaders: boolean) => {
1501
- const matrix = buildSelectionMatrix(includeHeaders);
1502
- if (matrix.length === 0) return;
1503
- downloadTextFile({
1504
- fileName: `${exportOptions.fileName}.csv`,
1505
- 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,
1506
1723
  });
1507
1724
  };
1508
1725
 
@@ -1552,9 +1769,9 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1552
1769
  const row = rows.find((candidate) => candidate.id === rowId);
1553
1770
  if (row === undefined) return null;
1554
1771
  const cells = [
1555
- ...row.getLeftVisibleCells(),
1772
+ ...row.getStartVisibleCells(),
1556
1773
  ...row.getCenterVisibleCells(),
1557
- ...row.getRightVisibleCells(),
1774
+ ...row.getEndVisibleCells(),
1558
1775
  ];
1559
1776
  return (
1560
1777
  cells.find((cell) => edit.canEditCell(row, cell.column))?.column.id ??
@@ -1719,14 +1936,109 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1719
1936
  const handleGridKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
1720
1937
  const target = event.target as HTMLElement;
1721
1938
 
1722
- // Focus is on something inside a cell - a checkbox, a link, an editor. Its
1723
- // keys are its own; the one the grid still answers is the way back out.
1939
+ // The body cell the key was pressed in, whether the focus is on the cell
1940
+ // itself or on a control inside it. `null` for a header control or a tab
1941
+ // guard, neither of which is part of cell navigation.
1942
+ const keyCellElement =
1943
+ target.dataset.cell === "true"
1944
+ ? target
1945
+ : target.closest<HTMLElement>('[data-cell="true"]');
1946
+ if (keyCellElement === null) return;
1947
+
1948
+ if (event.key === "Tab") {
1949
+ // Inside a row's controls the row is a form, and Tab walks it the way
1950
+ // the browser walks any form: through the row's open editors, the
1951
+ // buttons in its cells and, on an open row, the lane's save and cancel.
1952
+ // That part is left to the browser. What the grid decides is the two
1953
+ // ends: past the row's last control the cursor moves to the next row's
1954
+ // first cell, and before its first control to the previous row's last -
1955
+ // the cell is put on the cursor and left alone, whatever it holds. A cell
1956
+ // editor never gets here, it claims Tab itself to commit and move on.
1957
+ if (target.dataset.cell !== "true") {
1958
+ const rowElement = keyCellElement.closest<HTMLElement>(
1959
+ '[data-dg-part="row"]',
1960
+ );
1961
+ const tabbables =
1962
+ rowElement === null
1963
+ ? []
1964
+ : Array.from(
1965
+ rowElement.querySelectorAll<HTMLElement>(FOCUSABLE_IN_CELL),
1966
+ ).filter((element) => element.tabIndex >= 0);
1967
+ const index = tabbables.indexOf(target);
1968
+ // A control the walk cannot place is left to the browser rather than
1969
+ // guessed about.
1970
+ if (index < 0) return;
1971
+ // The step itself is taken here rather than left to the browser: a
1972
+ // select editor keeps its option list in the row, and the browser's
1973
+ // Tab lands on that scrollable list, which the editor then closes
1974
+ // under the focus. Walking the controls, and only the controls, is
1975
+ // what the browser would do if the list were not there.
1976
+ const nextControl = tabbables[index + (event.shiftKey ? -1 : 1)];
1977
+ if (nextControl !== undefined) {
1978
+ event.preventDefault();
1979
+ nextControl.focus();
1980
+ // What Tab does on a text field, and what the caret otherwise loses.
1981
+ if (isInputElement(nextControl)) nextControl.select();
1982
+ return;
1983
+ }
1984
+ const rowIndex = rows.findIndex(
1985
+ (row) => row.id === keyCellElement.dataset.rowId,
1986
+ );
1987
+ const nextRowIndex = rowIndex + (event.shiftKey ? -1 : 1);
1988
+ const nextRow = rowIndex < 0 ? undefined : rows[nextRowIndex];
1989
+ const nextColumn = event.shiftKey
1990
+ ? orderedColumns[orderedColumns.length - 1]
1991
+ : orderedColumns[0];
1992
+ if (nextRow !== undefined && nextColumn !== undefined) {
1993
+ event.preventDefault();
1994
+ moveFocusedCell({
1995
+ rowIndex: nextRowIndex,
1996
+ columnIndex: event.shiftKey ? orderedColumns.length - 1 : 0,
1997
+ });
1998
+ // The focus-following effect stands down while the focus sits in an
1999
+ // editor, which is exactly where it sits now: move it by hand. The
2000
+ // adjacent row is mounted whenever there is any overscan; without
2001
+ // one, letting go of the editor is what lets the effect take over.
2002
+ const container = scrollContainerRef.current;
2003
+ const nextCell =
2004
+ container === null
2005
+ ? null
2006
+ : findCellElement(container, {
2007
+ rowId: nextRow.id,
2008
+ columnId: nextColumn.id,
2009
+ });
2010
+ if (nextCell !== null) nextCell.focus({ preventScroll: true });
2011
+ else target.blur();
2012
+ return;
2013
+ }
2014
+ // No row past this one: the body is left, below.
2015
+ }
2016
+
2017
+ // Tab leaves the body in one hop, from a cell or from the last control
2018
+ // of the last row. Not prevented: the focus is moved onto the guard past
2019
+ // the edge being left and the browser's own Tab then runs from there,
2020
+ // which is what puts it on the next thing after the grid - or, going
2021
+ // back, on the last header control, since the leading guard sits below
2022
+ // them.
2023
+ const guard = event.shiftKey
2024
+ ? leadingGuardRef.current
2025
+ : trailingGuardRef.current;
2026
+ if (guard === null) return;
2027
+ guardHopRef.current = true;
2028
+ guard.focus({ preventScroll: true });
2029
+ return;
2030
+ }
2031
+
2032
+ // An open editor owns the rest of its keys; the cell editor stops the
2033
+ // ones it acts on before they get here, a row-shaped one lets them pass.
2034
+ if (target.closest('[data-dg-part="editor"]') !== null) return;
2035
+
2036
+ // Focus is on something inside a cell - a checkbox, a link. Its keys are
2037
+ // its own; the one the grid still answers is the way back out.
1724
2038
  if (target.dataset.cell !== "true") {
1725
2039
  if (event.key !== "Escape") return;
1726
- const cellElement = target.closest<HTMLElement>('[data-cell="true"]');
1727
- if (cellElement === null) return;
1728
2040
  event.preventDefault();
1729
- cellElement.focus();
2041
+ keyCellElement.focus();
1730
2042
  return;
1731
2043
  }
1732
2044
 
@@ -1821,9 +2133,9 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1821
2133
  // Space selects the row, from any of its cells.
1822
2134
  //
1823
2135
  // Not routed through `handleRowActivate`, which is the *click* path and only
1824
- // selects in `"row"` mode: with the cell cursor on, the checkbox is no
1825
- // longer a tab stop (see useCellControlTabIndex), so this is the keyboard's
1826
- // way to a selection under every mode that has one - checkbox included.
2136
+ // selects in `"row"` mode: the checkbox is not in the page's tab order, so
2137
+ // this is the keyboard's way to a selection under every mode that has one -
2138
+ // checkbox included.
1827
2139
  if (event.key === " ") {
1828
2140
  event.preventDefault();
1829
2141
  const row = rows[rowIndex];
@@ -1881,12 +2193,27 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1881
2193
  return;
1882
2194
  }
1883
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
+
1884
2201
  // Never take the focus from elsewhere on the page. A consumer moving the
1885
2202
  // cell while the user is typing in a form somewhere means "move the ring",
1886
2203
  // not "move the caret" - the scroll above already did the visible part.
2204
+ if (!wantsCellFocusRef.current && !container.contains(activeElement)) {
2205
+ return;
2206
+ }
2207
+ // Mid-hop: Tab parked the focus on a guard and the browser is about to
2208
+ // carry it out of the grid. This effect can run in between - a discrete
2209
+ // event flushes its renders before the default action - and pulling the
2210
+ // focus back onto the cell here would send the browser's Tab off from
2211
+ // the cell instead. A guard the user *arrived* on has already asked for
2212
+ // the cell, and that intent is honoured.
1887
2213
  if (
1888
2214
  !wantsCellFocusRef.current &&
1889
- !container.contains(document.activeElement)
2215
+ activeElement !== null &&
2216
+ activeElement.closest('[data-dg-part="tab-guard"]') !== null
1890
2217
  ) {
1891
2218
  return;
1892
2219
  }
@@ -1897,7 +2224,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1897
2224
  wantsCellFocusRef.current = false;
1898
2225
  // Focus is already inside - on the control Enter stepped into. The cell is
1899
2226
  // where the ring belongs, so there is nothing to move.
1900
- if (cellElement.contains(document.activeElement)) return;
2227
+ if (cellElement.contains(activeElement)) return;
1901
2228
  // An open editor outranks the ring, wherever it is. Row mode opens a whole
1902
2229
  // row from one gesture, so the caret legitimately sits in a cell other
1903
2230
  // than the one that gesture landed the ring on - the lane's pencil is the
@@ -1905,8 +2232,8 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1905
2232
  // the caret back onto that cell would leave the row open with the caret in
1906
2233
  // none of its editors, and the user with nothing to type into.
1907
2234
  if (
1908
- document.activeElement instanceof Element &&
1909
- document.activeElement.closest('[data-dg-part="editor"]') !== null
2235
+ activeElement !== null &&
2236
+ activeElement.closest('[data-dg-part="editor"]') !== null
1910
2237
  ) {
1911
2238
  return;
1912
2239
  }
@@ -2005,9 +2332,9 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2005
2332
  </Menu.Item>
2006
2333
  <Menu.Item
2007
2334
  disabled={selectedDataColumnCount === 0}
2008
- onClick={() => exportSelectionCsv(exportIncludesHeaders)}
2335
+ onClick={exportSelection}
2009
2336
  >
2010
- {labels.exportCsv}
2337
+ {labels.exportCells}
2011
2338
  </Menu.Item>
2012
2339
  {/* Stays open on click: it is the setting the item above reads, and
2013
2340
  reopening the menu to change your mind about headers is a worse
@@ -2128,7 +2455,11 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2128
2455
  : undefined;
2129
2456
 
2130
2457
  return (
2131
- <div className={classes.tableWrapper}>
2458
+ <div
2459
+ ref={tableWrapperRef}
2460
+ className={classes.tableWrapper}
2461
+ data-dg-filter-sidebar={filters.surface === "sidebar" || undefined}
2462
+ >
2132
2463
  <div
2133
2464
  ref={scrollContainerRef}
2134
2465
  className={classes.scrollContainer}
@@ -2166,7 +2497,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2166
2497
  aria-rowcount={
2167
2498
  rows.length +
2168
2499
  pinnedRowCount +
2169
- headerGroups.length +
2500
+ headerRowCount +
2170
2501
  (hasSummaryRow ? 1 : 0)
2171
2502
  }
2172
2503
  aria-colcount={orderedColumns.length}
@@ -2186,11 +2517,16 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2186
2517
  data-dg-header-row
2187
2518
  // The scrolled-under shadow belongs to the boundary between
2188
2519
  // header and body - the last header row, not every stacked
2189
- // group row above it. See the stylesheet.
2190
- data-dg-header-last={
2191
- groupIndex === headerGroups.length - 1 || undefined
2192
- }
2193
- 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(" ")}
2194
2530
  >
2195
2531
  {headerGroup.headers.map((header) => (
2196
2532
  <TMDataGridHeaderCell
@@ -2202,8 +2538,34 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2202
2538
  ))}
2203
2539
  </div>
2204
2540
  ))}
2541
+
2542
+ {hasHeaderFilterRow && (
2543
+ <TMDataGridHeaderFilterRow
2544
+ leafHeaders={leafHeaders}
2545
+ layoutFor={layoutFor}
2546
+ ariaRowIndex={headerRowCount}
2547
+ />
2548
+ )}
2205
2549
  </div>
2206
2550
 
2551
+ {/* The leading tab guard, below the header controls and above the
2552
+ first body row - see `handleGuardFocus`. Not `aria-hidden`: it
2553
+ is focusable, and a focusable node hidden from the tree is what
2554
+ screen readers have no way to describe.
2555
+ Out of the tab order while there is no cell to hand focus to,
2556
+ so an empty body is not two dead stops. */}
2557
+ {cellSelection && (
2558
+ <div
2559
+ ref={leadingGuardRef}
2560
+ tabIndex={tabStopCell === null ? -1 : 0}
2561
+ role="presentation"
2562
+ data-dg-part="tab-guard"
2563
+ data-guard="leading"
2564
+ className={classes.tabGuard}
2565
+ onFocus={handleGuardFocus}
2566
+ />
2567
+ )}
2568
+
2207
2569
  {/* The entry block - new rows being typed, stuck under the header.
2208
2570
  Present only while `edit.addRow()` has open entries. */}
2209
2571
  {features.editing && newRowCount > 0 && (
@@ -2242,9 +2604,9 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2242
2604
  const isGroupRow = row.getIsGrouped();
2243
2605
  const isInteractive = rowGesturesFor(row);
2244
2606
  const rowCells = [
2245
- ...row.getLeftVisibleCells(),
2607
+ ...row.getStartVisibleCells(),
2246
2608
  ...row.getCenterVisibleCells(),
2247
- ...row.getRightVisibleCells(),
2609
+ ...row.getEndVisibleCells(),
2248
2610
  ];
2249
2611
  // Row mode opens every editable cell of the row at once, and
2250
2612
  // rows accumulate: a second row opening leaves the first one
@@ -2255,10 +2617,14 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2255
2617
  // A parked row is not one of them. It keeps its form, so it is
2256
2618
  // still "open", but it has been decided: it renders its draft
2257
2619
  // through the column's own renderer until `begin` reopens it.
2620
+ // A committed entry row in the body has a form too - every
2621
+ // entry row does - but it is a value row until reopened, and
2622
+ // reopening takes it back to the entry block.
2258
2623
  const rowEditing =
2259
2624
  features.editMode === "row" &&
2260
- editOpenRowIds.includes(row.id) &&
2261
- !editDraftRowIds.includes(row.id);
2625
+ editOpenRowIdSet.has(row.id) &&
2626
+ !editDraftRowIdSet.has(row.id) &&
2627
+ !newRowIdSet.has(row.id);
2262
2628
  // Cell selection takes the body's tab stop off the row and puts
2263
2629
  // it on a cell - two stops per row would make Tab a way of
2264
2630
  // walking the grid, which is what the arrow keys are for. Space
@@ -2295,7 +2661,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2295
2661
  // The tree's own rows. `data-grouped` rather than a class, to
2296
2662
  // match how the rest of the row's state is published - and so
2297
2663
  // a consumer can restyle them without reaching into modules.
2298
- data-grouped={isGroupRow}
2664
+ data-grouped={isGroupRow || undefined}
2299
2665
  data-depth={row.depth}
2300
2666
  // Which edge block the row sits in, for styling hooks.
2301
2667
  data-pinned={pinnedAt}
@@ -2304,13 +2670,16 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2304
2670
  // still hold ids from before the mode changed - TanStack never
2305
2671
  // prunes it - and a row must not report itself selected in a
2306
2672
  // mode where selecting is not a thing.
2307
- data-selected={features.rowSelection && row.getIsSelected()}
2673
+ data-selected={
2674
+ (features.rowSelection && row.getIsSelected()) || undefined
2675
+ }
2308
2676
  // Being selected is state; painting it is a display choice, so
2309
2677
  // the two are separate attributes.
2310
2678
  data-selected-bg={
2311
- features.rowSelection &&
2312
- features.showSelectedBackground &&
2313
- row.getIsSelected()
2679
+ (features.rowSelection &&
2680
+ features.showSelectedBackground &&
2681
+ row.getIsSelected()) ||
2682
+ undefined
2314
2683
  }
2315
2684
  // The highlighted row is its own concept, so its own attribute
2316
2685
  // pair - `data-highlighted` / `aria-current` against
@@ -2322,32 +2691,40 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2322
2691
  // so a consumer styling or querying rows by it would cast far
2323
2692
  // wider than they meant to.
2324
2693
  data-highlighted={
2325
- features.highlightRow && row.id === highlightedRowId
2694
+ (features.highlightRow && row.id === highlightedRowId) ||
2695
+ undefined
2326
2696
  }
2327
2697
  // Marked deleted under draft: struck through and inert
2328
- // until submitAll reports it, or the mark is toggled back.
2698
+ // until saveDrafts reports it, or the mark is toggled back.
2329
2699
  data-deleted={
2330
- deletedRowIds.length > 0 && deletedRowIds.includes(row.id)
2700
+ (deletedRowIds.length > 0 && deletedRowIds.includes(row.id)) ||
2701
+ undefined
2331
2702
  }
2332
2703
  // Carrying a dirty draft - the row-level face of the cells'
2333
2704
  // own data-dirty markers, for row-scoped styling.
2334
- data-dirty={
2335
- editDirtyRowIds.length > 0 &&
2336
- editDirtyRowIds.includes(row.id)
2337
- }
2338
- // Committed into the draft store, waiting for Save.
2705
+ data-dirty={editDirtyRowIdSet.has(row.id) || undefined}
2706
+ // Committed into the draft store, waiting for Save. A
2707
+ // committed entry row is one too - it is in the body
2708
+ // because it is committed - and carries `data-new` besides.
2339
2709
  data-draft={
2340
- editDraftRowIds.length > 0 &&
2341
- editDraftRowIds.includes(row.id)
2710
+ editDraftRowIdSet.has(row.id) ||
2711
+ newRowIdSet.has(row.id) ||
2712
+ undefined
2342
2713
  }
2714
+ // An entered row not yet in `data`, committed into the draft
2715
+ // store and sorted, filtered and grouped with the rest.
2716
+ data-new={newRowIdSet.has(row.id) || undefined}
2343
2717
  // The menu is anchored to the rowgroup, so Mantine's own
2344
2718
  // `data-expanded` lands there rather than on a row. This is
2345
2719
  // what says which row the open menu is about.
2346
2720
  data-context-menu={
2347
- contextMenuContent !== null &&
2348
- contextMenuTarget?.rowId === row.id
2721
+ (contextMenuContent !== null &&
2722
+ contextMenuTarget?.rowId === row.id) ||
2723
+ undefined
2724
+ }
2725
+ data-selects-on-click={
2726
+ (selectsOnRowClick && !isGroupRow) || undefined
2349
2727
  }
2350
- data-selects-on-click={selectsOnRowClick && !isGroupRow}
2351
2728
  aria-selected={
2352
2729
  features.rowSelection ? row.getIsSelected() : undefined
2353
2730
  }
@@ -2359,7 +2736,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2359
2736
  // From the row's position in the whole view, so the stripes
2360
2737
  // survive the virtualizer's moving window - see the prop.
2361
2738
  data-striped={
2362
- striped && viewIndex >= 0 ? viewIndex % 2 === 1 : undefined
2739
+ (striped && viewIndex >= 0 && viewIndex % 2 === 1) || undefined
2363
2740
  }
2364
2741
  className={[classes.bodyRow, rowClassNameFor(row)]
2365
2742
  .filter(Boolean)
@@ -2492,13 +2869,10 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2492
2869
  row.id,
2493
2870
  cell.column.id,
2494
2871
  ),
2495
- tabIndex: isCellAt(
2496
- tabStopCell,
2497
- row.id,
2498
- cell.column.id,
2499
- )
2500
- ? 0
2501
- : -1,
2872
+ // Focusable by click and by the grid, never by
2873
+ // Tab - the guards are the way in. See
2874
+ // `tabStopCell`.
2875
+ tabIndex: -1,
2502
2876
  // "Selected" means "in the block Ctrl+C would
2503
2877
  // take" - which under `"single"` is the one cell
2504
2878
  // the user is standing on.
@@ -2524,6 +2898,24 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2524
2898
  // Right-click is the menu's; the middle button is
2525
2899
  // the browser's.
2526
2900
  if (event.button !== 0) return;
2901
+ // Pressing a control in the cell - a checkbox, a
2902
+ // chevron, a button a renderer put there - is not
2903
+ // a selection gesture, and a block the user built
2904
+ // should not pay for it. The focus still follows
2905
+ // the press, through the bubbling `onFocus`, so
2906
+ // the cursor ends up on the row acted on; only
2907
+ // the range survives.
2908
+ const pressed = event.target as HTMLElement;
2909
+ const control =
2910
+ pressed === event.currentTarget
2911
+ ? null
2912
+ : pressed.closest(FOCUSABLE_IN_CELL);
2913
+ if (
2914
+ control !== null &&
2915
+ event.currentTarget.contains(control)
2916
+ ) {
2917
+ return;
2918
+ }
2527
2919
  // Shift+click would otherwise smear the browser's
2528
2920
  // own text selection across everything between
2529
2921
  // the two cells.
@@ -2611,7 +3003,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2611
3003
  renderBodyRow(
2612
3004
  row,
2613
3005
  -1,
2614
- headerGroups.length + index + 1,
3006
+ headerRowCount + index + 1,
2615
3007
  "top",
2616
3008
  ),
2617
3009
  )}
@@ -2632,7 +3024,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2632
3024
  row,
2633
3025
  virtualItem.index,
2634
3026
  virtualItem.index +
2635
- headerGroups.length +
3027
+ headerRowCount +
2636
3028
  pinnedTopRows.length +
2637
3029
  1,
2638
3030
  );
@@ -2655,7 +3047,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2655
3047
  renderBodyRow(
2656
3048
  row,
2657
3049
  -1,
2658
- headerGroups.length +
3050
+ headerRowCount +
2659
3051
  pinnedTopRows.length +
2660
3052
  rows.length +
2661
3053
  index +
@@ -2711,7 +3103,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2711
3103
  <div
2712
3104
  role="row"
2713
3105
  aria-rowindex={
2714
- rows.length + pinnedRowCount + headerGroups.length + 1
3106
+ rows.length + pinnedRowCount + headerRowCount + 1
2715
3107
  }
2716
3108
  // Also what the height measurement above looks for. One
2717
3109
  // attribute serves both now that the part name *is* the
@@ -2727,7 +3119,9 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2727
3119
  role="cell"
2728
3120
  data-column-id={header.column.id}
2729
3121
  data-align={getColumnAlign(header.column)}
2730
- data-control-column={isControlColumn(header.column.id)}
3122
+ data-control-column={
3123
+ isControlColumn(header.column.id) || undefined
3124
+ }
2731
3125
  className={[
2732
3126
  classes.summaryCell,
2733
3127
  layout.isBoundary && layout.pinnedAt === "left"
@@ -2764,10 +3158,30 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2764
3158
  })}
2765
3159
  </div>
2766
3160
  )}
3161
+
3162
+ {/* The trailing tab guard, last child of the grid so that Shift+Tab
3163
+ from below the grid meets it before any cell. */}
3164
+ {cellSelection && (
3165
+ <div
3166
+ ref={trailingGuardRef}
3167
+ tabIndex={tabStopCell === null ? -1 : 0}
3168
+ role="presentation"
3169
+ data-dg-part="tab-guard"
3170
+ data-guard="trailing"
3171
+ className={classes.tabGuard}
3172
+ onFocus={handleGuardFocus}
3173
+ />
3174
+ )}
2767
3175
  </div>
2768
3176
  </div>
2769
3177
 
2770
- <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 />}
2771
3185
  </div>
2772
3186
  );
2773
3187
  }