@jielga/tmdatagrid 0.5.0 → 1.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 (62) hide show
  1. package/README.md +25 -2
  2. package/dist/index.d.ts +856 -14
  3. package/dist/index.js +3370 -1546
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles.css +1 -1
  6. package/dist/styles.layer.css +1 -0
  7. package/package.json +12 -2
  8. package/skills/columns/SKILL.md +1 -1
  9. package/skills/features/SKILL.md +1 -1
  10. package/skills/getting-started/SKILL.md +1 -1
  11. package/skills/options/SKILL.md +1 -1
  12. package/skills/server-side/SKILL.md +1 -1
  13. package/skills/testing/SKILL.md +198 -0
  14. package/src/tmdatagrid/components/TMDataGrid.module.css +11 -0
  15. package/src/tmdatagrid/components/TMDataGrid.tsx +34 -0
  16. package/src/tmdatagrid/components/TMDataGridCellEditor.tsx +256 -0
  17. package/src/tmdatagrid/components/TMDataGridColumnsPanel.tsx +35 -12
  18. package/src/tmdatagrid/components/TMDataGridDetailsColumn.tsx +3 -0
  19. package/src/tmdatagrid/components/TMDataGridEditActions.tsx +62 -0
  20. package/src/tmdatagrid/components/TMDataGridEditColumn.tsx +237 -0
  21. package/src/tmdatagrid/components/TMDataGridEntryRows.tsx +164 -0
  22. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +77 -62
  23. package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +6 -0
  24. package/src/tmdatagrid/components/TMDataGridFooter.tsx +10 -3
  25. package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +2 -0
  26. package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +15 -2
  27. package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +21 -4
  28. package/src/tmdatagrid/components/TMDataGridRowNumberColumn.tsx +44 -0
  29. package/src/tmdatagrid/components/TMDataGridSearch.tsx +2 -0
  30. package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +3 -0
  31. package/src/tmdatagrid/components/TMDataGridTable.module.css +206 -0
  32. package/src/tmdatagrid/components/TMDataGridTable.tsx +841 -57
  33. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +12 -7
  34. package/src/tmdatagrid/components/editors/TMDataGridBooleanEditor.tsx +29 -0
  35. package/src/tmdatagrid/components/editors/TMDataGridDateEditor.tsx +57 -0
  36. package/src/tmdatagrid/components/editors/TMDataGridMultiSelectEditor.tsx +45 -0
  37. package/src/tmdatagrid/components/editors/TMDataGridNumberEditor.tsx +38 -0
  38. package/src/tmdatagrid/components/editors/TMDataGridSelectEditor.tsx +53 -0
  39. package/src/tmdatagrid/components/editors/TMDataGridStringEditor.tsx +31 -0
  40. package/src/tmdatagrid/components/editors/editorShared.ts +48 -0
  41. package/src/tmdatagrid/components/filters/DgAutocompleteFilter.tsx +46 -0
  42. package/src/tmdatagrid/components/filters/DgDateRangeFilter.tsx +43 -0
  43. package/src/tmdatagrid/components/filters/DgRangeSliderFilter.tsx +52 -0
  44. package/src/tmdatagrid/components/filters/DgTriStateFilter.tsx +38 -0
  45. package/src/tmdatagrid/components/filters/TMDataGridFilterValueInput.tsx +122 -0
  46. package/src/tmdatagrid/components/icons.ts +4 -0
  47. package/src/tmdatagrid/core/autosize.ts +1 -1
  48. package/src/tmdatagrid/core/capabilities.ts +37 -1
  49. package/src/tmdatagrid/core/cellNavigation.ts +41 -0
  50. package/src/tmdatagrid/core/columnUtils.ts +31 -6
  51. package/src/tmdatagrid/core/editEngine.ts +857 -0
  52. package/src/tmdatagrid/core/filterControls.ts +45 -0
  53. package/src/tmdatagrid/core/filterOperators.ts +72 -4
  54. package/src/tmdatagrid/core/labels.ts +68 -1
  55. package/src/tmdatagrid/core/labelsSv.ts +24 -1
  56. package/src/tmdatagrid/core/matchHighlight.ts +96 -0
  57. package/src/tmdatagrid/core/persistence.ts +97 -3
  58. package/src/tmdatagrid/core/quickSearch.ts +144 -0
  59. package/src/tmdatagrid/core/rowPinning.ts +37 -0
  60. package/src/tmdatagrid/core/rowSelection.ts +10 -1
  61. package/src/tmdatagrid/index.ts +51 -0
  62. package/src/tmdatagrid/useTMDataGrid.tsx +456 -15
@@ -1,6 +1,7 @@
1
1
  import { Checkbox, Loader, Menu, type MenuProps, Text } from "@mantine/core";
