@jielga/tmdatagrid 2.0.0-beta.8 → 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.
- package/README.md +5 -212
- package/dist/index.d.ts +1323 -796
- package/dist/index.js +4719 -3193
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/docs/adding-rows.md +132 -0
- package/docs/anatomy.md +119 -0
- package/docs/card-view.md +108 -0
- package/docs/cell-selection.md +194 -0
- package/docs/column-layout.md +182 -0
- package/docs/column-menu.md +66 -0
- package/docs/columns.md +268 -0
- package/docs/components.md +311 -0
- package/docs/draft-store.md +242 -0
- package/docs/editing.md +303 -0
- package/docs/editors.md +250 -0
- package/docs/export.md +319 -0
- package/docs/filtering.md +362 -0
- package/docs/getting-started.md +123 -0
- package/docs/grouping.md +165 -0
- package/docs/loading-and-empty.md +92 -0
- package/docs/localization.md +79 -0
- package/docs/menu.md +143 -0
- package/docs/migrating-to-2.md +163 -0
- package/docs/pagination.md +144 -0
- package/docs/persistence.md +114 -0
- package/docs/portfolio-rebalancer.md +94 -0
- package/docs/query-builder.md +179 -0
- package/docs/quick-search.md +84 -0
- package/docs/row-details.md +115 -0
- package/docs/row-interaction.md +149 -0
- package/docs/row-pinning.md +132 -0
- package/docs/row-selection.md +136 -0
- package/docs/row-styling.md +133 -0
- package/docs/scrolling.md +112 -0
- package/docs/server-query.md +246 -0
- package/docs/server-side.md +206 -0
- package/docs/sorting.md +101 -0
- package/docs/styling.md +126 -0
- package/docs/summary-row.md +76 -0
- package/docs/testing.md +744 -0
- package/docs/toolbar.md +161 -0
- package/docs/use-tm-data-grid.md +361 -0
- package/package.json +22 -46
- package/skills/appearance/SKILL.md +72 -19
- package/skills/cell-selection/SKILL.md +69 -78
- package/skills/columns/SKILL.md +90 -34
- package/skills/data/SKILL.md +86 -16
- package/skills/editing/SKILL.md +83 -50
- package/skills/editing/references/common-mistakes.md +77 -69
- package/skills/editing/references/editing-api.md +31 -23
- package/skills/editing/references/editors-and-validation.md +24 -17
- package/skills/filtering/SKILL.md +148 -40
- package/skills/getting-started/SKILL.md +17 -15
- package/skills/grouping/SKILL.md +31 -16
- package/skills/options/SKILL.md +8 -8
- package/skills/rows/SKILL.md +22 -18
- package/skills/server-side/SKILL.md +170 -17
- package/skills/testing/SKILL.md +150 -32
- package/skills/testing-components/SKILL.md +230 -0
- package/skills/testing-editing/SKILL.md +240 -0
- package/src/{tmdatagrid/TMDataGridContext.ts → TMDataGridContext.ts} +7 -19
- package/src/{tmdatagrid/components → components}/TMDataGrid.module.css +7 -1
- package/src/{tmdatagrid/components → components}/TMDataGrid.tsx +38 -21
- package/src/{tmdatagrid/components → components}/TMDataGridCellEditor.tsx +73 -7
- package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.module.css +5 -1
- package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.tsx +47 -55
- package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.tsx +9 -55
- package/src/{tmdatagrid/components → components}/TMDataGridDraftActions.tsx +41 -24
- package/src/{tmdatagrid/components → components}/TMDataGridEditColumn.tsx +23 -63
- package/src/components/TMDataGridEntryRows.tsx +354 -0
- package/src/components/TMDataGridExportPicker.module.css +77 -0
- package/src/components/TMDataGridExportPicker.tsx +234 -0
- package/src/components/TMDataGridFilterPanel.module.css +54 -0
- package/src/components/TMDataGridFilterPanel.tsx +348 -0
- package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.tsx +15 -8
- package/src/components/TMDataGridFilterSurface.module.css +54 -0
- package/src/components/TMDataGridFilterSurface.tsx +167 -0
- package/src/{tmdatagrid/components → components}/TMDataGridFooter.tsx +53 -90
- package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.tsx +9 -72
- package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.module.css +12 -2
- package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.tsx +58 -21
- package/src/components/TMDataGridHeaderFilterRow.module.css +51 -0
- package/src/components/TMDataGridHeaderFilterRow.tsx +301 -0
- package/src/components/TMDataGridMenu.tsx +357 -0
- package/src/{tmdatagrid/components → components}/TMDataGridSelectColumn.tsx +15 -53
- package/src/{tmdatagrid/components → components}/TMDataGridTable.module.css +88 -65
- package/src/{tmdatagrid/components → components}/TMDataGridTable.tsx +579 -165
- package/src/{tmdatagrid/components → components}/TMDataGridToolbar.module.css +5 -0
- package/src/components/TMDataGridToolbar.tsx +181 -0
- package/src/{tmdatagrid/components → components}/editors/TMDataGridBooleanEditor.tsx +3 -3
- package/src/{tmdatagrid/components → components}/editors/TMDataGridDateEditor.tsx +3 -3
- package/src/{tmdatagrid/components → components}/editors/TMDataGridMultiSelectEditor.tsx +3 -3
- package/src/{tmdatagrid/components → components}/editors/TMDataGridNumberEditor.tsx +3 -3
- package/src/{tmdatagrid/components → components}/editors/TMDataGridSelectEditor.tsx +3 -3
- package/src/{tmdatagrid/components → components}/editors/TMDataGridStringEditor.tsx +3 -3
- package/src/{tmdatagrid/components → components}/editors/editorShared.ts +16 -2
- package/src/{tmdatagrid/components → components}/filters/DgAutocompleteFilter.tsx +5 -4
- package/src/{tmdatagrid/components → components}/filters/DgDateRangeFilter.tsx +22 -5
- package/src/{tmdatagrid/components → components}/filters/DgRangeSliderFilter.tsx +5 -1
- package/src/{tmdatagrid/components → components}/filters/DgTriStateFilter.tsx +5 -1
- package/src/{tmdatagrid/components → components}/filters/TMDataGridFilterValueInput.tsx +44 -28
- package/src/components/filters/controlLayout.ts +32 -0
- package/src/components/filters/filterControlFor.ts +65 -0
- package/src/components/generatedColumns.tsx +187 -0
- package/src/{tmdatagrid/components → components}/icons.ts +1 -0
- package/src/{tmdatagrid/components → components}/sticky.module.css +44 -0
- package/src/components/useHideableColumns.ts +52 -0
- package/src/{tmdatagrid/core → core}/autosize.ts +5 -2
- package/src/{tmdatagrid/core → core}/capabilities.ts +5 -5
- package/src/{tmdatagrid/core → core}/columnOptions.ts +60 -8
- package/src/{tmdatagrid/core → core}/columnOrdering.ts +33 -14
- package/src/{tmdatagrid/core → core}/columnUtils.ts +44 -5
- package/src/core/controlledStateSync.ts +108 -0
- package/src/core/deletedRows.ts +34 -0
- package/src/core/dom.ts +74 -0
- package/src/{tmdatagrid/core → core}/editEngine.ts +1172 -388
- package/src/{tmdatagrid/core → core}/editorFocus.ts +8 -4
- package/src/core/export.ts +704 -0
- package/src/{tmdatagrid/core → core}/filterControls.ts +38 -1
- package/src/{tmdatagrid/core → core}/filterOperators.ts +64 -1
- package/src/core/filterSurface.ts +99 -0
- package/src/{tmdatagrid/core → core}/grouping.ts +21 -0
- package/src/{tmdatagrid/core → core}/labels.ts +51 -6
- package/src/{tmdatagrid/core → core}/labelsSv.ts +27 -6
- package/src/core/pageReset.ts +120 -0
- package/src/core/pagination.ts +81 -0
- package/src/{tmdatagrid/core → core}/persistence.ts +22 -5
- package/src/{tmdatagrid/core → core}/summary.ts +20 -4
- package/src/{tmdatagrid/index.ts → index.ts} +70 -36
- package/src/{tmdatagrid/useTMDataGrid.tsx → useTMDataGrid.tsx} +534 -123
- package/src/useTMDataGridExport.ts +78 -0
- package/src/tmdatagrid/components/TMDataGridEntryRows.tsx +0 -298
- package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +0 -35
- package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +0 -354
- package/src/tmdatagrid/components/TMDataGridToolbar.tsx +0 -161
- package/src/tmdatagrid/core/cellExport.ts +0 -320
- /package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.module.css +0 -0
- /package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.module.css +0 -0
- /package/src/{tmdatagrid/components → components}/TMDataGridFooter.module.css +0 -0
- /package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.module.css +0 -0
- /package/src/{tmdatagrid/components → components}/TMDataGridRowNumberColumn.tsx +0 -0
- /package/src/{tmdatagrid/components → components}/TMDataGridSearch.tsx +0 -0
- /package/src/{tmdatagrid/core → core}/cellNavigation.ts +0 -0
- /package/src/{tmdatagrid/core → core}/cellRange.ts +0 -0
- /package/src/{tmdatagrid/core → core}/controlledState.ts +0 -0
- /package/src/{tmdatagrid/core → core}/draftCellContext.ts +0 -0
- /package/src/{tmdatagrid/core → core}/expanding.ts +0 -0
- /package/src/{tmdatagrid/core → core}/matchHighlight.ts +0 -0
- /package/src/{tmdatagrid/core → core}/quickSearch.ts +0 -0
- /package/src/{tmdatagrid/core → core}/resizePreview.ts +0 -0
- /package/src/{tmdatagrid/core → core}/rowPinning.ts +0 -0
- /package/src/{tmdatagrid/core → core}/rowSelection.ts +0 -0
- /package/src/{tmdatagrid/core → core}/sizes.ts +0 -0
- /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
|
+
}
|