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

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 (150) hide show
  1. package/README.md +5 -212
  2. package/dist/index.d.ts +1664 -632
  3. package/dist/index.js +5226 -3223
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles.css +1 -1
  6. package/docs/anatomy.md +102 -0
  7. package/docs/cell-selection.md +154 -0
  8. package/docs/column-layout.md +204 -0
  9. package/docs/columns.md +262 -0
  10. package/docs/components.md +304 -0
  11. package/docs/editing.md +603 -0
  12. package/docs/editors.md +250 -0
  13. package/docs/export.md +326 -0
  14. package/docs/filtering.md +358 -0
  15. package/docs/getting-started.md +123 -0
  16. package/docs/grouping.md +165 -0
  17. package/docs/loading-and-empty.md +92 -0
  18. package/docs/localization.md +79 -0
  19. package/docs/menu.md +143 -0
  20. package/docs/pagination.md +144 -0
  21. package/docs/persistence.md +111 -0
  22. package/docs/portfolio-rebalancer.md +94 -0
  23. package/docs/query-builder.md +175 -0
  24. package/docs/quick-search.md +83 -0
  25. package/docs/row-details.md +113 -0
  26. package/docs/row-interaction.md +148 -0
  27. package/docs/row-pinning.md +132 -0
  28. package/docs/row-selection.md +134 -0
  29. package/docs/row-styling.md +133 -0
  30. package/docs/scrolling.md +111 -0
  31. package/docs/server-query.md +246 -0
  32. package/docs/server-side.md +206 -0
  33. package/docs/sorting.md +101 -0
  34. package/docs/styling.md +126 -0
  35. package/docs/summary-row.md +76 -0
  36. package/docs/testing.md +309 -0
  37. package/docs/toolbar.md +161 -0
  38. package/docs/use-tm-data-grid.md +361 -0
  39. package/package.json +21 -45
  40. package/skills/appearance/SKILL.md +70 -17
  41. package/skills/cell-selection/SKILL.md +70 -76
  42. package/skills/columns/SKILL.md +131 -32
  43. package/skills/data/SKILL.md +100 -23
  44. package/skills/editing/SKILL.md +217 -96
  45. package/skills/editing/references/common-mistakes.md +111 -24
  46. package/skills/editing/references/editing-api.md +63 -39
  47. package/skills/editing/references/editors-and-validation.md +77 -19
  48. package/skills/filtering/SKILL.md +148 -40
  49. package/skills/getting-started/SKILL.md +18 -16
  50. package/skills/grouping/SKILL.md +32 -15
  51. package/skills/options/SKILL.md +39 -9
  52. package/skills/rows/SKILL.md +22 -18
  53. package/skills/server-side/SKILL.md +170 -17
  54. package/skills/testing/SKILL.md +10 -7
  55. package/src/{tmdatagrid/TMDataGridContext.ts → TMDataGridContext.ts} +7 -19
  56. package/src/{tmdatagrid/components → components}/TMDataGrid.module.css +7 -1
  57. package/src/{tmdatagrid/components → components}/TMDataGrid.tsx +39 -23
  58. package/src/{tmdatagrid/components → components}/TMDataGridCellEditor.tsx +106 -38
  59. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.module.css +5 -1
  60. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.tsx +47 -55
  61. package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.tsx +4 -4
  62. package/src/components/TMDataGridDraftActions.tsx +307 -0
  63. package/src/{tmdatagrid/components → components}/TMDataGridEditColumn.tsx +58 -50
  64. package/src/{tmdatagrid/components → components}/TMDataGridEntryRows.tsx +150 -115
  65. package/src/components/TMDataGridExportPicker.module.css +77 -0
  66. package/src/components/TMDataGridExportPicker.tsx +234 -0
  67. package/src/components/TMDataGridFilterPanel.module.css +54 -0
  68. package/src/components/TMDataGridFilterPanel.tsx +348 -0
  69. package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.tsx +7 -5
  70. package/src/components/TMDataGridFilterSurface.module.css +54 -0
  71. package/src/components/TMDataGridFilterSurface.tsx +167 -0
  72. package/src/{tmdatagrid/components → components}/TMDataGridFooter.tsx +53 -13
  73. package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.tsx +4 -3
  74. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.module.css +10 -0
  75. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.tsx +100 -28
  76. package/src/components/TMDataGridHeaderFilterRow.module.css +51 -0
  77. package/src/components/TMDataGridHeaderFilterRow.tsx +301 -0
  78. package/src/components/TMDataGridMenu.tsx +354 -0
  79. package/src/{tmdatagrid/components → components}/TMDataGridSelectColumn.tsx +12 -7
  80. package/src/{tmdatagrid/components → components}/TMDataGridTable.module.css +90 -67
  81. package/src/{tmdatagrid/components → components}/TMDataGridTable.tsx +678 -156
  82. package/src/components/TMDataGridToolbar.module.css +21 -0
  83. package/src/components/TMDataGridToolbar.tsx +181 -0
  84. package/src/{tmdatagrid/components → components}/editors/TMDataGridBooleanEditor.tsx +3 -3
  85. package/src/{tmdatagrid/components → components}/editors/TMDataGridDateEditor.tsx +3 -3
  86. package/src/{tmdatagrid/components → components}/editors/TMDataGridMultiSelectEditor.tsx +3 -3
  87. package/src/components/editors/TMDataGridNumberEditor.tsx +70 -0
  88. package/src/{tmdatagrid/components → components}/editors/TMDataGridSelectEditor.tsx +3 -3
  89. package/src/{tmdatagrid/components → components}/editors/TMDataGridStringEditor.tsx +3 -3
  90. package/src/{tmdatagrid/components → components}/editors/editorShared.ts +17 -31
  91. package/src/{tmdatagrid/components → components}/filters/DgAutocompleteFilter.tsx +5 -4
  92. package/src/{tmdatagrid/components → components}/filters/DgDateRangeFilter.tsx +22 -5
  93. package/src/{tmdatagrid/components → components}/filters/DgRangeSliderFilter.tsx +5 -1
  94. package/src/{tmdatagrid/components → components}/filters/DgTriStateFilter.tsx +5 -1
  95. package/src/{tmdatagrid/components → components}/filters/TMDataGridFilterValueInput.tsx +44 -28
  96. package/src/components/filters/controlLayout.ts +32 -0
  97. package/src/components/filters/filterControlFor.ts +65 -0
  98. package/src/{tmdatagrid/components → components}/icons.ts +1 -0
  99. package/src/{tmdatagrid/components → components}/sticky.module.css +44 -0
  100. package/src/components/useHideableColumns.ts +52 -0
  101. package/src/{tmdatagrid/core → core}/autosize.ts +5 -2
  102. package/src/{tmdatagrid/core → core}/capabilities.ts +14 -6
  103. package/src/{tmdatagrid/core → core}/columnOptions.ts +46 -0
  104. package/src/{tmdatagrid/core → core}/columnOrdering.ts +33 -14
  105. package/src/{tmdatagrid/core → core}/columnUtils.ts +44 -5
  106. package/src/core/controlledState.ts +179 -0
  107. package/src/core/controlledStateSync.ts +108 -0
  108. package/src/core/deletedRows.ts +34 -0
  109. package/src/core/dom.ts +74 -0
  110. package/src/core/editEngine.ts +2476 -0
  111. package/src/{tmdatagrid/core → core}/editorFocus.ts +8 -4
  112. package/src/core/export.ts +843 -0
  113. package/src/{tmdatagrid/core → core}/filterControls.ts +38 -1
  114. package/src/{tmdatagrid/core → core}/filterOperators.ts +64 -1
  115. package/src/core/filterSurface.ts +99 -0
  116. package/src/{tmdatagrid/core → core}/labels.ts +66 -8
  117. package/src/{tmdatagrid/core → core}/labelsSv.ts +26 -3
  118. package/src/core/pageReset.ts +120 -0
  119. package/src/{tmdatagrid/core → core}/persistence.ts +22 -5
  120. package/src/core/resizePreview.ts +141 -0
  121. package/src/core/summary.ts +59 -0
  122. package/src/core/useSettledTableState.ts +36 -0
  123. package/src/{tmdatagrid/index.ts → index.ts} +75 -12
  124. package/src/{tmdatagrid/useTMDataGrid.tsx → useTMDataGrid.tsx} +734 -135
  125. package/src/useTMDataGridExport.ts +78 -0
  126. package/src/tmdatagrid/components/TMDataGridEditActions.tsx +0 -162
  127. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +0 -35
  128. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +0 -354
  129. package/src/tmdatagrid/components/TMDataGridToolbar.module.css +0 -12
  130. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +0 -162
  131. package/src/tmdatagrid/components/editors/TMDataGridNumberEditor.tsx +0 -40
  132. package/src/tmdatagrid/core/cellExport.ts +0 -320
  133. package/src/tmdatagrid/core/editEngine.ts +0 -1006
  134. package/src/tmdatagrid/core/summary.ts +0 -35
  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}/cellNavigation.ts +0 -0
  142. /package/src/{tmdatagrid/core → core}/cellRange.ts +0 -0
  143. /package/src/{tmdatagrid/core → core}/draftCellContext.ts +0 -0
  144. /package/src/{tmdatagrid/core → core}/expanding.ts +0 -0
  145. /package/src/{tmdatagrid/core → core}/grouping.ts +0 -0
  146. /package/src/{tmdatagrid/core → core}/matchHighlight.ts +0 -0
  147. /package/src/{tmdatagrid/core → core}/quickSearch.ts +0 -0
  148. /package/src/{tmdatagrid/core → core}/rowPinning.ts +0 -0
  149. /package/src/{tmdatagrid/core → core}/rowSelection.ts +0 -0
  150. /package/src/{tmdatagrid/core → core}/sizes.ts +0 -0
