@jielga/tmdatagrid 2.0.0-beta.9 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +5 -212
  2. package/dist/index.d.ts +1281 -768
  3. package/dist/index.js +4607 -3250
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles.css +1 -1
  6. package/docs/adding-rows.md +132 -0
  7. package/docs/anatomy.md +119 -0
  8. package/docs/card-view.md +108 -0
  9. package/docs/cell-selection.md +194 -0
  10. package/docs/column-layout.md +182 -0
  11. package/docs/column-menu.md +66 -0
  12. package/docs/columns.md +268 -0
  13. package/docs/components.md +311 -0
  14. package/docs/draft-store.md +242 -0
  15. package/docs/editing.md +303 -0
  16. package/docs/editors.md +250 -0
  17. package/docs/export.md +319 -0
  18. package/docs/filtering.md +362 -0
  19. package/docs/getting-started.md +123 -0
  20. package/docs/grouping.md +165 -0
  21. package/docs/loading-and-empty.md +92 -0
  22. package/docs/localization.md +79 -0
  23. package/docs/menu.md +143 -0
  24. package/docs/migrating-to-2.md +163 -0
  25. package/docs/pagination.md +144 -0
  26. package/docs/persistence.md +114 -0
  27. package/docs/portfolio-rebalancer.md +94 -0
  28. package/docs/query-builder.md +179 -0
  29. package/docs/quick-search.md +84 -0
  30. package/docs/row-details.md +115 -0
  31. package/docs/row-interaction.md +149 -0
  32. package/docs/row-pinning.md +132 -0
  33. package/docs/row-selection.md +136 -0
  34. package/docs/row-styling.md +133 -0
  35. package/docs/scrolling.md +112 -0
  36. package/docs/server-query.md +246 -0
  37. package/docs/server-side.md +206 -0
  38. package/docs/sorting.md +101 -0
  39. package/docs/styling.md +126 -0
  40. package/docs/summary-row.md +76 -0
  41. package/docs/testing.md +744 -0
  42. package/docs/toolbar.md +161 -0
  43. package/docs/use-tm-data-grid.md +361 -0
  44. package/package.json +22 -46
  45. package/skills/appearance/SKILL.md +72 -19
  46. package/skills/cell-selection/SKILL.md +46 -47
  47. package/skills/columns/SKILL.md +90 -34
  48. package/skills/data/SKILL.md +86 -16
  49. package/skills/editing/SKILL.md +67 -40
  50. package/skills/editing/references/common-mistakes.md +77 -69
  51. package/skills/editing/references/editing-api.md +22 -19
  52. package/skills/editing/references/editors-and-validation.md +24 -17
  53. package/skills/filtering/SKILL.md +148 -40
  54. package/skills/getting-started/SKILL.md +17 -15
  55. package/skills/grouping/SKILL.md +31 -16
  56. package/skills/options/SKILL.md +7 -7
  57. package/skills/rows/SKILL.md +22 -18
  58. package/skills/server-side/SKILL.md +170 -17
  59. package/skills/testing/SKILL.md +150 -32
  60. package/skills/testing-components/SKILL.md +230 -0
  61. package/skills/testing-editing/SKILL.md +240 -0
  62. package/src/{tmdatagrid/components → components}/TMDataGrid.tsx +38 -21
  63. package/src/{tmdatagrid/components → components}/TMDataGridCellEditor.tsx +54 -8
  64. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.module.css +5 -1
  65. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.tsx +47 -55
  66. package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.tsx +5 -51
  67. package/src/{tmdatagrid/components → components}/TMDataGridDraftActions.tsx +41 -24
  68. package/src/{tmdatagrid/components → components}/TMDataGridEditColumn.tsx +12 -59
  69. package/src/{tmdatagrid/components → components}/TMDataGridEntryRows.tsx +164 -92
  70. package/src/components/TMDataGridExportPicker.module.css +77 -0
  71. package/src/components/TMDataGridExportPicker.tsx +234 -0
  72. package/src/components/TMDataGridFilterPanel.module.css +54 -0
  73. package/src/components/TMDataGridFilterPanel.tsx +348 -0
  74. package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.tsx +15 -8
  75. package/src/components/TMDataGridFilterSurface.module.css +54 -0
  76. package/src/components/TMDataGridFilterSurface.tsx +167 -0
  77. package/src/{tmdatagrid/components → components}/TMDataGridFooter.tsx +53 -90
  78. package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.tsx +5 -69
  79. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.module.css +12 -2
  80. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.tsx +58 -21
  81. package/src/components/TMDataGridHeaderFilterRow.module.css +51 -0
  82. package/src/components/TMDataGridHeaderFilterRow.tsx +301 -0
  83. package/src/components/TMDataGridMenu.tsx +357 -0
  84. package/src/{tmdatagrid/components → components}/TMDataGridSelectColumn.tsx +11 -48
  85. package/src/{tmdatagrid/components → components}/TMDataGridTable.module.css +69 -56
  86. package/src/{tmdatagrid/components → components}/TMDataGridTable.tsx +240 -138
  87. package/src/{tmdatagrid/components → components}/TMDataGridToolbar.module.css +5 -0
  88. package/src/components/TMDataGridToolbar.tsx +181 -0
  89. package/src/{tmdatagrid/components → components}/editors/TMDataGridBooleanEditor.tsx +3 -3
  90. package/src/{tmdatagrid/components → components}/editors/TMDataGridDateEditor.tsx +3 -3
  91. package/src/{tmdatagrid/components → components}/editors/TMDataGridMultiSelectEditor.tsx +3 -3
  92. package/src/{tmdatagrid/components → components}/editors/TMDataGridNumberEditor.tsx +3 -3
  93. package/src/{tmdatagrid/components → components}/editors/TMDataGridSelectEditor.tsx +3 -3
  94. package/src/{tmdatagrid/components → components}/editors/TMDataGridStringEditor.tsx +3 -3
  95. package/src/{tmdatagrid/components → components}/editors/editorShared.ts +16 -2
  96. package/src/{tmdatagrid/components → components}/filters/DgAutocompleteFilter.tsx +5 -4
  97. package/src/{tmdatagrid/components → components}/filters/DgDateRangeFilter.tsx +22 -5
  98. package/src/{tmdatagrid/components → components}/filters/DgRangeSliderFilter.tsx +5 -1
  99. package/src/{tmdatagrid/components → components}/filters/DgTriStateFilter.tsx +5 -1
  100. package/src/{tmdatagrid/components → components}/filters/TMDataGridFilterValueInput.tsx +44 -28
  101. package/src/components/filters/controlLayout.ts +32 -0
  102. package/src/components/filters/filterControlFor.ts +65 -0
  103. package/src/components/generatedColumns.tsx +187 -0
  104. package/src/{tmdatagrid/components → components}/icons.ts +1 -0
  105. package/src/{tmdatagrid/components → components}/sticky.module.css +44 -0
  106. package/src/components/useHideableColumns.ts +52 -0
  107. package/src/{tmdatagrid/core → core}/autosize.ts +5 -2
  108. package/src/{tmdatagrid/core → core}/columnOptions.ts +60 -8
  109. package/src/{tmdatagrid/core → core}/columnOrdering.ts +33 -14
  110. package/src/{tmdatagrid/core → core}/columnUtils.ts +44 -5
  111. package/src/core/controlledStateSync.ts +108 -0
  112. package/src/core/deletedRows.ts +34 -0
  113. package/src/core/dom.ts +74 -0
  114. package/src/{tmdatagrid/core → core}/editEngine.ts +1107 -460
  115. package/src/core/export.ts +704 -0
  116. package/src/{tmdatagrid/core → core}/filterControls.ts +38 -1
  117. package/src/{tmdatagrid/core → core}/filterOperators.ts +64 -1
  118. package/src/core/filterSurface.ts +99 -0
  119. package/src/{tmdatagrid/core → core}/grouping.ts +21 -0
  120. package/src/{tmdatagrid/core → core}/labels.ts +51 -6
  121. package/src/{tmdatagrid/core → core}/labelsSv.ts +27 -6
  122. package/src/core/pageReset.ts +120 -0
  123. package/src/core/pagination.ts +81 -0
  124. package/src/{tmdatagrid/core → core}/persistence.ts +22 -5
  125. package/src/{tmdatagrid/core → core}/summary.ts +20 -4
  126. package/src/{tmdatagrid/index.ts → index.ts} +69 -35
  127. package/src/{tmdatagrid/useTMDataGrid.tsx → useTMDataGrid.tsx} +428 -109
  128. package/src/useTMDataGridExport.ts +78 -0
  129. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +0 -35
  130. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +0 -354
  131. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +0 -161
  132. package/src/tmdatagrid/core/cellExport.ts +0 -320
  133. /package/src/{tmdatagrid/TMDataGridContext.ts → TMDataGridContext.ts} +0 -0
  134. /package/src/{tmdatagrid/components → components}/TMDataGrid.module.css +0 -0
  135. /package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.module.css +0 -0
  136. /package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.module.css +0 -0
  137. /package/src/{tmdatagrid/components → components}/TMDataGridFooter.module.css +0 -0
  138. /package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.module.css +0 -0
  139. /package/src/{tmdatagrid/components → components}/TMDataGridRowNumberColumn.tsx +0 -0
  140. /package/src/{tmdatagrid/components → components}/TMDataGridSearch.tsx +0 -0
  141. /package/src/{tmdatagrid/core → core}/capabilities.ts +0 -0
  142. /package/src/{tmdatagrid/core → core}/cellNavigation.ts +0 -0
  143. /package/src/{tmdatagrid/core → core}/cellRange.ts +0 -0
  144. /package/src/{tmdatagrid/core → core}/controlledState.ts +0 -0
  145. /package/src/{tmdatagrid/core → core}/draftCellContext.ts +0 -0
  146. /package/src/{tmdatagrid/core → core}/editorFocus.ts +0 -0
  147. /package/src/{tmdatagrid/core → core}/expanding.ts +0 -0
  148. /package/src/{tmdatagrid/core → core}/matchHighlight.ts +0 -0
  149. /package/src/{tmdatagrid/core → core}/quickSearch.ts +0 -0
  150. /package/src/{tmdatagrid/core → core}/resizePreview.ts +0 -0
  151. /package/src/{tmdatagrid/core → core}/rowPinning.ts +0 -0
  152. /package/src/{tmdatagrid/core → core}/rowSelection.ts +0 -0
  153. /package/src/{tmdatagrid/core → core}/sizes.ts +0 -0
  154. /package/src/{tmdatagrid/core → core}/useSettledTableState.ts +0 -0
