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

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.
@@ -0,0 +1,225 @@
1
+ ---
2
+ name: testing-editing
3
+ description: >
4
+ Test TMDataGrid editing flows from Playwright: adding a row through the entry
5
+ row, where its temporary id (__new__1) goes at commit and at save, finding an
6
+ added row when the grid does not know the id the app gave it, asserting a
7
+ failed validation on an entry row, cell and row edits, deletions, and the
8
+ draft store (data-new, data-draft, data-dirty, data-deleted, row-state,
9
+ save-all and data-draft-count). Load when writing or fixing a test that adds,
10
+ edits, deletes or saves rows, or when a locator for a new row stops resolving
11
+ after the row is committed.
12
+ metadata:
13
+ type: core
14
+ library: '@jielga/tmdatagrid'
15
+ library_version: '2.0.0-beta.23'
16
+ sources:
17
+ - 'Jielga/TMDataGrid:packages/tmdatagrid/docs/testing.md'
18
+ - 'Jielga/TMDataGrid:packages/tmdatagrid/docs/editing.md'
19
+ - 'Jielga/TMDataGrid:packages/tmdatagrid/src/components/TMDataGridEntryRows.tsx'
20
+ - 'Jielga/TMDataGrid:packages/tmdatagrid/src/components/TMDataGridEditColumn.tsx'
21
+ ---
22
+
23
+ # TMDataGrid - Testing editing
24
+
25
+ Builds on the `testing` skill: parts are `[data-dg-part]`, coordinates are
26
+ `[data-row-id]` / `[data-column-id]`, and `DataGrid` is its page object.
27
+
28
+ ## The life of an added row
29
+
30
+ `edit.addRow()` opens an entry row keyed by a temporary id. What happens at ✓
31
+ depends on `editing.draft`, and that decides what a test can hold on to:
32
+
33
+ | Step | Without `draft` | With `draft: true` |
34
+ | --- | --- | --- |
35
+ | `addRow()` | `[data-dg-part="entry-row"][data-row-id="__new__1"]`; editors are `[data-dg-part="editor"][data-row-id="__new__1"][data-column-id="..."]` | Same |
36
+ | ✓ fails validation | Entry row stays; failing cells carry `data-invalid="true"` | Same |
37
+ | ✓ (`confirm-new-row`) | **Entry row removed, temp id gone.** `onRowAdd({ tempId, value })` runs in the app; the row exists again only when the app puts it in `data`, under the id the app's `getRowId` returns | Becomes a body row **still keyed by the temp id**: `[data-dg-part="row"][data-row-id="__new__1"][data-new="true"][data-draft="true"]`, lane shows `[data-dg-part="row-state"][data-state="new"]`, `[data-dg-part="save-all"]` counts it in `data-draft-count` |
38
+ | Save (`save-all`) | - | As ✓ without `draft`: temp id gone, row comes back through `data` under the app's id |
39
+ | `newRowsSticky: true` | - | The committed row stays an `entry-row` with `data-committed="true"`, outside `data-dg-row-count`, until Save |
40
+
41
+ The grid never learns the id the app assigns (a database id, a negative
42
+ counter, a uuid - whatever `onRowAdd` or `onSaveDrafts` does). The temp id →
43
+ real id mapping does not exist anywhere in the DOM.
44
+
45
+ ## The rule: find an added row by content, not by id
46
+
47
+ Once `onRowAdd` or `saveDrafts` has run, the only thing the test knows about
48
+ the row is what it typed. Narrow the grid to a value unique to that row, assert
49
+ that exactly one row is left, read that row's cells:
50
+
51
+ ```ts
52
+ async expectRowAdded(uniqueValue: string, cells: Record<string, string>) {
53
+ await this.search(uniqueValue); // or filterBy on a column when there is no quick search
54
+ await this.expectRowCount(1); // retries until the app has put the row in data
55
+ const row = this.part("row"); // exactly one body row now
56
+ for (const [columnId, text] of Object.entries(cells)) {
57
+ await expect(row.locator(`[data-column-id="${columnId}"]`)).toHaveText(text);
58
+ }
59
+ await this.search("");
60
+ }
61
+ ```
62
+
63
+ - The `data-dg-row-count` assertion is the wait. Nothing else signals that
64
+ `onRowAdd` finished: `aria-busy` reflects `meta.loading` only.
65
+ - Use a value no other row has - put a run id in the name. Two matches means
66
+ `expectRowCount(1)` fails, correctly.
67
+ - Scoped to the grid and to one column: no `page.getByText`, no virtualization
68
+ problem, no strict-mode ambiguity.
69
+
70
+ ## Page object methods
71
+
72
+ Add these to the `DataGrid` class from the `testing` skill:
73
+
74
+ ```ts
75
+ entryRow(): Locator {
76
+ return this.part("entry-row");
77
+ }
78
+
79
+ /** Built-in editors of an open row: an entry row, or a row opened in row mode. */
80
+ async fillRow(rowId: string, values: Record<string, string>): Promise<void> {
81
+ for (const [columnId, value] of Object.entries(values)) {
82
+ await this.part("editor", { rowId, columnId })
83
+ .locator('[data-dg-part="editor-input"]')
84
+ .fill(value);
85
+ }
86
+ }
87
+
88
+ async commitEntryRow(rowId: string): Promise<void> {
89
+ await this.part("confirm-new-row", { rowId }).click();
90
+ }
91
+
92
+ /** Save in TMDataGrid.DraftActions, waited until the store is empty. */
93
+ async saveDrafts(): Promise<void> {
94
+ await this.part("save-all").click();
95
+ await expect(this.part("save-all")).toHaveAttribute("data-draft-count", "0");
96
+ }
97
+ ```
98
+
99
+ `fillRow` covers the built-in editors. A column with `meta.edit.editor` renders
100
+ the app's own component inside the `editor` part; scope through
101
+ `part("editor", { rowId, columnId })` and fill whatever is inside.
102
+
103
+ ## Recipes
104
+
105
+ ### Add a row, without draft
106
+
107
+ ```ts
108
+ await page.getByRole("button", { name: "Add row" }).click(); // the app's button → edit.addRow()
109
+ const tempId = (await grid.entryRow().getAttribute("data-row-id"))!;
110
+ await grid.fillRow(tempId, { name: "Nordkvist-4711", city: "Stockholm" });
111
+ await grid.commitEntryRow(tempId);
112
+
113
+ await expect(grid.entryRow()).toHaveCount(0); // entry row gone; tempId is now meaningless
114
+ await grid.expectRowAdded("Nordkvist-4711", { name: "Nordkvist-4711", city: "Stockholm" });
115
+ ```
116
+
117
+ ### Add a row, with draft, then save
118
+
119
+ ```ts
120
+ await grid.commitEntryRow(tempId);
121
+ const row = grid.part("row", { rowId: tempId }); // tempId is still the row's id
122
+ await expect(row).toHaveAttribute("data-new", "true");
123
+ await expect(row).toHaveAttribute("data-draft", "true");
124
+ await expect(grid.part("row-state", { rowId: tempId })).toHaveAttribute("data-state", "new");
125
+ await expect(grid.part("save-all")).toHaveAttribute("data-draft-count", "1");
126
+
127
+ await grid.saveDrafts();
128
+ await expect(row).toHaveCount(0); // tempId dies here
129
+ await grid.expectRowAdded("Nordkvist-4711", { city: "Stockholm" });
130
+ ```
131
+
132
+ Reopening a committed draft row: `edit-row` (the lane's pencil) or a
133
+ double-click puts it back in the entry block, still under the temp id; ✕
134
+ (`discard-new-row`) removes it.
135
+
136
+ ### A ✓ that fails validation
137
+
138
+ ```ts
139
+ await grid.commitEntryRow(tempId);
140
+ await expect(grid.entryRow()).toHaveCount(1); // still open
141
+ await expect(grid.entryRow().locator('[data-column-id="email"]'))
142
+ .toHaveAttribute("data-invalid", "true");
143
+ ```
144
+
145
+ ### Change a cell or a row
146
+
147
+ ```ts
148
+ const cell = grid.cell({ rowId: "42", columnId: "salary" });
149
+ await cell.dblclick(); // cell mode: opens the editor
150
+ await grid.fillRow("42", { salary: "52000" });
151
+ await page.keyboard.press("Enter"); // commits
152
+
153
+ // Without draft: onCommit ran, the app applied it, the grid holds nothing
154
+ await expect(cell).toHaveText("52 000");
155
+
156
+ // Under draft: the grid holds it until Save
157
+ await expect(cell).toHaveAttribute("data-dirty", "true");
158
+ await expect(grid.part("row", { rowId: "42" })).toHaveAttribute("data-dirty", "true");
159
+ await expect(grid.part("row-state", { rowId: "42" })).toHaveAttribute("data-state", "edited");
160
+ ```
161
+
162
+ Row mode: `edit-row` opens the row, `fillRow` reaches its editors, `save-row`
163
+ commits, `cancel-row` discards. `cellConfirm` mode: `editor-confirm` /
164
+ `editor-cancel` beside the input, or Enter / Escape.
165
+
166
+ ### Delete a row
167
+
168
+ ```ts
169
+ const before = Number(await grid.grid.getAttribute("data-dg-row-count"));
170
+ await grid.part("delete-row", { rowId: "42" }).click();
171
+
172
+ // Without draft: onRowDelete ran, the app removed it from data
173
+ await grid.expectRowCount(before - 1);
174
+
175
+ // Under draft: marked until Save; restore-row undoes the mark
176
+ await expect(grid.part("row", { rowId: "42" })).toHaveAttribute("data-deleted", "true");
177
+ await grid.part("restore-row", { rowId: "42" }).click();
178
+ ```
179
+
180
+ ## Attribute values
181
+
182
+ A state attribute is present, with the value `"true"`, only while it applies:
183
+ `data-new`, `data-draft`, `data-dirty`, `data-deleted` on a body row,
184
+ `data-committed` on an entry row, `data-dirty`, `data-invalid`, `data-editing`
185
+ on a cell. `[data-new]` and `[data-new="true"]` match the same rows,
186
+ `toHaveAttribute("data-new")` and `toHaveAttribute("data-new", "true")` both
187
+ hold, and the negative is `not.toHaveAttribute("data-new")`.
188
+
189
+ ## Common mistakes
190
+
191
+ ### Looking for the new row's text on the page
192
+
193
+ `page.getByText("Nordkvist")` searches the whole document: other grids, the
194
+ toolbar, a toast. And the row may be virtualized out of the DOM, so the
195
+ locator fails or flakes depending on sort order and viewport. Narrow with
196
+ search or a filter, assert `data-dg-row-count` is 1, read that one row.
197
+
198
+ ### Keeping the temp id after ✓ without draft
199
+
200
+ `grid.part("row", { rowId: "__new__1" })` never resolves: without `draft` the
201
+ grid drops the entry and the id at ✓, and the row's real id is the app's. Only
202
+ under `draft: true` does the temp id outlive ✓ - and only until Save.
203
+
204
+ ### Waiting for the added row with a timeout
205
+
206
+ The app's `onRowAdd` may be async. `expectRowCount(1)` after narrowing retries
207
+ until the row is in `data`; a `waitForTimeout` either wastes time or is too
208
+ short on CI.
209
+
210
+ ### Asserting the negative with `"false"`
211
+
212
+ `toHaveAttribute("data-new", "false")` never passes: a state that does not
213
+ apply is an absent attribute, not `"false"`. Use
214
+ `not.toHaveAttribute("data-new")`, and `:not([data-new])` in CSS.
215
+
216
+ ### Asserting a deletion with `toHaveCount(0)`
217
+
218
+ A row outside the viewport has no element either. Assert `data-dg-row-count`
219
+ dropped by one, or, under `draft`, `data-deleted="true"` on the row.
220
+
221
+ ### Expecting the grid to show the change without draft
222
+
223
+ Without `draft: true` the grid holds nothing after a commit: no `data-dirty`,
224
+ no `row-state`, `save-all` stays disabled at `data-draft-count="0"`. The
225
+ assertion is the cell's text after the app has applied the change to `data`.
@@ -51,7 +51,7 @@ function DetailsCell<TData extends RowData>({
51
51
  row.toggleExpanded();
52
52
  }}
53
53
  >
54
- <span className={classes.chevron} data-expanded={expanded}>
54
+ <span className={classes.chevron} data-expanded={expanded || undefined}>
55
55
  <ChevronRightIcon size={16} stroke={1.6} />
56
56
  </span>
57
57
  </UnstyledButton>
@@ -106,7 +106,7 @@ function DetailsHeader<TData extends RowData>({
106
106
  )
107
107
  }
108
108
  >
109
- <span className={classes.chevron} data-expanded={allExpanded}>
109
+ <span className={classes.chevron} data-expanded={allExpanded || undefined}>
110
110
  <ChevronRightIcon size={16} stroke={1.6} />
111
111
  </span>
112
112
  </UnstyledButton>
@@ -16,6 +16,7 @@ import {
16
16
  isColumnEditableForRow,
17
17
  isControlColumn,
18
18
  } from "../core/columnUtils";
19
+ import { draftCellContext } from "../core/draftCellContext";
19
20
  import { getEditFieldName, type TMDataGridEditApi } from "../core/editEngine";
20
21
  import { focusEditorContent } from "../core/editorFocus";
21
22
  import {
@@ -45,10 +46,11 @@ function isEntryCellEditable(
45
46
  }
46
47
 
47
48
  /**
48
- * One cell of an entry row: the lane's controls, a value once the row is
49
- * committed, or an editor. The invalid marker subscribes here, per cell, as
50
- * a body cell's does - a refused ✓ paints the cells its issues name and
51
- * nothing else. No dirty marker: everything in an entry row is new.
49
+ * One cell of an entry row: the lane's controls, an editor for a cell the
50
+ * open row edits, or a value through the column's own renderer. The invalid
51
+ * marker subscribes here, per cell, as a body cell's does - a refused ✓
52
+ * paints the cells its issues name and nothing else. No dirty marker:
53
+ * everything in an entry row is new.
52
54
  */
53
55
  function EntryCell({
54
56
  entryRow,
@@ -76,12 +78,22 @@ function EntryCell({
76
78
  });
77
79
  const editable =
78
80
  cell !== undefined && isEntryCellEditable(entryRow, column, edit);
81
+ // An open row's cells outside the editors follow the form as it is typed,
82
+ // as a body row's do: the column's renderer over a context reading the
83
+ // form's values - the whole row, so a computed cell repaints with any
84
+ // field. `undefined` for a committed row, whose values the entry table
85
+ // already holds as data, and for the lanes.
86
+ const draftValues = useSelector(edit.store, (state) =>
87
+ committed || isControlColumn(column.id)
88
+ ? undefined
89
+ : state.rows[entryRow.id]?.values,
90
+ );
79
91
  return (
80
92
  <div
81
93
  role="cell"
82
94
  data-column-id={column.id}
83
95
  data-align={getColumnAlign(column)}
84
- data-control-column={isControlColumn(column.id)}
96
+ data-control-column={isControlColumn(column.id) || undefined}
85
97
  data-invalid={isInvalid || undefined}
86
98
  // A committed row re-opens where it is double-clicked, the same
87
99
  // gesture a body cell answers - and, like a body cell, a cell that
@@ -108,16 +120,10 @@ function EntryCell({
108
120
  zIndex: layout.pinnedAt ? pinnedZ : undefined,
109
121
  }}
110
122
  >
111
- {cell !== undefined && column.id === EDIT_COLUMN_ID ? (
123
+ {cell === undefined ? null : column.id === EDIT_COLUMN_ID ? (
112
124
  // The lane's cell - the entry row's controls.
113
125
  flexRender(cell.column.columnDef.cell, cell.getContext())
114
- ) : committed && cell !== undefined ? (
115
- // Entered, awaiting Save all: a value row through the columns' own
116
- // renderers, over the draft the entry table was fed.
117
- <span className={classes.cellContent}>
118
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
119
- </span>
120
- ) : editable && cell !== undefined ? (
126
+ ) : !committed && editable ? (
121
127
  <TMDataGridCellEditor
122
128
  cell={cell}
123
129
  row={entryRow}
@@ -125,6 +131,24 @@ function EntryCell({
125
131
  onClose={() => {}}
126
132
  inEntryBlock
127
133
  />
134
+ ) : committed || !isControlColumn(column.id) ? (
135
+ // A value through the column's own renderer. Every cell of a row
136
+ // entered and awaiting Save all, over the draft the entry table was
137
+ // fed - and, while the row is open, every cell it does not open (a
138
+ // display column, `meta.edit.enabled` off), over the form as it is
139
+ // typed, as a body row in row mode shows them. Blank was how a
140
+ // calculated column or a button cell vanished the moment a
141
+ // committed row was reopened. The generated lanes stay blank while
142
+ // the row is open: a checkbox or a chevron there would act on the
143
+ // entry table's own state, which reaches nothing.
144
+ <span className={classes.cellContent}>
145
+ {flexRender(
146
+ cell.column.columnDef.cell,
147
+ draftValues !== undefined
148
+ ? draftCellContext(cell, draftValues)
149
+ : cell.getContext(),
150
+ )}
151
+ </span>
128
152
  ) : null}
129
153
  </div>
130
154
  );
@@ -245,18 +269,19 @@ export function TMDataGridEntryRows({
245
269
  focusEditorContent(editor);
246
270
  });
247
271
 
248
- // Open (uncommitted) rows keep the seed values frozen at addRow - the live values
249
- // belong to the forms, which the editors read directly, and this table only
250
- // provides row and cell identity. A committed row's cells render values, and
251
- // a committed row is data: its values come from the draft store, which is
252
- // also what the row keeps while a reopen is undecided.
272
+ // What the rows render over. A committed row is data: its values are the
273
+ // draft store's, which stand through a reopen. An open row's cells read
274
+ // the form itself - the editors directly, the rest through EntryCell's
275
+ // draft overlay - so its entry here gives the row an identity and a
276
+ // fallback: the committed values it was reopened from, else the seed
277
+ // frozen at addRow (the form's `defaultValues`, which a reopen keeps at
278
+ // the seed and writes the committed values over).
253
279
  const data = useMemo(
254
280
  () =>
255
281
  newRows.map(
256
- ({ tempId, committed }) =>
257
- ((committed
258
- ? committedValues[tempId]
259
- : edit.getForm(tempId)?.options.defaultValues) ??
282
+ ({ tempId }) =>
283
+ (committedValues[tempId] ??
284
+ edit.getForm(tempId)?.options.defaultValues ??
260
285
  {}) as TMDataGridRowData,
261
286
  ),
262
287
  [newRows, committedValues, edit],
@@ -288,10 +313,10 @@ export function TMDataGridEntryRows({
288
313
  data-dg-part="entry-row"
289
314
  data-row-id={entryRow.id}
290
315
  data-new
291
- data-committed={committed}
316
+ data-committed={committed || undefined}
292
317
  // The same marker body rows carry once committed, so one selector
293
318
  // reaches everything parked in the draft store.
294
- data-draft={committed}
319
+ data-draft={committed || undefined}
295
320
  className={classes.entryRow}
296
321
  >
297
322
  {orderedColumns.map((column) => (
@@ -330,7 +330,7 @@ export function TMDataGridFooter({
330
330
  <Box
331
331
  data-dg-part="footer"
332
332
  className={footerClassName}
333
- data-paging-suspended={!paging}
333
+ data-paging-suspended={!paging || undefined}
334
334
  {...others}
335
335
  >
336
336
  {/* The default render is the controls in order - which is what makes
@@ -82,7 +82,7 @@ function GroupCell<TData extends RowData>({
82
82
  row.toggleExpanded();
83
83
  }}
84
84
  >
85
- <span className={classes.chevron} data-expanded={expanded}>
85
+ <span className={classes.chevron} data-expanded={expanded || undefined}>
86
86
  <ChevronRightIcon size={16} stroke={1.6} />
87
87
  </span>
88
88
  <span className={classes.groupLabel}>{label}</span>
@@ -90,11 +90,11 @@
90
90
 
91
91
  /* See the sort ActionIcon: an unsorted column's arrow is an invitation, not a
92
92
  state, so it sits back until the pointer is on it. */
93
- .sortAction[data-sorted="false"] {
93
+ .sortAction:not([data-sorted]) {
94
94
  opacity: 0.55;
95
95
  }
96
96
 
97
- .sortAction[data-sorted="false"]:hover {
97
+ .sortAction:not([data-sorted]):hover {
98
98
  opacity: 1;
99
99
  }
100
100
 
@@ -540,11 +540,11 @@ export function TMDataGridHeaderCell({
540
540
  // The same coordinate the body, summary and entry cells carry, so one
541
541
  // selector reaches a column's header and its cells alike.
542
542
  data-column-id={column.id}
543
- data-active={isSorted || isFiltered}
543
+ data-active={isSorted || isFiltered || undefined}
544
544
  data-align={align}
545
545
  // A control lane is a fixed track, so it cannot take the cell padding the
546
546
  // scale grows for text. See isControlColumn.
547
- data-control-column={isControlColumn(column.id)}
547
+ data-control-column={isControlColumn(column.id) || undefined}
548
548
  // Only meaningful on a sortable column: "none" announces that this
549
549
  // header sorts, which is wrong on one that does not.
550
550
  aria-sort={
@@ -639,12 +639,12 @@ export function TMDataGridHeaderCell({
639
639
  {canSort && (
640
640
  <ActionIcon
641
641
  className={`${classes.headerAction} ${classes.sortAction}`}
642
- data-pinned-visible={isSorted}
642
+ data-pinned-visible={isSorted || undefined}
643
643
  // The arrow is the only sort indicator now that the title no
644
644
  // longer tints, so it takes the colour while the sort holds. On
645
645
  // an unsorted column it is only the hover affordance, and stays a
646
646
  // faded grey so the two are not confused.
647
- data-sorted={isSorted}
647
+ data-sorted={isSorted || undefined}
648
648
  variant="subtle"
649
649
  color={isSorted ? undefined : "gray"}
650
650
  size="xs"
@@ -685,7 +685,7 @@ export function TMDataGridHeaderCell({
685
685
  // Held visible for the right-click menu too: that one opens at
686
686
  // the pointer and is portaled, so the hover that revealed the
687
687
  // actions is lost the moment the pointer enters the dropdown.
688
- data-pinned-visible={menuOpened || contextMenuOpened}
688
+ data-pinned-visible={menuOpened || contextMenuOpened || undefined}
689
689
  variant="subtle"
690
690
  color="gray"
691
691
  size="xs"
@@ -271,7 +271,7 @@ export function TMDataGridHeaderFilterRow({
271
271
  aria-label={labels.filterOn(getColumnLabel(column))}
272
272
  data-dg-part="header-filter-cell"
273
273
  data-column-id={column.id}
274
- data-control-column={isControlColumn(column.id)}
274
+ data-control-column={isControlColumn(column.id) || undefined}
275
275
  className={[
276
276
  classes.headerFilterCell,
277
277
  layout.isBoundary && layout.pinnedAt === "left"
@@ -332,16 +332,16 @@ function TMDataGridBodyCell({
332
332
  // selectors also require `data-cell`, so they are unaffected.
333
333
  data-row-id={cell.row.id}
334
334
  data-column-id={cell.column.id}
335
- data-focused={nav?.focused}
335
+ data-focused={nav?.focused || undefined}
336
336
  aria-selected={nav?.selected}
337
- data-selected={nav?.selected}
337
+ data-selected={nav?.selected || undefined}
338
338
  // One attribute per side rather than a class per combination: the
339
339
  // stylesheet draws the outline edge by edge, and sixteen classes for the
340
340
  // sixteen corners of a rectangle is not a stylesheet anyone can read.
341
- data-edge-top={nav?.edges?.top}
342
- data-edge-bottom={nav?.edges?.bottom}
343
- data-edge-left={nav?.edges?.left}
344
- data-edge-right={nav?.edges?.right}
341
+ data-edge-top={nav?.edges?.top || undefined}
342
+ data-edge-bottom={nav?.edges?.bottom || undefined}
343
+ data-edge-left={nav?.edges?.left || undefined}
344
+ data-edge-right={nav?.edges?.right || undefined}
345
345
  // Bubbles, so focus landing on a control inside the cell counts as
346
346
  // landing on the cell - the ring follows the user into the checkbox
347
347
  // rather than being left behind on whichever cell they came from.
@@ -356,7 +356,7 @@ function TMDataGridBodyCell({
356
356
  data-align={getColumnAlign(cell.column)}
357
357
  // A control lane is a fixed track, so it cannot take the cell padding the
358
358
  // scale grows for text. See isControlColumn.
359
- data-control-column={isControl}
359
+ data-control-column={isControl || undefined}
360
360
  onContextMenu={onContextMenu}
361
361
  className={[
362
362
  classes.bodyCell,
@@ -2674,7 +2674,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2674
2674
  // The tree's own rows. `data-grouped` rather than a class, to
2675
2675
  // match how the rest of the row's state is published - and so
2676
2676
  // a consumer can restyle them without reaching into modules.
2677
- data-grouped={isGroupRow}
2677
+ data-grouped={isGroupRow || undefined}
2678
2678
  data-depth={row.depth}
2679
2679
  // Which edge block the row sits in, for styling hooks.
2680
2680
  data-pinned={pinnedAt}
@@ -2683,13 +2683,16 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2683
2683
  // still hold ids from before the mode changed - TanStack never
2684
2684
  // prunes it - and a row must not report itself selected in a
2685
2685
  // mode where selecting is not a thing.
2686
- data-selected={features.rowSelection && row.getIsSelected()}
2686
+ data-selected={
2687
+ (features.rowSelection && row.getIsSelected()) || undefined
2688
+ }
2687
2689
  // Being selected is state; painting it is a display choice, so
2688
2690
  // the two are separate attributes.
2689
2691
  data-selected-bg={
2690
- features.rowSelection &&
2691
- features.showSelectedBackground &&
2692
- row.getIsSelected()
2692
+ (features.rowSelection &&
2693
+ features.showSelectedBackground &&
2694
+ row.getIsSelected()) ||
2695
+ undefined
2693
2696
  }
2694
2697
  // The highlighted row is its own concept, so its own attribute
2695
2698
  // pair - `data-highlighted` / `aria-current` against
@@ -2701,33 +2704,40 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2701
2704
  // so a consumer styling or querying rows by it would cast far
2702
2705
  // wider than they meant to.
2703
2706
  data-highlighted={
2704
- features.highlightRow && row.id === highlightedRowId
2707
+ (features.highlightRow && row.id === highlightedRowId) ||
2708
+ undefined
2705
2709
  }
2706
2710
  // Marked deleted under draft: struck through and inert
2707
2711
  // until submitAll reports it, or the mark is toggled back.
2708
2712
  data-deleted={
2709
- deletedRowIds.length > 0 && deletedRowIds.includes(row.id)
2713
+ (deletedRowIds.length > 0 && deletedRowIds.includes(row.id)) ||
2714
+ undefined
2710
2715
  }
2711
2716
  // Carrying a dirty draft - the row-level face of the cells'
2712
2717
  // own data-dirty markers, for row-scoped styling.
2713
- data-dirty={editDirtyRowIdSet.has(row.id)}
2718
+ data-dirty={editDirtyRowIdSet.has(row.id) || undefined}
2714
2719
  // Committed into the draft store, waiting for Save. A
2715
2720
  // committed entry row is one too - it is in the body
2716
2721
  // because it is committed - and carries `data-new` besides.
2717
2722
  data-draft={
2718
- editDraftRowIdSet.has(row.id) || newRowIdSet.has(row.id)
2723
+ editDraftRowIdSet.has(row.id) ||
2724
+ newRowIdSet.has(row.id) ||
2725
+ undefined
2719
2726
  }
2720
2727
  // An entered row not yet in `data`, committed into the draft
2721
2728
  // store and sorted, filtered and grouped with the rest.
2722
- data-new={newRowIdSet.has(row.id)}
2729
+ data-new={newRowIdSet.has(row.id) || undefined}
2723
2730
  // The menu is anchored to the rowgroup, so Mantine's own
2724
2731
  // `data-expanded` lands there rather than on a row. This is
2725
2732
  // what says which row the open menu is about.
2726
2733
  data-context-menu={
2727
- contextMenuContent !== null &&
2728
- contextMenuTarget?.rowId === row.id
2734
+ (contextMenuContent !== null &&
2735
+ contextMenuTarget?.rowId === row.id) ||
2736
+ undefined
2737
+ }
2738
+ data-selects-on-click={
2739
+ (selectsOnRowClick && !isGroupRow) || undefined
2729
2740
  }
2730
- data-selects-on-click={selectsOnRowClick && !isGroupRow}
2731
2741
  aria-selected={
2732
2742
  features.rowSelection ? row.getIsSelected() : undefined
2733
2743
  }
@@ -2739,7 +2749,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2739
2749
  // From the row's position in the whole view, so the stripes
2740
2750
  // survive the virtualizer's moving window - see the prop.
2741
2751
  data-striped={
2742
- striped && viewIndex >= 0 ? viewIndex % 2 === 1 : undefined
2752
+ (striped && viewIndex >= 0 && viewIndex % 2 === 1) || undefined
2743
2753
  }
2744
2754
  className={[classes.bodyRow, rowClassNameFor(row)]
2745
2755
  .filter(Boolean)
@@ -3122,7 +3132,9 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
3122
3132
  role="cell"
3123
3133
  data-column-id={header.column.id}
3124
3134
  data-align={getColumnAlign(header.column)}
3125
- data-control-column={isControlColumn(header.column.id)}
3135
+ data-control-column={
3136
+ isControlColumn(header.column.id) || undefined
3137
+ }
3126
3138
  className={[
3127
3139
  classes.summaryCell,
3128
3140
  layout.isBoundary && layout.pinnedAt === "left"