@@ -1,10 +1,12 @@
1
1
  import {
2
2
  flexRender,
3
3
  useTable,
4
+ type Cell,
4
5
  type Column,
5
6
  type Row,
6
7
  } from "@tanstack/react-table";
7
8
  import { useSelector } from "@tanstack/react-store";
9
+ import { shallow } from "@tanstack/store";
8
10
  import { useLayoutEffect, useMemo, useRef } from "react";
9
11
  import classes from "./TMDataGridTable.module.css";
10
12
  import sticky from "./sticky.module.css";
@@ -14,7 +16,7 @@ import {
14
16
  isColumnEditableForRow,
15
17
  isControlColumn,
16
18
  } from "../core/columnUtils";
17
- import { getEditFieldName } from "../core/editEngine";
19
+ import { getEditFieldName, type TMDataGridEditApi } from "../core/editEngine";
18
20
  import { focusEditorContent } from "../core/editorFocus";
19
21
  import {
20
22
  tmDataGridFeatures,
@@ -25,6 +27,8 @@ import { EDIT_COLUMN_ID } from "./TMDataGridEditColumn";
25
27
  import type { TMDataGridColumnLayout } from "./TMDataGridTable";
26
28
 
27
29
  type ErasedColumn = Column<TMDataGridFeatures, TMDataGridRowData, unknown>;
30
+ type EntryRow = Row<TMDataGridFeatures, TMDataGridRowData>;
31
+ type EntryCellApi = Cell<TMDataGridFeatures, TMDataGridRowData, unknown>;
28
32
 
29
33
  /**
30
34
  * Whether an entry cell edits. `edit.canEditCell` asks the main table for
@@ -34,12 +38,98 @@ type ErasedColumn = Column<TMDataGridFeatures, TMDataGridRowData, unknown>;
34
38
  function isEntryCellEditable(
35
39
  row: Row<TMDataGridFeatures, TMDataGridRowData>,
36
40
  column: ErasedColumn,
41
+ edit: TMDataGridEditApi,
37
42
  ): boolean {
38
- if (isControlColumn(column.id)) return false;
39
- if (getEditFieldName(column) === null) return false;
43
+ if (!edit.isColumnEditable(column)) return false;
40
44
  return isColumnEditableForRow(column, row);
41
45
  }
42
46
 
47
+ /**
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.
52
+ */
53
+ function EntryCell({
54
+ entryRow,
55
+ column,
56
+ cell,
57
+ committed,
58
+ layout,
59
+ rowHeight,
60
+ pinnedZ,
61
+ }: {
62
+ entryRow: EntryRow;
63
+ column: ErasedColumn;
64
+ cell: EntryCellApi | undefined;
65
+ committed: boolean;
66
+ layout: TMDataGridColumnLayout;
67
+ rowHeight: number;
68
+ pinnedZ: string;
69
+ }) {
70
+ const { edit } = useTMDataGridContext();
71
+ const fieldName = getEditFieldName(column);
72
+ const isInvalid = useSelector(edit.store, (state) => {
73
+ if (fieldName === null) return false;
74
+ const projection = state.rows[entryRow.id];
75
+ return projection !== undefined && projection.errorFields.includes(fieldName);
76
+ });
77
+ const editable =
78
+ cell !== undefined && isEntryCellEditable(entryRow, column, edit);
79
+ return (
80
+ <div
81
+ role="cell"
82
+ data-column-id={column.id}
83
+ data-align={getColumnAlign(column)}
84
+ data-control-column={isControlColumn(column.id)}
85
+ data-invalid={isInvalid || undefined}
86
+ // A committed row re-opens where it is double-clicked, the same
87
+ // gesture a body cell answers - and, like a body cell, a cell that
88
+ // takes no edit answers nothing.
89
+ onDoubleClick={
90
+ committed && editable
91
+ ? () => edit.begin({ rowId: entryRow.id, columnId: column.id })
92
+ : undefined
93
+ }
94
+ className={[
95
+ classes.entryCell,
96
+ layout.isBoundary && layout.pinnedAt === "left" ? sticky.stickyLeft : "",
97
+ layout.isBoundary && layout.pinnedAt === "right"
98
+ ? sticky.stickyRight
99
+ : "",
100
+ ]
101
+ .filter(Boolean)
102
+ .join(" ")}
103
+ style={{
104
+ minHeight: rowHeight,
105
+ left: layout.pinnedAt === "left" ? layout.offset : undefined,
106
+ right: layout.pinnedAt === "right" ? layout.offset : undefined,
107
+ position: layout.pinnedAt ? "sticky" : undefined,
108
+ zIndex: layout.pinnedAt ? pinnedZ : undefined,
109
+ }}
110
+ >
111
+ {cell !== undefined && column.id === EDIT_COLUMN_ID ? (
112
+ // The lane's cell - the entry row's controls.
113
+ 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 ? (
121
+ <TMDataGridCellEditor
122
+ cell={cell}
123
+ row={entryRow}
124
+ takeSeedText={() => undefined}
125
+ onClose={() => {}}
126
+ inEntryBlock
127
+ />
128
+ ) : null}
129
+ </div>
130
+ );
131
+ }
132
+
43
133
  /**
44
134
  * The sticky entry block - one row of open editors per `edit.addRow()`,
45
135
  * pinned under the header. The one place stickiness is genuinely required:
@@ -52,9 +142,9 @@ function isEntryCellEditable(
52
142
  * validators all apply unchanged. The forms live in the same engine map as
53
143
  * every other draft, keyed by their temporary ids.
54
144
  *
55
- * Enter commits the entry (`onRowAdd`) under the immediate modes and parks
56
- * it under draft, where `submitAll` reports it in `added`; Escape discards
57
- * it. The edit lane, when present, offers the same pair as buttons.
145
+ * Enter commits the entry - `onRowAdd`, or a park under `editing.draft`,
146
+ * where `saveDrafts` reports it in `created`; Escape discards it. The edit
147
+ * lane, when present, offers the same pair as buttons.
58
148
  */
59
149
  export function TMDataGridEntryRows({
60
150
  orderedColumns,
@@ -66,13 +156,34 @@ export function TMDataGridEntryRows({
66
156
  rowHeight: number;
67
157
  }) {
68
158
  const { table, edit, features } = useTMDataGridContext();
69
- const newRows = useSelector(edit.store, (state) => state.newRows);
70
- // The reopen gesture: `begin` on a confirmed entry row flips it back to
159
+ const sticky = features.editNewRowsSticky;
160
+ // A row being *typed* into is always here - it exists nowhere else to
161
+ // scroll back to. A committed row is a body row, sorted and filtered with
162
+ // the rest (the hook feeds it to the table as `data`), unless
163
+ // `newRowsSticky` keeps it pinned here until the save. Only the rows the
164
+ // block renders go into its table: after an import `newRows` runs to
165
+ // thousands, every one of them committed and in the body.
166
+ const newRows = useSelector(
167
+ edit.store,
168
+ (state) =>
169
+ sticky
170
+ ? state.newRows
171
+ : state.newRows.filter((newRow) => !newRow.committed),
172
+ { compare: shallow },
173
+ );
174
+ // What a committed row renders from. It has no form - its values are the
175
+ // draft store's - and this is the same snapshot the table feeds a
176
+ // committed row in the body.
177
+ const committedValues = useSelector(
178
+ edit.store,
179
+ (state) => state.committedValues,
180
+ );
181
+ // The reopen gesture: `begin` on a committed entry row flips it back to
71
182
  // editors and names the cell double-clicked - where the caret goes.
72
183
  const activeEntry = useSelector(edit.store, (state) =>
73
184
  state.active !== null &&
74
185
  state.newRows.some(
75
- (newRow) => newRow.tempId === state.active?.rowId && !newRow.confirmed,
186
+ (newRow) => newRow.tempId === state.active?.rowId && !newRow.committed,
76
187
  )
77
188
  ? state.active
78
189
  : null,
@@ -109,7 +220,7 @@ export function TMDataGridEntryRows({
109
220
  });
110
221
 
111
222
  /**
112
- * The caret for a reopen: `begin` on a confirmed row re-arms its editors
223
+ * The caret for a reopen: `begin` on a committed row re-arms its editors
113
224
  * and the double-clicked cell's editor should hold the caret, not the
114
225
  * row's first. Keyed separately from the add effect above - the row was
115
226
  * already focused once when it was added.
@@ -134,21 +245,21 @@ export function TMDataGridEntryRows({
134
245
  focusEditorContent(editor);
135
246
  });
136
247
 
137
- // Unconfirmed rows keep the seed values frozen at addRow - the live values
248
+ // Open (uncommitted) rows keep the seed values frozen at addRow - the live values
138
249
  // belong to the forms, which the editors read directly, and this table only
139
- // provides row and cell identity. A confirmed row's cells render values, so
140
- // there the draft itself is the row; the memo recomputes on every
141
- // confirm/reopen because either flips `newRows`' identity.
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.
142
253
  const data = useMemo(
143
254
  () =>
144
255
  newRows.map(
145
- ({ tempId, confirmed }) =>
146
- ((confirmed
147
- ? edit.getForm(tempId)?.state.values
256
+ ({ tempId, committed }) =>
257
+ ((committed
258
+ ? committedValues[tempId]
148
259
  : edit.getForm(tempId)?.options.defaultValues) ??
149
260
  {}) as TMDataGridRowData,
150
261
  ),
151
- [newRows, edit],
262
+ [newRows, committedValues, edit],
152
263
  );
153
264
 
154
265
  const entryTable = useTable({
@@ -160,30 +271,16 @@ export function TMDataGridEntryRows({
160
271
 
161
272
  if (newRows.length === 0) return null;
162
273
 
163
- const entryRows = entryTable.getCoreRowModel().rows;
164
- const confirmedById = new Map(
165
- newRows.map((newRow) => [newRow.tempId, newRow.confirmed]),
274
+ const stickyRows = entryTable.getCoreRowModel().rows;
275
+ const committedById = new Map(
276
+ newRows.map((newRow) => [newRow.tempId, newRow.committed]),
166
277
  );
167
- // A row being *typed* into is always sticky - it exists nowhere else to
168
- // scroll back to. A confirmed row joins the scrolling flow unless
169
- // `newRowsSticky` keeps it pinned, so entering many rows cannot fill the
170
- // viewport with sticky chrome.
171
- const stickyRows = entryRows.filter(
172
- (entryRow) =>
173
- features.editNewRowsSticky || confirmedById.get(entryRow.id) !== true,
174
- );
175
- const flowRows = features.editNewRowsSticky
176
- ? []
177
- : entryRows.filter((entryRow) => confirmedById.get(entryRow.id) === true);
178
278
 
179
- const renderEntryRow = (
180
- entryRow: (typeof entryRows)[number],
181
- pinnedZ: string,
182
- ) => {
279
+ const renderEntryRow = (entryRow: EntryRow, pinnedZ: string) => {
183
280
  const cellsById = new Map(
184
281
  entryRow.getAllCells().map((cell) => [cell.column.id, cell]),
185
282
  );
186
- const confirmed = confirmedById.get(entryRow.id) === true;
283
+ const committed = committedById.get(entryRow.id) === true;
187
284
  return (
188
285
  <div
189
286
  key={entryRow.id}
@@ -191,73 +288,24 @@ export function TMDataGridEntryRows({
191
288
  data-dg-part="entry-row"
192
289
  data-row-id={entryRow.id}
193
290
  data-new
194
- data-confirmed={confirmed}
291
+ data-committed={committed}
292
+ // The same marker body rows carry once committed, so one selector
293
+ // reaches everything parked in the draft store.
294
+ data-draft={committed}
195
295
  className={classes.entryRow}
196
296
  >
197
- {orderedColumns.map((column) => {
198
- const cell = cellsById.get(column.id);
199
- const layout = layoutFor(column.id);
200
- const editable =
201
- cell !== undefined && isEntryCellEditable(entryRow, column);
202
- return (
203
- <div
204
- key={column.id}
205
- role="cell"
206
- data-column-id={column.id}
207
- data-align={getColumnAlign(column)}
208
- data-control-column={isControlColumn(column.id)}
209
- // A confirmed row re-opens where it is double-clicked, the
210
- // same gesture a body cell answers.
211
- onDoubleClick={
212
- confirmed && !isControlColumn(column.id)
213
- ? () =>
214
- edit.begin({
215
- rowId: entryRow.id,
216
- columnId: editable ? column.id : null,
217
- })
218
- : undefined
219
- }
220
- className={[
221
- classes.entryCell,
222
- layout.isBoundary && layout.pinnedAt === "left"
223
- ? sticky.stickyLeft
224
- : "",
225
- layout.isBoundary && layout.pinnedAt === "right"
226
- ? sticky.stickyRight
227
- : "",
228
- ]
229
- .filter(Boolean)
230
- .join(" ")}
231
- style={{
232
- minHeight: rowHeight,
233
- left: layout.pinnedAt === "left" ? layout.offset : undefined,
234
- right: layout.pinnedAt === "right" ? layout.offset : undefined,
235
- position: layout.pinnedAt ? "sticky" : undefined,
236
- zIndex: layout.pinnedAt ? pinnedZ : undefined,
237
- }}
238
- >
239
- {cell !== undefined && column.id === EDIT_COLUMN_ID ? (
240
- // The lane's cell - the entry row's controls.
241
- flexRender(cell.column.columnDef.cell, cell.getContext())
242
- ) : confirmed && cell !== undefined ? (
243
- // Entered, awaiting Save all: a value row through the
244
- // columns' own renderers, over the draft the memo above
245
- // fed this table.
246
- <span className={classes.cellContent}>
247
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
248
- </span>
249
- ) : editable && cell !== undefined ? (
250
- <TMDataGridCellEditor
251
- cell={cell}
252
- row={entryRow}
253
- takeSeedText={() => undefined}
254
- onClose={() => {}}
255
- inEntryBlock
256
- />
257
- ) : null}
258
- </div>
259
- );
260
- })}
297
+ {orderedColumns.map((column) => (
298
+ <EntryCell
299
+ key={column.id}
300
+ entryRow={entryRow}
301
+ column={column}
302
+ cell={cellsById.get(column.id)}
303
+ committed={committed}
304
+ layout={layoutFor(column.id)}
305
+ rowHeight={rowHeight}
306
+ pinnedZ={pinnedZ}
307
+ />
308
+ ))}
261
309
  </div>
262
310
  );
263
311
  };
@@ -276,19 +324,6 @@ export function TMDataGridEntryRows({
276
324
  )}
277
325
  </div>
278
326
  )}
279
- {flowRows.length > 0 && (
280
- <div
281
- role="rowgroup"
282
- data-dg-entry-flow-block
283
- className={classes.entryFlowBlock}
284
- >
285
- {/* In flow, so a pinned cell stacks like a body row's, under the
286
- sticky blocks it scrolls past. */}
287
- {flowRows.map((entryRow) =>
288
- renderEntryRow(entryRow, "var(--dg-z-pinned-cell, 2)"),
289
- )}
290
- </div>
291
- )}
292
327
  </>
293
328
  );
294
329
  }
@@ -0,0 +1,77 @@
1
+ .title {
2
+ font-weight: 500;
3
+ }
4
+
5
+ .list {
6
+ display: flex;
7
+ flex-direction: column;
8
+ border: 1px solid var(--mantine-color-default-border);
9
+ border-radius: var(--mantine-radius-default);
10
+ overflow: hidden;
11
+ }
12
+
13
+ .listHeader {
14
+ display: flex;
15
+ align-items: center;
16
+ justify-content: space-between;
17
+ gap: var(--mantine-spacing-sm);
18
+ padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
19
+ background-color: var(--mantine-color-default-hover);
20
+ border-bottom: 1px solid var(--mantine-color-default-border);
21
+ }
22
+
23
+ .headerLabel {
24
+ font-weight: 500;
25
+ cursor: pointer;
26
+ }
27
+
28
+ .rows {
29
+ display: flex;
30
+ flex-direction: column;
31
+ padding: calc(var(--mantine-spacing-xs) / 2) 0;
32
+ }
33
+
34
+ /* Each column is one full-width row: the label stretches over the free space
35
+ so the whole row toggles the box, and the row highlights on hover like a
36
+ menu item. */
37
+ .row {
38
+ padding: 0 var(--mantine-spacing-sm);
39
+ }
40
+
41
+ .row:hover {
42
+ background-color: var(--mantine-color-default-hover);
43
+ }
44
+
45
+ .rowBody {
46
+ align-items: center;
47
+ }
48
+
49
+ .rowLabelWrapper {
50
+ flex: 1;
51
+ min-width: 0;
52
+ }
53
+
54
+ .rowLabel {
55
+ display: flex;
56
+ align-items: center;
57
+ justify-content: space-between;
58
+ gap: var(--mantine-spacing-sm);
59
+ padding-block: calc(var(--mantine-spacing-xs) * 0.75);
60
+ cursor: pointer;
61
+ }
62
+
63
+ .rowText {
64
+ overflow: hidden;
65
+ text-overflow: ellipsis;
66
+ white-space: nowrap;
67
+ }
68
+
69
+ .rowHint {
70
+ flex: none;
71
+ font-size: var(--mantine-font-size-xs);
72
+ color: var(--mantine-color-dimmed);
73
+ }
74
+
75
+ .empty {
76
+ padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
77
+ }
@@ -0,0 +1,234 @@
1
+ import {
2
+ Button,
3
+ Checkbox,
4
+ Group,
5
+ type MantineSize,
6
+ Modal,
7
+ ScrollArea,
8
+ Stack,
9
+ Text,
10
+ TextInput,
11
+ } from "@mantine/core";
12
+ import { useSelector } from "@tanstack/react-store";
13
+ import { useState } from "react";
14
+ import classes from "./TMDataGridExportPicker.module.css";
15
+ import { useTMDataGridContext } from "../TMDataGridContext";
16
+ import { getColumnLabel, showColumnSearch } from "../core/columnUtils";
17
+ import {
18
+ countSelectedExportRows,
19
+ getExportableColumns,
20
+ resolveExportOptions,
21
+ type TMDataGridExportPickerRequest,
22
+ } from "../core/export";
23
+ import type { TMDataGridSize } from "../core/sizes";
24
+ import { useTMDataGridExport } from "../useTMDataGridExport";
25
+ import { SearchIcon } from "./icons";
26
+
27
+ /**
28
+ * The column picker behind `columns="custom"` on the export menu items: a
29
+ * list of every exportable column, the visible ones ticked and the hidden
30
+ * ones marked, a select-all row over the list, a search box once the list is
31
+ * long, and an Export button that downloads the ticked ones.
32
+ *
33
+ * Mounted once by the root and driven by `ui.state.exportPicker`, because the
34
+ * menu item that opens it unmounts with the dropdown on the click. The form
35
+ * inside is keyed on the request, so each opening starts from the visible
36
+ * columns again rather than from the last choice.
37
+ */
38
+ export function TMDataGridExportPicker() {
39
+ const { ui, labels, size, controlSize, exportOptions } =
40
+ useTMDataGridContext();
41
+ const request = useSelector(ui, (state) => state.exportPicker);
42
+ const format = request
43
+ ? resolveExportOptions(exportOptions, request.options).format
44
+ : null;
45
+
46
+ return (
47
+ <Modal
48
+ opened={request !== null}
49
+ onClose={() => ui.actions.closeExportPicker()}
50
+ title={
51
+ format && labels.exportPickerTitle(format.extension.toUpperCase())
52
+ }
53
+ size={MODAL_WIDTH[size]}
54
+ closeButtonProps={{ size: CLOSE_BUTTON_SIZE[controlSize] }}
55
+ classNames={{ title: classes.title }}
56
+ styles={{ title: { fontSize: `var(--mantine-font-size-${controlSize})` } }}
57
+ >
58
+ {request && <PickerForm request={request} />}
59
+ </Modal>
60
+ );
61
+ }
62
+
63
+ /** Dialog width per grid size: the same three steps as the controls. */
64
+ const MODAL_WIDTH: Record<TMDataGridSize, MantineSize> = {
65
+ xs: "xs",
66
+ sm: "xs",
67
+ md: "sm",
68
+ lg: "sm",
69
+ xl: "md",
70
+ };
71
+
72
+ /** The close button one step above the controls, which is Mantine's own ratio. */
73
+ const CLOSE_BUTTON_SIZE: Record<string, MantineSize> = {
74
+ xs: "sm",
75
+ sm: "md",
76
+ md: "lg",
77
+ };
78
+
79
+ function PickerForm({ request }: { request: TMDataGridExportPickerRequest }) {
80
+ const { table, ui, labels, controlSize } = useTMDataGridContext();
81
+ const { exportAll, exportSelected } = useTMDataGridExport(request.options);
82
+ const columns = getExportableColumns(table);
83
+ const [search, setSearch] = useState("");
84
+ const [checked, setChecked] = useState<ReadonlySet<string>>(
85
+ () =>
86
+ new Set(
87
+ columns
88
+ .filter((column) => column.getIsVisible())
89
+ .map((column) => column.id),
90
+ ),
91
+ );
92
+
93
+ const searchable = showColumnSearch("auto", columns.length);
94
+ const needle = search.trim().toLowerCase();
95
+ const listed = needle
96
+ ? columns.filter((column) =>
97
+ getColumnLabel(column).toLowerCase().includes(needle),
98
+ )
99
+ : columns;
100
+
101
+ // Select all works on the listed columns: with a search active it ticks the
102
+ // matches and leaves the rest as they are, the way a filtered list reads.
103
+ const listedChecked = listed.filter((column) => checked.has(column.id));
104
+ const allListed = listed.length > 0 && listedChecked.length === listed.length;
105
+ const someListed = listedChecked.length > 0 && !allListed;
106
+
107
+ const toggle = (columnId: string, on: boolean) =>
108
+ setChecked((previous) => {
109
+ const next = new Set(previous);
110
+ if (on) next.add(columnId);
111
+ else next.delete(columnId);
112
+ return next;
113
+ });
114
+
115
+ const toggleListed = (on: boolean) =>
116
+ setChecked((previous) => {
117
+ const next = new Set(previous);
118
+ for (const column of listed) {
119
+ if (on) next.add(column.id);
120
+ else next.delete(column.id);
121
+ }
122
+ return next;
123
+ });
124
+
125
+ const confirm = async () => {
126
+ // Render order, whatever order the boxes were ticked in.
127
+ const ids = columns
128
+ .filter((column) => checked.has(column.id))
129
+ .map((column) => column.id);
130
+ const run = request.rows === "selected" ? exportSelected : exportAll;
131
+ await run({ columns: ids });
132
+ ui.actions.closeExportPicker();
133
+ };
134
+
135
+ const hint = labels.exportPickerHint(
136
+ request.rows === "selected" ? countSelectedExportRows(table) : null,
137
+ );
138
+
139
+ return (
140
+ <Stack gap="sm" data-dg-part="export-picker">
141
+ <Text size={controlSize} c="dimmed" data-dg-part="export-picker-hint">
142
+ {hint}
143
+ </Text>
144
+
145
+ {searchable && (
146
+ <TextInput
147
+ value={search}
148
+ onChange={(event) => setSearch(event.currentTarget.value)}
149
+ placeholder={labels.columnsSearchPlaceholder}
150
+ leftSection={<SearchIcon size={16} stroke={1.6} />}
151
+ size={controlSize}
152
+ data-dg-part="export-picker-search"
153
+ data-autofocus
154
+ />
155
+ )}
156
+
157
+ <div className={classes.list}>
158
+ <div className={classes.listHeader}>
159
+ <Checkbox
160
+ size={controlSize}
161
+ label={labels.exportPickerSelectAll}
162
+ checked={allListed}
163
+ indeterminate={someListed}
164
+ onChange={(event) => toggleListed(event.currentTarget.checked)}
165
+ classNames={{ label: classes.headerLabel }}
166
+ data-dg-part="export-column-all"
167
+ />
168
+ <Text size="xs" c="dimmed" data-dg-part="export-picker-count">
169
+ {labels.exportPickerCount(checked.size, columns.length)}
170
+ </Text>
171
+ </div>
172
+
173
+ <ScrollArea.Autosize mah="50vh" type="auto">
174
+ <div className={classes.rows}>
175
+ {listed.map((column) => (
176
+ <Checkbox
177
+ key={column.id}
178
+ size={controlSize}
179
+ label={
180
+ <>
181
+ <span className={classes.rowText}>
182
+ {getColumnLabel(column)}
183
+ </span>
184
+ {!column.getIsVisible() && (
185
+ <span className={classes.rowHint}>
186
+ {labels.exportPickerHidden}
187
+ </span>
188
+ )}
189
+ </>
190
+ }
191
+ checked={checked.has(column.id)}
192
+ onChange={(event) =>
193
+ toggle(column.id, event.currentTarget.checked)
194
+ }
195
+ classNames={{
196
+ root: classes.row,
197
+ body: classes.rowBody,
198
+ labelWrapper: classes.rowLabelWrapper,
199
+ label: classes.rowLabel,
200
+ }}
201
+ data-dg-part="export-column"
202
+ data-column-id={column.id}
203
+ />
204
+ ))}
205
+ {listed.length === 0 && (
206
+ <Text size={controlSize} c="dimmed" className={classes.empty}>
207
+ {labels.columnsNoMatch(search)}
208
+ </Text>
209
+ )}
210
+ </div>
211
+ </ScrollArea.Autosize>
212
+ </div>
213
+
214
+ <Group justify="flex-end" gap="xs">
215
+ <Button
216
+ variant="default"
217
+ size={controlSize}
218
+ onClick={() => ui.actions.closeExportPicker()}
219
+ data-dg-part="export-picker-cancel"
220
+ >
221
+ {labels.exportPickerCancel}
222
+ </Button>
223
+ <Button
224
+ size={controlSize}
225
+ disabled={checked.size === 0}
226
+ onClick={() => void confirm()}
227
+ data-dg-part="export-picker-confirm"
228
+ >
229
+ {labels.exportPickerConfirm}
230
+ </Button>
231
+ </Group>
232
+ </Stack>
233
+ );
234
+ }