@jielga/tmdatagrid 2.0.0-beta.9 → 2.0.1

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 (157) hide show
  1. package/README.md +5 -212
  2. package/dist/index.d.ts +1281 -768
  3. package/dist/index.js +4607 -3250
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles.css +1 -1
  6. package/docs/adding-rows.md +132 -0
  7. package/docs/anatomy.md +119 -0
  8. package/docs/card-view.md +108 -0
  9. package/docs/cell-selection.md +194 -0
  10. package/docs/column-layout.md +182 -0
  11. package/docs/column-menu.md +66 -0
  12. package/docs/columns.md +269 -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 +49 -48
  47. package/skills/columns/SKILL.md +125 -70
  48. package/skills/columns/references/columns-api.md +59 -0
  49. package/skills/data/SKILL.md +112 -18
  50. package/skills/editing/SKILL.md +76 -42
  51. package/skills/editing/references/common-mistakes.md +77 -69
  52. package/skills/editing/references/editing-api.md +25 -20
  53. package/skills/editing/references/editors-and-validation.md +80 -18
  54. package/skills/filtering/SKILL.md +155 -41
  55. package/skills/getting-started/SKILL.md +116 -16
  56. package/skills/grouping/SKILL.md +31 -16
  57. package/skills/migrating-to-2/SKILL.md +244 -0
  58. package/skills/options/SKILL.md +24 -12
  59. package/skills/rows/SKILL.md +22 -18
  60. package/skills/rows/references/rows-api.md +10 -6
  61. package/skills/server-side/SKILL.md +170 -17
  62. package/skills/testing/SKILL.md +150 -32
  63. package/skills/testing-components/SKILL.md +230 -0
  64. package/skills/testing-editing/SKILL.md +240 -0
  65. package/src/{tmdatagrid/components → components}/TMDataGrid.tsx +38 -21
  66. package/src/{tmdatagrid/components → components}/TMDataGridCellEditor.tsx +54 -8
  67. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.module.css +5 -1
  68. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.tsx +47 -55
  69. package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.tsx +5 -51
  70. package/src/{tmdatagrid/components → components}/TMDataGridDraftActions.tsx +41 -24
  71. package/src/{tmdatagrid/components → components}/TMDataGridEditColumn.tsx +12 -59
  72. package/src/{tmdatagrid/components → components}/TMDataGridEntryRows.tsx +164 -92
  73. package/src/components/TMDataGridExportPicker.module.css +77 -0
  74. package/src/components/TMDataGridExportPicker.tsx +234 -0
  75. package/src/components/TMDataGridFilterPanel.module.css +54 -0
  76. package/src/components/TMDataGridFilterPanel.tsx +348 -0
  77. package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.tsx +15 -8
  78. package/src/components/TMDataGridFilterSurface.module.css +54 -0
  79. package/src/components/TMDataGridFilterSurface.tsx +167 -0
  80. package/src/{tmdatagrid/components → components}/TMDataGridFooter.tsx +53 -90
  81. package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.tsx +5 -69
  82. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.module.css +12 -2
  83. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.tsx +58 -21
  84. package/src/components/TMDataGridHeaderFilterRow.module.css +51 -0
  85. package/src/components/TMDataGridHeaderFilterRow.tsx +301 -0
  86. package/src/components/TMDataGridMenu.tsx +357 -0
  87. package/src/{tmdatagrid/components → components}/TMDataGridSelectColumn.tsx +11 -48
  88. package/src/{tmdatagrid/components → components}/TMDataGridTable.module.css +69 -56
  89. package/src/{tmdatagrid/components → components}/TMDataGridTable.tsx +240 -138
  90. package/src/{tmdatagrid/components → components}/TMDataGridToolbar.module.css +5 -0
  91. package/src/components/TMDataGridToolbar.tsx +181 -0
  92. package/src/{tmdatagrid/components → components}/editors/TMDataGridBooleanEditor.tsx +3 -3
  93. package/src/{tmdatagrid/components → components}/editors/TMDataGridDateEditor.tsx +3 -3
  94. package/src/{tmdatagrid/components → components}/editors/TMDataGridMultiSelectEditor.tsx +3 -3
  95. package/src/{tmdatagrid/components → components}/editors/TMDataGridNumberEditor.tsx +3 -3
  96. package/src/{tmdatagrid/components → components}/editors/TMDataGridSelectEditor.tsx +3 -3
  97. package/src/{tmdatagrid/components → components}/editors/TMDataGridStringEditor.tsx +3 -3
  98. package/src/{tmdatagrid/components → components}/editors/editorShared.ts +16 -2
  99. package/src/{tmdatagrid/components → components}/filters/DgAutocompleteFilter.tsx +5 -4
  100. package/src/{tmdatagrid/components → components}/filters/DgDateRangeFilter.tsx +22 -5
  101. package/src/{tmdatagrid/components → components}/filters/DgRangeSliderFilter.tsx +5 -1
  102. package/src/{tmdatagrid/components → components}/filters/DgTriStateFilter.tsx +5 -1
  103. package/src/{tmdatagrid/components → components}/filters/TMDataGridFilterValueInput.tsx +44 -28
  104. package/src/components/filters/controlLayout.ts +32 -0
  105. package/src/components/filters/filterControlFor.ts +65 -0
  106. package/src/components/generatedColumns.tsx +187 -0
  107. package/src/{tmdatagrid/components → components}/icons.ts +1 -0
  108. package/src/{tmdatagrid/components → components}/sticky.module.css +44 -0
  109. package/src/components/useHideableColumns.ts +52 -0
  110. package/src/{tmdatagrid/core → core}/autosize.ts +5 -2
  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 +1107 -460
  118. package/src/core/export.ts +704 -0
  119. package/src/{tmdatagrid/core → core}/filterControls.ts +38 -1
  120. package/src/{tmdatagrid/core → core}/filterOperators.ts +64 -1
  121. package/src/core/filterSurface.ts +99 -0
  122. package/src/{tmdatagrid/core → core}/grouping.ts +21 -0
  123. package/src/{tmdatagrid/core → core}/labels.ts +51 -6
  124. package/src/{tmdatagrid/core → core}/labelsSv.ts +27 -6
  125. package/src/core/pageReset.ts +120 -0
  126. package/src/core/pagination.ts +81 -0
  127. package/src/{tmdatagrid/core → core}/persistence.ts +22 -5
  128. package/src/{tmdatagrid/core → core}/summary.ts +20 -4
  129. package/src/{tmdatagrid/index.ts → index.ts} +69 -35
  130. package/src/{tmdatagrid/useTMDataGrid.tsx → useTMDataGrid.tsx} +428 -109
  131. package/src/useTMDataGridExport.ts +78 -0
  132. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +0 -35
  133. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +0 -354
  134. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +0 -161
  135. package/src/tmdatagrid/core/cellExport.ts +0 -320
  136. /package/src/{tmdatagrid/TMDataGridContext.ts → TMDataGridContext.ts} +0 -0
  137. /package/src/{tmdatagrid/components → components}/TMDataGrid.module.css +0 -0
  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}/capabilities.ts +0 -0
  145. /package/src/{tmdatagrid/core → core}/cellNavigation.ts +0 -0
  146. /package/src/{tmdatagrid/core → core}/cellRange.ts +0 -0
  147. /package/src/{tmdatagrid/core → core}/controlledState.ts +0 -0
  148. /package/src/{tmdatagrid/core → core}/draftCellContext.ts +0 -0
  149. /package/src/{tmdatagrid/core → core}/editorFocus.ts +0 -0
  150. /package/src/{tmdatagrid/core → core}/expanding.ts +0 -0
  151. /package/src/{tmdatagrid/core → core}/matchHighlight.ts +0 -0
  152. /package/src/{tmdatagrid/core → core}/quickSearch.ts +0 -0
  153. /package/src/{tmdatagrid/core → core}/resizePreview.ts +0 -0
  154. /package/src/{tmdatagrid/core → core}/rowPinning.ts +0 -0
  155. /package/src/{tmdatagrid/core → core}/rowSelection.ts +0 -0
  156. /package/src/{tmdatagrid/core → core}/sizes.ts +0 -0
  157. /package/src/{tmdatagrid/core → core}/useSettledTableState.ts +0 -0
