@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,704 @@
1
+ import type { Column, Row, RowData } from "@tanstack/react-table";
2
+ import type { TMDataGridRowData } from "../TMDataGridContext";
3
+ import type { TMDataGridFeatures, TMDataGridTable } from "../useTMDataGrid";
4
+ import type { TMDataGridRangeBounds } from "./cellRange";
5
+ import { getColumnLabel, isGeneratedColumn } from "./columnUtils";
6
+ import { isRowMarkedDeleted } from "./deletedRows";
7
+
8
+ /**
9
+ * The byte order mark Excel looks for before it will read a file as UTF-8.
10
+ * Built from its code point rather than pasted in, because a literal BOM is
11
+ * invisible in a source file and easily deleted by accident.
12
+ */
13
+ const UTF8_BOM = String.fromCharCode(0xfeff);
14
+
15
+ type ErasedRow = Row<TMDataGridFeatures, TMDataGridRowData>;
16
+ type ErasedColumn = Column<TMDataGridFeatures, TMDataGridRowData, unknown>;
17
+ type ErasedTable = TMDataGridTable<TMDataGridRowData>;
18
+
19
+ /**
20
+ * What an export writes: the exported columns in render order, their labels,
21
+ * and one array of raw values per row.
22
+ *
23
+ * Raw values rather than text, so a format decides how a number, a date or an
24
+ * array is written - JSON keeps a number a number, and a spreadsheet format
25
+ * can write a typed cell.
26
+ */
27
+ export type TMDataGridExportData = {
28
+ columnIds: Array<string>;
29
+ /** `getColumnLabel` per column: `meta.label`, a string header, or the id. */
30
+ headers: Array<string>;
31
+ rows: Array<Array<unknown>>;
32
+ };
33
+
34
+ export type TMDataGridExportWriteOptions = {
35
+ /** Whether the format writes the column labels as its first row. */
36
+ includeHeaders: boolean;
37
+ };
38
+
39
+ /**
40
+ * A file format an export can be written in.
41
+ *
42
+ * The grid ships `csvExcelFormat`, `csvFormat`, `tsvFormat` and `jsonFormat`;
43
+ * an addon package or your own code adds one by implementing this shape.
44
+ * `write` may be async and may answer a `Blob`, which is what a binary format
45
+ * such as xlsx needs.
46
+ */
47
+ export type TMDataGridExportFormat = {
48
+ /** Identifies the format, for a menu or a test. */
49
+ id: string;
50
+ /** File extension without the dot, appended to the file name. */
51
+ extension: string;
52
+ /** The `Blob` type the download is served under. */
53
+ mimeType: string;
54
+ /**
55
+ * The decimal mark this format writes, when it writes text. Ctrl+C follows
56
+ * it, so what is copied matches what is exported. Unset means the Nordic
57
+ * default, a comma.
58
+ */
59
+ decimalComma?: boolean;
60
+ write: (
61
+ data: TMDataGridExportData,
62
+ options: TMDataGridExportWriteOptions,
63
+ ) => string | Blob | Promise<string | Blob>;
64
+ };
65
+
66
+ /**
67
+ * Which columns an export takes: the visible ones, every exportable column
68
+ * hidden or not, or a list of column ids. The generated lanes and columns
69
+ * with `meta.enableExport: false` are never taken, whatever is asked for.
70
+ */
71
+ export type TMDataGridExportColumns = "visible" | "all" | ReadonlyArray<string>;
72
+
73
+ /** How the grid exports: the format, the file name and whether headers go in. */
74
+ export type TMDataGridExportOptions = {
75
+ /** Defaults to `csvExcelFormat()`. */
76
+ format?: TMDataGridExportFormat;
77
+ /** Without extension. Defaults to `"export"`. */
78
+ fileName?: string;
79
+ /** Column labels as the first row. Defaults to `true`. */
80
+ includeHeaders?: boolean;
81
+ /** Defaults to `"visible"`. See {@link TMDataGridExportColumns}. */
82
+ columns?: TMDataGridExportColumns;
83
+ };
84
+
85
+ /**
86
+ * What the column picker was opened for: which rows, and the options of the
87
+ * item that opened it. Held in `ui.state.exportPicker` while it is open.
88
+ */
89
+ export type TMDataGridExportPickerRequest = {
90
+ rows: "all" | "selected";
91
+ options: TMDataGridExportOptions;
92
+ };
93
+
94
+ /** `TMDataGridExportOptions` with every default filled in. */
95
+ export type TMDataGridExportSettings = Required<TMDataGridExportOptions>;
96
+
97
+ /**
98
+ * The value written for a cell, in place of `row.getValue(column.id)`. See
99
+ * `meta.exportValue`.
100
+ */
101
+ export type TMDataGridExportValueGetter = (args: {
102
+ value: unknown;
103
+ row: ErasedRow;
104
+ column: ErasedColumn;
105
+ }) => unknown;
106
+
107
+ /**
108
+ * Which rows an export takes: every filtered and sorted row across all pages,
109
+ * the selected ones among those, or a list of your own.
110
+ */
111
+ export type TMDataGridExportRows<TData extends RowData> =
112
+ | "all"
113
+ | "selected"
114
+ | ReadonlyArray<Row<TMDataGridFeatures, TData>>;
115
+
116
+ /**
117
+ * One value as text.
118
+ *
119
+ * Deliberately not the rendered cell: what a cell renders is React, and often a
120
+ * badge, a link or an icon rather than the value. The value is what a
121
+ * spreadsheet wants, and it is the one thing every column is guaranteed to
122
+ * have. `meta.exportValue` is where a column substitutes something else.
123
+ */
124
+ export function formatExportValue(
125
+ value: unknown,
126
+ { decimalComma }: { decimalComma: boolean },
127
+ ): string {
128
+ if (value === null || value === undefined) return "";
129
+ if (typeof value === "number") {
130
+ if (!Number.isFinite(value)) return "";
131
+ const text = String(value);
132
+ return decimalComma ? text.replace(".", ",") : text;
133
+ }
134
+ if (typeof value === "string") return value;
135
+ if (typeof value === "boolean") return value ? "true" : "false";
136
+ // A multiSelect cell holds an array of values; a spreadsheet cell holds one
137
+ // string, so the elements are joined the way they read on screen.
138
+ if (Array.isArray(value)) {
139
+ return value
140
+ .map((entry) => formatExportValue(entry, { decimalComma }))
141
+ .join(", ");
142
+ }
143
+ // `sv-SE` is ISO-shaped (2026-07-31, 2026-07-31 14:05:00), which is both the
144
+ // Nordic form and the one Excel parses as a date rather than as text.
145
+ if (value instanceof Date) {
146
+ return Number.isNaN(value.getTime()) ? "" : value.toLocaleString("sv-SE");
147
+ }
148
+ // An object in a cell is a shape the grid cannot know. JSON at least keeps
149
+ // what was there, where `String(value)` would write "[object Object]".
150
+ if (typeof value === "object") {
151
+ try {
152
+ return JSON.stringify(value);
153
+ } catch {
154
+ return "";
155
+ }
156
+ }
157
+ return String(value);
158
+ }
159
+
160
+ const FORMULA_LEADS = new Set(["=", "+", "-", "@", "\t", "\r"]);
161
+
162
+ /**
163
+ * Keeps a spreadsheet from running a cell as a formula.
164
+ *
165
+ * Excel and Sheets evaluate a cell that starts with `=`, `+`, `-` or `@`, so a
166
+ * value one user typed into the grid would run in another user's spreadsheet
167
+ * when the file is opened. The defence is the standard one: a leading
168
+ * apostrophe, which every spreadsheet reads as "text follows".
169
+ *
170
+ * Text that parses as a number is left alone - `-5` and `+4670123456` are
171
+ * numbers to the spreadsheet too, and an apostrophe would turn them into text.
172
+ * A phone number written with spaces (`+46 70 123 45 67`) does not parse and
173
+ * is prefixed; `escapeFormulas: false` on the format is the way out for a grid
174
+ * whose data is trusted.
175
+ */
176
+ export function guardFormula(text: string): string {
177
+ if (!FORMULA_LEADS.has(text.charAt(0))) return text;
178
+ const trimmed = text.trim();
179
+ if (trimmed !== "" && Number.isFinite(Number(trimmed))) return text;
180
+ return `'${text}`;
181
+ }
182
+
183
+ type TextCellOptions = { decimalComma: boolean; escapeFormulas: boolean };
184
+
185
+ /**
186
+ * A value as the text a delimited format writes. Numbers skip the formula
187
+ * guard: `-1,5` does not parse as a number, but it is one, and the guard is
188
+ * about strings that came from the data.
189
+ */
190
+ function textCell(value: unknown, options: TextCellOptions): string {
191
+ const text = formatExportValue(value, { decimalComma: options.decimalComma });
192
+ if (!options.escapeFormulas || typeof value === "number") return text;
193
+ return guardFormula(text);
194
+ }
195
+
196
+ function textRows(
197
+ data: TMDataGridExportData,
198
+ { includeHeaders }: TMDataGridExportWriteOptions,
199
+ options: TextCellOptions,
200
+ ): Array<Array<string>> {
201
+ const lines: Array<Array<string>> = [];
202
+ if (includeHeaders) {
203
+ lines.push(
204
+ data.headers.map((header) =>
205
+ options.escapeFormulas ? guardFormula(header) : header,
206
+ ),
207
+ );
208
+ }
209
+ for (const row of data.rows) {
210
+ lines.push(row.map((value) => textCell(value, options)));
211
+ }
212
+ return lines;
213
+ }
214
+
215
+ /**
216
+ * Quotes a field when it holds something that would otherwise end it early.
217
+ * Doubling the quote is how both CSV and Excel's own clipboard format escape
218
+ * one, so the same rule serves both.
219
+ */
220
+ function escapeField(value: string, separator: string): string {
221
+ const needsQuotes =
222
+ value.includes(separator) ||
223
+ value.includes('"') ||
224
+ value.includes("\n") ||
225
+ value.includes("\r");
226
+ return needsQuotes ? `"${value.replaceAll('"', '""')}"` : value;
227
+ }
228
+
229
+ /** Rows of text as lines, `separator` between fields and CRLF between rows. */
230
+ function toDelimited(lines: Array<Array<string>>, separator: string): string {
231
+ return lines
232
+ .map((row) => row.map((value) => escapeField(value, separator)).join(separator))
233
+ .join("\r\n");
234
+ }
235
+
236
+ export type TMDataGridCsvFormatOptions = {
237
+ /** Between fields. `csvExcelFormat` defaults to `";"`, `csvFormat` to `","`. */
238
+ separator?: string;
239
+ /** Write numbers as `1,5` rather than `1.5`. `csvExcelFormat` defaults to `true`, `csvFormat` to `false`. */
240
+ decimalComma?: boolean;
241
+ /** Prefix text that a spreadsheet would run as a formula. Defaults to `true`. See {@link guardFormula}. */
242
+ escapeFormulas?: boolean;
243
+ };
244
+
245
+ /**
246
+ * A CSV that opens straight into columns in Excel.
247
+ *
248
+ * Three things make that true, and all three are needed:
249
+ *
250
+ * | Part | Why |
251
+ * | ---- | --- |
252
+ * | `sep=;` first line | Excel's own directive - it stops guessing and uses this |
253
+ * | UTF-8 BOM | without it Excel reads the file as ANSI, and å ä ö arrive broken |
254
+ * | CRLF line endings | what Excel writes, and what its importer is happiest with |
255
+ *
256
+ * The defaults are the Nordic ones, because they are the ones that need
257
+ * choosing: an Excel running a Swedish, Norwegian, Danish or Finnish locale
258
+ * reads `;` as its list separator and `,` as its decimal mark, and a file
259
+ * written the other way opens as one column of text.
260
+ *
261
+ * The `sep=` line is Excel's alone; Sheets and Numbers show it as a first row.
262
+ * `csvFormat` is the one for them.
263
+ */
264
+ export function csvExcelFormat({
265
+ separator = ";",
266
+ decimalComma = true,
267
+ escapeFormulas = true,
268
+ }: TMDataGridCsvFormatOptions = {}): TMDataGridExportFormat {
269
+ return {
270
+ id: "csvExcel",
271
+ extension: "csv",
272
+ mimeType: "text/csv;charset=utf-8",
273
+ decimalComma,
274
+ write: (data, options) => {
275
+ const lines = textRows(data, options, { decimalComma, escapeFormulas });
276
+ return `${UTF8_BOM}sep=${separator}\r\n${toDelimited(lines, separator)}\r\n`;
277
+ },
278
+ };
279
+ }
280
+
281
+ /**
282
+ * Plain CSV as RFC 4180 has it: commas, a dot as the decimal mark, CRLF, and a
283
+ * UTF-8 BOM so that Excel too reads it as UTF-8. No `sep=` line, so Google
284
+ * Sheets, Numbers and every tool that reads CSV take it as is.
285
+ */
286
+ export function csvFormat({
287
+ separator = ",",
288
+ decimalComma = false,
289
+ escapeFormulas = true,
290
+ }: TMDataGridCsvFormatOptions = {}): TMDataGridExportFormat {
291
+ return {
292
+ id: "csv",
293
+ extension: "csv",
294
+ mimeType: "text/csv;charset=utf-8",
295
+ decimalComma,
296
+ write: (data, options) => {
297
+ const lines = textRows(data, options, { decimalComma, escapeFormulas });
298
+ return `${UTF8_BOM}${toDelimited(lines, separator)}\r\n`;
299
+ },
300
+ };
301
+ }
302
+
303
+ export type TMDataGridTsvFormatOptions = Omit<
304
+ TMDataGridCsvFormatOptions,
305
+ "separator"
306
+ >;
307
+
308
+ /**
309
+ * Tab-separated text, the clipboard shape as a file: tabs between fields, CRLF
310
+ * between rows, a UTF-8 BOM. Every spreadsheet opens it into columns without
311
+ * a separator to guess.
312
+ */
313
+ export function tsvFormat({
314
+ decimalComma = true,
315
+ escapeFormulas = true,
316
+ }: TMDataGridTsvFormatOptions = {}): TMDataGridExportFormat {
317
+ return {
318
+ id: "tsv",
319
+ extension: "tsv",
320
+ mimeType: "text/tab-separated-values;charset=utf-8",
321
+ decimalComma,
322
+ write: (data, options) => {
323
+ const lines = textRows(data, options, { decimalComma, escapeFormulas });
324
+ return `${UTF8_BOM}${toDelimited(lines, "\t")}\r\n`;
325
+ },
326
+ };
327
+ }
328
+
329
+ export type TMDataGridJsonFormatOptions = {
330
+ /** Indentation passed to `JSON.stringify`. Defaults to `2`. */
331
+ space?: number;
332
+ };
333
+
334
+ /** A value as JSON keeps it: dates as ISO strings, the unrepresentable as `null`. */
335
+ function jsonValue(value: unknown): unknown {
336
+ if (value === undefined) return null;
337
+ if (typeof value === "number" && !Number.isFinite(value)) return null;
338
+ if (value instanceof Date) {
339
+ return Number.isNaN(value.getTime()) ? null : value.toISOString();
340
+ }
341
+ return value;
342
+ }
343
+
344
+ /**
345
+ * An array with one object per row, keyed by the column labels, values as the
346
+ * data holds them. Two columns with the same label collapse into one key, the
347
+ * later column winning. `includeHeaders` has no meaning here and is ignored.
348
+ */
349
+ export function jsonFormat({
350
+ space = 2,
351
+ }: TMDataGridJsonFormatOptions = {}): TMDataGridExportFormat {
352
+ return {
353
+ id: "json",
354
+ extension: "json",
355
+ mimeType: "application/json",
356
+ write: (data) => {
357
+ const records = data.rows.map((row) =>
358
+ Object.fromEntries(
359
+ row.map((value, index) => [data.headers[index], jsonValue(value)]),
360
+ ),
361
+ );
362
+ return JSON.stringify(records, null, space);
363
+ },
364
+ };
365
+ }
366
+
367
+ export const DEFAULT_EXPORT_OPTIONS: TMDataGridExportSettings = {
368
+ format: csvExcelFormat(),
369
+ fileName: "export",
370
+ includeHeaders: true,
371
+ columns: "visible",
372
+ };
373
+
374
+ /**
375
+ * The defaults with each override folded over them in turn. Field by field,
376
+ * so an override that spells a field as `undefined` leaves the earlier value
377
+ * rather than blanking it.
378
+ */
379
+ export function resolveExportOptions(
380
+ ...overrides: Array<TMDataGridExportOptions | undefined>
381
+ ): TMDataGridExportSettings {
382
+ const resolved = { ...DEFAULT_EXPORT_OPTIONS };
383
+ for (const override of overrides) {
384
+ if (!override) continue;
385
+ if (override.format !== undefined) resolved.format = override.format;
386
+ if (override.fileName !== undefined) resolved.fileName = override.fileName;
387
+ if (override.includeHeaders !== undefined) {
388
+ resolved.includeHeaders = override.includeHeaders;
389
+ }
390
+ if (override.columns !== undefined) resolved.columns = override.columns;
391
+ }
392
+ return resolved;
393
+ }
394
+
395
+ /**
396
+ * Every leaf column in render order - left, center, right - hidden ones in
397
+ * the place they would take if shown. `getAllLeafColumns` is definition
398
+ * order, which pinning and reordering have long since left behind.
399
+ */
400
+ function allLeafColumns(table: ErasedTable): Array<ErasedColumn> {
401
+ const ordered = [
402
+ ...table.getStartLeafColumns(),
403
+ ...table.getCenterLeafColumns(),
404
+ ...table.getEndLeafColumns(),
405
+ ];
406
+ const seen = new Set(ordered.map((column) => column.id));
407
+ // The flat list rather than `getAllLeafColumns`: that one runs through the
408
+ // ordering step too, which is where `groupedColumnMode: "remove"` drops a
409
+ // grouped column, so it is the only list that still holds one.
410
+ const missing = table
411
+ .getAllFlatColumns()
412
+ .filter((column) => column.columns.length === 0 && !seen.has(column.id));
413
+ if (missing.length === 0) return ordered;
414
+ // A removed grouped column goes first, where the tree lane showing its
415
+ // value sits. Anything else the lanes left out goes last.
416
+ const grouping = table.store.state.grouping;
417
+ const grouped = grouping.flatMap((id) =>
418
+ missing.filter((column) => column.id === id),
419
+ );
420
+ const rest = missing.filter((column) => !grouping.includes(column.id));
421
+ return [...grouped, ...ordered, ...rest];
422
+ }
423
+
424
+ /**
425
+ * Every column an export could take, in render order: the data columns minus
426
+ * the generated lanes and `meta.enableExport: false`, hidden ones included.
427
+ * What the column picker lists; `column.getIsVisible()` says which of them a
428
+ * `"visible"` export would take.
429
+ */
430
+ export function getExportableColumns<TData extends RowData>(
431
+ table: TMDataGridTable<TData>,
432
+ ): Array<Column<TMDataGridFeatures, TData, unknown>> {
433
+ const erased = table as unknown as ErasedTable;
434
+ return allLeafColumns(erased).filter(isExportedColumn) as unknown as Array<
435
+ Column<TMDataGridFeatures, TData, unknown>
436
+ >;
437
+ }
438
+
439
+ /** The columns `columns` names, in render order. */
440
+ function selectColumns(
441
+ table: ErasedTable,
442
+ columns: TMDataGridExportColumns,
443
+ ): Array<ErasedColumn> {
444
+ const all = allLeafColumns(table).filter(isExportedColumn);
445
+ if (columns === "all") return all;
446
+ if (columns === "visible") return all.filter((column) => column.getIsVisible());
447
+ const wanted = new Set(columns);
448
+ return all.filter((column) => wanted.has(column.id));
449
+ }
450
+
451
+ /**
452
+ * The generated lanes - the checkbox, the details chevron, the edit lane, the
453
+ * row numbers, the tree lane - hold controls rather than data, so a column of
454
+ * empty strings is all they could contribute, and pasting one into a
455
+ * spreadsheet only shifts everything to its right. The tree lane is a display
456
+ * column with no accessor, so on a grouped grid the value it shows lives in
457
+ * the grouped column, which stays exportable even while `groupedColumnMode`
458
+ * has taken it off the screen. `meta.enableExport: false` is the consumer's
459
+ * way of saying the same about a column of their own.
460
+ */
461
+ function isExportedColumn(column: ErasedColumn): boolean {
462
+ if (isGeneratedColumn(column.id)) return false;
463
+ return column.columnDef.meta?.enableExport !== false;
464
+ }
465
+
466
+ /**
467
+ * Every data row in render order, all pages.
468
+ *
469
+ * The sorted model: after filtering, grouping and sorting, before expansion
470
+ * and paging - so a paged grid exports the whole filtered set, and a grouped
471
+ * grid exports the records under every group whether or not it is open.
472
+ *
473
+ * Walked by hand rather than taken from `flatRows`: in table-core
474
+ * 9.0.0-beta.21 the grouped model lists every leaf twice there (once as a
475
+ * leaf row, once re-parented under its group), and its order puts the
476
+ * groups last. Depth-first over `rows` is exactly render order, once each.
477
+ */
478
+ function leafRows(table: ErasedTable): Array<ErasedRow> {
479
+ const rows: Array<ErasedRow> = [];
480
+ const walk = (list: ReadonlyArray<ErasedRow>) => {
481
+ for (const row of list) {
482
+ if (row.getIsGrouped()) walk(row.subRows);
483
+ // A row marked for deletion under `editing.draft` is left out: the
484
+ // export is the data as the user means it, and they deleted that row.
485
+ else if (!isRowMarkedDeleted(table, row.id)) rows.push(row);
486
+ }
487
+ };
488
+ walk(table.getSortedRowModel().rows);
489
+ return rows;
490
+ }
491
+
492
+ /**
493
+ * How many rows `rows: "selected"` would export: the ticked rows of the
494
+ * current view. Not the size of the selection map, which keeps rows the
495
+ * filters have since hidden. Free while nothing is selected.
496
+ */
497
+ export function countSelectedExportRows<TData extends RowData>(
498
+ table: TMDataGridTable<TData>,
499
+ ): number {
500
+ const erased = table as unknown as ErasedTable;
501
+ if (Object.keys(erased.store.state.rowSelection).length === 0) return 0;
502
+ return leafRows(erased).filter((row) => row.getIsSelected()).length;
503
+ }
504
+
505
+ function collectExportData(
506
+ rows: ReadonlyArray<ErasedRow>,
507
+ columns: ReadonlyArray<ErasedColumn>,
508
+ ): TMDataGridExportData {
509
+ return {
510
+ columnIds: columns.map((column) => column.id),
511
+ headers: columns.map((column) => getColumnLabel(column)),
512
+ rows: rows.map((row) =>
513
+ columns.map((column) => {
514
+ const value = row.getValue(column.id);
515
+ const exportValue = column.columnDef.meta?.exportValue;
516
+ return exportValue ? exportValue({ value, row, column }) : value;
517
+ }),
518
+ ),
519
+ };
520
+ }
521
+
522
+ export type BuildExportDataArgs<TData extends RowData> = {
523
+ table: TMDataGridTable<TData>;
524
+ /** Defaults to `"all"`. */
525
+ rows?: TMDataGridExportRows<TData>;
526
+ /** Defaults to `"visible"`. Ignored under `bounds`. */
527
+ columns?: TMDataGridExportColumns;
528
+ /**
529
+ * A rectangle over `rows` and the visible columns, both by index - the
530
+ * cell-range path. `rows` is then the list the indices refer to, usually
531
+ * the displayed rows.
532
+ */
533
+ bounds?: TMDataGridRangeBounds;
534
+ };
535
+
536
+ /**
537
+ * What an export writes, before any format touches it.
538
+ *
539
+ * Columns are `"visible"` (the data columns on screen, in render order),
540
+ * `"all"` (every exportable column, hidden or not) or a list of ids; the
541
+ * generated lanes and any column with `meta.enableExport: false` are left out
542
+ * whichever is asked for. Rows are `"all"` (every filtered and sorted row
543
+ * across every page, group rows flattened to their records), `"selected"`
544
+ * (those of them the user has ticked, in the same order - the selection map
545
+ * is walked through the row list rather than the other way round, because
546
+ * TanStack's selected row models ignore filtering and sorting), or a list of
547
+ * your own.
548
+ */
549
+ export function buildExportData<TData extends RowData>({
550
+ table,
551
+ rows = "all",
552
+ columns: which = "visible",
553
+ bounds,
554
+ }: BuildExportDataArgs<TData>): TMDataGridExportData {
555
+ // The same erasure the context provider performs: this only ever reads
556
+ // generic row and column APIs.
557
+ const erased = table as unknown as ErasedTable;
558
+ // The bounds index into every visible column, lanes included, so under
559
+ // them the slice comes before the filter.
560
+ const columns = bounds
561
+ ? [
562
+ ...erased.getStartVisibleLeafColumns(),
563
+ ...erased.getCenterVisibleLeafColumns(),
564
+ ...erased.getEndVisibleLeafColumns(),
565
+ ]
566
+ .slice(bounds.left, bounds.right + 1)
567
+ .filter(isExportedColumn)
568
+ : selectColumns(erased, which);
569
+ if (columns.length === 0) return { columnIds: [], headers: [], rows: [] };
570
+
571
+ let list: ReadonlyArray<ErasedRow>;
572
+ if (rows === "all") list = leafRows(erased);
573
+ else if (rows === "selected") {
574
+ list = leafRows(erased).filter((row) => row.getIsSelected());
575
+ } else list = rows as unknown as ReadonlyArray<ErasedRow>;
576
+ if (bounds) list = list.slice(bounds.top, bounds.bottom + 1);
577
+
578
+ return collectExportData(list, columns);
579
+ }
580
+
581
+ /**
582
+ * Writes `data` in the format and downloads it. Awaits the format, since a
583
+ * binary format builds its file asynchronously.
584
+ */
585
+ export async function writeExportFile(
586
+ data: TMDataGridExportData,
587
+ { format, fileName, includeHeaders }: TMDataGridExportSettings,
588
+ ): Promise<void> {
589
+ const content = await format.write(data, { includeHeaders });
590
+ downloadFile({
591
+ fileName: `${fileName}.${format.extension}`,
592
+ content,
593
+ mimeType: format.mimeType,
594
+ });
595
+ }
596
+
597
+ export type ExportGridArgs<TData extends RowData> = {
598
+ table: TMDataGridTable<TData>;
599
+ /** Defaults to `"all"`. See {@link TMDataGridExportRows}. */
600
+ rows?: TMDataGridExportRows<TData>;
601
+ /** Merged over `DEFAULT_EXPORT_OPTIONS`. */
602
+ options?: TMDataGridExportOptions;
603
+ };
604
+
605
+ /**
606
+ * Downloads the grid as a file: {@link buildExportData} through the format's
607
+ * `write` and a download.
608
+ *
609
+ * Inside the grid, `useTMDataGridExport` and the `TMDataGrid.Menu.Export*`
610
+ * items call this with the grid's own `exportOptions`; this is the entry point
611
+ * for code that holds the table and nothing else.
612
+ *
613
+ * Nothing is downloaded when no column is exportable. A grid with no rows
614
+ * still downloads its header row, since an empty file is the honest answer to
615
+ * an empty view.
616
+ *
617
+ * Async because a format may be. Safari refuses a download that starts after
618
+ * the click gesture has ended, which a format that takes long enough to build
619
+ * can run into; the text formats resolve synchronously and never do.
620
+ */
621
+ export async function exportGrid<TData extends RowData>({
622
+ table,
623
+ rows,
624
+ options,
625
+ }: ExportGridArgs<TData>): Promise<void> {
626
+ const resolved = resolveExportOptions(options);
627
+ const data = buildExportData({ table, rows, columns: resolved.columns });
628
+ if (data.columnIds.length === 0) return;
629
+ await writeExportFile(data, resolved);
630
+ }
631
+
632
+ export type TMDataGridClipboardTextOptions = {
633
+ /** Defaults to `true`, the Nordic mark. */
634
+ decimalComma?: boolean;
635
+ /** Defaults to `true`. See {@link guardFormula}. */
636
+ escapeFormulas?: boolean;
637
+ };
638
+
639
+ /**
640
+ * The clipboard format spreadsheets read: tab between cells, CRLF between rows,
641
+ * values only.
642
+ *
643
+ * Tabs rather than commas because that is what Excel, Sheets and Numbers all
644
+ * put on the clipboard themselves - paste it and the cells land in cells. A
645
+ * comma-separated string pastes into a single column, which is the thing this
646
+ * exists to avoid. No header row: Excel's own copy carries none either, and a
647
+ * header pasted into the middle of a sheet is a row of text where numbers
648
+ * were expected.
649
+ *
650
+ * Also accepts an already-formatted string matrix, written as is.
651
+ */
652
+ export function toClipboardText(
653
+ data: TMDataGridExportData | Array<Array<string>>,
654
+ { decimalComma = true, escapeFormulas = true }: TMDataGridClipboardTextOptions = {},
655
+ ): string {
656
+ const lines = Array.isArray(data)
657
+ ? data
658
+ : textRows(data, { includeHeaders: false }, { decimalComma, escapeFormulas });
659
+ return toDelimited(lines, "\t");
660
+ }
661
+
662
+ /**
663
+ * Puts text on the clipboard, reporting whether it landed.
664
+ *
665
+ * The async clipboard API only resolves for a document that has the focus and a
666
+ * user gesture behind it - both true when this runs off Ctrl+C or a menu item.
667
+ * It is still allowed to reject (a permissions policy, a page that lost focus
668
+ * mid-copy), so the result is a boolean the caller can act on.
669
+ */
670
+ export async function writeClipboardText(text: string): Promise<boolean> {
671
+ try {
672
+ await navigator.clipboard.writeText(text);
673
+ return true;
674
+ } catch {
675
+ return false;
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Downloads a file, through the one mechanism a library can use: an anchor
681
+ * with an object URL behind it, clicked. Revoked on the next frame -
682
+ * immediately would race the browser's own read of it.
683
+ */
684
+ export function downloadFile({
685
+ fileName,
686
+ content,
687
+ mimeType,
688
+ }: {
689
+ fileName: string;
690
+ content: string | Blob;
691
+ mimeType: string;
692
+ }): void {
693
+ const blob =
694
+ content instanceof Blob ? content : new Blob([content], { type: mimeType });
695
+ const url = URL.createObjectURL(blob);
696
+ const anchor = document.createElement("a");
697
+ anchor.href = url;
698
+ anchor.download = fileName;
699
+ anchor.style.display = "none";
700
+ document.body.append(anchor);
701
+ anchor.click();
702
+ anchor.remove();
703
+ setTimeout(() => URL.revokeObjectURL(url), 0);
704
+ }