2
2
  import {
3
3
  type Cell,
4
+ type Column,
4
5
  flexRender,
5
6
  type Row,
6
7
  type RowData,
@@ -9,9 +10,11 @@ import {
9
10
  import { useSelector } from "@tanstack/react-store";
10
11
  import { useVirtualizer } from "@tanstack/react-virtual";
11
12
  import {
13
+ type CSSProperties,
12
14
  type KeyboardEvent,
13
15
  type MouseEvent,
14
16
  type ReactNode,
17
+ type UIEvent,
15
18
  useCallback,
16
19
  useEffect,
17
20
  useRef,
@@ -22,8 +25,15 @@ import sticky from "./sticky.module.css";
22
25
  import { type TMDataGridRowData, useTMDataGridContext } from "../TMDataGridContext";
23
26
  import { TMDataGridFilterPanel } from "./TMDataGridFilterPanel";
24
27
  import { TMDataGridHeaderCell } from "./TMDataGridHeaderCell";
28
+ import {
29
+ TMDataGridCellEditor,
30
+ type TMDataGridCellEditorClose,
31
+ } from "./TMDataGridCellEditor";
32
+ import { TMDataGridEntryRows } from "./TMDataGridEntryRows";
25
33
  import { getColumnAlign, isControlColumn } from "../core/columnUtils";
34
+ import { getEditFieldName } from "../core/editEngine";
26
35
  import {
36
+ getNextEditableCell,
27
37
  resolveCellMove,
28
38
  type TMDataGridCellPosition,
29
39
  } from "../core/cellNavigation";
@@ -46,8 +56,12 @@ import { autosizeColumn } from "../core/autosize";
46
56
  import {
47
57
  getDisplayedRows,
48
58
  getSelectableRowIds,
59
+ isPagingActive,
49
60
  resolveRowSelectionClick,
50
61
  } from "../core/rowSelection";
62
+ import { readPinnedRows } from "../core/rowPinning";
63
+ import { buildMatchNeedles, findMatchRanges } from "../core/matchHighlight";
64
+ import { ROW_NUMBER_COLUMN_ID } from "./TMDataGridRowNumberColumn";
51
65
  import { SearchIcon } from "./icons";
52
66
  import type { TMDataGridFeatures } from "../useTMDataGrid";
53
67
 
@@ -187,25 +201,87 @@ export type TMDataGridCellNav = {
187
201
  edges: { top: boolean; bottom: boolean; left: boolean; right: boolean } | null;
188
202
  };
189
203
 
204
+ /**
205
+ * The text with its matched slices wrapped in `<mark>` — or handed back
206
+ * whole when nothing matches. Only ever called on string output; a renderer
207
+ * returning its own elements never reaches this.
208
+ */
209
+ function withMatchHighlights(
210
+ text: string,
211
+ needles: ReadonlyArray<string>,
212
+ ): ReactNode {
213
+ const ranges = findMatchRanges(text, needles);
214
+ if (ranges === null) return text;
215
+ const parts: Array<ReactNode> = [];
216
+ let cursor = 0;
217
+ for (const range of ranges) {
218
+ if (range.start > cursor) parts.push(text.slice(cursor, range.start));
219
+ parts.push(
220
+ <mark key={range.start} className={classes.matchHighlight}>
221
+ {text.slice(range.start, range.end)}
222
+ </mark>,
223
+ );
224
+ cursor = range.end;
225
+ }
226
+ if (cursor < text.length) parts.push(text.slice(cursor));
227
+ return parts;
228
+ }
229
+
190
230
  function TMDataGridBodyCell({
191
231
  cell,
192
232
  rowHeight,
193
233
  layout,
194
234
  nav,
235
+ editor,
236
+ contentOverride,
237
+ highlightNeedles,
195
238
  onFocus,
239
+ onClick,
196
240
  onMouseDown,
197
241
  onMouseEnter,
242
+ onDoubleClick,
198
243
  onContextMenu,
199
244
  }: {
200
245
  cell: Cell<TMDataGridFeatures, TMDataGridRowData, unknown>;
201
246
  rowHeight: number;
202
247
  layout: TMDataGridColumnLayout;
203
248
  nav?: TMDataGridCellNav;
249
+ /** The mounted editor, when this is the cell being edited. */
250
+ editor?: ReactNode;
251
+ /**
252
+ * Replaces the column renderer's output — the row-number gutter, whose
253
+ * value only the body knows. `undefined` means "render normally".
254
+ */
255
+ contentOverride?: ReactNode;
256
+ /**
257
+ * Needles to highlight in string output — see `enableMatchHighlighting`.
258
+ * `undefined` almost always: only set while a highlightable filter is
259
+ * active for this column.
260
+ */
261
+ highlightNeedles?: ReadonlyArray<string>;
204
262
  onFocus?: () => void;
263
+ onClick?: (event: MouseEvent<HTMLDivElement>) => void;
205
264
  onMouseDown?: (event: MouseEvent<HTMLDivElement>) => void;
206
265
  onMouseEnter?: () => void;
266
+ onDoubleClick?: (event: MouseEvent<HTMLDivElement>) => void;
207
267
  onContextMenu?: (event: MouseEvent<HTMLDivElement>) => void;
208
268
  }) {
269
+ // The edit markers subscribe here, per cell, all to the one projection
270
+ // store — so typing in an editor repaints the edited row's cells and
271
+ // nothing else. Constant `false` everywhere while editing is off.
272
+ const { table, edit } = useTMDataGridContext();
273
+ const fieldName = getEditFieldName(cell.column);
274
+ const cellRowId = cell.row.id;
275
+ const isDirty = useSelector(edit.store, (state) => {
276
+ if (fieldName === null) return false;
277
+ const projection = state.rows[cellRowId];
278
+ return projection !== undefined && projection.dirtyFields.includes(fieldName);
279
+ });
280
+ const isInvalid = useSelector(edit.store, (state) => {
281
+ if (fieldName === null) return false;
282
+ const projection = state.rows[cellRowId];
283
+ return projection !== undefined && projection.errorFields.includes(fieldName);
284
+ });
209
285
  return (
210
286
  <div
211
287
  // `gridcell` rather than `cell` is the whole promise of cell selection:
@@ -218,10 +294,12 @@ function TMDataGridBodyCell({
218
294
  // a cell is the grid's, one from inside a cell belongs to whatever holds
219
295
  // the focus in there.
220
296
  data-cell={nav ? true : undefined}
221
- data-row-id={nav?.rowId}
222
- // Always present, not only under cell selection: autosize finds a
223
- // column's mounted cells by it. The cell-navigation selectors also
224
- // require `data-cell`, so they are unaffected.
297
+ // Both coordinates are always present, not only under cell selection:
298
+ // autosize finds a column's mounted cells by the column id, and the pair
299
+ // is how a test addresses one cell —
300
+ // `[data-row-id="42"][data-column-id="name"]`. The cell-navigation
301
+ // selectors also require `data-cell`, so they are unaffected.
302
+ data-row-id={cell.row.id}
225
303
  data-column-id={cell.column.id}
226
304
  data-focused={nav?.focused}
227
305
  aria-selected={nav?.selected}
@@ -237,8 +315,13 @@ function TMDataGridBodyCell({
237
315
  // landing on the cell — the ring follows the user into the checkbox
238
316
  // rather than being left behind on whichever cell they came from.
239
317
  onFocus={onFocus}
318
+ onClick={onClick}
240
319
  onMouseDown={onMouseDown}
241
320
  onMouseEnter={onMouseEnter}
321
+ onDoubleClick={onDoubleClick}
322
+ data-editing={editor !== undefined || undefined}
323
+ data-dirty={isDirty || undefined}
324
+ data-invalid={isInvalid || undefined}
242
325
  data-align={getColumnAlign(cell.column)}
243
326
  // A control lane is a fixed track, so it cannot take the cell padding the
244
327
  // scale grows for text. See isControlColumn.
@@ -274,7 +357,29 @@ function TMDataGridBodyCell({
274
357
  : undefined,
275
358
  }}
276
359
  >
277
- <span className={classes.cellContent}>{renderCellContent(cell)}</span>
360
+ {editor ?? (
361
+ <span className={classes.cellContent}>
362
+ {(() => {
363
+ // Highlighting replicates the *default* renderer — value, then
364
+ // `toString` — with the matched slices marked. A column with its
365
+ // own `cell` opts out by existing: its renderer's identity is not
366
+ // the default's, and the grid will not rummage inside its output.
367
+ if (
368
+ highlightNeedles !== undefined &&
369
+ contentOverride === undefined &&
370
+ !cell.getIsPlaceholder() &&
371
+ !cell.getIsAggregated() &&
372
+ cell.column.columnDef.cell === table.getDefaultColumnDef().cell
373
+ ) {
374
+ const value = cell.renderValue();
375
+ if (typeof value === "string" || typeof value === "number") {
376
+ return withMatchHighlights(String(value), highlightNeedles);
377
+ }
378
+ }
379
+ return contentOverride ?? renderCellContent(cell);
380
+ })()}
381
+ </span>
382
+ )}
278
383
  </div>
279
384
  );
280
385
  }
@@ -368,12 +473,101 @@ export type TMDataGridRowContextMenu<TData extends RowData> = (
368
473
  args: TMDataGridRowContextMenuArgs<TData>,
369
474
  ) => ReactNode;
370
475
 
476
+ /**
477
+ * What the `onCellClick` family receives — the cell with its row and column,
478
+ * in the consumer's own row type, plus the pointer event.
479
+ */
480
+ export type TMDataGridCellEventArgs<TData extends RowData> = {
481
+ cell: Cell<TMDataGridFeatures, TData, unknown>;
482
+ row: Row<TMDataGridFeatures, TData>;
483
+ column: Column<TMDataGridFeatures, TData, unknown>;
484
+ event: MouseEvent<HTMLElement>;
485
+ };
486
+
487
+ /**
488
+ * Inline styles for a row. Widened to accept CSS variables, because setting
489
+ * `--row-bg` is the supported way to colour a row — see {@link
490
+ * TMDataGridTableProps.rowStyle}.
491
+ */
492
+ export type TMDataGridRowStyle =
493
+ | CSSProperties
494
+ | (CSSProperties & Record<`--${string}`, string | number>);
495
+
371
496
  export type TMDataGridTableProps<TData extends RowData> = {
372
497
  /**
373
498
  * Called when a body row is clicked. Runs in addition to row selection under
374
499
  * `selectionMode: "row"`, not instead of it.
375
500
  */
376
501
  onRowClick?: (row: Row<TMDataGridFeatures, TData>) => void;
502
+ /**
503
+ * Called when a body cell is clicked. Composes, never suppresses: whatever
504
+ * the click already does — selecting, highlighting, moving the cell cursor
505
+ * — still happens. Group rows sit out the cell handlers the way they sit
506
+ * out `onRowClick`, and for the same reason.
507
+ */
508
+ onCellClick?: (args: TMDataGridCellEventArgs<TData>) => void;
509
+ /** As {@link onCellClick}; a double-click that opens an editor still does. */
510
+ onCellDoubleClick?: (args: TMDataGridCellEventArgs<TData>) => void;
511
+ /**
512
+ * As {@link onCellClick}; `rowContextMenu` and the cell-selection menu still
513
+ * open. To suppress the browser's own menu on a grid with neither, call
514
+ * `event.preventDefault()` here.
515
+ */
516
+ onCellContextMenu?: (args: TMDataGridCellEventArgs<TData>) => void;
517
+ /**
518
+ * Class for a body row — a string, or a function of the row (group rows
519
+ * included; test `row.getIsGrouped()` to skip them). Added after the grid's
520
+ * own row class.
521
+ */
522
+ rowClassName?:
523
+ | string
524
+ | ((row: Row<TMDataGridFeatures, TData>) => string | undefined);
525
+ /**
526
+ * Inline style for a body row. To colour a row, set `--row-bg` rather than
527
+ * `background`: the row's own background, its sticky pinned cells and the
528
+ * cell-range tint all read that variable, and hover/selection/highlight
529
+ * keep working on top of it — a raw `background` bypasses all of them.
530
+ *
531
+ * ```tsx
532
+ * rowStyle={(row) =>
533
+ * row.original.overdue ? { "--row-bg": "var(--mantine-color-red-0)" } : undefined
534
+ * }
535
+ * ```
536
+ */
537
+ rowStyle?:
538
+ | TMDataGridRowStyle
539
+ | ((row: Row<TMDataGridFeatures, TData>) => TMDataGridRowStyle | undefined);
540
+ /**
541
+ * Every second row takes `--dg-row-striped-bg`. Computed from the row's
542
+ * position in the view — sorting and filtering restripe, and the stripes
543
+ * stay put while the virtualizer mounts and unmounts rows.
544
+ */
545
+ striped?: boolean;
546
+ /**
547
+ * Called when the scroll *arrives* at an edge — once per arrival, not per
548
+ * scroll event, and not on mount (the grid starts at the top-left edge).
549
+ * For loading more rows as the end approaches, `onReachEnd` is the better
550
+ * hook: it fires rows-early and latches per row count.
551
+ */
552
+ onScrollToTop?: () => void;
553
+ /** As {@link onScrollToTop}, for the bottom edge. */
554
+ onScrollToBottom?: () => void;
555
+ /** As {@link onScrollToTop}, for the left edge. */
556
+ onScrollToLeft?: () => void;
557
+ /** As {@link onScrollToTop}, for the right edge. */
558
+ onScrollToRight?: () => void;
559
+ /**
560
+ * Rendered centred in the body when the view is empty — the branded blank
561
+ * slate. It takes over states 3 and 4 of the empty matrix (see the docs):
562
+ * loading still shows the loader, and open entry rows still show only
563
+ * themselves. `hasActiveFilters` says which emptiness this is — a filter
564
+ * that matched nothing wants a "clear filters" invitation, a grid with no
565
+ * data wants a "create the first one".
566
+ */
567
+ renderEmptyState?: (args: {
568
+ hasActiveFilters: boolean;
569
+ table: Table<TMDataGridFeatures, TData>;
570
+ }) => ReactNode;
377
571
  /**
378
572
  * Contents of the menu a right-click on a row opens, at the pointer. Return
379
573
  * `null` to leave a row without one — the browser's own menu stays suppressed
@@ -428,6 +622,14 @@ export type TMDataGridTableProps<TData extends RowData> = {
428
622
  * How many rows before the end {@link onReachEnd} fires at. Defaults to 10.
429
623
  */
430
624
  reachEndThreshold?: number;
625
+ /**
626
+ * Accessible name for the grid — what a screen reader announces on entry,
627
+ * and what `getByRole("grid", { name })` matches. Worth setting on any page
628
+ * holding more than one grid; without it they are all just "grid".
629
+ */
630
+ "aria-label"?: string;
631
+ /** As {@link "aria-label"}, pointing at an element that already names it. */
632
+ "aria-labelledby"?: string;
431
633
  };
432
634
 
433
635
  /**
@@ -438,15 +640,29 @@ export type TMDataGridTableProps<TData extends RowData> = {
438
640
  */
439
641
  export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
440
642
  onRowClick,
643
+ onCellClick,
644
+ onCellDoubleClick,
645
+ onCellContextMenu,
646
+ rowClassName,
647
+ rowStyle,
648
+ striped = false,
649
+ onScrollToTop,
650
+ onScrollToBottom,
651
+ onScrollToLeft,
652
+ onScrollToRight,
653
+ renderEmptyState,
441
654
  rowContextMenu,
442
655
  rowContextMenuProps,
443
656
  cellExport,
444
657
  onReachEnd,
445
658
  reachEndThreshold = 10,
659
+ "aria-label": ariaLabel,
660
+ "aria-labelledby": ariaLabelledBy,
446
661
  }: TMDataGridTableProps<TData>) {
447
662
  const {
448
663
  table,
449
664
  ui,
665
+ edit,
450
666
  features,
451
667
  labels,
452
668
  rowHeight,
@@ -454,6 +670,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
454
670
  renderDetails,
455
671
  renderDetailsEstHeight,
456
672
  overscan,
673
+ scrollerRef,
457
674
  } = useTMDataGridContext();
458
675
 
459
676
  // The body depends on every state slice (sorting, filters, paging, sizing,
@@ -465,6 +682,11 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
465
682
  // with cell selection off never re-renders for it.
466
683
  const focusedCell = useSelector(ui, (state) => state.focusedCell);
467
684
  const cellRange = useSelector(ui, (state) => state.cellRange);
685
+ // Which cell has an editor open. The projection (dirty/error markers) is
686
+ // subscribed per cell instead — see TMDataGridBodyCell.
687
+ const editActive = useSelector(edit.store, (state) => state.active);
688
+ const newRowCount = useSelector(edit.store, (state) => state.newRows.length);
689
+ const deletedRowIds = useSelector(edit.store, (state) => state.deletedRowIds);
468
690
 
469
691
  const { loading, noResultsLabel = labels.noResults } =
470
692
  table.options.meta ?? {};
@@ -475,6 +697,37 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
475
697
  // the rows as delivered, so the same branch serves server paging.
476
698
  const rows = getDisplayedRows(table, features);
477
699
 
700
+ // Per-column highlight needles, or `null` while off or nothing is active —
701
+ // the common case, costing one flag check per render.
702
+ const matchNeedles = features.matchHighlighting
703
+ ? buildMatchNeedles(table)
704
+ : null;
705
+
706
+ // The edge blocks. `getDisplayedRows` above has already taken these out of
707
+ // the scrolling order; stale ids resolve to nothing — see readPinnedRows.
708
+ const pinnedTopRows = features.rowPinning ? readPinnedRows(table, "top") : [];
709
+ const pinnedBottomRows = features.rowPinning
710
+ ? readPinnedRows(table, "bottom")
711
+ : [];
712
+ const pinnedRowCount = pinnedTopRows.length + pinnedBottomRows.length;
713
+
714
+ // The row-number gutter's numbers: one pass over the view per render, only
715
+ // while the lane exists. A cell cannot know its display position, so the
716
+ // body hands each row its number — group rows get none (they are headings
717
+ // over the rows being counted), and paging continues the count across
718
+ // pages rather than restarting every page at 1.
719
+ let rowNumberById: Map<string, number> | null = null;
720
+ if (features.rowNumbers) {
721
+ rowNumberById = new Map();
722
+ let n = isPagingActive(table, features)
723
+ ? table.store.state.pagination.pageIndex *
724
+ table.store.state.pagination.pageSize
725
+ : 0;
726
+ for (const viewRow of rows) {
727
+ if (!viewRow.getIsGrouped()) rowNumberById.set(viewRow.id, ++n);
728
+ }
729
+ }
730
+
478
731
  /**
479
732
  * Whether this row opens a detail panel underneath it.
480
733
  *
@@ -505,6 +758,79 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
505
758
  }
506
759
  });
507
760
 
761
+ /**
762
+ * The header's height, published as `--dg-header-height` for the entry
763
+ * block to stick under — never measured otherwise (`top: 0` needs no
764
+ * number). A ResizeObserver, mounted only while the block is in use: the
765
+ * same discipline `renderDetails` applies to `measureElement`.
766
+ */
767
+ const gridElementRef = useRef<HTMLDivElement>(null);
768
+ useEffect(() => {
769
+ if (newRowCount === 0 && pinnedTopRows.length === 0) return;
770
+ const grid = gridElementRef.current;
771
+ if (grid === null) return;
772
+ const headerRows = grid.querySelectorAll<HTMLElement>(
773
+ "[data-dg-header-row]",
774
+ );
775
+ const measure = () => {
776
+ let total = 0;
777
+ for (const headerRow of headerRows) total += headerRow.offsetHeight;
778
+ grid.style.setProperty("--dg-header-height", `${total}px`);
779
+ };
780
+ measure();
781
+ const observer = new ResizeObserver(measure);
782
+ for (const headerRow of headerRows) observer.observe(headerRow);
783
+ return () => observer.disconnect();
784
+ }, [newRowCount, pinnedTopRows.length]);
785
+
786
+ /**
787
+ * The entry block's height, published as `--dg-entry-height` so the pinned
788
+ * top block can stack under it — measured only while both blocks exist,
789
+ * which is the only time the offset is anything but 0.
790
+ */
791
+ useEffect(() => {
792
+ if (newRowCount === 0 || pinnedTopRows.length === 0) return;
793
+ const grid = gridElementRef.current;
794
+ if (grid === null) return;
795
+ const entryBlock = grid.querySelector<HTMLElement>("[data-dg-entry-block]");
796
+ if (entryBlock === null) return;
797
+ const measure = () =>
798
+ grid.style.setProperty("--dg-entry-height", `${entryBlock.offsetHeight}px`);
799
+ measure();
800
+ const observer = new ResizeObserver(measure);
801
+ observer.observe(entryBlock);
802
+ return () => {
803
+ observer.disconnect();
804
+ grid.style.removeProperty("--dg-entry-height");
805
+ };
806
+ }, [newRowCount, pinnedTopRows.length]);
807
+
808
+ /**
809
+ * The summary row's height, published as `--dg-summary-height` so the
810
+ * pinned bottom block sticks above it rather than underneath it.
811
+ */
812
+ useEffect(() => {
813
+ if (pinnedBottomRows.length === 0) return;
814
+ const grid = gridElementRef.current;
815
+ if (grid === null) return;
816
+ const summaryRow = grid.querySelector<HTMLElement>(
817
+ '[data-dg-part="summary-row"]',
818
+ );
819
+ if (summaryRow === null) return;
820
+ const measure = () =>
821
+ grid.style.setProperty(
822
+ "--dg-summary-height",
823
+ `${summaryRow.offsetHeight}px`,
824
+ );
825
+ measure();
826
+ const observer = new ResizeObserver(measure);
827
+ observer.observe(summaryRow);
828
+ return () => {
829
+ observer.disconnect();
830
+ grid.style.removeProperty("--dg-summary-height");
831
+ };
832
+ }, [pinnedBottomRows.length]);
833
+
508
834
  // A persisted pageIndex can outlive the data that produced it; TanStack only
509
835
  // auto-resets on live filter/sort/data changes, not on restored state. The
510
836
  // guard makes the dependency-free effect idempotent.
@@ -556,6 +882,35 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
556
882
  const paddingBottom =
557
883
  virtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0);
558
884
 
885
+ /**
886
+ * `api.scrollToRow`, implemented where the virtualizer is.
887
+ *
888
+ * Registered in an effect rather than during render, and with no dependency
889
+ * array so it re-registers on every one: the closure has to see this render's
890
+ * `rows`, which sorting, filtering and paging all change. A grid whose Table
891
+ * has unmounted leaves the hook's default behind, which answers `false`.
892
+ */
893
+ useEffect(() => {
894
+ scrollerRef.current = ({ rowId, align = "auto" }) => {
895
+ // Already parked at an edge, and outside the scrolling order — see
896
+ // `getDisplayedRows`. Nothing to scroll, but the row *is* on screen, so
897
+ // reporting failure would be the wrong answer.
898
+ if (
899
+ pinnedTopRows.some((row) => row.id === rowId) ||
900
+ pinnedBottomRows.some((row) => row.id === rowId)
901
+ ) {
902
+ return true;
903
+ }
904
+ const index = rows.findIndex((row) => row.id === rowId);
905
+ if (index < 0) return false;
906
+ virtualizer.scrollToIndex(index, { align });
907
+ return true;
908
+ };
909
+ return () => {
910
+ scrollerRef.current = () => false;
911
+ };
912
+ });
913
+
559
914
  /**
560
915
  * Infinite scroll. Latched on the row count rather than debounced: one call
561
916
  * per count means a pending fetch is never asked twice, and the append that
@@ -679,7 +1034,17 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
679
1034
  const layoutFor = (columnId: string) =>
680
1035
  columnLayout.get(columnId) ?? UNPINNED_LAYOUT;
681
1036
 
682
- const isEmpty = rows.length === 0;
1037
+ // Pinned rows count as content: with every visible row pinned to an edge,
1038
+ // an empty-state message beside them would contradict what is on screen.
1039
+ const isEmpty = rows.length === 0 && pinnedRowCount === 0;
1040
+ // Which emptiness this is. The body already subscribes to the whole table
1041
+ // store, so these reads are reactive.
1042
+ const emptyGlobalFilter = table.store.state.globalFilter;
1043
+ const hasActiveFilters =
1044
+ table.store.state.columnFilters.length > 0 ||
1045
+ (typeof emptyGlobalFilter === "string"
1046
+ ? emptyGlobalFilter.trim() !== ""
1047
+ : emptyGlobalFilter != null);
683
1048
 
684
1049
  /**
685
1050
  * The summary row exists by declaration, not by flag: it renders exactly
@@ -738,6 +1103,35 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
738
1103
  onRowClick?.(row as unknown as Row<TMDataGridFeatures, TData>);
739
1104
  };
740
1105
 
1106
+ // The value-or-function pair resolved per row, across the same erasure
1107
+ // crossing `onRowClick` makes.
1108
+ const rowClassNameFor = (
1109
+ row: Row<TMDataGridFeatures, TMDataGridRowData>,
1110
+ ): string | undefined =>
1111
+ typeof rowClassName === "function"
1112
+ ? rowClassName(row as unknown as Row<TMDataGridFeatures, TData>)
1113
+ : rowClassName;
1114
+ const rowStyleFor = (
1115
+ row: Row<TMDataGridFeatures, TMDataGridRowData>,
1116
+ ): CSSProperties | undefined =>
1117
+ typeof rowStyle === "function"
1118
+ ? rowStyle(row as unknown as Row<TMDataGridFeatures, TData>)
1119
+ : rowStyle;
1120
+
1121
+ // The same erasure crossing `onRowClick` makes, once for all three cell
1122
+ // handlers.
1123
+ const cellEventArgs = (
1124
+ cell: Cell<TMDataGridFeatures, TMDataGridRowData, unknown>,
1125
+ row: Row<TMDataGridFeatures, TMDataGridRowData>,
1126
+ event: MouseEvent<HTMLElement>,
1127
+ ): TMDataGridCellEventArgs<TData> =>
1128
+ ({
1129
+ cell,
1130
+ row,
1131
+ column: cell.column,
1132
+ event,
1133
+ }) as unknown as TMDataGridCellEventArgs<TData>;
1134
+
741
1135
  // ---------------------------------------------------------------------------
742
1136
  // Cell selection
743
1137
  //
@@ -956,6 +1350,92 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
956
1350
  setRangeTo(cell, { extend });
957
1351
  };
958
1352
 
1353
+ // ---------------------------------------------------------------------------
1354
+ // Cell editing — the gestures. The engine owns the forms; this component
1355
+ // owns where the editor opens from (keys, double-click) and where the
1356
+ // focus lands when it closes.
1357
+ // ---------------------------------------------------------------------------
1358
+
1359
+ /** The pending type-to-edit seed, consumed by the editor host on mount. */
1360
+ const editSeedRef = useRef<string | undefined>(undefined);
1361
+ const takeEditSeed = () => {
1362
+ const seed = editSeedRef.current;
1363
+ editSeedRef.current = undefined;
1364
+ return seed;
1365
+ };
1366
+
1367
+ /** DOM focus back onto a cell — after Escape, or a commit with nowhere to go. */
1368
+ const refocusCell = (position: TMDataGridCellPosition) => {
1369
+ const container = scrollContainerRef.current;
1370
+ if (container === null) return;
1371
+ wantsCellFocusRef.current = true;
1372
+ findCellElement(container, position)?.focus();
1373
+ };
1374
+
1375
+ const openEditor = (position: TMDataGridCellPosition) => {
1376
+ if (cellSelection) ui.actions.setFocusedCell(position);
1377
+ // Row mode edits whole rows: any editable cell's open gesture opens the
1378
+ // row, exactly as the lane's pencil does.
1379
+ edit.begin({
1380
+ rowId: position.rowId,
1381
+ columnId: features.editMode === "row" ? null : position.columnId,
1382
+ });
1383
+ };
1384
+
1385
+ const handleEditorClose = (
1386
+ position: TMDataGridCellPosition,
1387
+ close: TMDataGridCellEditorClose,
1388
+ ) => {
1389
+ // Where the keyboard goes: Enter moves down, Tab to the next editable
1390
+ // cell (the deferring batch variants move the same way, draft in tow) —
1391
+ // everything else, and a saved row, goes back to where the edit was.
1392
+ const move =
1393
+ close.via === "enter"
1394
+ ? "down"
1395
+ : close.via === "tab" || close.via === "defer-tab"
1396
+ ? 1
1397
+ : close.via === "shift-tab" || close.via === "defer-shift-tab"
1398
+ ? -1
1399
+ : null;
1400
+ if (move === null || features.editMode === "row") {
1401
+ refocusCell(position);
1402
+ return;
1403
+ }
1404
+ const rowIndex = rows.findIndex((row) => row.id === position.rowId);
1405
+ const columnIndex = orderedColumns.findIndex(
1406
+ (column) => column.id === position.columnId,
1407
+ );
1408
+ if (rowIndex < 0 || columnIndex < 0) {
1409
+ refocusCell(position);
1410
+ return;
1411
+ }
1412
+ if (move === "down") {
1413
+ if (rowIndex + 1 < rows.length) {
1414
+ moveFocusedCell({ rowIndex: rowIndex + 1, columnIndex });
1415
+ } else {
1416
+ refocusCell(position);
1417
+ }
1418
+ return;
1419
+ }
1420
+ const next = getNextEditableCell({
1421
+ from: { rowIndex, columnIndex },
1422
+ direction: move,
1423
+ rowCount: rows.length,
1424
+ columnCount: orderedColumns.length,
1425
+ isEditable: (coords) => {
1426
+ const row = rows[coords.rowIndex];
1427
+ const column = orderedColumns[coords.columnIndex];
1428
+ return (
1429
+ row !== undefined &&
1430
+ column !== undefined &&
1431
+ edit.canEditCell(row, column)
1432
+ );
1433
+ },
1434
+ });
1435
+ if (next !== null) moveFocusedCell(next);
1436
+ else refocusCell(position);
1437
+ };
1438
+
959
1439
  /**
960
1440
  * Space, from a cell. Checkbox semantics rather than click semantics: it adds
961
1441
  * to the selection instead of replacing it, so a keyboard user builds one the
@@ -1031,6 +1511,51 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1031
1511
  return;
1032
1512
  }
1033
1513
 
1514
+ // Editing keys, ahead of the step-into pair — an editable cell's Enter
1515
+ // opens its editor, and only a non-editable cell's falls through to the
1516
+ // control it holds.
1517
+ const keyRow = rows[rowIndex];
1518
+ const keyColumn = orderedColumns[columnIndex];
1519
+ if (
1520
+ features.editing &&
1521
+ keyRow !== undefined &&
1522
+ keyColumn !== undefined &&
1523
+ edit.canEditCell(keyRow, keyColumn)
1524
+ ) {
1525
+ if (event.key === "Enter" || event.key === "F2") {
1526
+ event.preventDefault();
1527
+ openEditor({ rowId, columnId });
1528
+ return;
1529
+ }
1530
+ // The quick single-cell gestures — Delete-to-clear and type-to-edit —
1531
+ // sit out row mode, whose whole point is that nothing commits until
1532
+ // the row's explicit save.
1533
+ if (features.editMode !== "row") {
1534
+ // Clear-and-commit, without opening anything — the spreadsheet Delete.
1535
+ if (event.key === "Delete" || event.key === "Backspace") {
1536
+ event.preventDefault();
1537
+ void edit
1538
+ .clearCell(rowId, columnId)
1539
+ .then(() => refocusCell({ rowId, columnId }));
1540
+ return;
1541
+ }
1542
+ // Type-to-edit: a printable character opens the editor seeded with
1543
+ // itself. Space stays the row-selection key, modifiers stay shortcuts.
1544
+ if (
1545
+ event.key.length === 1 &&
1546
+ event.key !== " " &&
1547
+ !event.ctrlKey &&
1548
+ !event.metaKey &&
1549
+ !event.altKey
1550
+ ) {
1551
+ event.preventDefault();
1552
+ editSeedRef.current = event.key;
1553
+ openEditor({ rowId, columnId });
1554
+ return;
1555
+ }
1556
+ }
1557
+ }
1558
+
1034
1559
  // Step into the cell. The pair Excel and AG Grid both use, and the pair the
1035
1560
  // editor will take over: F2 has never meant anything else, and Enter is
1036
1561
  // what a keyboard user reaches for. Nothing to step into leaves the key
@@ -1269,12 +1794,52 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1269
1794
  );
1270
1795
 
1271
1796
  /**
1272
- * The dropdown is anchored to a fixed viewport point, so it does not travel
1273
- * with the row when the body scrolls — and under virtualization the row it
1274
- * belongs to may unmount entirely. Closing is the honest answer, and matches
1275
- * what every desktop context menu does.
1797
+ * Which edges the scroll position is currently at. Seeded "at top-left",
1798
+ * which is where a grid mounts — so the callbacks report *arrivals*, and
1799
+ * mounting fires nothing.
1276
1800
  */
1277
- const handleScroll = contextMenuTarget ? closeContextMenu : undefined;
1801
+ const scrollEdgesRef = useRef({
1802
+ top: true,
1803
+ bottom: false,
1804
+ left: true,
1805
+ right: false,
1806
+ });
1807
+ const hasEdgeCallbacks =
1808
+ onScrollToTop !== undefined ||
1809
+ onScrollToBottom !== undefined ||
1810
+ onScrollToLeft !== undefined ||
1811
+ onScrollToRight !== undefined;
1812
+
1813
+ /**
1814
+ * The context menu's dropdown is anchored to a fixed viewport point, so it
1815
+ * does not travel with the row when the body scrolls — and under
1816
+ * virtualization the row it belongs to may unmount entirely. Closing is
1817
+ * the honest answer, and matches what every desktop context menu does.
1818
+ *
1819
+ * The edge callbacks share the handler. Reading scroll offsets here costs
1820
+ * no layout; the 1px tolerance absorbs fractional offsets under browser
1821
+ * zoom, where a "fully scrolled" position can land at 799.5 of 800.
1822
+ */
1823
+ const handleScroll =
1824
+ contextMenuTarget || hasEdgeCallbacks
1825
+ ? (event: UIEvent<HTMLDivElement>) => {
1826
+ if (contextMenuTarget) closeContextMenu();
1827
+ if (!hasEdgeCallbacks) return;
1828
+ const el = event.currentTarget;
1829
+ const next = {
1830
+ top: el.scrollTop <= 1,
1831
+ bottom: el.scrollTop + el.clientHeight >= el.scrollHeight - 1,
1832
+ left: el.scrollLeft <= 1,
1833
+ right: el.scrollLeft + el.clientWidth >= el.scrollWidth - 1,
1834
+ };
1835
+ const previous = scrollEdgesRef.current;
1836
+ scrollEdgesRef.current = next;
1837
+ if (next.top && !previous.top) onScrollToTop?.();
1838
+ if (next.bottom && !previous.bottom) onScrollToBottom?.();
1839
+ if (next.left && !previous.left) onScrollToLeft?.();
1840
+ if (next.right && !previous.right) onScrollToRight?.();
1841
+ }
1842
+ : undefined;
1278
1843
 
1279
1844
  return (
1280
1845
  <div className={classes.tableWrapper}>
@@ -1286,11 +1851,18 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1286
1851
  onScroll={handleScroll}
1287
1852
  >
1288
1853
  <div
1854
+ ref={gridElementRef}
1289
1855
  // A `table` is content; a `grid` is a widget with a keyboard cursor
1290
1856
  // in it. Which one this is depends on whether cell selection is on,
1291
1857
  // and saying `grid` without one would promise arrow keys that do
1292
1858
  // nothing.
1293
1859
  role={cellSelection ? "grid" : "table"}
1860
+ aria-label={ariaLabel}
1861
+ aria-labelledby={ariaLabelledBy}
1862
+ // Set while `meta.loading` is, whether or not the body has rows to
1863
+ // show — a refetch over existing rows is still a fetch, and a test
1864
+ // waiting for the grid to settle has nothing else to wait on.
1865
+ aria-busy={loading === true ? true : undefined}
1294
1866
  aria-multiselectable={
1295
1867
  cellSelection && features.rowSelection && features.multiRowSelection
1296
1868
  ? true
@@ -1306,9 +1878,17 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1306
1878
  // have to be stated rather than inferred from what is rendered. Every
1307
1879
  // row carries its aria-rowindex for the same reason.
1308
1880
  aria-rowcount={
1309
- rows.length + headerGroups.length + (hasSummaryRow ? 1 : 0)
1881
+ rows.length +
1882
+ pinnedRowCount +
1883
+ headerGroups.length +
1884
+ (hasSummaryRow ? 1 : 0)
1310
1885
  }
1311
1886
  aria-colcount={orderedColumns.length}
1887
+ // Body rows the grid is currently showing — the page under
1888
+ // pagination, everything the filters left otherwise, edge blocks
1889
+ // included. Most of them are not in the DOM, so this is what a test
1890
+ // waits on rather than counting nodes.
1891
+ data-dg-row-count={rows.length + pinnedRowCount}
1312
1892
  style={{ gridTemplateColumns, minWidth: gridMinWidth }}
1313
1893
  >
1314
1894
  <div role="rowgroup" style={{ display: "contents" }}>
@@ -1317,6 +1897,13 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1317
1897
  key={headerGroup.id}
1318
1898
  role="row"
1319
1899
  aria-rowindex={groupIndex + 1}
1900
+ data-dg-header-row
1901
+ // The scrolled-under shadow belongs to the boundary between
1902
+ // header and body — the last header row, not every stacked
1903
+ // group row above it. See the stylesheet.
1904
+ data-dg-header-last={
1905
+ groupIndex === headerGroups.length - 1 || undefined
1906
+ }
1320
1907
  className={classes.headerRow}
1321
1908
  >
1322
1909
  {headerGroup.headers.map((header) => (
@@ -1330,8 +1917,14 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1330
1917
  ))}
1331
1918
  </div>
1332
1919
 
1333
- {paddingTop > 0 && (
1334
- <div aria-hidden style={{ gridColumn: "1/-1", height: paddingTop }} />
1920
+ {/* The entry block — new rows being typed, stuck under the header.
1921
+ Present only while `edit.addRow()` has open entries. */}
1922
+ {features.editing && newRowCount > 0 && (
1923
+ <TMDataGridEntryRows
1924
+ orderedColumns={orderedColumns}
1925
+ layoutFor={layoutFor}
1926
+ rowHeight={rowHeight}
1927
+ />
1335
1928
  )}
1336
1929
 
1337
1930
  <TMDataGridBodyRowGroup
@@ -1343,11 +1936,40 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1343
1936
  onContextMenu={handleContextMenu}
1344
1937
  onClose={closeContextMenu}
1345
1938
  >
1346
- {virtualItems.map((virtualItem) => {
1347
- const row = rows[virtualItem.index];
1348
- if (!row) return null;
1939
+ {(() => {
1940
+ /**
1941
+ * One renderer for every body row, wherever it sits: the
1942
+ * virtualized centre, or a pinned edge block. `viewIndex` is the
1943
+ * row's index in the scrolling view — `-1` for a pinned row,
1944
+ * which takes it out of striping, row measurement and the cell
1945
+ * range, all of which are statements about the scrolling order.
1946
+ * Scoped here rather than a component so the row keeps closing
1947
+ * over the body's handlers without threading them as props.
1948
+ */
1949
+ const renderBodyRow = (
1950
+ row: Row<TMDataGridFeatures, TMDataGridRowData>,
1951
+ viewIndex: number,
1952
+ ariaRowIndex: number,
1953
+ pinnedAt?: "top" | "bottom",
1954
+ ) => {
1349
1955
  const isGroupRow = row.getIsGrouped();
1350
1956
  const isInteractive = rowGesturesFor(row);
1957
+ const rowCells = [
1958
+ ...row.getLeftVisibleCells(),
1959
+ ...row.getCenterVisibleCells(),
1960
+ ...row.getRightVisibleCells(),
1961
+ ];
1962
+ // Row mode opens every editable cell of the row at once;
1963
+ // exactly one of them may take the focus.
1964
+ const rowEditing =
1965
+ editActive !== null &&
1966
+ editActive.rowId === row.id &&
1967
+ editActive.columnId === null;
1968
+ const firstEditableColumnId = rowEditing
1969
+ ? (rowCells.find((cell) =>
1970
+ edit.canEditCell(row, cell.column),
1971
+ )?.column.id ?? null)
1972
+ : null;
1351
1973
  // Cell selection takes the body's tab stop off the row and puts
1352
1974
  // it on a cell — two stops per row would make Tab a way of
1353
1975
  // walking the grid, which is what the arrow keys are for. Space
@@ -1358,10 +1980,11 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1358
1980
  (selectsOnRowClick || features.highlightRow);
1359
1981
  return (
1360
1982
  <div
1361
- key={virtualItem.key}
1983
+ key={row.id}
1362
1984
  role="row"
1363
- aria-rowindex={virtualItem.index + headerGroups.length + 1}
1364
- data-testid={`dg-row-${row.id}`}
1985
+ aria-rowindex={ariaRowIndex}
1986
+ data-dg-part="row"
1987
+ data-row-id={row.id}
1365
1988
  // Only measured when details are on: without them every row
1366
1989
  // is exactly `rowHeight`, and the estimate above is already
1367
1990
  // the answer — so a plain grid keeps its ResizeObserver-free
@@ -1372,13 +1995,21 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1372
1995
  // element back to its item. The panel is inside this element,
1373
1996
  // so what gets measured is the row *and* its detail — which
1374
1997
  // is the whole reason no second virtual item is needed.
1375
- data-index={renderDetails ? virtualItem.index : undefined}
1376
- ref={renderDetails ? virtualizer.measureElement : undefined}
1998
+ data-index={
1999
+ renderDetails && viewIndex >= 0 ? viewIndex : undefined
2000
+ }
2001
+ ref={
2002
+ renderDetails && viewIndex >= 0
2003
+ ? virtualizer.measureElement
2004
+ : undefined
2005
+ }
1377
2006
  // The tree's own rows. `data-grouped` rather than a class, to
1378
2007
  // match how the rest of the row's state is published — and so
1379
2008
  // a consumer can restyle them without reaching into modules.
1380
2009
  data-grouped={isGroupRow}
1381
2010
  data-depth={row.depth}
2011
+ // Which edge block the row sits in, for styling hooks.
2012
+ data-pinned={pinnedAt}
1382
2013
  // Gated on the flag, not just on `getIsSelected()`: under
1383
2014
  // `"highlight"` there is no selection, but `rowSelection` may
1384
2015
  // still hold ids from before the mode changed — TanStack never
@@ -1404,6 +2035,11 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1404
2035
  data-highlighted={
1405
2036
  features.highlightRow && row.id === highlightedRowId
1406
2037
  }
2038
+ // Marked deleted under batch: struck through and inert
2039
+ // until submitAll reports it, or the mark is toggled back.
2040
+ data-deleted={
2041
+ deletedRowIds.length > 0 && deletedRowIds.includes(row.id)
2042
+ }
1407
2043
  // The menu is anchored to the rowgroup, so Mantine's own
1408
2044
  // `data-expanded` lands there rather than on a row. This is
1409
2045
  // what says which row the open menu is about.
@@ -1420,10 +2056,18 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1420
2056
  ? true
1421
2057
  : undefined
1422
2058
  }
1423
- className={classes.bodyRow}
2059
+ // From the row's position in the whole view, so the stripes
2060
+ // survive the virtualizer's moving window — see the prop.
2061
+ data-striped={
2062
+ striped && viewIndex >= 0 ? viewIndex % 2 === 1 : undefined
2063
+ }
2064
+ className={[classes.bodyRow, rowClassNameFor(row)]
2065
+ .filter(Boolean)
2066
+ .join(" ")}
1424
2067
  style={{
1425
2068
  minHeight: rowHeight,
1426
2069
  cursor: isInteractive ? "pointer" : undefined,
2070
+ ...rowStyleFor(row),
1427
2071
  }}
1428
2072
  // Keyboard parity with the checkbox the row replaces.
1429
2073
  tabIndex={takesKeyboard ? 0 : undefined}
@@ -1480,16 +2124,65 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1480
2124
  : undefined
1481
2125
  }
1482
2126
  >
1483
- {[
1484
- ...row.getLeftVisibleCells(),
1485
- ...row.getCenterVisibleCells(),
1486
- ...row.getRightVisibleCells(),
1487
- ].map((cell, cellIndex) => (
2127
+ {rowCells.map((cell, cellIndex) => {
2128
+ const isEditingCell =
2129
+ (editActive !== null &&
2130
+ editActive.rowId === row.id &&
2131
+ editActive.columnId === cell.column.id) ||
2132
+ (rowEditing && edit.canEditCell(row, cell.column));
2133
+ return (
1488
2134
  <TMDataGridBodyCell
1489
2135
  key={cell.id}
1490
2136
  cell={cell}
1491
2137
  rowHeight={rowHeight}
1492
2138
  layout={layoutFor(cell.column.id)}
2139
+ contentOverride={
2140
+ rowNumberById !== null &&
2141
+ cell.column.id === ROW_NUMBER_COLUMN_ID
2142
+ ? rowNumberById.get(row.id)
2143
+ : undefined
2144
+ }
2145
+ highlightNeedles={matchNeedles?.get(cell.column.id)}
2146
+ editor={
2147
+ isEditingCell ? (
2148
+ <TMDataGridCellEditor
2149
+ cell={cell}
2150
+ row={row}
2151
+ takeSeedText={takeEditSeed}
2152
+ autoFocus={
2153
+ !rowEditing ||
2154
+ cell.column.id === firstEditableColumnId
2155
+ }
2156
+ onClose={(close) =>
2157
+ handleEditorClose(
2158
+ { rowId: row.id, columnId: cell.column.id },
2159
+ close,
2160
+ )
2161
+ }
2162
+ />
2163
+ ) : undefined
2164
+ }
2165
+ onClick={
2166
+ onCellClick && !isGroupRow
2167
+ ? (event) =>
2168
+ onCellClick(cellEventArgs(cell, row, event))
2169
+ : undefined
2170
+ }
2171
+ onDoubleClick={
2172
+ (features.editing || onCellDoubleClick) && !isGroupRow
2173
+ ? (event) => {
2174
+ onCellDoubleClick?.(
2175
+ cellEventArgs(cell, row, event),
2176
+ );
2177
+ if (!features.editing) return;
2178
+ if (!edit.canEditCell(row, cell.column)) return;
2179
+ openEditor({
2180
+ rowId: row.id,
2181
+ columnId: cell.column.id,
2182
+ });
2183
+ }
2184
+ : undefined
2185
+ }
1493
2186
  // The cells come out in the same left → centre → right
1494
2187
  // order as `orderedColumns`, which is what lets the index
1495
2188
  // here stand for the column index everywhere else.
@@ -1514,17 +2207,9 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1514
2207
  // take" — which under `"single"` is the one cell
1515
2208
  // the user is standing on.
1516
2209
  selected: cellRangeSelection
1517
- ? isWithinBounds(
1518
- rangeBounds,
1519
- virtualItem.index,
1520
- cellIndex,
1521
- )
2210
+ ? isWithinBounds(rangeBounds, viewIndex, cellIndex)
1522
2211
  : isCellAt(focusedCell, row.id, cell.column.id),
1523
- edges: boundsEdges(
1524
- rangeBounds,
1525
- virtualItem.index,
1526
- cellIndex,
1527
- ),
2212
+ edges: boundsEdges(rangeBounds, viewIndex, cellIndex),
1528
2213
  }
1529
2214
  : undefined
1530
2215
  }
@@ -1567,14 +2252,19 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1567
2252
  : undefined
1568
2253
  }
1569
2254
  onContextMenu={
1570
- rowContextMenu || cellRangeSelection
1571
- ? () => {
2255
+ rowContextMenu || cellRangeSelection || onCellContextMenu
2256
+ ? (event) => {
1572
2257
  contextMenuColumnRef.current = cell.column.id;
2258
+ if (isGroupRow) return;
2259
+ onCellContextMenu?.(
2260
+ cellEventArgs(cell, row, event),
2261
+ );
1573
2262
  }
1574
2263
  : undefined
1575
2264
  }
1576
2265
  />
1577
- ))}
2266
+ );
2267
+ })}
1578
2268
 
1579
2269
  {/* A second grid row inside the row element, on the same
1580
2270
  subgrid tracks — which is what lets one measurement cover
@@ -1585,7 +2275,8 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1585
2275
  <div
1586
2276
  role="cell"
1587
2277
  aria-colspan={orderedColumns.length}
1588
- data-testid={`dg-details-${row.id}`}
2278
+ data-dg-part="details"
2279
+ data-row-id={row.id}
1589
2280
  className={classes.detailsCell}
1590
2281
  // The row underneath may select, highlight or open a
1591
2282
  // context menu. A panel is content, not part of that
@@ -1604,39 +2295,132 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1604
2295
  )}
1605
2296
  </div>
1606
2297
  );
1607
- })}
2298
+ };
2299
+
2300
+ return (
2301
+ <>
2302
+ {/* The pinned edge blocks. Same sticky mechanics as the
2303
+ entry block — the *block* is the sticky element, its rows
2304
+ flow normally inside it. `role="presentation"` keeps the
2305
+ wrapper out of the accessibility tree, so the rows stay
2306
+ direct children of the rowgroup. Inside the rowgroup so
2307
+ a right-click on a pinned row still opens the menu. */}
2308
+ {pinnedTopRows.length > 0 && (
2309
+ <div
2310
+ role="presentation"
2311
+ data-dg-part="pinned-top"
2312
+ className={classes.pinnedTopBlock}
2313
+ >
2314
+ {pinnedTopRows.map((row, index) =>
2315
+ renderBodyRow(
2316
+ row,
2317
+ -1,
2318
+ headerGroups.length + index + 1,
2319
+ "top",
2320
+ ),
2321
+ )}
2322
+ </div>
2323
+ )}
2324
+
2325
+ {paddingTop > 0 && (
2326
+ <div
2327
+ aria-hidden
2328
+ style={{ gridColumn: "1/-1", height: paddingTop }}
2329
+ />
2330
+ )}
2331
+
2332
+ {virtualItems.map((virtualItem) => {
2333
+ const row = rows[virtualItem.index];
2334
+ if (!row) return null;
2335
+ return renderBodyRow(
2336
+ row,
2337
+ virtualItem.index,
2338
+ virtualItem.index +
2339
+ headerGroups.length +
2340
+ pinnedTopRows.length +
2341
+ 1,
2342
+ );
2343
+ })}
2344
+
2345
+ {paddingBottom > 0 && (
2346
+ <div
2347
+ aria-hidden
2348
+ style={{ gridColumn: "1/-1", height: paddingBottom }}
2349
+ />
2350
+ )}
2351
+
2352
+ {pinnedBottomRows.length > 0 && (
2353
+ <div
2354
+ role="presentation"
2355
+ data-dg-part="pinned-bottom"
2356
+ className={classes.pinnedBottomBlock}
2357
+ >
2358
+ {pinnedBottomRows.map((row, index) =>
2359
+ renderBodyRow(
2360
+ row,
2361
+ -1,
2362
+ headerGroups.length +
2363
+ pinnedTopRows.length +
2364
+ rows.length +
2365
+ index +
2366
+ 1,
2367
+ "bottom",
2368
+ ),
2369
+ )}
2370
+ </div>
2371
+ )}
2372
+ </>
2373
+ );
2374
+ })()}
1608
2375
  </TMDataGridBodyRowGroup>
1609
2376
 
2377
+ {/* The empty matrix, one state at a time: loading wins (data is on
2378
+ its way, "empty" would be a lie), open entry rows show only
2379
+ themselves (a "no rows" under the row being typed reads as a
2380
+ contradiction), then the consumer's blank slate, then the two
2381
+ defaults — filtered-empty with its search icon, truly-empty as
2382
+ plain text, because only one of them is the user's own doing. */}
1610
2383
  {isEmpty && loading === true && (
1611
2384
  <div role="row" className={classes.messageRow}>
1612
2385
  <Loader size="lg" aria-label={labels.loading} />
1613
2386
  </div>
1614
2387
  )}
1615
2388
 
1616
- {isEmpty && loading !== true && (
2389
+ {isEmpty && loading !== true && newRowCount === 0 && (
1617
2390
  <div role="row" className={classes.messageRow}>
1618
- <SearchIcon size={40} stroke={1.4} opacity={0.4} />
1619
- <Text size={controlSize} c="dimmed">
1620
- {noResultsLabel}
1621
- </Text>
2391
+ {renderEmptyState !== undefined ? (
2392
+ renderEmptyState({
2393
+ hasActiveFilters,
2394
+ table: table as unknown as Table<TMDataGridFeatures, TData>,
2395
+ })
2396
+ ) : hasActiveFilters ? (
2397
+ <>
2398
+ <SearchIcon size={40} stroke={1.4} opacity={0.4} />
2399
+ <Text size={controlSize} c="dimmed">
2400
+ {noResultsLabel}
2401
+ </Text>
2402
+ </>
2403
+ ) : (
2404
+ <Text size={controlSize} c="dimmed">
2405
+ {labels.noRows}
2406
+ </Text>
2407
+ )}
1622
2408
  </div>
1623
2409
  )}
1624
2410
 
1625
- {paddingBottom > 0 && (
1626
- <div
1627
- aria-hidden
1628
- style={{ gridColumn: "1/-1", height: paddingBottom }}
1629
- />
1630
- )}
1631
-
1632
2411
  {/* Sticky along the bottom edge, the way the header is along the
1633
2412
  top. `flexRender` with the header context, which is what a
1634
2413
  TanStack `footer` renderer is written against. */}
1635
2414
  {hasSummaryRow && (
1636
2415
  <div
1637
2416
  role="row"
1638
- aria-rowindex={rows.length + headerGroups.length + 1}
1639
- data-testid="dg-summary-row"
2417
+ aria-rowindex={
2418
+ rows.length + pinnedRowCount + headerGroups.length + 1
2419
+ }
2420
+ // Also what the height measurement above looks for. One
2421
+ // attribute serves both now that the part name *is* the
2422
+ // published contract rather than a testing-only alias.
2423
+ data-dg-part="summary-row"
1640
2424
  className={classes.summaryRow}
1641
2425
  >
1642
2426
  {leafHeaders.map((header) => {