@@ -0,0 +1,744 @@
1
+ # Testing
2
+
3
+ The grid publishes a fixed set of roles, ARIA attributes and `data-*` hooks so
4
+ that you can write tests against structure rather than against copy or class
5
+ names. Everything on this page is supported. Anything else in the
6
+ DOM is internal and may change without notice.
7
+
8
+ > Renaming or dropping anything on this page is a breaking change, so it moves
9
+ > only with a major version.
10
+
11
+ ## Selector attributes
12
+
13
+ The grid publishes three kinds of selector, and no `data-testid` of its own:
14
+
15
+ - `data-dg-part` - what an element is (`"row"`, `"filter-button"`)
16
+ - `data-row-id` and `data-column-id` - which one, using your own ids
17
+ - roles and ARIA - the same thing a screen reader reads
18
+
19
+ `data-testid` is left to your suite, whose `testIdAttribute` may be `data-qa` or
20
+ `data-cy` rather than `data-testid`. The one exception is
21
+ `<TMDataGrid data-testid>`, which sets the value you pass.
22
+
23
+ ## Naming a grid
24
+
25
+ Parts repeat across grids on the same page. Name the grid and scope through it:
26
+
27
+ ```tsx
28
+ <TMDataGrid {...grid} data-testid="orders">
29
+ <TMDataGrid.Table<Order> aria-label="Orders" />
30
+ </TMDataGrid>
31
+ ```
32
+
33
+ ```ts
34
+ const orders = page.getByTestId("orders");
35
+ await expect(orders.locator('[data-dg-part="row"][data-row-id="42"]')).toBeVisible();
36
+ ```
37
+
38
+ `data-testid` and `id` go on the root element (which also carries
39
+ `data-dg-root`); `aria-label` (or `aria-labelledby`) goes on
40
+ `TMDataGrid.Table`, because the accessible name belongs to the element carrying
41
+ the `grid` role.
42
+
43
+ ## Structure
44
+
45
+ | Element | Role | Attributes |
46
+ | --- | --- | --- |
47
+ | Root | - | `data-dg-root`, `data-size` |
48
+ | Grid | `table`, or `grid` under cell selection | `aria-rowcount`, `aria-colcount`, `aria-busy`, `data-dg-row-count` |
49
+ | Scroll container | - | `data-dg-scroll-container` |
50
+ | Header row | `row` | `data-dg-header-row`, `aria-rowindex` |
51
+ | Header cell | `columnheader` | `data-dg-part="header"`, `data-column-id`, `aria-sort`, `data-active` |
52
+ | Body row | `row` | `data-dg-part="row"`, `data-row-id`, `aria-rowindex`, `data-selected`, `data-highlighted`, `data-grouped`, `data-depth`, `data-pinned`, `data-deleted`, `data-dirty`, `data-draft`, `data-new`, `data-striped` |
53
+ | Body cell | `cell`, or `gridcell` under cell selection | `data-row-id`, `data-column-id`, `data-align`, `data-editing`, `data-dirty`, `data-invalid`, `data-focused`, `data-selected` |
54
+
55
+ **The role changes with cell selection.** `cellSelection` turns the grid's
56
+ `table` into a `grid` and every `cell` into a `gridcell`, so a suite written on
57
+ `getByRole("cell")` breaks when the feature is switched on. Query cells by their
58
+ coordinates instead:
59
+
60
+ ```ts
61
+ const cell = orders.locator(
62
+ '[data-row-id="42"][data-column-id="total"]:not([data-dg-part])',
63
+ );
64
+ ```
65
+
66
+ Body cells carry no `data-dg-part`; the coordinate pair identifies them.
67
+ The `editor` part of an open cell carries the same pair, so `:not([data-dg-part])` keeps the locator on the cell while the cell is being edited.
68
+
69
+ **The body scrolls inside the scroll container.** The element carrying
70
+ `data-dg-scroll-container` is the one to scroll in a test, whether to load the
71
+ next page of an infinite-scroll grid or to move the virtualizer without the api:
72
+
73
+ ```ts
74
+ await orders.locator("[data-dg-scroll-container]").evaluate((element) => {
75
+ element.scrollTop = element.scrollHeight;
76
+ });
77
+ ```
78
+
79
+ **State attributes are present only while they apply.** A boolean attribute
80
+ such as `data-selected`, `data-new` or `data-invalid` is rendered with the
81
+ value `"true"` while its state holds and omitted otherwise.
82
+ `[data-new]` and `[data-new="true"]` match the same elements; to test the negative, assert that the attribute is absent:
83
+
84
+ ```ts
85
+ const drafts = orders.locator('[data-dg-part="row"][data-draft]');
86
+ await expect(drafts.first()).not.toHaveAttribute("data-deleted");
87
+ ```
88
+
89
+ ## Parts
90
+
91
+ Row and column ids come from your data (`getRowId` and the column definitions),
92
+ so a part that repeats is addressed by adding the coordinate.
93
+
94
+ ### Whole-grid
95
+
96
+ | `data-dg-part` | What it is |
97
+ | --- | --- |
98
+ | `toolbar` | The toolbar row |
99
+ | `summary-count` | The visible/total count |
100
+ | `loading` | The toolbar spinner |
101
+ | `search`, `search-clear` | Quick search input and its ✕ |
102
+ | `filter-button` | The funnel toggle |
103
+ | `filter-panel` | The panel of filter rows, wherever it is rendered |
104
+ | `filter-popup`, `filter-sidebar` | The surface holding it, under `filters.surface` |
105
+ | `filter-panel-close` | The surface's ✕; absent on a hand-placed panel |
106
+ | `filter-add`, `filter-clear-all` | The panel's footer buttons |
107
+ | `header-filter-row` | The header filter row, under `filters.inHeader` |
108
+ | `filter-pills` | The active-filter pill group. `TMDataGridFilterPills` renders where you place it, inside or outside the root; outside, scope through its own container |
109
+ | `menu-button` | The burger, `TMDataGrid.Menu` |
110
+ | `menu-export`, `menu-export-selected` | `TMDataGrid.Menu.Export` and `TMDataGrid.Menu.ExportSelected`, in the menu dropdown |
111
+ | `export-picker` | The export column picker, in a dialog. Holds `export-picker-search`, `export-picker-hint`, `export-column-all` with the count `export-picker-count`, and the buttons `export-picker-confirm` and `export-picker-cancel` |
112
+ | `columns-panel`, `columns-search` | The column chooser panel, and the search box in the panel or in `TMDataGrid.Menu.Columns` |
113
+ | `columns-toggle-all`, `columns-reset` | Show/hide all and Reset layout, in the panel or in the menu |
114
+ | `footer` | The pager row |
115
+ | `page-size`, `page-range`, `page-number`, `page-prev`, `page-next` | The pager |
116
+ | `summary-row` | The footer summary row |
117
+ | `pinned-top`, `pinned-bottom` | The pinned-row edge blocks |
118
+ | `select-all` | The header select-all checkbox |
119
+ | `details-toggle-all` | Expand/collapse every detail panel |
120
+ | `save-all`, `discard-all` | `TMDataGrid.DraftActions`. `save-all` carries `data-draft-count` - the rows the save will send |
121
+ | `editor-confirm`, `editor-cancel` | `cellConfirm`'s ✓ and ✕ |
122
+ | `editor-input` | The input inside a built-in editor |
123
+ | `sort-index` | A column's position in a multi-column sort |
124
+ | `tab-guard` | The body's tab stop under [cell selection](/docs/cell-selection); `data-guard` is `leading` (before the rows) or `trailing` (after them). Zero-size, and focusing one puts the cursor on a cell |
125
+
126
+ ### Keyed by `data-row-id`
127
+
128
+ | `data-dg-part` | What it is |
129
+ | --- | --- |
130
+ | `row` | A body row, pinned or not. A committed new row is one of them, marked `data-new` |
131
+ | `entry-row` | An entry row being typed into. Carries `data-new`, and `data-committed` / `data-draft` once committed, which is where a committed row stays under `editing.newRowsSticky`. Its cells carry `data-column-id` and, on a failed ✓, `data-invalid` |
132
+ | `details` | A row's detail panel |
133
+ | `select-row` | Its selection checkbox |
134
+ | `details-toggle`, `group-toggle` | Its detail and tree chevrons |
135
+ | `edit-row`, `delete-row` | The edit lane, idle. `edit-row` also reopens an entered new row |
136
+ | `save-row`, `cancel-row` | The edit lane's Save and Cancel on an open row |
137
+ | `row-state` | The draft store's change marker; `data-state` is `new`, `edited` or `deleted` |
138
+ | `revert-row` | Drops a committed row's draft |
139
+ | `restore-row` | Undo a deletion mark |
140
+ | `confirm-new-row`, `discard-new-row` | An entry row's ✓ (commit) and ✕ |
141
+ | `open-rows-note` | `DraftActions`' count of rows still open. Carries `data-open-count`; absent while there are none |
142
+
143
+ ### Keyed by `data-column-id`
144
+
145
+ | `data-dg-part` | What it is |
146
+ | --- | --- |
147
+ | `header` | A column header. A click sorts a sortable column; `aria-sort` holds the result |
148
+ | `header-sort`, `header-menu`, `header-filter` | Its three action buttons. `header-sort` and `header-menu` are hidden until the header is hovered or holds focus: sort through `header`, and hover `header` before clicking `header-menu`. `header-filter` is absent under `filters.inHeader`, where the control below is the indicator |
149
+ | `header-resize` | The resize handle at the column's edge; present only when the column can resize. Drag it to resize, double-click it to fit the column to its content |
150
+ | `header-filter-cell`, `header-filter-operator` | One column's header filter control and its operator button |
151
+ | `filter-row` | One row of the filter panel |
152
+ | `filter-pill` | One active-filter pill. Holds a label button and `filter-pill-remove`, its ✕ |
153
+ | `columns-toggle` | The checkbox of one column, in the panel or in the menu |
154
+ | `export-column` | The checkbox of one column in the export picker |
155
+
156
+ ### Keyed by both
157
+
158
+ | `data-dg-part` | What it is |
159
+ | --- | --- |
160
+ | `editor` | An open cell editor |
161
+
162
+ Within a filter row the three controls are `filter-column`,
163
+ `filter-operator` and `filter-value` (or `filter-value-from` /
164
+ `filter-value-to` for `between`), and `filter-remove` is the row's ✕.
165
+ None of them carries `data-column-id`; scope through the row.
166
+
167
+ A column declaring `meta.filter.control` or `meta.edit.editor` renders your component
168
+ in that slot, so `filter-value` and `editor-input` cover the built-ins only.
169
+ `filter-row` and `editor` still apply; scope your own queries through them.
170
+
171
+ `editor-input` is also where the grid puts the caret when an editor opens. An
172
+ editor that does not publish it is focused on the first focusable element inside
173
+ its `editor` instead, so a custom editor needs the attribute only to name which
174
+ of several inputs the caret should land in.
175
+
176
+ Every icon-only control also carries an `aria-label` drawn from `labels`. Those
177
+ are translated, so they make brittle selectors. Prefer the parts above unless
178
+ your grid runs in one language.
179
+
180
+ ## Portals
181
+
182
+ These surfaces render in a portal at the end of `<body>`, outside the grid's root, so a locator scoped to the root does not reach them:
183
+
184
+ - the dropdown of `TMDataGrid.Menu` - `page.getByRole("menu")`; holds `columns-toggle`, `columns-toggle-all`, `columns-reset`, `menu-export` and `menu-export-selected`
185
+ - a column's menu, opened by `header-menu` or by a right click on the header - `page.getByRole("menu")`; its items (sort, filter, group, pin, hide) carry no part, so reach one by role and label, `menu.getByRole("menuitem", { name: "Group by Location" })`, and note that the label is translated
186
+ - the `header-filter-operator` menu - `page.getByRole("menu")`
187
+ - the export column picker - `page.getByRole("dialog")`; holds the `export-*` parts
188
+ - the listbox of every `Select` or `MultiSelect` the grid renders: `page-size`, `filter-column`, `filter-operator`, and the `filter-value` of a boolean or select-type filter - the element named by the input's `aria-controls`; each option carries its value in `value`, a Mantine detail the page object below relies on
189
+
190
+ One menu or dialog is open at a time, so the page-level locator is unambiguous.
191
+ `filter-popup` and `filter-sidebar` render inside the root.
192
+
193
+ ## Virtualization
194
+
195
+ The grid is always virtualized: only the rows in the viewport plus overscan are
196
+ in the DOM. A row at index 500 has no element, and Playwright cannot scroll to
197
+ what it cannot find.
198
+
199
+ **Count rows off the grid, not off the DOM.** `aria-rowcount` includes every
200
+ header row - stacked column groups and the `filters.inHeader` row among them -
201
+ and the summary row, so how many it adds is a function of the grid's
202
+ configuration. `data-dg-row-count` counts the body rows alone: the current page
203
+ under pagination, or everything the filters left otherwise. Assert on that one.
204
+
205
+ ```ts
206
+ const grid = orders.getByRole("table");
207
+ await expect(grid).toHaveAttribute("data-dg-row-count", "3");
208
+ ```
209
+
210
+ **Reach a row by narrowing to it.** Filtering or searching is faster and more
211
+ stable than scrolling:
212
+
213
+ ```ts
214
+ await orders.locator('[data-dg-part="search"]').fill("Nordkvist");
215
+ await expect(orders.locator('[data-row-id="42"]').first()).toBeVisible();
216
+ ```
217
+
218
+ **Or scroll to it.** When the row must be reached in place, such as when testing
219
+ the scroll itself, `scrollToRow` moves the virtualizer:
220
+
221
+ ```ts
222
+ const found = api.scrollToRow({ rowId: "42", align: "center" });
223
+ ```
224
+
225
+ It returns `false` when the row is not in the current view (filtered out, on
226
+ another page, or an unknown id) and scrolls nothing. From a Playwright test it
227
+ has to be called through the page, since the API lives in React:
228
+
229
+ ```ts
230
+ await page.evaluate(() => window.__ordersGrid.scrollToRow({ rowId: "42" }));
231
+ ```
232
+
233
+ which requires the app to expose the grid on `window`. Narrowing needs no such
234
+ hook, and scrolling the element carrying `data-dg-scroll-container` moves the virtualizer without it.
235
+
236
+ ## Waiting
237
+
238
+ `meta.loading` sets `aria-busy` on the grid whether or not the body has rows, so
239
+ a refetch over existing rows is still visible to a test:
240
+
241
+ ```ts
242
+ await expect(grid).toHaveAttribute("aria-busy", "true");
243
+ await expect(grid).not.toHaveAttribute("aria-busy");
244
+ ```
245
+
246
+ Quick search debounces (250 ms by default, `debounce` on `TMDataGrid.Search`).
247
+ Assert on `data-dg-row-count` rather than adding a timeout. Playwright retries
248
+ the assertion until the debounce lands.
249
+
250
+ ## A page object
251
+
252
+ The class below is the one the grid's own Playwright suite runs against the demos on this site.
253
+ Copy it as it is; it depends on `@playwright/test` and on the contract on this page only.
254
+
255
+ <!-- source: playwright/support/DataGrid.ts -->
256
+ ```ts
257
+ import { type Locator, type Page, expect } from "@playwright/test";
258
+
259
+ type PartKey = { rowId?: string; columnId?: string };
260
+
261
+ /**
262
+ * A page object for one TMDataGrid, written against the grid's published
263
+ * test contract only: `data-dg-part`, `data-row-id` / `data-column-id`, roles
264
+ * and ARIA. It imports nothing but `@playwright/test`, so it can be copied
265
+ * into any app's suite as it is.
266
+ */
267
+ export class DataGrid {
268
+ readonly page: Page;
269
+ readonly root: Locator;
270
+ readonly grid: Locator;
271
+
272
+ /** `root` is the grid's root element, the one carrying `data-dg-root`. */
273
+ constructor(root: Locator) {
274
+ this.page = root.page();
275
+ this.root = root;
276
+ // Cell selection flips the role from `table` to `grid`.
277
+ this.grid = root.getByRole("table").or(root.getByRole("grid"));
278
+ }
279
+
280
+ /** The grid whose `<TMDataGrid data-testid>` is `testId`. */
281
+ static byTestId(page: Page, testId: string): DataGrid {
282
+ return new DataGrid(page.getByTestId(testId));
283
+ }
284
+
285
+ /** A named part, narrowed by row or column when the part repeats. */
286
+ part(name: string, key: PartKey = {}): Locator {
287
+ return this.root.locator(partSelector(name, key));
288
+ }
289
+
290
+ /**
291
+ * A part inside the open `TMDataGrid.Menu` dropdown. The dropdown renders
292
+ * in a portal at the end of `<body>`, outside the grid's root, so it cannot
293
+ * be reached through `part()`.
294
+ */
295
+ menuPart(name: string, key: PartKey = {}): Locator {
296
+ return this.page.getByRole("menu").locator(partSelector(name, key));
297
+ }
298
+
299
+ /**
300
+ * A body cell by its coordinates. `:not([data-dg-part])` leaves out the
301
+ * `editor` part inside an open cell, which carries the same pair.
302
+ */
303
+ cell({ rowId, columnId }: { rowId: string; columnId: string }): Locator {
304
+ return this.root.locator(
305
+ `[data-row-id="${rowId}"][data-column-id="${columnId}"]:not([data-dg-part])`,
306
+ );
307
+ }
308
+
309
+ async search(text: string): Promise<void> {
310
+ await this.part("search").fill(text);
311
+ }
312
+
313
+ /** Clicks a sortable header once: unsorted, ascending, descending. */
314
+ async sortBy(columnId: string): Promise<void> {
315
+ // The header, not its `header-sort` arrow: the arrow is display: none
316
+ // until the header is hovered, and a click on it also reaches the
317
+ // header's own sort handler, so it advances the sort two steps.
318
+ await this.part("header", { columnId }).click();
319
+ }
320
+
321
+ /**
322
+ * Opens the filter panel and types `value` into the filter row of
323
+ * `columnId`, adding that row when there is none. Covers the built-in text
324
+ * and number inputs; a boolean or select-type filter renders a `Select` in
325
+ * `filter-value`, which takes `chooseOption` instead.
326
+ */
327
+ async filterBy({
328
+ columnId,
329
+ value,
330
+ }: {
331
+ columnId: string;
332
+ value: string;
333
+ }): Promise<void> {
334
+ const panel = this.part("filter-panel");
335
+ if (!(await panel.isVisible())) {
336
+ await this.part("filter-button").click();
337
+ }
338
+ await expect(panel).toBeVisible();
339
+
340
+ const row = this.part("filter-row", { columnId });
341
+ // Opening the panel seeds a row on the first filterable column only. Any
342
+ // other column gets a row of its own: "Add filter" appends one on the next
343
+ // unused column, which is then pointed at `columnId`. A seeded row left
344
+ // without a value filters nothing.
345
+ if ((await row.count()) === 0) {
346
+ const rows = this.part("filter-row");
347
+ const before = await rows.count();
348
+ await this.part("filter-add").click();
349
+ await expect(rows).toHaveCount(before + 1);
350
+ // Re-pointing a row at the column it is already on would remove it.
351
+ if ((await row.count()) === 0) {
352
+ await this.chooseOption({
353
+ select: rows.last().locator('[data-dg-part="filter-column"]'),
354
+ value: columnId,
355
+ });
356
+ }
357
+ }
358
+ await row.locator('[data-dg-part="filter-value"]').fill(value);
359
+ }
360
+
361
+ /** Shows or hides a column through the column items of `TMDataGrid.Menu`. */
362
+ async toggleColumn(columnId: string): Promise<void> {
363
+ const toggle = this.menuPart("columns-toggle", { columnId });
364
+ if (!(await toggle.isVisible())) {
365
+ await this.part("menu-button").click();
366
+ }
367
+ await toggle.click();
368
+ }
369
+
370
+ /**
371
+ * Opens the column menu of `columnId` and returns it. The `header-menu`
372
+ * button shows only while its header is hovered, so the header is hovered
373
+ * first. The menu renders in a portal, and its items carry no part: reach
374
+ * them by role and label, `menu.getByRole("menuitem", { name: "Filter" })`.
375
+ */
376
+ async openColumnMenu(columnId: string): Promise<Locator> {
377
+ await this.part("header", { columnId }).hover();
378
+ await this.part("header-menu", { columnId }).click();
379
+ const menu = this.page.getByRole("menu");
380
+ await expect(menu).toBeVisible();
381
+ return menu;
382
+ }
383
+
384
+ /**
385
+ * Picks an option of a Mantine `Select` the grid renders (`page-size`,
386
+ * `filter-column`, `filter-operator`). Its listbox renders in a portal, so
387
+ * it is found through the input's `aria-controls`, and the option by its
388
+ * value: a column id or a page size, never a translated label.
389
+ */
390
+ async chooseOption({
391
+ select,
392
+ value,
393
+ }: {
394
+ select: Locator;
395
+ value: string;
396
+ }): Promise<void> {
397
+ await select.click();
398
+ await expect(select).toHaveAttribute("aria-expanded", "true");
399
+ const listboxId = await select.getAttribute("aria-controls");
400
+ if (listboxId === null) {
401
+ throw new Error("The select has no listbox: aria-controls is missing.");
402
+ }
403
+ await this.page
404
+ .locator(`[id="${listboxId}"] [role="option"][value="${value}"]`)
405
+ .click();
406
+ }
407
+
408
+ /** The entry row opened by `edit.addRow()`; `data-row-id` is its temp id. */
409
+ entryRow(): Locator {
410
+ return this.part("entry-row");
411
+ }
412
+
413
+ /**
414
+ * Types into the built-in editors of an open row: an entry row, or a row
415
+ * opened in row mode.
416
+ */
417
+ async fillRow(rowId: string, values: Record<string, string>): Promise<void> {
418
+ for (const [columnId, value] of Object.entries(values)) {
419
+ await this.part("editor", { rowId, columnId })
420
+ .locator('[data-dg-part="editor-input"]')
421
+ .fill(value);
422
+ }
423
+ }
424
+
425
+ async commitEntryRow(rowId: string): Promise<void> {
426
+ await this.part("confirm-new-row", { rowId }).click();
427
+ }
428
+
429
+ /** Presses Save in `TMDataGrid.DraftActions` and waits for the store to empty. */
430
+ async saveDrafts(): Promise<void> {
431
+ await this.part("save-all").click();
432
+ await expect(this.part("save-all")).toHaveAttribute(
433
+ "data-draft-count",
434
+ "0",
435
+ );
436
+ }
437
+
438
+ /**
439
+ * Finds a row the test added by a value unique to it, since the grid does
440
+ * not know the id the app gave the row. Retries until the app has put the
441
+ * row in `data`.
442
+ */
443
+ async expectRowAdded(
444
+ uniqueValue: string,
445
+ cells: Record<string, string>,
446
+ ): Promise<void> {
447
+ await this.search(uniqueValue);
448
+ await this.expectRowCount(1);
449
+ const row = this.part("row");
450
+ for (const [columnId, text] of Object.entries(cells)) {
451
+ await expect(
452
+ row.locator(`[data-column-id="${columnId}"]:not([data-dg-part])`),
453
+ ).toHaveText(text);
454
+ }
455
+ await this.search("");
456
+ }
457
+
458
+ async expectRowCount(count: number): Promise<void> {
459
+ await expect(this.grid).toHaveAttribute(
460
+ "data-dg-row-count",
461
+ String(count),
462
+ );
463
+ }
464
+
465
+ async expectSettled(): Promise<void> {
466
+ await expect(this.grid).not.toHaveAttribute("aria-busy");
467
+ }
468
+ }
469
+
470
+ function partSelector(name: string, key: PartKey): string {
471
+ return (
472
+ `[data-dg-part="${name}"]` +
473
+ (key.rowId === undefined ? "" : `[data-row-id="${key.rowId}"]`) +
474
+ (key.columnId === undefined ? "" : `[data-column-id="${key.columnId}"]`)
475
+ );
476
+ }
477
+ ```
478
+
479
+ ```ts
480
+ test("filters to one employee", async ({ page }) => {
481
+ await page.goto("/employees");
482
+ const grid = DataGrid.byTestId(page, "employees");
483
+ await grid.expectSettled();
484
+
485
+ await grid.filterBy({ columnId: "lastName", value: "Nordkvist" });
486
+ await grid.expectRowCount(1);
487
+ await expect(grid.cell({ rowId: "42", columnId: "city" })).toHaveText(
488
+ "Stockholm",
489
+ );
490
+ });
491
+ ```
492
+
493
+ ## Recipes
494
+
495
+ Each row names the parts a step uses and the attribute that proves it; `grid` is a `DataGrid`.
496
+
497
+ | Interaction | Steps | Assertion |
498
+ | --- | --- | --- |
499
+ | Quick search | `grid.search("Cecilia")`; `search-clear` | `grid.expectRowCount(10)`, then the full count again |
500
+ | Sort | `grid.sortBy("lastName")` once, then again | `aria-sort` on the `header` is `ascending`, then `descending` |
501
+ | Filter | `grid.filterBy({ columnId, value })`; `filter-clear-all` | `grid.expectRowCount(n)`, then the full count again |
502
+ | Remove a filter | `filter-remove` inside its `filter-row`, or `filter-pill-remove` inside its `filter-pill` | the `filter-pill` has count 0; the row count |
503
+ | Hide a column | `grid.toggleColumn("location")`; `grid.menuPart("columns-reset")` | the `header` has count 0, then is visible again |
504
+ | Page | `page-next`; `grid.chooseOption({ select: grid.part("page-size"), value: "50" })` | the `page-range` text changes and the first `row` has a new `data-row-id`; `grid.expectRowCount(50)` |
505
+ | Select rows | `select-row` of a row; `select-all` | `data-selected="true"` on the `row`; `[data-dg-part="row"]:not([data-selected])` has count 0 |
506
+ | Group | `grid.openColumnMenu("location")`, then the "Group by" item; `group-toggle` of a group row | rows carry `data-grouped`; `data-dg-row-count` grows when the group expands and shrinks when it collapses |
507
+ | Load more | scroll `data-dg-scroll-container` to its `scrollHeight` | `data-dg-row-count` grows, then `grid.expectSettled()` |
508
+ | Export | `menu-button`, then `menu-export` in the menu | `page.waitForEvent("download")` and `download.suggestedFilename()` |
509
+ | Edit a cell | `grid.cell({ rowId, columnId }).dblclick()`, `grid.fillRow(rowId, { columnId: value })`, Enter | the cell's text; after Escape instead of Enter, the text is unchanged |
510
+
511
+ ## Editing
512
+
513
+ ### Adding a row
514
+
515
+ [`edit.addRow()`](/docs/editing) opens an entry row, `data-dg-part="entry-row"`,
516
+ keyed by a temporary id (`__new__1`, `__new__2`, …). Its editors are `editor`
517
+ parts keyed by that id and the column. What ✓ does with the row depends on
518
+ `editing.draft`:
519
+
520
+ | Step | Without `draft` | With `draft: true` |
521
+ | --- | --- | --- |
522
+ | `addRow()` | `entry-row[data-row-id="__new__1"]` | Same |
523
+ | ✓ fails validation | The entry row stays; the failing cells carry `data-invalid` | Same |
524
+ | ✓ (`confirm-new-row`) | The entry row is removed and the temp id is gone. `onRowAdd` receives the values, and the row exists again only once your app puts it in `data`, under the id your `getRowId` returns | The row becomes a body row, still keyed by the temp id: `row[data-row-id="__new__1"][data-new="true"][data-draft="true"]`, with `row-state[data-state="new"]` in its lane and `save-all[data-draft-count]` counting it |
525
+ | Save (`save-all`) | - | As ✓ without `draft`: the temp id is gone, and the row comes back through `data` under your id |
526
+
527
+ The grid never learns which id your app gave the row, so once `onRowAdd` or
528
+ `saveDrafts` has run, the row cannot be addressed by id. Find it by its content
529
+ instead: narrow the grid to a value unique to the row, assert that one row is
530
+ left, and read that row's cells. The row-count assertion is also the wait,
531
+ since it retries until the app has put the row in `data`:
532
+
533
+ ```ts
534
+ test("adds an employee", async ({ page }) => {
535
+ await page.goto("/employees");
536
+ const grid = DataGrid.byTestId(page, "employees");
537
+ await grid.expectSettled();
538
+
539
+ // The app's own button, calling edit.addRow()
540
+ await page.getByRole("button", { name: "Add row" }).click();
541
+ const tempId = (await grid.entryRow().getAttribute("data-row-id"))!;
542
+ await grid.fillRow(tempId, { name: "Nordkvist-4711", city: "Stockholm" });
543
+ await grid.commitEntryRow(tempId);
544
+
545
+ await expect(grid.entryRow()).toHaveCount(0);
546
+ await grid.expectRowAdded("Nordkvist-4711", {
547
+ name: "Nordkvist-4711",
548
+ city: "Stockholm",
549
+ });
550
+ });
551
+ ```
552
+
553
+ Use a value no other row has, such as a name with a run id in it, so that the
554
+ search leaves one row. If the grid has no quick search, narrow with `filterBy`
555
+ on a column instead.
556
+
557
+ Under `draft: true` the temp id stays valid until Save, so the committed row is
558
+ asserted directly, and the content-based check applies after the save:
559
+
560
+ ```ts
561
+ await grid.commitEntryRow(tempId);
562
+ const row = grid.part("row", { rowId: tempId });
563
+ await expect(row).toHaveAttribute("data-new", "true");
564
+ await expect(grid.part("row-state", { rowId: tempId })).toHaveAttribute(
565
+ "data-state",
566
+ "new",
567
+ );
568
+ await expect(grid.part("save-all")).toHaveAttribute("data-draft-count", "1");
569
+
570
+ await grid.saveDrafts();
571
+ await expect(row).toHaveCount(0);
572
+ await grid.expectRowAdded("Nordkvist-4711", { city: "Stockholm" });
573
+ ```
574
+
575
+ Under `newRowsSticky: true` the committed row stays in the entry block instead,
576
+ as `entry-row[data-row-id="__new__1"][data-committed="true"]`, outside
577
+ `data-dg-row-count`, until the save.
578
+
579
+ A ✓ that fails validation keeps the entry row open, with `data-invalid` on the
580
+ failing cells. The cell and its open editor share the column id, so the
581
+ selector leaves the editor out:
582
+
583
+ ```ts
584
+ await grid.commitEntryRow(tempId);
585
+ await expect(grid.entryRow()).toHaveCount(1);
586
+ await expect(
587
+ grid.entryRow().locator('[data-column-id="email"]:not([data-dg-part])'),
588
+ ).toHaveAttribute("data-invalid", "true");
589
+ ```
590
+
591
+ ### Changing a row
592
+
593
+ Without `draft`, a commit goes to `onCommit` and the grid keeps nothing of it, so
594
+ the assertion is the cell's text once your app has applied the change. Under
595
+ `draft: true` the change stays in the grid until Save, marked on the cell, the
596
+ row and the lane:
597
+
598
+ ```ts
599
+ const cell = grid.cell({ rowId: "42", columnId: "salary" });
600
+ await cell.dblclick();
601
+ await grid.fillRow("42", { salary: "52000" });
602
+ await page.keyboard.press("Enter");
603
+
604
+ // Without draft: the app applied it
605
+ await expect(cell).toHaveText("52 000");
606
+
607
+ // Under draft: the grid holds it
608
+ await expect(cell).toHaveAttribute("data-dirty", "true");
609
+ await expect(grid.part("row", { rowId: "42" })).toHaveAttribute("data-dirty", "true");
610
+ await expect(grid.part("row-state", { rowId: "42" })).toHaveAttribute(
611
+ "data-state",
612
+ "edited",
613
+ );
614
+ ```
615
+
616
+ In row mode, `edit-row` opens the row, `save-row` commits it and `cancel-row`
617
+ discards it; `fillRow` reaches the open row's editors the same way.
618
+
619
+ ### Deleting a row
620
+
621
+ `delete-row` calls `onRowDelete` at once, or marks the row under `draft: true`:
622
+
623
+ ```ts
624
+ const before = Number(await grid.grid.getAttribute("data-dg-row-count"));
625
+ await grid.part("delete-row", { rowId: "42" }).click();
626
+
627
+ // Without draft: the app removed it from data
628
+ await grid.expectRowCount(before - 1);
629
+
630
+ // Under draft: marked until Save, and Restore undoes the mark
631
+ await expect(grid.part("row", { rowId: "42" })).toHaveAttribute("data-deleted", "true");
632
+ await grid.part("restore-row", { rowId: "42" }).click();
633
+ ```
634
+
635
+ Assert a deletion on `data-dg-row-count`, not on the row's element: a row
636
+ outside the viewport has no element either, so `toHaveCount(0)` passes for it
637
+ whether or not it was deleted.
638
+
639
+ ## Component tests
640
+
641
+ Playwright 1.62 and later mount a component in a real browser through the `mount` fixture of `@playwright/test`.
642
+ A `*.story.tsx` file exports one component per scenario, a gallery page served by your dev server renders a story by id, and the test drives the result with the same locators as a page test.
643
+ What the gallery page must expose, and a React gallery of a few dozen lines, are in [Component testing](https://playwright.dev/docs/test-components).
644
+ A real browser covers what jsdom cannot: virtualization, column resize and reorder, sticky pinned columns, and the clipboard.
645
+
646
+ The project points `baseURL` at the gallery and starts its dev server:
647
+
648
+ ```ts
649
+ projects: [
650
+ {
651
+ name: "components",
652
+ testDir: "tests/components",
653
+ use: {
654
+ baseURL: "http://localhost:5274/",
655
+ serviceWorkers: "block",
656
+ permissions: ["clipboard-read", "clipboard-write"],
657
+ },
658
+ },
659
+ ],
660
+ webServer: [{ command: "npm run gallery", url: "http://localhost:5274/" }],
661
+ ```
662
+
663
+ A story owns everything the grid needs.
664
+ Render it inside `<MantineProvider env="test">`, in the gallery or in the story, which turns Mantine's transitions off.
665
+ Put the grid in a flex column of fixed height with `flex: 1` and `minHeight: 0` on the grid itself, as on the [Styling](/docs/styling) page; a plain block container grows with the rows and nothing virtualizes.
666
+ Expose the api on `window` in the story where a test needs `scrollToRow`:
667
+
668
+ ```tsx
669
+ // Orders.story.tsx
670
+ declare global {
671
+ interface Window {
672
+ __grid?: TMDataGridApi<Order>;
673
+ }
674
+ }
675
+
676
+ export const Virtualized = () => {
677
+ const grid = useTMDataGrid<Order>({
678
+ data: orders, // 5000 rows
679
+ columns,
680
+ getRowId: (row) => row.id,
681
+ });
682
+ useEffect(() => {
683
+ window.__grid = grid;
684
+ }, [grid]);
685
+ return (
686
+ <div style={{ display: "flex", flexDirection: "column", height: 400 }}>
687
+ <TMDataGrid {...grid} style={{ flex: 1, minHeight: 0 }} data-testid="orders">
688
+ <TMDataGrid.Table<Order> />
689
+ </TMDataGrid>
690
+ </div>
691
+ );
692
+ };
693
+ ```
694
+
695
+ `mount` returns the gallery root; the page object takes the grid's root inside it.
696
+ The spec is typechecked apart from the story, so it declares `__grid` on `Window` as well:
697
+
698
+ ```ts
699
+ test("reaches a row past the viewport", async ({ mount, page }) => {
700
+ const component = await mount("Orders/Virtualized");
701
+ const grid = new DataGrid(component.locator("[data-dg-root]"));
702
+ await grid.expectRowCount(5000);
703
+ await expect(grid.part("row", { rowId: "4500" })).toHaveCount(0);
704
+
705
+ const found = await page.evaluate(() => {
706
+ return window.__grid!.scrollToRow({ rowId: "4500", align: "center" });
707
+ });
708
+ expect(found).toBe(true);
709
+ await expect(grid.part("row", { rowId: "4500" })).toBeVisible();
710
+ });
711
+ ```
712
+
713
+ A story id is a string: `mount` accepts any id, and a renamed story fails at run time.
714
+ To get completion and prop checking, register the ids in Playwright's `Stories` interface:
715
+
716
+ ```ts
717
+ declare module "@playwright/test" {
718
+ interface Stories {
719
+ "Orders/Virtualized": typeof Virtualized;
720
+ }
721
+ }
722
+ ```
723
+
724
+ A clipboard test needs `cellSelection` on in the story and the `permissions` above in the project; it selects a range, presses `Control+C` and reads `navigator.clipboard.readText()` through `page.evaluate`.
725
+ The copied text separates cells with a tab and rows with `\r\n`.
726
+
727
+ The grid's own component suite is written this way: the stories, the gallery and the specs are in the [repository](https://github.com/Jielga/TMDataGrid/tree/main/playwright).
728
+
729
+ ## React Testing Library
730
+
731
+ Under jsdom there is no layout, so the virtualizer mounts a handful of rows
732
+ whatever the data says. Assert on the grid's own counts rather than on the
733
+ number of row elements:
734
+
735
+ ```tsx
736
+ const rowCount = Number(
737
+ screen.getByRole("table").getAttribute("data-dg-row-count"),
738
+ );
739
+ expect(rowCount).toBe(3);
740
+ ```
741
+
742
+ Mantine's transitions never settle under jsdom, so a Popover's dropdown mounts
743
+ empty, including the filter and column panels. Render inside
744
+ `<MantineProvider env="test">`.