@jielga/tmdatagrid-xlsx 2.0.0-beta.14
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/LICENSE +21 -0
- package/README.md +31 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +59 -0
- package/dist/index.js.map +1 -0
- package/docs/xlsx.md +72 -0
- package/package.json +69 -0
- package/skills/xlsx/SKILL.md +107 -0
- package/src/index.ts +6 -0
- package/src/xlsxFormat.ts +160 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jielga
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# @jielga/tmdatagrid-xlsx
|
|
2
|
+
|
|
3
|
+
Excel (xlsx) export format for [@jielga/tmdatagrid](https://www.npmjs.com/package/@jielga/tmdatagrid), on [exceljs](https://www.npmjs.com/package/exceljs).
|
|
4
|
+
|
|
5
|
+
- Documentation: <https://jielga.github.io/TMDataGrid/docs/xlsx>
|
|
6
|
+
- Source, issues and the contributor guide: <https://github.com/Jielga/TMDataGrid>
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
bun add @jielga/tmdatagrid-xlsx
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm install @jielga/tmdatagrid-xlsx
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`xlsxFormat()` is a `TMDataGridExportFormat`, so it goes wherever the grid takes one:
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
import { useTMDataGrid } from "@jielga/tmdatagrid";
|
|
20
|
+
import { xlsxFormat } from "@jielga/tmdatagrid-xlsx";
|
|
21
|
+
|
|
22
|
+
const grid = useTMDataGrid({
|
|
23
|
+
data,
|
|
24
|
+
columns,
|
|
25
|
+
exportOptions: { format: xlsxFormat() },
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Numbers, dates and booleans arrive as typed cells, so nothing depends on the reader's locale.
|
|
30
|
+
|
|
31
|
+
See [docs/xlsx.md](docs/xlsx.md) for the options and the cell typing.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { TMDataGridExportFormat } from "@jielga/tmdatagrid";
|
|
2
|
+
//#region .types-tmp/xlsxFormat.d.ts
|
|
3
|
+
type TMDataGridXlsxFormatOptions = {
|
|
4
|
+
/** Worksheet name. Defaults to `"Sheet1"`. */
|
|
5
|
+
sheetName?: string;
|
|
6
|
+
/** Column widths from the longest text in each column, capped at 60 characters. Defaults to `true`. */
|
|
7
|
+
autoWidth?: boolean;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* A real Excel workbook rather than a CSV that Excel can open.
|
|
11
|
+
*
|
|
12
|
+
* That is the whole reason the format exists: a typed cell needs no separator,
|
|
13
|
+
* no decimal mark and no BOM to survive the trip, so nothing depends on the
|
|
14
|
+
* reader's locale. Numbers arrive as numbers, dates as dates with a number
|
|
15
|
+
* format, booleans as booleans, and a text cell can hold a semicolon or a
|
|
16
|
+
* newline without ending the row.
|
|
17
|
+
*
|
|
18
|
+
* `exceljs` is why this is a separate package: it is a large dependency, and a
|
|
19
|
+
* grid that exports CSV should not carry a spreadsheet writer.
|
|
20
|
+
*/
|
|
21
|
+
declare function xlsxFormat({ sheetName, autoWidth }?: TMDataGridXlsxFormatOptions): TMDataGridExportFormat;
|
|
22
|
+
//#endregion
|
|
23
|
+
export { type TMDataGridXlsxFormatOptions, xlsxFormat };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import e from "exceljs";
|
|
2
|
+
//#region src/xlsxFormat.ts
|
|
3
|
+
var t = 60, n = 10, r = 2, i = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
4
|
+
function a(e) {
|
|
5
|
+
return e.getHours() === 0 && e.getMinutes() === 0 && e.getSeconds() === 0 && e.getMilliseconds() === 0;
|
|
6
|
+
}
|
|
7
|
+
function o(e) {
|
|
8
|
+
return a(e) ? "yyyy-mm-dd" : "yyyy-mm-dd hh:mm";
|
|
9
|
+
}
|
|
10
|
+
function s(e) {
|
|
11
|
+
let t = e.toLocaleString("sv-SE");
|
|
12
|
+
return a(e) ? t.slice(0, 10) : t.slice(0, 16);
|
|
13
|
+
}
|
|
14
|
+
function c(e) {
|
|
15
|
+
if (e == null) return "";
|
|
16
|
+
if (typeof e == "number") return Number.isFinite(e) ? String(e) : "";
|
|
17
|
+
if (typeof e == "string") return e;
|
|
18
|
+
if (typeof e == "boolean") return e ? "true" : "false";
|
|
19
|
+
if (e instanceof Date) return Number.isNaN(e.getTime()) ? "" : s(e);
|
|
20
|
+
if (Array.isArray(e)) return e.map((e) => c(e)).join(", ");
|
|
21
|
+
if (typeof e == "object") try {
|
|
22
|
+
return JSON.stringify(e);
|
|
23
|
+
} catch {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
return String(e);
|
|
27
|
+
}
|
|
28
|
+
function l(e) {
|
|
29
|
+
return e == null ? null : typeof e == "number" ? Number.isFinite(e) ? e : null : e instanceof Date ? Number.isNaN(e.getTime()) ? null : e : typeof e == "boolean" ? e : c(e);
|
|
30
|
+
}
|
|
31
|
+
function u({ sheetName: a = "Sheet1", autoWidth: s = !0 } = {}) {
|
|
32
|
+
return {
|
|
33
|
+
id: "xlsx",
|
|
34
|
+
extension: "xlsx",
|
|
35
|
+
mimeType: i,
|
|
36
|
+
write: async (u, { includeHeaders: d }) => {
|
|
37
|
+
let f = new e.Workbook(), p = f.addWorksheet(a), m = u.headers.map((e) => d ? e.length : 0);
|
|
38
|
+
if (d) {
|
|
39
|
+
let e = p.addRow(u.headers);
|
|
40
|
+
e.font = { bold: !0 };
|
|
41
|
+
}
|
|
42
|
+
for (let e of u.rows) {
|
|
43
|
+
let t = p.addRow(e.map((e) => l(e)));
|
|
44
|
+
e.forEach((e, n) => {
|
|
45
|
+
e instanceof Date && !Number.isNaN(e.getTime()) && (t.getCell(n + 1).numFmt = o(e));
|
|
46
|
+
let r = c(e).length;
|
|
47
|
+
r > (m[n] ?? 0) && (m[n] = r);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return s && m.forEach((e, i) => {
|
|
51
|
+
p.getColumn(i + 1).width = Math.min(t, Math.max(n, e + r));
|
|
52
|
+
}), new Blob([await f.xlsx.writeBuffer()], { type: i });
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
export { u as xlsxFormat };
|
|
58
|
+
|
|
59
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/xlsxFormat.ts"],"sourcesContent":["import ExcelJS from \"exceljs\";\r\nimport type {\r\n TMDataGridExportData,\r\n TMDataGridExportFormat,\r\n TMDataGridExportWriteOptions,\r\n} from \"@jielga/tmdatagrid\";\r\n\r\n/**\r\n * How wide a column may get from `autoWidth`. A long free-text cell would\r\n * otherwise push a column past the width of the window, and a column nobody\r\n * can see past is worse than one that clips.\r\n */\r\nconst MAX_COLUMN_WIDTH = 60;\r\n\r\n/** Narrow columns still need room for their header and the filter arrow. */\r\nconst MIN_COLUMN_WIDTH = 10;\r\n\r\n/** Excel measures a column in characters, and a cell needs a little padding. */\r\nconst WIDTH_PADDING = 2;\r\n\r\nconst MIME_TYPE =\r\n \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\";\r\n\r\n/** A date at midnight is a date; anything else is a moment in time. */\r\nfunction isMidnight(value: Date): boolean {\r\n return (\r\n value.getHours() === 0 &&\r\n value.getMinutes() === 0 &&\r\n value.getSeconds() === 0 &&\r\n value.getMilliseconds() === 0\r\n );\r\n}\r\n\r\nfunction dateNumberFormat(value: Date): string {\r\n return isMidnight(value) ? \"yyyy-mm-dd\" : \"yyyy-mm-dd hh:mm\";\r\n}\r\n\r\n/** The date as its number format renders it, for a width and for a joined array. */\r\nfunction dateText(value: Date): string {\r\n const text = value.toLocaleString(\"sv-SE\");\r\n return isMidnight(value) ? text.slice(0, 10) : text.slice(0, 16);\r\n}\r\n\r\n/**\r\n * A value as text, for the cells a spreadsheet has no type for and for the\r\n * width measurement.\r\n *\r\n * Deliberately not `formatExportValue` from the grid: that one writes a\r\n * decimal comma and an `sv-SE` date, both of which would be wrong here. A\r\n * typed cell carries the value itself and Excel renders it in the reader's own\r\n * locale, so only the untypable values reach this.\r\n */\r\nfunction cellText(value: unknown): string {\r\n if (value === null || value === undefined) return \"\";\r\n if (typeof value === \"number\") return Number.isFinite(value) ? String(value) : \"\";\r\n if (typeof value === \"string\") return value;\r\n if (typeof value === \"boolean\") return value ? \"true\" : \"false\";\r\n if (value instanceof Date) {\r\n return Number.isNaN(value.getTime()) ? \"\" : dateText(value);\r\n }\r\n // A multiSelect cell holds an array of values; a spreadsheet cell holds one\r\n // string, so the elements are joined the way they read on screen.\r\n if (Array.isArray(value)) {\r\n return value.map((entry) => cellText(entry)).join(\", \");\r\n }\r\n // An object in a cell is a shape the addon cannot know. JSON at least keeps\r\n // what was there, where `String(value)` would write \"[object Object]\".\r\n if (typeof value === \"object\") {\r\n try {\r\n return JSON.stringify(value);\r\n } catch {\r\n return \"\";\r\n }\r\n }\r\n return String(value);\r\n}\r\n\r\n/**\r\n * What goes into the worksheet cell: a number, a date, a boolean or a string.\r\n *\r\n * `null` for everything empty, which is what exceljs writes as a blank cell -\r\n * an empty string would be a cell holding text of length zero, and a formula\r\n * counting it would count it.\r\n */\r\nfunction cellValue(value: unknown): number | Date | boolean | string | null {\r\n if (value === null || value === undefined) return null;\r\n if (typeof value === \"number\") return Number.isFinite(value) ? value : null;\r\n if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;\r\n if (typeof value === \"boolean\") return value;\r\n return cellText(value);\r\n}\r\n\r\nexport type TMDataGridXlsxFormatOptions = {\r\n /** Worksheet name. Defaults to `\"Sheet1\"`. */\r\n sheetName?: string;\r\n /** Column widths from the longest text in each column, capped at 60 characters. Defaults to `true`. */\r\n autoWidth?: boolean;\r\n};\r\n\r\n/**\r\n * A real Excel workbook rather than a CSV that Excel can open.\r\n *\r\n * That is the whole reason the format exists: a typed cell needs no separator,\r\n * no decimal mark and no BOM to survive the trip, so nothing depends on the\r\n * reader's locale. Numbers arrive as numbers, dates as dates with a number\r\n * format, booleans as booleans, and a text cell can hold a semicolon or a\r\n * newline without ending the row.\r\n *\r\n * `exceljs` is why this is a separate package: it is a large dependency, and a\r\n * grid that exports CSV should not carry a spreadsheet writer.\r\n */\r\nexport function xlsxFormat({\r\n sheetName = \"Sheet1\",\r\n autoWidth = true,\r\n}: TMDataGridXlsxFormatOptions = {}): TMDataGridExportFormat {\r\n return {\r\n id: \"xlsx\",\r\n extension: \"xlsx\",\r\n mimeType: MIME_TYPE,\r\n write: async (\r\n data: TMDataGridExportData,\r\n { includeHeaders }: TMDataGridExportWriteOptions,\r\n ): Promise<Blob> => {\r\n const workbook = new ExcelJS.Workbook();\r\n const sheet = workbook.addWorksheet(sheetName);\r\n\r\n // The longest text seen per column, header included, for `autoWidth`.\r\n const widths = data.headers.map((header) =>\r\n includeHeaders ? header.length : 0,\r\n );\r\n\r\n if (includeHeaders) {\r\n const headerRow = sheet.addRow(data.headers);\r\n headerRow.font = { bold: true };\r\n }\r\n\r\n for (const row of data.rows) {\r\n const added = sheet.addRow(row.map((value) => cellValue(value)));\r\n row.forEach((value, index) => {\r\n if (value instanceof Date && !Number.isNaN(value.getTime())) {\r\n added.getCell(index + 1).numFmt = dateNumberFormat(value);\r\n }\r\n const length = cellText(value).length;\r\n if (length > (widths[index] ?? 0)) widths[index] = length;\r\n });\r\n }\r\n\r\n if (autoWidth) {\r\n widths.forEach((width, index) => {\r\n sheet.getColumn(index + 1).width = Math.min(\r\n MAX_COLUMN_WIDTH,\r\n Math.max(MIN_COLUMN_WIDTH, width + WIDTH_PADDING),\r\n );\r\n });\r\n }\r\n\r\n return new Blob([await workbook.xlsx.writeBuffer()], { type: MIME_TYPE });\r\n },\r\n };\r\n}\r\n"],"mappings":";;AAYA,IAAM,IAAmB,IAGnB,IAAmB,IAGnB,IAAgB,GAEhB,IACJ;AAGF,SAAS,EAAW,GAAsB;CACxC,OACE,EAAM,SAAS,MAAM,KACrB,EAAM,WAAW,MAAM,KACvB,EAAM,WAAW,MAAM,KACvB,EAAM,gBAAgB,MAAM;AAEhC;AAEA,SAAS,EAAiB,GAAqB;CAC7C,OAAO,EAAW,CAAK,IAAI,eAAe;AAC5C;AAGA,SAAS,EAAS,GAAqB;CACrC,IAAM,IAAO,EAAM,eAAe,OAAO;CACzC,OAAO,EAAW,CAAK,IAAI,EAAK,MAAM,GAAG,EAAE,IAAI,EAAK,MAAM,GAAG,EAAE;AACjE;AAWA,SAAS,EAAS,GAAwB;CACxC,IAAI,KAAU,MAA6B,OAAO;CAClD,IAAI,OAAO,KAAU,UAAU,OAAO,OAAO,SAAS,CAAK,IAAI,OAAO,CAAK,IAAI;CAC/E,IAAI,OAAO,KAAU,UAAU,OAAO;CACtC,IAAI,OAAO,KAAU,WAAW,OAAO,IAAQ,SAAS;CACxD,IAAI,aAAiB,MACnB,OAAO,OAAO,MAAM,EAAM,QAAQ,CAAC,IAAI,KAAK,EAAS,CAAK;CAI5D,IAAI,MAAM,QAAQ,CAAK,GACrB,OAAO,EAAM,KAAK,MAAU,EAAS,CAAK,CAAC,CAAC,CAAC,KAAK,IAAI;CAIxD,IAAI,OAAO,KAAU,UACnB,IAAI;EACF,OAAO,KAAK,UAAU,CAAK;CAC7B,QAAQ;EACN,OAAO;CACT;CAEF,OAAO,OAAO,CAAK;AACrB;AASA,SAAS,EAAU,GAAyD;CAK1E,OAJI,KAAU,OAAoC,OAC9C,OAAO,KAAU,WAAiB,OAAO,SAAS,CAAK,IAAI,IAAQ,OACnE,aAAiB,OAAa,OAAO,MAAM,EAAM,QAAQ,CAAC,IAAI,OAAO,IACrE,OAAO,KAAU,YAAkB,IAChC,EAAS,CAAK;AACvB;AAqBA,SAAgB,EAAW,EACzB,eAAY,UACZ,eAAY,OACmB,CAAC,GAA2B;CAC3D,OAAO;EACL,IAAI;EACJ,WAAW;EACX,UAAU;EACV,OAAO,OACL,GACA,EAAE,wBACgB;GAClB,IAAM,IAAW,IAAI,EAAQ,SAAS,GAChC,IAAQ,EAAS,aAAa,CAAS,GAGvC,IAAS,EAAK,QAAQ,KAAK,MAC/B,IAAiB,EAAO,SAAS,CACnC;GAEA,IAAI,GAAgB;IAClB,IAAM,IAAY,EAAM,OAAO,EAAK,OAAO;IAC3C,EAAU,OAAO,EAAE,MAAM,GAAK;GAChC;GAEA,KAAK,IAAM,KAAO,EAAK,MAAM;IAC3B,IAAM,IAAQ,EAAM,OAAO,EAAI,KAAK,MAAU,EAAU,CAAK,CAAC,CAAC;IAC/D,EAAI,SAAS,GAAO,MAAU;KAC5B,AAAI,aAAiB,QAAQ,CAAC,OAAO,MAAM,EAAM,QAAQ,CAAC,MACxD,EAAM,QAAQ,IAAQ,CAAC,CAAC,CAAC,SAAS,EAAiB,CAAK;KAE1D,IAAM,IAAS,EAAS,CAAK,CAAC,CAAC;KAC/B,AAAI,KAAU,EAAO,MAAU,OAAI,EAAO,KAAS;IACrD,CAAC;GACH;GAWA,OATI,KACF,EAAO,SAAS,GAAO,MAAU;IAC/B,EAAM,UAAU,IAAQ,CAAC,CAAC,CAAC,QAAQ,KAAK,IACtC,GACA,KAAK,IAAI,GAAkB,IAAQ,CAAa,CAClD;GACF,CAAC,GAGI,IAAI,KAAK,CAAC,MAAM,EAAS,KAAK,YAAY,CAAC,GAAG,EAAE,MAAM,EAAU,CAAC;EAC1E;CACF;AACF"}
|
package/docs/xlsx.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Excel export (xlsx)
|
|
2
|
+
|
|
3
|
+
`@jielga/tmdatagrid-xlsx` adds a real Excel workbook to the grid's [export formats](/docs/export).
|
|
4
|
+
It is a separate package because it is built on `exceljs`, and the grid never depends on a spreadsheet writer: a grid that exports CSV should not pay for one.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npm install @jielga/tmdatagrid-xlsx
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The package declares `@jielga/tmdatagrid` as a peer dependency, so the two versions stay in step.
|
|
11
|
+
|
|
12
|
+
## xlsxFormat
|
|
13
|
+
|
|
14
|
+
`xlsxFormat()` returns a `TMDataGridExportFormat`, which is what `exportOptions.format` takes.
|
|
15
|
+
Set it once and every export of that grid writes a workbook.
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
import { useTMDataGrid } from "@jielga/tmdatagrid";
|
|
19
|
+
import { xlsxFormat } from "@jielga/tmdatagrid-xlsx";
|
|
20
|
+
|
|
21
|
+
const grid = useTMDataGrid({
|
|
22
|
+
data,
|
|
23
|
+
columns,
|
|
24
|
+
exportOptions: { format: xlsxFormat(), fileName: "employees" },
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Every `TMDataGrid.Menu.Export` item takes its own `format`, so one menu can offer both.
|
|
29
|
+
The item without a `format` writes the grid's own, the CSV default here.
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
<TMDataGrid.Menu>
|
|
33
|
+
<TMDataGrid.Menu.Export />
|
|
34
|
+
<TMDataGrid.Menu.Export format={xlsxFormat()} label="Export all rows as xlsx" />
|
|
35
|
+
</TMDataGrid.Menu>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
A typed cell needs no separator, no decimal mark and no byte order mark to survive the trip, so an xlsx file reads the same whatever locale the reader's Excel runs.
|
|
39
|
+
That is what it buys over [`csvExcelFormat`](/docs/export).
|
|
40
|
+
|
|
41
|
+
Values are written like this:
|
|
42
|
+
|
|
43
|
+
| Value | Cell |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| `number`, finite | A number cell |
|
|
46
|
+
| `number`, `NaN` or infinite | Empty |
|
|
47
|
+
| `Date` at 00:00:00 | A date cell, number format `yyyy-mm-dd` |
|
|
48
|
+
| `Date` with a time | A date cell, number format `yyyy-mm-dd hh:mm` |
|
|
49
|
+
| `Date`, invalid | Empty |
|
|
50
|
+
| `boolean` | A boolean cell |
|
|
51
|
+
| `string` | Text |
|
|
52
|
+
| `null`, `undefined` | Empty |
|
|
53
|
+
| Array | Its elements as text, joined with `, ` |
|
|
54
|
+
| Any other object | `JSON.stringify` of it, empty when that throws |
|
|
55
|
+
|
|
56
|
+
`includeHeaders` writes the column labels as a bold first row.
|
|
57
|
+
It defaults to `true`, and `exportOptions.includeHeaders` is where you turn it off.
|
|
58
|
+
|
|
59
|
+
The format's `write` is async and answers a `Blob`.
|
|
60
|
+
Safari refuses a download that starts after the click gesture has ended, so a grid of hundreds of thousands of rows may need its own button rather than the menu item.
|
|
61
|
+
|
|
62
|
+
```demo
|
|
63
|
+
file: data/ExportXlsx.tsx
|
|
64
|
+
hint: The grid menu has an xlsx item beside the CSV one.
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Options
|
|
68
|
+
|
|
69
|
+
| Option | Type | Default | What it does |
|
|
70
|
+
| --- | --- | --- | --- |
|
|
71
|
+
| `sheetName` | `string` | `"Sheet1"` | The worksheet's name. |
|
|
72
|
+
| `autoWidth` | `boolean` | `true` | Sets each column's width from its longest text, header included, between 10 and 60 characters. Off leaves the widths unset, and Excel uses its own default. |
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jielga/tmdatagrid-xlsx",
|
|
3
|
+
"version": "2.0.0-beta.14",
|
|
4
|
+
"description": "Excel (xlsx) export format for @jielga/tmdatagrid, on exceljs.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Jielga/TMDataGrid.git",
|
|
10
|
+
"directory": "packages/tmdatagrid-xlsx"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://jielga.github.io/TMDataGrid/",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/Jielga/TMDataGrid/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"react",
|
|
18
|
+
"datagrid",
|
|
19
|
+
"excel",
|
|
20
|
+
"xlsx",
|
|
21
|
+
"export",
|
|
22
|
+
"tanstack-intent"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"intent": {
|
|
28
|
+
"docs": "docs/"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"src",
|
|
33
|
+
"!src/**/*.test.*",
|
|
34
|
+
"docs",
|
|
35
|
+
"skills",
|
|
36
|
+
"!skills/_artifacts"
|
|
37
|
+
],
|
|
38
|
+
"main": "./dist/index.js",
|
|
39
|
+
"module": "./dist/index.js",
|
|
40
|
+
"types": "./dist/index.d.ts",
|
|
41
|
+
"exports": {
|
|
42
|
+
".": {
|
|
43
|
+
"types": "./dist/index.d.ts",
|
|
44
|
+
"import": "./dist/index.js"
|
|
45
|
+
},
|
|
46
|
+
"./docs/*": "./docs/*",
|
|
47
|
+
"./package.json": "./package.json"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "vite build && tsc -p tsconfig.build.json && rolldown -c rolldown.dts.config.mjs",
|
|
51
|
+
"test": "vitest run",
|
|
52
|
+
"prepublishOnly": "oxlint && bun run build"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"@jielga/tmdatagrid": "^2.0.0-beta.14"
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"exceljs": "^4.4.0"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@jielga/tmdatagrid": "2.0.0-beta.14",
|
|
62
|
+
"@tanstack/intent": "0.3.6",
|
|
63
|
+
"rolldown": "~1.1.5",
|
|
64
|
+
"rolldown-plugin-dts": "^0.27.14",
|
|
65
|
+
"typescript": "~7.0.2",
|
|
66
|
+
"vite": "^8.1.5",
|
|
67
|
+
"vitest": "^4.1.10"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: xlsx
|
|
3
|
+
description: >
|
|
4
|
+
Excel (.xlsx) export for TMDataGrid through the @jielga/tmdatagrid-xlsx
|
|
5
|
+
addon. Covers why exceljs lives in a separate package, xlsxFormat() as a
|
|
6
|
+
TMDataGridExportFormat, setting it as exportOptions.format or per menu item
|
|
7
|
+
on TMDataGrid.Menu.Export and TMDataGrid.Menu.ExportSelected, how each value
|
|
8
|
+
becomes a typed cell (numbers, dates with their number format, booleans,
|
|
9
|
+
arrays, objects, empties), the bold header row under includeHeaders, and the
|
|
10
|
+
sheetName and autoWidth options. Load when a grid should download a real
|
|
11
|
+
workbook rather than a CSV, when an exported number or date arrives as text,
|
|
12
|
+
or when naming or sizing the worksheet.
|
|
13
|
+
metadata:
|
|
14
|
+
type: core
|
|
15
|
+
library: '@jielga/tmdatagrid-xlsx'
|
|
16
|
+
library_version: '2.0.0-beta.14'
|
|
17
|
+
sources:
|
|
18
|
+
- 'Jielga/TMDataGrid:packages/tmdatagrid-xlsx/docs/xlsx.md'
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
# TMDataGrid - Excel export (xlsx)
|
|
22
|
+
|
|
23
|
+
A real workbook rather than a CSV Excel can open. A typed cell carries no
|
|
24
|
+
separator, no decimal mark and no byte order mark, so the file reads the same
|
|
25
|
+
whatever locale the reader's Excel runs.
|
|
26
|
+
|
|
27
|
+
`exceljs` is the reason this is its own package: the grid never depends on a
|
|
28
|
+
spreadsheet writer.
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npm install @jielga/tmdatagrid-xlsx
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Setting the format
|
|
35
|
+
|
|
36
|
+
`xlsxFormat()` returns a `TMDataGridExportFormat`, so it goes anywhere the grid
|
|
37
|
+
takes one.
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
import { useTMDataGrid } from "@jielga/tmdatagrid";
|
|
41
|
+
import { xlsxFormat } from "@jielga/tmdatagrid-xlsx";
|
|
42
|
+
|
|
43
|
+
const grid = useTMDataGrid({
|
|
44
|
+
data,
|
|
45
|
+
columns,
|
|
46
|
+
exportOptions: { format: xlsxFormat(), fileName: "employees" },
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Each `TMDataGrid.Menu.Export` and `TMDataGrid.Menu.ExportSelected` item takes
|
|
51
|
+
its own `format`, which overrides `exportOptions` for that item alone - that is
|
|
52
|
+
how one menu offers both CSV and xlsx.
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
<TMDataGrid.Menu>
|
|
56
|
+
<TMDataGrid.Menu.Export />
|
|
57
|
+
<TMDataGrid.Menu.Export format={xlsxFormat()} />
|
|
58
|
+
<TMDataGrid.Menu.ExportSelected format={xlsxFormat()} />
|
|
59
|
+
</TMDataGrid.Menu>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## How values are written
|
|
63
|
+
|
|
64
|
+
| Value | Cell |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `number`, finite | A number cell |
|
|
67
|
+
| `number`, `NaN` or infinite | Empty |
|
|
68
|
+
| `Date` at 00:00:00 | A date cell, `yyyy-mm-dd` |
|
|
69
|
+
| `Date` with a time | A date cell, `yyyy-mm-dd hh:mm` |
|
|
70
|
+
| `Date`, invalid | Empty |
|
|
71
|
+
| `boolean` | A boolean cell |
|
|
72
|
+
| `string` | Text |
|
|
73
|
+
| `null`, `undefined` | Empty |
|
|
74
|
+
| Array | Its elements as text, joined with `, ` |
|
|
75
|
+
| Any other object | `JSON.stringify` of it, empty when that throws |
|
|
76
|
+
|
|
77
|
+
`includeHeaders` (default `true`) writes the column labels as a bold first row.
|
|
78
|
+
|
|
79
|
+
## Common mistakes
|
|
80
|
+
|
|
81
|
+
### HIGH Expecting the grid to export xlsx on its own
|
|
82
|
+
|
|
83
|
+
The grid ships CSV, TSV and JSON only. Without `format: xlsxFormat()` the menu
|
|
84
|
+
item downloads the CSV default, whatever the file is called.
|
|
85
|
+
|
|
86
|
+
Source: `packages/tmdatagrid-xlsx/docs/xlsx.md`.
|
|
87
|
+
|
|
88
|
+
### MEDIUM Formatting a value before it reaches the export
|
|
89
|
+
|
|
90
|
+
A number turned into a string is written as text, and Excel will not sum it.
|
|
91
|
+
Leave the value alone, or use `meta.exportValue` to hand over a real number or
|
|
92
|
+
`Date`.
|
|
93
|
+
|
|
94
|
+
Source: `packages/tmdatagrid-xlsx/docs/xlsx.md` (How values are written).
|
|
95
|
+
|
|
96
|
+
## Reference
|
|
97
|
+
|
|
98
|
+
| Name | Kind | Type | Default | What it does |
|
|
99
|
+
| --- | --- | --- | --- | --- |
|
|
100
|
+
| `xlsxFormat` | Export | `(options?) => TMDataGridExportFormat` | – | The format. `id`, `extension` and the download's MIME type are all xlsx. |
|
|
101
|
+
| `sheetName` | Option | `string` | `"Sheet1"` | The worksheet's name. |
|
|
102
|
+
| `autoWidth` | Option | `boolean` | `true` | Column widths from the longest text, header included, between 10 and 60 characters. |
|
|
103
|
+
| `TMDataGridXlsxFormatOptions` | Export | type | – | The options object. |
|
|
104
|
+
| `@jielga/tmdatagrid` | Peer dependency | `^2.0.0-beta.14` | – | The two versions release together. |
|
|
105
|
+
|
|
106
|
+
See also: the `cell-selection` skill for the grid's own export options, the
|
|
107
|
+
clipboard and `exportGrid`.
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import ExcelJS from "exceljs";
|
|
2
|
+
import type {
|
|
3
|
+
TMDataGridExportData,
|
|
4
|
+
TMDataGridExportFormat,
|
|
5
|
+
TMDataGridExportWriteOptions,
|
|
6
|
+
} from "@jielga/tmdatagrid";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How wide a column may get from `autoWidth`. A long free-text cell would
|
|
10
|
+
* otherwise push a column past the width of the window, and a column nobody
|
|
11
|
+
* can see past is worse than one that clips.
|
|
12
|
+
*/
|
|
13
|
+
const MAX_COLUMN_WIDTH = 60;
|
|
14
|
+
|
|
15
|
+
/** Narrow columns still need room for their header and the filter arrow. */
|
|
16
|
+
const MIN_COLUMN_WIDTH = 10;
|
|
17
|
+
|
|
18
|
+
/** Excel measures a column in characters, and a cell needs a little padding. */
|
|
19
|
+
const WIDTH_PADDING = 2;
|
|
20
|
+
|
|
21
|
+
const MIME_TYPE =
|
|
22
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
23
|
+
|
|
24
|
+
/** A date at midnight is a date; anything else is a moment in time. */
|
|
25
|
+
function isMidnight(value: Date): boolean {
|
|
26
|
+
return (
|
|
27
|
+
value.getHours() === 0 &&
|
|
28
|
+
value.getMinutes() === 0 &&
|
|
29
|
+
value.getSeconds() === 0 &&
|
|
30
|
+
value.getMilliseconds() === 0
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function dateNumberFormat(value: Date): string {
|
|
35
|
+
return isMidnight(value) ? "yyyy-mm-dd" : "yyyy-mm-dd hh:mm";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The date as its number format renders it, for a width and for a joined array. */
|
|
39
|
+
function dateText(value: Date): string {
|
|
40
|
+
const text = value.toLocaleString("sv-SE");
|
|
41
|
+
return isMidnight(value) ? text.slice(0, 10) : text.slice(0, 16);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A value as text, for the cells a spreadsheet has no type for and for the
|
|
46
|
+
* width measurement.
|
|
47
|
+
*
|
|
48
|
+
* Deliberately not `formatExportValue` from the grid: that one writes a
|
|
49
|
+
* decimal comma and an `sv-SE` date, both of which would be wrong here. A
|
|
50
|
+
* typed cell carries the value itself and Excel renders it in the reader's own
|
|
51
|
+
* locale, so only the untypable values reach this.
|
|
52
|
+
*/
|
|
53
|
+
function cellText(value: unknown): string {
|
|
54
|
+
if (value === null || value === undefined) return "";
|
|
55
|
+
if (typeof value === "number") return Number.isFinite(value) ? String(value) : "";
|
|
56
|
+
if (typeof value === "string") return value;
|
|
57
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
58
|
+
if (value instanceof Date) {
|
|
59
|
+
return Number.isNaN(value.getTime()) ? "" : dateText(value);
|
|
60
|
+
}
|
|
61
|
+
// A multiSelect cell holds an array of values; a spreadsheet cell holds one
|
|
62
|
+
// string, so the elements are joined the way they read on screen.
|
|
63
|
+
if (Array.isArray(value)) {
|
|
64
|
+
return value.map((entry) => cellText(entry)).join(", ");
|
|
65
|
+
}
|
|
66
|
+
// An object in a cell is a shape the addon cannot know. JSON at least keeps
|
|
67
|
+
// what was there, where `String(value)` would write "[object Object]".
|
|
68
|
+
if (typeof value === "object") {
|
|
69
|
+
try {
|
|
70
|
+
return JSON.stringify(value);
|
|
71
|
+
} catch {
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return String(value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* What goes into the worksheet cell: a number, a date, a boolean or a string.
|
|
80
|
+
*
|
|
81
|
+
* `null` for everything empty, which is what exceljs writes as a blank cell -
|
|
82
|
+
* an empty string would be a cell holding text of length zero, and a formula
|
|
83
|
+
* counting it would count it.
|
|
84
|
+
*/
|
|
85
|
+
function cellValue(value: unknown): number | Date | boolean | string | null {
|
|
86
|
+
if (value === null || value === undefined) return null;
|
|
87
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
88
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
|
89
|
+
if (typeof value === "boolean") return value;
|
|
90
|
+
return cellText(value);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type TMDataGridXlsxFormatOptions = {
|
|
94
|
+
/** Worksheet name. Defaults to `"Sheet1"`. */
|
|
95
|
+
sheetName?: string;
|
|
96
|
+
/** Column widths from the longest text in each column, capped at 60 characters. Defaults to `true`. */
|
|
97
|
+
autoWidth?: boolean;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A real Excel workbook rather than a CSV that Excel can open.
|
|
102
|
+
*
|
|
103
|
+
* That is the whole reason the format exists: a typed cell needs no separator,
|
|
104
|
+
* no decimal mark and no BOM to survive the trip, so nothing depends on the
|
|
105
|
+
* reader's locale. Numbers arrive as numbers, dates as dates with a number
|
|
106
|
+
* format, booleans as booleans, and a text cell can hold a semicolon or a
|
|
107
|
+
* newline without ending the row.
|
|
108
|
+
*
|
|
109
|
+
* `exceljs` is why this is a separate package: it is a large dependency, and a
|
|
110
|
+
* grid that exports CSV should not carry a spreadsheet writer.
|
|
111
|
+
*/
|
|
112
|
+
export function xlsxFormat({
|
|
113
|
+
sheetName = "Sheet1",
|
|
114
|
+
autoWidth = true,
|
|
115
|
+
}: TMDataGridXlsxFormatOptions = {}): TMDataGridExportFormat {
|
|
116
|
+
return {
|
|
117
|
+
id: "xlsx",
|
|
118
|
+
extension: "xlsx",
|
|
119
|
+
mimeType: MIME_TYPE,
|
|
120
|
+
write: async (
|
|
121
|
+
data: TMDataGridExportData,
|
|
122
|
+
{ includeHeaders }: TMDataGridExportWriteOptions,
|
|
123
|
+
): Promise<Blob> => {
|
|
124
|
+
const workbook = new ExcelJS.Workbook();
|
|
125
|
+
const sheet = workbook.addWorksheet(sheetName);
|
|
126
|
+
|
|
127
|
+
// The longest text seen per column, header included, for `autoWidth`.
|
|
128
|
+
const widths = data.headers.map((header) =>
|
|
129
|
+
includeHeaders ? header.length : 0,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
if (includeHeaders) {
|
|
133
|
+
const headerRow = sheet.addRow(data.headers);
|
|
134
|
+
headerRow.font = { bold: true };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const row of data.rows) {
|
|
138
|
+
const added = sheet.addRow(row.map((value) => cellValue(value)));
|
|
139
|
+
row.forEach((value, index) => {
|
|
140
|
+
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
|
141
|
+
added.getCell(index + 1).numFmt = dateNumberFormat(value);
|
|
142
|
+
}
|
|
143
|
+
const length = cellText(value).length;
|
|
144
|
+
if (length > (widths[index] ?? 0)) widths[index] = length;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (autoWidth) {
|
|
149
|
+
widths.forEach((width, index) => {
|
|
150
|
+
sheet.getColumn(index + 1).width = Math.min(
|
|
151
|
+
MAX_COLUMN_WIDTH,
|
|
152
|
+
Math.max(MIN_COLUMN_WIDTH, width + WIDTH_PADDING),
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return new Blob([await workbook.xlsx.writeBuffer()], { type: MIME_TYPE });
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|