@@ -0,0 +1,240 @@
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'
16
+ sources:
17
+ - 'Jielga/TMDataGrid:packages/tmdatagrid/docs/testing.md'
18
+ - 'Jielga/TMDataGrid:packages/tmdatagrid/docs/editing.md'
19
+ - 'Jielga/TMDataGrid:packages/tmdatagrid/docs/draft-store.md'
20
+ - 'Jielga/TMDataGrid:packages/tmdatagrid/src/components/TMDataGridEntryRows.tsx'
21
+ - 'Jielga/TMDataGrid:packages/tmdatagrid/src/components/TMDataGridEditColumn.tsx'
22
+ ---
23
+
24
+ # TMDataGrid - Testing editing
25
+
26
+ Builds on the `testing` skill: parts are `[data-dg-part]`, coordinates are
27
+ `[data-row-id]` / `[data-column-id]`, and `DataGrid` is its page object, built
28
+ with `DataGrid.byTestId(page, "orders")` or `new DataGrid(rootLocator)`.
29
+
30
+ ## The life of an added row
31
+
32
+ `edit.addRow()` opens an entry row keyed by a temporary id. What happens at ✓
33
+ depends on `editing.draft`, and that decides what a test can hold on to:
34
+
35
+ | Step | Without `draft` | With `draft: true` |
36
+ | --- | --- | --- |
37
+ | `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 |
38
+ | ✓ fails validation | Entry row stays; failing cells carry `data-invalid="true"` | Same |
39
+ | ✓ (`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` |
40
+ | Save (`save-all`) | - | As ✓ without `draft`: temp id gone, row comes back through `data` under the app's id |
41
+ | `newRowsSticky: true` | - | The committed row stays an `entry-row` with `data-committed="true"`, outside `data-dg-row-count`, until Save |
42
+
43
+ The grid never learns the id the app assigns (a database id, a negative
44
+ counter, a uuid - whatever `onRowAdd` or `onSaveDrafts` does). The temp id →
45
+ real id mapping does not exist anywhere in the DOM.
46
+
47
+ ## The rule: find an added row by content, not by id
48
+
49
+ Once `onRowAdd` or `saveDrafts` has run, the only thing the test knows about
50
+ the row is what it typed. Narrow the grid to a value unique to that row, assert
51
+ that exactly one row is left, read that row's cells:
52
+
53
+ ```ts
54
+ async expectRowAdded(uniqueValue: string, cells: Record<string, string>) {
55
+ await this.search(uniqueValue); // or filterBy on a column when there is no quick search
56
+ await this.expectRowCount(1); // retries until the app has put the row in data
57
+ const row = this.part("row"); // exactly one body row now
58
+ for (const [columnId, text] of Object.entries(cells)) {
59
+ await expect(
60
+ row.locator(`[data-column-id="${columnId}"]:not([data-dg-part])`),
61
+ ).toHaveText(text);
62
+ }
63
+ await this.search("");
64
+ }
65
+ ```
66
+
67
+ - The `data-dg-row-count` assertion is the wait. Nothing else signals that
68
+ `onRowAdd` finished: `aria-busy` reflects `meta.loading` only.
69
+ - Use a value no other row has - put a run id in the name. Two matches means
70
+ `expectRowCount(1)` fails, correctly.
71
+ - Scoped to the grid and to one column: no `page.getByText`, no virtualization
72
+ problem, no strict-mode ambiguity.
73
+
74
+ ## Page object methods
75
+
76
+ Add these to the `DataGrid` class from the `testing` skill:
77
+
78
+ ```ts
79
+ entryRow(): Locator {
80
+ return this.part("entry-row");
81
+ }
82
+
83
+ /** Built-in editors of an open row: an entry row, or a row opened in row mode. */
84
+ async fillRow(rowId: string, values: Record<string, string>): Promise<void> {
85
+ for (const [columnId, value] of Object.entries(values)) {
86
+ await this.part("editor", { rowId, columnId })
87
+ .locator('[data-dg-part="editor-input"]')
88
+ .fill(value);
89
+ }
90
+ }
91
+
92
+ async commitEntryRow(rowId: string): Promise<void> {
93
+ await this.part("confirm-new-row", { rowId }).click();
94
+ }
95
+
96
+ /** Save in TMDataGrid.DraftActions, waited until the store is empty. */
97
+ async saveDrafts(): Promise<void> {
98
+ await this.part("save-all").click();
99
+ await expect(this.part("save-all")).toHaveAttribute("data-draft-count", "0");
100
+ }
101
+ ```
102
+
103
+ `fillRow` covers the built-in editors. A column with `meta.edit.editor` renders
104
+ the app's own component inside the `editor` part; scope through
105
+ `part("editor", { rowId, columnId })` and fill whatever is inside.
106
+
107
+ ## Recipes
108
+
109
+ ### Add a row, without draft
110
+
111
+ ```ts
112
+ await page.getByRole("button", { name: "Add row" }).click(); // the app's button → edit.addRow()
113
+ const tempId = (await grid.entryRow().getAttribute("data-row-id"))!;
114
+ await grid.fillRow(tempId, { name: "Nordkvist-4711", city: "Stockholm" });
115
+ await grid.commitEntryRow(tempId);
116
+
117
+ await expect(grid.entryRow()).toHaveCount(0); // entry row gone; tempId is now meaningless
118
+ await grid.expectRowAdded("Nordkvist-4711", { name: "Nordkvist-4711", city: "Stockholm" });
119
+ ```
120
+
121
+ ### Add a row, with draft, then save
122
+
123
+ ```ts
124
+ await grid.commitEntryRow(tempId);
125
+ const row = grid.part("row", { rowId: tempId }); // tempId is still the row's id
126
+ await expect(row).toHaveAttribute("data-new", "true");
127
+ await expect(row).toHaveAttribute("data-draft", "true");
128
+ await expect(grid.part("row-state", { rowId: tempId })).toHaveAttribute("data-state", "new");
129
+ await expect(grid.part("save-all")).toHaveAttribute("data-draft-count", "1");
130
+
131
+ await grid.saveDrafts();
132
+ await expect(row).toHaveCount(0); // tempId dies here
133
+ await grid.expectRowAdded("Nordkvist-4711", { city: "Stockholm" });
134
+ ```
135
+
136
+ Reopening a committed draft row: `edit-row` (the lane's pencil) or a
137
+ double-click puts it back in the entry block, still under the temp id; ✕
138
+ (`discard-new-row`) removes it.
139
+
140
+ ### A ✓ that fails validation
141
+
142
+ ```ts
143
+ await grid.commitEntryRow(tempId);
144
+ await expect(grid.entryRow()).toHaveCount(1); // still open
145
+ await expect(
146
+ grid.entryRow().locator('[data-column-id="email"]:not([data-dg-part])'),
147
+ ).toHaveAttribute("data-invalid", "true");
148
+ ```
149
+
150
+ The cell and the open `editor` inside it share the column id, so the selector
151
+ leaves the editor out.
152
+
153
+ ### Change a cell or a row
154
+
155
+ ```ts
156
+ const cell = grid.cell({ rowId: "42", columnId: "salary" });
157
+ await cell.dblclick(); // cell mode: opens the editor
158
+ await grid.fillRow("42", { salary: "52000" });
159
+ await page.keyboard.press("Enter"); // commits
160
+
161
+ // Without draft: onCommit ran, the app applied it, the grid holds nothing
162
+ await expect(cell).toHaveText("52 000");
163
+
164
+ // Under draft: the grid holds it until Save
165
+ await expect(cell).toHaveAttribute("data-dirty", "true");
166
+ await expect(grid.part("row", { rowId: "42" })).toHaveAttribute("data-dirty", "true");
167
+ await expect(grid.part("row-state", { rowId: "42" })).toHaveAttribute("data-state", "edited");
168
+ ```
169
+
170
+ Row mode: `edit-row` opens the row, `fillRow` reaches its editors, `save-row`
171
+ commits, `cancel-row` discards. `cellConfirm` mode: `editor-confirm` /
172
+ `editor-cancel` beside the input, or Enter / Escape.
173
+
174
+ ### Delete a row
175
+
176
+ ```ts
177
+ const before = Number(await grid.grid.getAttribute("data-dg-row-count"));
178
+ await grid.part("delete-row", { rowId: "42" }).click();
179
+
180
+ // Without draft: onRowDelete ran, the app removed it from data
181
+ await grid.expectRowCount(before - 1);
182
+
183
+ // Under draft: marked until Save; restore-row undoes the mark
184
+ await expect(grid.part("row", { rowId: "42" })).toHaveAttribute("data-deleted", "true");
185
+ await grid.part("restore-row", { rowId: "42" }).click();
186
+ ```
187
+
188
+ ## Attribute values
189
+
190
+ A state attribute is present, with the value `"true"`, only while it applies:
191
+ `data-new`, `data-draft`, `data-dirty`, `data-deleted` on a body row,
192
+ `data-committed` on an entry row, `data-dirty`, `data-invalid`, `data-editing`
193
+ on a cell. `[data-new]` and `[data-new="true"]` match the same rows,
194
+ `toHaveAttribute("data-new")` and `toHaveAttribute("data-new", "true")` both
195
+ hold, and the negative is `not.toHaveAttribute("data-new")`.
196
+
197
+ ## Common mistakes
198
+
199
+ ### Looking for the new row's text on the page
200
+
201
+ `page.getByText("Nordkvist")` searches the whole document: other grids, the
202
+ toolbar, a toast. And the row may be virtualized out of the DOM, so the
203
+ locator fails or flakes depending on sort order and viewport. Narrow with
204
+ search or a filter, assert `data-dg-row-count` is 1, read that one row.
205
+
206
+ ### Keeping the temp id after ✓ without draft
207
+
208
+ `grid.part("row", { rowId: "__new__1" })` never resolves: without `draft` the
209
+ grid drops the entry and the id at ✓, and the row's real id is the app's. Only
210
+ under `draft: true` does the temp id outlive ✓ - and only until Save.
211
+
212
+ ### Waiting for the added row with a timeout
213
+
214
+ The app's `onRowAdd` may be async. `expectRowCount(1)` after narrowing retries
215
+ until the row is in `data`; a `waitForTimeout` either wastes time or is too
216
+ short on CI.
217
+
218
+ ### Matching the editor with a cell selector
219
+
220
+ While a cell is open, `[data-row-id="42"][data-column-id="salary"]` matches
221
+ the cell and the `editor` part inside it, and strict mode fails on the pair.
222
+ `DataGrid.cell()` appends `:not([data-dg-part])`; a hand-written locator on a
223
+ row's cells needs the same.
224
+
225
+ ### Asserting the negative with `"false"`
226
+
227
+ `toHaveAttribute("data-new", "false")` never passes: a state that does not
228
+ apply is an absent attribute, not `"false"`. Use
229
+ `not.toHaveAttribute("data-new")`, and `:not([data-new])` in CSS.
230
+
231
+ ### Asserting a deletion with `toHaveCount(0)`
232
+
233
+ A row outside the viewport has no element either. Assert `data-dg-row-count`
234
+ dropped by one, or, under `draft`, `data-deleted="true"` on the row.
235
+
236
+ ### Expecting the grid to show the change without draft
237
+
238
+ Without `draft: true` the grid holds nothing after a commit: no `data-dirty`,
239
+ no `row-state`, `save-all` stays disabled at `data-draft-count="0"`. The
240
+ assertion is the cell's text after the app has applied the change to `data`.
@@ -7,16 +7,17 @@ import { TMDataGridDraftActions } from "./TMDataGridDraftActions";
7
7
  import { TMDataGridFilterPanel } from "./TMDataGridFilterPanel";
8
8
  import { TMDataGridFilterPills } from "./TMDataGridFilterPills";
9
9
  import { TMDataGridFooter } from "./TMDataGridFooter";
10
+ import { TMDataGridMenu } from "./TMDataGridMenu";
10
11
  import { TMDataGridSearch } from "./TMDataGridSearch";
11
12
  import { TMDataGridTable } from "./TMDataGridTable";
12
13
  import {
13
- TMDataGridColumnsButton,
14
14
  TMDataGridFilterButton,
15
15
  TMDataGridLoadingIndicator,
16
16
  TMDataGridSummaryCount,
17
17
  TMDataGridToolbar,
18
18
  TMDataGridToolbarSpacer,
19
19
  } from "./TMDataGridToolbar";
20
+ import { TMDataGridExportPicker } from "./TMDataGridExportPicker";
20
21
  import {
21
22
  DEFAULT_TMDATAGRID_SIZE,
22
23
  SIZE_CONTROL_SIZE,
@@ -55,29 +56,15 @@ export type TMDataGridProps<TData extends RowData> = TMDataGridApi<TData> & {
55
56
  "data-testid"?: string;
56
57
  };
57
58
 
58
- /**
59
- * Root of the grid. Takes the object returned by `useTMDataGrid` - spread it -
60
- * and publishes it to the compound components below it:
61
- *
62
- * ```tsx
63
- * const grid = useTMDataGrid({ data, columns });
64
- *
65
- * <TMDataGrid {...grid}>
66
- * <TMDataGrid.Toolbar>
67
- * <TMDataGrid.SummaryCount />
68
- * <TMDataGrid.Spacer />
69
- * <TMDataGrid.ColumnsButton />
70
- * </TMDataGrid.Toolbar>
71
- * <TMDataGrid.Table />
72
- * <TMDataGrid.Footer />
73
- * </TMDataGrid>
74
- * ```
75
- */
59
+ // Documented on the `TMDataGrid` export below.
60
+ // oxlint-disable-next-line react/only-export-components -- exported as the Object.assign compound `TMDataGrid`, which the rule cannot see as a component
76
61
  function TMDataGridRoot<TData extends RowData>({
77
62
  table,
78
63
  ui,
79
64
  edit,
80
65
  features,
66
+ filters,
67
+ exportOptions,
81
68
  labels,
82
69
  renderDetails,
83
70
  renderDetailsEstHeight,
@@ -100,6 +87,8 @@ function TMDataGridRoot<TData extends RowData>({
100
87
  ui,
101
88
  edit,
102
89
  features,
90
+ filters,
91
+ exportOptions,
103
92
  labels,
104
93
  renderDetails,
105
94
  renderDetailsEstHeight,
@@ -116,6 +105,8 @@ function TMDataGridRoot<TData extends RowData>({
116
105
  ui,
117
106
  edit,
118
107
  features,
108
+ filters,
109
+ exportOptions,
119
110
  labels,
120
111
  renderDetails,
121
112
  renderDetailsEstHeight,
@@ -141,10 +132,33 @@ function TMDataGridRoot<TData extends RowData>({
141
132
  >
142
133
  {children}
143
134
  </div>
135
+ {/* Portaled by Mantine, so outside the root element; inside the
136
+ provider, since it reads the grid. */}
137
+ <TMDataGridExportPicker />
144
138
  </TMDataGridContextProvider>
145
139
  );
146
140
  }
147
141
 
142
+ /**
143
+ * Root of the grid. Takes the object returned by `useTMDataGrid` - spread it -
144
+ * and publishes it to the compound components below it:
145
+ *
146
+ * ```tsx
147
+ * const grid = useTMDataGrid({ data, columns });
148
+ *
149
+ * <TMDataGrid {...grid}>
150
+ * <TMDataGrid.Toolbar>
151
+ * <TMDataGrid.SummaryCount />
152
+ * <TMDataGrid.Spacer />
153
+ * <TMDataGrid.Menu>
154
+ * <TMDataGrid.Menu.Columns />
155
+ * </TMDataGrid.Menu>
156
+ * </TMDataGrid.Toolbar>
157
+ * <TMDataGrid.Table />
158
+ * <TMDataGrid.Footer />
159
+ * </TMDataGrid>
160
+ * ```
161
+ */
148
162
  export const TMDataGrid = Object.assign(TMDataGridRoot, {
149
163
  Toolbar: TMDataGridToolbar,
150
164
  Spacer: TMDataGridToolbarSpacer,
@@ -152,7 +166,7 @@ export const TMDataGrid = Object.assign(TMDataGridRoot, {
152
166
  LoadingIndicator: TMDataGridLoadingIndicator,
153
167
  Search: TMDataGridSearch,
154
168
  DraftActions: TMDataGridDraftActions,
155
- ColumnsButton: TMDataGridColumnsButton,
169
+ Menu: TMDataGridMenu,
156
170
  FilterButton: TMDataGridFilterButton,
157
171
  Table: TMDataGridTable,
158
172
  Footer: TMDataGridFooter,
@@ -163,6 +177,9 @@ export const TMDataGrid = Object.assign(TMDataGridRoot, {
163
177
  * rendered outside `<TMDataGrid>` - a page header, for instance.
164
178
  */
165
179
  FilterPills: TMDataGridFilterPills,
166
- /** Rendered by `TMDataGrid.ColumnsButton`; exported for custom layouts. */
180
+ /**
181
+ * The column chooser as plain controls, for a Popover, a Drawer or an
182
+ * inline layout; `TMDataGrid.Menu.Columns` is the same thing as menu items.
183
+ */
167
184
  ColumnsPanel: TMDataGridColumnsPanel,
168
185
  });
@@ -1,4 +1,4 @@
1
- import { ActionIcon } from "@mantine/core";
1
+ import { ActionIcon, Tooltip } from "@mantine/core";
2
2
  import { FieldApi } from "@tanstack/react-form";
3
3
  import type { Cell, Row } from "@tanstack/react-table";
4
4
  import {
@@ -11,6 +11,7 @@ import {
11
11
  } from "react";
12
12
  import classes from "./TMDataGridTable.module.css";
13
13
  import { type TMDataGridRowData, useTMDataGridContext } from "../TMDataGridContext";
14
+ import { isButtonElement, isInputElement, isNode } from "../core/dom";
14
15
  import {
15
16
  getEditFieldName,
16
17
  normalizeFieldValidate,
@@ -27,6 +28,7 @@ import { TMDataGridMultiSelectEditor } from "./editors/TMDataGridMultiSelectEdit
27
28
  import { TMDataGridNumberEditor } from "./editors/TMDataGridNumberEditor";
28
29
  import { TMDataGridSelectEditor } from "./editors/TMDataGridSelectEditor";
29
30
  import { TMDataGridStringEditor } from "./editors/TMDataGridStringEditor";
31
+ import { useFieldError } from "./editors/editorShared";
30
32
 
31
33
  const BUILT_IN_EDITORS: Record<
32
34
  string,
@@ -138,6 +140,17 @@ export function TMDataGridCellEditor({
138
140
  });
139
141
  useEffect(() => field.mount(), [field]);
140
142
 
143
+ // The field's message, read here rather than in each editor: the tooltip
144
+ // below is the host's, so a column's own `meta.edit.editor` gets it too.
145
+ const error = useFieldError(field);
146
+ // When that tooltip is up, held here rather than left to Tooltip's own
147
+ // `events`: those bring floating-ui's dismiss interaction with them, which
148
+ // puts an `onKeyDown` on the anchor and stops Escape inside the editor
149
+ // before the host's handler below can cancel on it. A controlled `opened`
150
+ // turns the interaction off, so hover and focus are tracked here instead.
151
+ const [messageHovered, setMessageHovered] = useState(false);
152
+ const [messageFocused, setMessageFocused] = useState(false);
153
+
141
154
  // Type-to-edit: the seed replaces the value, as it does in a spreadsheet.
142
155
  // Only for the types where keystrokes are the input.
143
156
  useEffect(() => {
@@ -190,7 +203,7 @@ export function TMDataGridCellEditor({
190
203
  // Enter on the ✓ or ✕ is the button's own press, and does what a click on
191
204
  // it does: commit and stay, or cancel. Left to the branch below it would
192
205
  // commit and move down, and the same button would mean two things.
193
- if (event.key === "Enter" && event.target instanceof HTMLButtonElement) {
206
+ if (event.key === "Enter" && isButtonElement(event.target)) {
194
207
  event.stopPropagation();
195
208
  return;
196
209
  }
@@ -220,7 +233,7 @@ export function TMDataGridCellEditor({
220
233
  const next = tabbables[index + (event.shiftKey ? -1 : 1)];
221
234
  if (index >= 0 && next !== undefined) {
222
235
  next.focus();
223
- if (next instanceof HTMLInputElement) next.select();
236
+ if (isInputElement(next)) next.select();
224
237
  return;
225
238
  }
226
239
  deferAndClose(event.shiftKey ? "defer-shift-tab" : "defer-tab");
@@ -234,8 +247,12 @@ export function TMDataGridCellEditor({
234
247
  * Focus left the editor entirely - a click somewhere else. Under `"cell"`
235
248
  * that commits (Sheets): a row the user has walked away from is a row they
236
249
  * are done typing into, and where the commit lands is `editing.draft`'s
237
- * business. A commit blocked by validation keeps the form and its invalid
238
- * marker, with the editor closed.
250
+ * business. A refused commit - validation, or `onCommit` rejecting - leaves
251
+ * the editor where it is, invalid, with the value still in it: closed, the
252
+ * cell would render the refused value as if it had landed, and the message
253
+ * would go with the editor. The engine holds the same line when another
254
+ * row is opened over a refused one (see `begin`). The user fixes the value,
255
+ * or Escapes.
239
256
  */
240
257
  const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
241
258
  if (closingRef.current) return;
@@ -244,7 +261,7 @@ export function TMDataGridCellEditor({
244
261
  // nothing there; the lane's buttons and the keys end the edit.
245
262
  if (isRowShaped) return;
246
263
  const next = event.relatedTarget;
247
- if (next instanceof Node && event.currentTarget.contains(next)) return;
264
+ if (isNode(next) && event.currentTarget.contains(next)) return;
248
265
  closingRef.current = true;
249
266
  if (features.editMode === "cellConfirm") {
250
267
  // The draft stays, dirty-marked, waiting for its ✓.
@@ -252,7 +269,8 @@ export function TMDataGridCellEditor({
252
269
  return;
253
270
  }
254
271
  void edit.commit(row.id).then((ok) => {
255
- if (!ok) edit.deactivate();
272
+ // Open for another try: the next blur or key has to be able to commit.
273
+ if (!ok) closingRef.current = false;
256
274
  });
257
275
  };
258
276
 
@@ -289,7 +307,35 @@ export function TMDataGridCellEditor({
289
307
  onMouseDown={(event) => event.stopPropagation()}
290
308
  onDoubleClick={(event) => event.stopPropagation()}
291
309
  >
292
- <Editor {...args} />
310
+ {/* The validation message, on the editor rather than under it: inside a
311
+ narrow column Mantine's inline error wraps, grows the row and is
312
+ clipped, so the input keeps the invalid border and the text comes up
313
+ beside it. The editor gets a box of its own because Tooltip needs a
314
+ child that takes a ref, which a `meta.edit.editor` need not forward. */}
315
+ <Tooltip
316
+ label={error}
317
+ opened={error !== undefined && (messageHovered || messageFocused)}
318
+ withArrow
319
+ position="bottom-start"
320
+ >
321
+ <div
322
+ className={classes.cellEditorField}
323
+ onMouseEnter={() => setMessageHovered(true)}
324
+ onMouseLeave={() => setMessageHovered(false)}
325
+ onFocus={() => setMessageFocused(true)}
326
+ onBlur={(event) => {
327
+ // Focus moving inside the editor - into an open dropdown, say - is
328
+ // not focus leaving it.
329
+ const next = event.relatedTarget;
330
+ if (isNode(next) && event.currentTarget.contains(next)) {
331
+ return;
332
+ }
333
+ setMessageFocused(false);
334
+ }}
335
+ >
336
+ <Editor {...args} />
337
+ </div>
338
+ </Tooltip>
293
339
  {/* cellConfirm's chrome: the draft only commits through the ✓ (or
294
340
  Enter), so the pair sits right beside the input. */}
295
341
  {features.editMode === "cellConfirm" && (
@@ -12,7 +12,11 @@
12
12
  display: flex;
13
13
  flex-direction: column;
14
14
  gap: var(--mantine-spacing-sm);
15
- padding: 0 var(--mantine-spacing-sm) var(--mantine-spacing-sm);
15
+ padding: var(--mantine-spacing-sm);
16
+ }
17
+
18
+ .columnsPanelSearch + * .columnsPanelList {
19
+ padding-top: 0;
16
20
  }
17
21
 
18
22
  .columnsPanelFooter {
@@ -1,4 +1,6 @@
1
1
  import {
2
+ Box,
3
+ type BoxProps,
2
4
  Button,
3
5
  Checkbox,
4
6
  ScrollArea,
@@ -6,36 +8,42 @@ import {
6
8
  TextInput,
7
9
  Tooltip,
8
10
  } from "@mantine/core";
9
- import { useSelector } from "@tanstack/react-store";
10
11
  import { useState } from "react";
11
12
  import classes from "./TMDataGridColumnsPanel.module.css";
12
13
  import { useTMDataGridContext } from "../TMDataGridContext";
13
- import { getColumnLabel } from "../core/columnUtils";
14
+ import {
15
+ getColumnLabel,
16
+ showColumnSearch,
17
+ type TMDataGridColumnSearchable,
18
+ } from "../core/columnUtils";
19
+ import { useHideableColumns } from "./useHideableColumns";
14
20
  import { SearchIcon } from "./icons";
15
21
 
16
22
  /**
17
- * The "Manage columns" surface. Reachable from every column header menu and
18
- * from the grid's burger menu, so it lives on its own and is rendered by
19
- * `TMDataGrid.ColumnsButton`.
23
+ * The "Manage columns" surface as plain controls, for a host that is not a
24
+ * menu - a Popover, a Drawer, an inline layout. `TMDataGrid.Menu.Columns` is
25
+ * the same chooser as menu items.
20
26
  */
21
- export function TMDataGridColumnsPanel() {
22
- const { table, labels, controlSize, resetSettings } = useTMDataGridContext();
27
+ /** Mantine's style props (`w={320}`, `p="sm"`) are set on the panel block. */
28
+ export type TMDataGridColumnsPanelProps = BoxProps & {
29
+ /**
30
+ * The search box over the list: `"auto"` (the default) from six hideable
31
+ * columns, `true` always, `false` never.
32
+ */
33
+ searchable?: TMDataGridColumnSearchable;
34
+ };
35
+
36
+ export function TMDataGridColumnsPanel({
37
+ className,
38
+ searchable = "auto",
39
+ ...others
40
+ }: TMDataGridColumnsPanelProps = {}) {
41
+ const { labels, controlSize, resetSettings } = useTMDataGridContext();
23
42
  const [search, setSearch] = useState("");
24
43
 
25
- const columnVisibility = useSelector(
26
- table.store,
27
- (state) => state.columnVisibility,
28
- );
44
+ const { columns, columnVisibility, shownCount, setAllVisible } =
45
+ useHideableColumns();
29
46
 
30
- // Only what can actually be hidden. A column with `enableHiding: false` is
31
- // left out rather than listed and disabled: a box that cannot be ticked only
32
- // invites the question, and every generated lane is one - the checkbox and
33
- // edit lanes hold the controls the grid needs, the tree column follows the
34
- // grouping state, the row-number gutter follows `enableRowNumbers`. None of
35
- // them is a setting.
36
- const columns = table
37
- .getAllLeafColumns()
38
- .filter((column) => column.getCanHide());
39
47
  const needle = search.trim().toLowerCase();
40
48
  const visibleInPanel = needle
41
49
  ? columns.filter((column) =>
@@ -43,41 +51,25 @@ export function TMDataGridColumnsPanel() {
43
51
  )
44
52
  : columns;
45
53
 
46
- const shownCount = columns.filter(
47
- (column) => columnVisibility[column.id] !== false,
48
- ).length;
49
-
50
- /**
51
- * Show or hide every listed column.
52
- *
53
- * Not `table.toggleAllColumnsVisible`, which writes a visibility entry for
54
- * *every* leaf column: showing all would publish the tree column - hidden
55
- * because nothing is grouped, not because the user hid it - and hiding all
56
- * would force the same column visible, since it writes `!getCanHide()` for
57
- * the columns it will not touch. Either way a lane the panel never listed
58
- * changes state, and persistence then keeps it that way.
59
- */
60
- const setAllVisible = (visible: boolean) => {
61
- table.setColumnVisibility((previous) => {
62
- const next = { ...previous };
63
- for (const column of columns) next[column.id] = visible;
64
- return next;
65
- });
66
- };
67
-
68
54
  return (
69
- <div data-dg-part="columns-panel" className={classes.columnsPanel}>
70
- <div className={classes.columnsPanelSearch}>
71
- <TextInput
72
- value={search}
73
- onChange={(event) => setSearch(event.currentTarget.value)}
74
- placeholder={labels.columnsSearchPlaceholder}
75
- leftSection={<SearchIcon size={16} stroke={1.6} />}
76
- size={controlSize}
77
- data-dg-part="columns-search"
78
- data-autofocus
79
- />
80
- </div>
55
+ <Box
56
+ data-dg-part="columns-panel"
57
+ className={[classes.columnsPanel, className].filter(Boolean).join(" ")}
58
+ {...others}
59
+ >
60
+ {showColumnSearch(searchable, columns.length) && (
61
+ <div className={classes.columnsPanelSearch}>
62
+ <TextInput
63
+ value={search}
64
+ onChange={(event) => setSearch(event.currentTarget.value)}
65
+ placeholder={labels.columnsSearchPlaceholder}
66
+ leftSection={<SearchIcon size={16} stroke={1.6} />}
67
+ size={controlSize}
68
+ data-dg-part="columns-search"
69
+ data-autofocus
70
+ />
71
+ </div>
72
+ )}
81
73
 
82
74
  <ScrollArea.Autosize mah={260} type="auto">
83
75
  <div className={classes.columnsPanelList}>
@@ -126,6 +118,6 @@ export function TMDataGridColumnsPanel() {
126
118
  </Button>
127
119
  </Tooltip>
128
120
  </div>
129
- </div>
121
+ </Box>
130
122
  );
131
123
  }