@excom/data-table 0.1.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/.rush/temp/chunked-rush-logs/data-table.apply-exports.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/data-table.build_docs.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/data-table.build_package-metas.chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/all.log +1 -0
- package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/state.json +3 -0
- package/.rush/temp/operation/build_docs/all.log +1 -0
- package/.rush/temp/operation/build_docs/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_docs/state.json +3 -0
- package/.rush/temp/operation/build_package-metas/all.log +1 -0
- package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_package-metas/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +3 -0
- package/config/rig.json +5 -0
- package/data-table.ts +396 -0
- package/data-th.ts +49 -0
- package/index.css +5 -0
- package/index.ts +23 -0
- package/package.json +44 -0
- package/rush-logs/data-table.apply-exports.cache.log +1 -0
- package/rush-logs/data-table.apply-exports.log +1 -0
- package/rush-logs/data-table.build_docs.cache.log +1 -0
- package/rush-logs/data-table.build_docs.log +1 -0
- package/rush-logs/data-table.build_package-metas.cache.log +1 -0
- package/rush-logs/data-table.build_package-metas.log +1 -0
- package/src/data-table.css +124 -0
- package/support/custom-elements.json +366 -0
- package/support/demos/filter.html +67 -0
- package/support/demos/simple.html +14 -0
- package/support/dist-docs/data-table.md +245 -0
- package/support/dist-docs/data-th.md +29 -0
- package/support/docs/INTERNAL.md +7 -0
- package/support/docs/README.md +74 -0
- package/support/package-meta.json +280 -0
- package/support/tests/data-table.test.ts +704 -0
- package/support/tests/filter.view.test.ts +33 -0
- package/support/tests/simple.view.test.ts +31 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
describe,
|
|
4
|
+
expect,
|
|
5
|
+
fixture,
|
|
6
|
+
it,
|
|
7
|
+
vi,
|
|
8
|
+
wait,
|
|
9
|
+
waitForEvent,
|
|
10
|
+
} from "@excom/heft-rig/profiles/default/config/test-utils";
|
|
11
|
+
import "../../index";
|
|
12
|
+
import type { DataTableSortDetail } from "../../data-table";
|
|
13
|
+
import { invokeCommand } from "@excom/neutron";
|
|
14
|
+
|
|
15
|
+
/** Invoke `--export` from a button carrying `data` as `data-*`, and settle. */
|
|
16
|
+
const exportWith = async (
|
|
17
|
+
table: Element,
|
|
18
|
+
data: Record<string, unknown> = {},
|
|
19
|
+
) => {
|
|
20
|
+
const button = document.createElement("button");
|
|
21
|
+
Object.entries(data).forEach(([key, value]) => {
|
|
22
|
+
button.dataset[key] = String(value);
|
|
23
|
+
});
|
|
24
|
+
invokeCommand(table, "--export", button);
|
|
25
|
+
await Promise.resolve();
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const rowOrder = (tr: HTMLElement) =>
|
|
29
|
+
parseInt(tr.style.getPropertyValue("--data-tr-order") || "0", 10);
|
|
30
|
+
|
|
31
|
+
const isFilteredOut = (tr: HTMLElement) =>
|
|
32
|
+
tr.style.getPropertyValue("--data-tr-display") === "none";
|
|
33
|
+
|
|
34
|
+
describe("data-table", () => {
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
document.body.innerHTML = "";
|
|
37
|
+
vi.restoreAllMocks();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("sorts table", async () => {
|
|
41
|
+
const dataTable = fixture<HTMLDataTableElement>(
|
|
42
|
+
`<data-table>
|
|
43
|
+
<data-thead>
|
|
44
|
+
<data-tr>
|
|
45
|
+
<data-th sort-direction="desc" column-type="string">Name</data-th>
|
|
46
|
+
<data-th column-type="number">Age</data-th>
|
|
47
|
+
<data-th column-type="date">Birthday</data-th>
|
|
48
|
+
</data-tr>
|
|
49
|
+
</data-thead>
|
|
50
|
+
<data-tbody>
|
|
51
|
+
<data-tr>
|
|
52
|
+
<data-td>John Doe</data-td>
|
|
53
|
+
<data-td>49</data-td>
|
|
54
|
+
<data-td>1974-10-01</data-td>
|
|
55
|
+
</data-tr>
|
|
56
|
+
<data-tr>
|
|
57
|
+
<data-td>Jane Doe</data-td>
|
|
58
|
+
<data-td>45</data-td>
|
|
59
|
+
<data-td>1978-10-01</data-td>
|
|
60
|
+
</data-tr>
|
|
61
|
+
</data-tbody>
|
|
62
|
+
</data-table>`,
|
|
63
|
+
);
|
|
64
|
+
const ths = dataTable.querySelectorAll("data-th");
|
|
65
|
+
const dataTbody = dataTable.querySelector("data-tbody")!;
|
|
66
|
+
const dataTrs = dataTbody.querySelectorAll("data-tr");
|
|
67
|
+
const johnTr = dataTrs[0] as HTMLElement;
|
|
68
|
+
const janeTr = dataTrs[1] as HTMLElement;
|
|
69
|
+
expect(ths[0]).dom.to.equalTag(
|
|
70
|
+
`<data-th column-type="string" sort-direction="desc"></data-th>`,
|
|
71
|
+
);
|
|
72
|
+
// Default action of data-table-sort is delayed one macrotask
|
|
73
|
+
await wait(0);
|
|
74
|
+
expect(dataTbody.firstElementChild).toBe(johnTr);
|
|
75
|
+
expect(dataTbody.lastElementChild).toBe(janeTr);
|
|
76
|
+
expect(rowOrder(johnTr)).toBe(1);
|
|
77
|
+
expect(rowOrder(janeTr)).toBe(2);
|
|
78
|
+
|
|
79
|
+
// Sort by name asc, by clicking the header
|
|
80
|
+
await waitForEvent(dataTable, "data-table-sort", () => {
|
|
81
|
+
ths[0].dispatchEvent(new CustomEvent("click", { bubbles: true }));
|
|
82
|
+
});
|
|
83
|
+
await wait(0);
|
|
84
|
+
expect(ths[0]).dom.to.equalTag(
|
|
85
|
+
`<data-th column-type="string" sort-direction="asc"></data-th>`,
|
|
86
|
+
);
|
|
87
|
+
expect(dataTbody.firstElementChild).toBe(johnTr);
|
|
88
|
+
expect(rowOrder(janeTr)).toBe(1);
|
|
89
|
+
expect(rowOrder(johnTr)).toBe(2);
|
|
90
|
+
|
|
91
|
+
// sort by age asc
|
|
92
|
+
await waitForEvent(dataTable, "data-table-sort", () => {
|
|
93
|
+
ths[1].dispatchEvent(new CustomEvent("click", { bubbles: true }));
|
|
94
|
+
});
|
|
95
|
+
await wait(0);
|
|
96
|
+
expect(ths[1]).dom.to.equalTag(
|
|
97
|
+
`<data-th column-type="number" sort-direction="asc"></data-th>`,
|
|
98
|
+
);
|
|
99
|
+
expect(rowOrder(janeTr)).toBe(1);
|
|
100
|
+
expect(rowOrder(johnTr)).toBe(2);
|
|
101
|
+
|
|
102
|
+
// sort by age desc
|
|
103
|
+
await waitForEvent(dataTable, "data-table-sort", () => {
|
|
104
|
+
ths[1].dispatchEvent(new CustomEvent("click", { bubbles: true }));
|
|
105
|
+
});
|
|
106
|
+
await wait(0);
|
|
107
|
+
expect(ths[1]).dom.to.equalTag(
|
|
108
|
+
`<data-th column-type="number" sort-direction="desc"></data-th>`,
|
|
109
|
+
);
|
|
110
|
+
expect(rowOrder(johnTr)).toBe(1);
|
|
111
|
+
expect(rowOrder(janeTr)).toBe(2);
|
|
112
|
+
|
|
113
|
+
// sort by birthday asc
|
|
114
|
+
await waitForEvent(dataTable, "data-table-sort", () => {
|
|
115
|
+
ths[2].dispatchEvent(new CustomEvent("click", { bubbles: true }));
|
|
116
|
+
});
|
|
117
|
+
await wait(0);
|
|
118
|
+
expect(ths[2]).dom.to.equalTag(
|
|
119
|
+
`<data-th column-type="date" sort-direction="asc"></data-th>`,
|
|
120
|
+
);
|
|
121
|
+
expect(rowOrder(janeTr)).toBe(1);
|
|
122
|
+
expect(rowOrder(johnTr)).toBe(2);
|
|
123
|
+
|
|
124
|
+
// sort by birthday desc
|
|
125
|
+
await waitForEvent(dataTable, "data-table-sort", () => {
|
|
126
|
+
ths[2].dispatchEvent(new CustomEvent("click", { bubbles: true }));
|
|
127
|
+
});
|
|
128
|
+
await wait(0);
|
|
129
|
+
expect(ths[2]).dom.to.equalTag(
|
|
130
|
+
`<data-th column-type="date" sort-direction="desc"></data-th>`,
|
|
131
|
+
);
|
|
132
|
+
expect(rowOrder(johnTr)).toBe(1);
|
|
133
|
+
expect(rowOrder(janeTr)).toBe(2);
|
|
134
|
+
expect(dataTbody.firstElementChild).toBe(johnTr);
|
|
135
|
+
expect(dataTbody.lastElementChild).toBe(janeTr);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("filters table", async () => {
|
|
139
|
+
const dataTable = fixture<HTMLDataTableElement>(
|
|
140
|
+
`<data-table>
|
|
141
|
+
<data-thead>
|
|
142
|
+
<data-tr>
|
|
143
|
+
<data-th column-type="string">Name</data-th>
|
|
144
|
+
<data-th column-type="number">Age</data-th>
|
|
145
|
+
<data-th column-type="date">Birthday</data-th>
|
|
146
|
+
</data-tr>
|
|
147
|
+
</data-thead>
|
|
148
|
+
<data-tbody>
|
|
149
|
+
<data-tr>
|
|
150
|
+
<data-td>John Doe</data-td>
|
|
151
|
+
<data-td>49</data-td>
|
|
152
|
+
<data-td>1974-10-01</data-td>
|
|
153
|
+
</data-tr>
|
|
154
|
+
<data-tr>
|
|
155
|
+
<data-td>Jane Doe</data-td>
|
|
156
|
+
<data-td>45</data-td>
|
|
157
|
+
<data-td>1978-10-01</data-td>
|
|
158
|
+
</data-tr>
|
|
159
|
+
</data-tbody>
|
|
160
|
+
</data-table>`,
|
|
161
|
+
);
|
|
162
|
+
const dataTbody = dataTable.querySelector("data-tbody")!;
|
|
163
|
+
const dataTrs = dataTbody.querySelectorAll("data-tr");
|
|
164
|
+
const johnTr = dataTrs[0] as HTMLElement;
|
|
165
|
+
const janeTr = dataTrs[1] as HTMLElement;
|
|
166
|
+
|
|
167
|
+
expect(isFilteredOut(johnTr)).toBe(false);
|
|
168
|
+
expect(isFilteredOut(janeTr)).toBe(false);
|
|
169
|
+
|
|
170
|
+
dataTable.filterValue = "jane";
|
|
171
|
+
expect(isFilteredOut(johnTr)).toBe(true);
|
|
172
|
+
expect(isFilteredOut(janeTr)).toBe(false);
|
|
173
|
+
|
|
174
|
+
dataTable.filterCasing = true;
|
|
175
|
+
|
|
176
|
+
expect(isFilteredOut(johnTr)).toBe(true);
|
|
177
|
+
expect(isFilteredOut(janeTr)).toBe(true);
|
|
178
|
+
|
|
179
|
+
dataTable.filterValue = "John";
|
|
180
|
+
|
|
181
|
+
expect(isFilteredOut(johnTr)).toBe(false);
|
|
182
|
+
expect(isFilteredOut(janeTr)).toBe(true);
|
|
183
|
+
|
|
184
|
+
dataTable.filterValue = "john";
|
|
185
|
+
|
|
186
|
+
expect(isFilteredOut(johnTr)).toBe(true);
|
|
187
|
+
expect(isFilteredOut(janeTr)).toBe(true);
|
|
188
|
+
|
|
189
|
+
dataTable.filterCasing = false;
|
|
190
|
+
|
|
191
|
+
expect(isFilteredOut(johnTr)).toBe(false);
|
|
192
|
+
expect(isFilteredOut(janeTr)).toBe(true);
|
|
193
|
+
|
|
194
|
+
dataTable.filterValue = null;
|
|
195
|
+
|
|
196
|
+
expect(isFilteredOut(johnTr)).toBe(false);
|
|
197
|
+
expect(isFilteredOut(janeTr)).toBe(false);
|
|
198
|
+
expect(dataTbody.firstElementChild).toBe(johnTr);
|
|
199
|
+
expect(dataTbody.lastElementChild).toBe(janeTr);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("exports visible rows as csv / json", async () => {
|
|
203
|
+
const dataTable = fixture<HTMLDataTableElement>(
|
|
204
|
+
`<data-table>
|
|
205
|
+
<data-thead>
|
|
206
|
+
<data-tr>
|
|
207
|
+
<data-th column-type="string">Name</data-th>
|
|
208
|
+
<data-th column-type="number">Age</data-th>
|
|
209
|
+
</data-tr>
|
|
210
|
+
</data-thead>
|
|
211
|
+
<data-tbody>
|
|
212
|
+
<data-tr>
|
|
213
|
+
<data-td>John Doe</data-td>
|
|
214
|
+
<data-td>49</data-td>
|
|
215
|
+
</data-tr>
|
|
216
|
+
<data-tr>
|
|
217
|
+
<data-td>Jane Doe</data-td>
|
|
218
|
+
<data-td>45</data-td>
|
|
219
|
+
</data-tr>
|
|
220
|
+
</data-tbody>
|
|
221
|
+
</data-table>`,
|
|
222
|
+
);
|
|
223
|
+
dataTable.filterValue = "jane";
|
|
224
|
+
|
|
225
|
+
const csv = await captureDownload(() => exportWith(dataTable));
|
|
226
|
+
expect(csv.download).toMatch(/^export_table_.*\.csv$/);
|
|
227
|
+
expect(csv.href).toMatch(/^data:text\/csv;charset=utf-8,/);
|
|
228
|
+
const csvBody = decodeURIComponent(
|
|
229
|
+
csv.href.replace("data:text/csv;charset=utf-8,", ""),
|
|
230
|
+
);
|
|
231
|
+
expect(csvBody).toContain("Jane Doe");
|
|
232
|
+
expect(csvBody).not.toContain("John Doe");
|
|
233
|
+
expect(csvBody).toContain("Name");
|
|
234
|
+
|
|
235
|
+
const json = await captureDownload(() => exportWith(dataTable, { fileType: "json", fileName: "people" }));
|
|
236
|
+
expect(json.download).toBe("people.json");
|
|
237
|
+
const jsonBody = JSON.parse(
|
|
238
|
+
decodeURIComponent(
|
|
239
|
+
json.href.replace("data:text/json;charset=utf-8,", ""),
|
|
240
|
+
),
|
|
241
|
+
);
|
|
242
|
+
expect(jsonBody).toEqual([{ Name: "Jane Doe", Age: "45" }]);
|
|
243
|
+
|
|
244
|
+
const fullCsv = await captureDownload(() => exportWith(dataTable, { full: true }));
|
|
245
|
+
const fullBody = decodeURIComponent(
|
|
246
|
+
fullCsv.href.replace("data:text/csv;charset=utf-8,", ""),
|
|
247
|
+
);
|
|
248
|
+
expect(fullBody).toContain("John Doe");
|
|
249
|
+
expect(fullBody).toContain("Jane Doe");
|
|
250
|
+
|
|
251
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
252
|
+
const none = await captureDownload(() => exportWith(dataTable, { fileType: "xml" }));
|
|
253
|
+
expect(none.href).toBe("");
|
|
254
|
+
expect(error).toHaveBeenCalledTimes(1);
|
|
255
|
+
expect(error.mock.calls[0][0]).toMatch(/invalid data-file-type "xml"/);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
async function captureDownload(trigger: () => void | Promise<void>) {
|
|
260
|
+
const captured: { href: string; download: string } = {
|
|
261
|
+
href: "",
|
|
262
|
+
download: "",
|
|
263
|
+
};
|
|
264
|
+
const orig = HTMLAnchorElement.prototype.click;
|
|
265
|
+
HTMLAnchorElement.prototype.click = function click() {
|
|
266
|
+
captured.href = this.getAttribute("href") ?? "";
|
|
267
|
+
captured.download = this.getAttribute("download") ?? "";
|
|
268
|
+
};
|
|
269
|
+
try {
|
|
270
|
+
await trigger();
|
|
271
|
+
} finally {
|
|
272
|
+
HTMLAnchorElement.prototype.click = orig;
|
|
273
|
+
}
|
|
274
|
+
return captured;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
describe("data-table (sort edge cases)", () => {
|
|
278
|
+
afterEach(() => {
|
|
279
|
+
document.body.innerHTML = "";
|
|
280
|
+
vi.restoreAllMocks();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const twoRows = `
|
|
284
|
+
<data-tbody>
|
|
285
|
+
<data-tr>
|
|
286
|
+
<data-td>John Doe</data-td>
|
|
287
|
+
<data-td>49</data-td>
|
|
288
|
+
</data-tr>
|
|
289
|
+
<data-tr>
|
|
290
|
+
<data-td>Jane Doe</data-td>
|
|
291
|
+
<data-td>45</data-td>
|
|
292
|
+
</data-tr>
|
|
293
|
+
</data-tbody>`;
|
|
294
|
+
|
|
295
|
+
/** Build a table, attach a `data-table-sort` spy, then connect it. */
|
|
296
|
+
const connectWithSpy = async (inner: string) => {
|
|
297
|
+
const table = document.createElement("data-table") as HTMLDataTableElement;
|
|
298
|
+
table.innerHTML = inner;
|
|
299
|
+
const sortSpy = vi.fn();
|
|
300
|
+
table.addEventListener("data-table-sort", sortSpy);
|
|
301
|
+
document.body.append(table);
|
|
302
|
+
await wait(0);
|
|
303
|
+
return { table, sortSpy };
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
it("does not sort on connect without a data-tbody", async () => {
|
|
307
|
+
const { sortSpy } = await connectWithSpy(
|
|
308
|
+
`<data-thead><data-tr><data-th sort-direction="asc">Name</data-th></data-tr></data-thead>`,
|
|
309
|
+
);
|
|
310
|
+
expect(sortSpy).not.toHaveBeenCalled();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("does not sort a single-row table", async () => {
|
|
314
|
+
const { table, sortSpy } = await connectWithSpy(
|
|
315
|
+
`<data-thead><data-tr><data-th sort-direction="asc">Name</data-th></data-tr></data-thead>
|
|
316
|
+
<data-tbody><data-tr><data-td>Only</data-td></data-tr></data-tbody>`,
|
|
317
|
+
);
|
|
318
|
+
expect(sortSpy).not.toHaveBeenCalled();
|
|
319
|
+
expect(rowOrder(table.querySelector("data-tbody > data-tr")!)).toBe(0);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("does not sort on connect when no header is active", async () => {
|
|
323
|
+
const { table, sortSpy } = await connectWithSpy(
|
|
324
|
+
`<data-thead><data-tr><data-th>Name</data-th><data-th>Age</data-th></data-tr></data-thead>
|
|
325
|
+
${twoRows}`,
|
|
326
|
+
);
|
|
327
|
+
expect(sortSpy).not.toHaveBeenCalled();
|
|
328
|
+
expect(table.activeDataTh).toBe(null);
|
|
329
|
+
table.querySelectorAll("data-tbody > data-tr").forEach((tr) => {
|
|
330
|
+
expect(rowOrder(tr as HTMLElement)).toBe(0);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("sorts on connect when a header already carries sort-direction", async () => {
|
|
335
|
+
const { table, sortSpy } = await connectWithSpy(
|
|
336
|
+
`<data-thead><data-tr><data-th>Name</data-th><data-th sort-direction="asc" column-type="number">Age</data-th></data-tr></data-thead>
|
|
337
|
+
${twoRows}`,
|
|
338
|
+
);
|
|
339
|
+
expect(sortSpy).toHaveBeenCalledTimes(1);
|
|
340
|
+
const detail = sortSpy.mock.calls[0][0].detail as DataTableSortDetail;
|
|
341
|
+
expect(detail.sortDirection).toBe("asc");
|
|
342
|
+
expect(detail.columnType).toBe("number");
|
|
343
|
+
expect(detail.columnIndex).toBe(1);
|
|
344
|
+
expect(detail.rows).toHaveLength(2);
|
|
345
|
+
const [john, jane] = [
|
|
346
|
+
...table.querySelectorAll("data-tbody > data-tr"),
|
|
347
|
+
] as HTMLElement[];
|
|
348
|
+
expect(rowOrder(jane)).toBe(1);
|
|
349
|
+
expect(rowOrder(john)).toBe(2);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("defaults to desc / string when the active header has no sort-direction or column-type", async () => {
|
|
353
|
+
const { table, sortSpy } = await connectWithSpy(
|
|
354
|
+
`<data-thead><data-tr><data-th>Name</data-th><data-th>Age</data-th></data-tr></data-thead>
|
|
355
|
+
${twoRows}`,
|
|
356
|
+
);
|
|
357
|
+
const nameTh = table.querySelector("data-th")!;
|
|
358
|
+
// a `data-th-sort` that did not come from a sort-direction change
|
|
359
|
+
nameTh.dispatchEvent(new CustomEvent("data-th-sort", { bubbles: true }));
|
|
360
|
+
await wait(0);
|
|
361
|
+
expect(sortSpy).toHaveBeenCalledTimes(1);
|
|
362
|
+
const detail = sortSpy.mock.calls[0][0].detail as DataTableSortDetail;
|
|
363
|
+
expect(detail.sortDirection).toBe("desc");
|
|
364
|
+
expect(detail.columnType).toBe("string");
|
|
365
|
+
expect(detail.columnIndex).toBe(0);
|
|
366
|
+
const [john, jane] = [
|
|
367
|
+
...table.querySelectorAll("data-tbody > data-tr"),
|
|
368
|
+
] as HTMLElement[];
|
|
369
|
+
expect(rowOrder(john)).toBe(1);
|
|
370
|
+
expect(rowOrder(jane)).toBe(2);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("treats a missing cell as an empty string when sorting", async () => {
|
|
374
|
+
const dataTable = fixture<HTMLDataTableElement>(
|
|
375
|
+
`<data-table>
|
|
376
|
+
<data-thead>
|
|
377
|
+
<data-tr>
|
|
378
|
+
<data-th>Name</data-th>
|
|
379
|
+
<data-th>Age</data-th>
|
|
380
|
+
</data-tr>
|
|
381
|
+
</data-thead>
|
|
382
|
+
<data-tbody>
|
|
383
|
+
<data-tr>
|
|
384
|
+
<data-td>Short row</data-td>
|
|
385
|
+
</data-tr>
|
|
386
|
+
<data-tr>
|
|
387
|
+
<data-td>Jane Doe</data-td>
|
|
388
|
+
<data-td>45</data-td>
|
|
389
|
+
</data-tr>
|
|
390
|
+
<data-tr>
|
|
391
|
+
<data-td>Other short row</data-td>
|
|
392
|
+
</data-tr>
|
|
393
|
+
</data-tbody>
|
|
394
|
+
</data-table>`,
|
|
395
|
+
);
|
|
396
|
+
const ths = dataTable.querySelectorAll("data-th");
|
|
397
|
+
const [short, jane, otherShort] = [
|
|
398
|
+
...dataTable.querySelectorAll("data-tbody > data-tr"),
|
|
399
|
+
] as HTMLElement[];
|
|
400
|
+
await waitForEvent(dataTable, "data-table-sort", () => {
|
|
401
|
+
ths[1].dispatchEvent(new CustomEvent("click", { bubbles: true }));
|
|
402
|
+
});
|
|
403
|
+
await wait(0);
|
|
404
|
+
expect(ths[1].getAttribute("sort-direction")).toBe("asc");
|
|
405
|
+
// "" sorts before "45"; the two empty cells keep their relative order
|
|
406
|
+
expect(rowOrder(short)).toBe(1);
|
|
407
|
+
expect(rowOrder(otherShort)).toBe(2);
|
|
408
|
+
expect(rowOrder(jane)).toBe(3);
|
|
409
|
+
// DOM order is untouched
|
|
410
|
+
expect(dataTable.querySelector("data-tbody")!.firstElementChild).toBe(
|
|
411
|
+
short,
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("skips the default sort when data-table-sort is prevented", async () => {
|
|
416
|
+
const dataTable = fixture<HTMLDataTableElement>(
|
|
417
|
+
`<data-table>
|
|
418
|
+
<data-thead><data-tr><data-th>Name</data-th><data-th>Age</data-th></data-tr></data-thead>
|
|
419
|
+
${twoRows}
|
|
420
|
+
</data-table>`,
|
|
421
|
+
);
|
|
422
|
+
dataTable.addEventListener("data-table-sort", (e) => e.preventDefault());
|
|
423
|
+
const ths = dataTable.querySelectorAll("data-th");
|
|
424
|
+
await waitForEvent(dataTable, "data-table-sort", () => {
|
|
425
|
+
ths[0].dispatchEvent(new CustomEvent("click", { bubbles: true }));
|
|
426
|
+
});
|
|
427
|
+
await wait(0);
|
|
428
|
+
expect(ths[0].getAttribute("sort-direction")).toBe("asc");
|
|
429
|
+
expect(dataTable.activeDataTh).toBe(ths[0]);
|
|
430
|
+
dataTable.querySelectorAll("data-tbody > data-tr").forEach((tr) => {
|
|
431
|
+
expect(
|
|
432
|
+
(tr as HTMLElement).style.getPropertyValue("--data-tr-order"),
|
|
433
|
+
).toBe("");
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("ignores a data-table-sort without rows", async () => {
|
|
438
|
+
const dataTable = fixture<HTMLDataTableElement>(
|
|
439
|
+
`<data-table>
|
|
440
|
+
<data-thead><data-tr><data-th>Name</data-th></data-tr></data-thead>
|
|
441
|
+
${twoRows}
|
|
442
|
+
</data-table>`,
|
|
443
|
+
);
|
|
444
|
+
dataTable.dispatchEvent(
|
|
445
|
+
new CustomEvent("data-table-sort", {
|
|
446
|
+
cancelable: true,
|
|
447
|
+
detail: { columnIndex: 0, sortFn: () => 0, rows: [] },
|
|
448
|
+
}),
|
|
449
|
+
);
|
|
450
|
+
dataTable.dispatchEvent(
|
|
451
|
+
new CustomEvent("data-table-sort", { cancelable: true, detail: {} }),
|
|
452
|
+
);
|
|
453
|
+
await wait(0);
|
|
454
|
+
dataTable.querySelectorAll("data-tbody > data-tr").forEach((tr) => {
|
|
455
|
+
expect(rowOrder(tr as HTMLElement)).toBe(0);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("moves the active header and clears sort-direction on the previous one", async () => {
|
|
460
|
+
const dataTable = fixture<HTMLDataTableElement>(
|
|
461
|
+
`<data-table>
|
|
462
|
+
<data-thead><data-tr><data-th sort-direction="desc">Name</data-th><data-th>Age</data-th></data-tr></data-thead>
|
|
463
|
+
${twoRows}
|
|
464
|
+
</data-table>`,
|
|
465
|
+
);
|
|
466
|
+
const ths = dataTable.querySelectorAll("data-th");
|
|
467
|
+
await wait(0);
|
|
468
|
+
expect(dataTable.activeDataTh).toBe(ths[0]);
|
|
469
|
+
await waitForEvent(dataTable, "data-table-sort", () => {
|
|
470
|
+
ths[1].dispatchEvent(new CustomEvent("click", { bubbles: true }));
|
|
471
|
+
});
|
|
472
|
+
await wait(0);
|
|
473
|
+
expect(dataTable.activeDataTh).toBe(ths[1]);
|
|
474
|
+
expect(ths[0]).dom.to.equalTag(`<data-th></data-th>`);
|
|
475
|
+
expect(ths[1]).dom.to.equalTag(`<data-th sort-direction="asc"></data-th>`);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
describe("data-table (provision)", () => {
|
|
480
|
+
afterEach(() => {
|
|
481
|
+
document.body.innerHTML = "";
|
|
482
|
+
vi.restoreAllMocks();
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const buildTable = (nameThAttrs = "") =>
|
|
486
|
+
fixture<HTMLDataTableElement>(
|
|
487
|
+
`<data-table>
|
|
488
|
+
<data-thead>
|
|
489
|
+
<data-tr>
|
|
490
|
+
<data-th column-type="string" ${nameThAttrs}>Name</data-th>
|
|
491
|
+
<data-th column-type="number">Age</data-th>
|
|
492
|
+
</data-tr>
|
|
493
|
+
</data-thead>
|
|
494
|
+
<data-tbody>
|
|
495
|
+
<data-tr>
|
|
496
|
+
<data-td>John Doe</data-td>
|
|
497
|
+
<data-td>49</data-td>
|
|
498
|
+
</data-tr>
|
|
499
|
+
<data-tr>
|
|
500
|
+
<data-td>Jane Doe</data-td>
|
|
501
|
+
<data-td>45</data-td>
|
|
502
|
+
</data-tr>
|
|
503
|
+
<data-tr>
|
|
504
|
+
<data-td>Ada Lovelace</data-td>
|
|
505
|
+
<data-td>36</data-td>
|
|
506
|
+
</data-tr>
|
|
507
|
+
</data-tbody>
|
|
508
|
+
</data-table>`,
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
it("reports the row count and no sort / filter after connect", async () => {
|
|
512
|
+
const dataTable = buildTable();
|
|
513
|
+
expect(dataTable.provision).toEqual({
|
|
514
|
+
totalRows: 3,
|
|
515
|
+
visibleRows: 3,
|
|
516
|
+
sortColumnIndex: null,
|
|
517
|
+
sortDirection: null,
|
|
518
|
+
filterValue: null,
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("reports an initial sort-direction header on connect", async () => {
|
|
523
|
+
const dataTable = buildTable(`sort-direction="asc"`);
|
|
524
|
+
expect(dataTable.provision).toEqual(
|
|
525
|
+
expect.objectContaining({ sortColumnIndex: 0, sortDirection: "asc" }),
|
|
526
|
+
);
|
|
527
|
+
await wait(0);
|
|
528
|
+
expect(dataTable.provision?.totalRows).toBe(3);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("updates the sort facts after a header click", async () => {
|
|
532
|
+
const dataTable = buildTable();
|
|
533
|
+
const ths = dataTable.querySelectorAll("data-th");
|
|
534
|
+
const provisionSpy = vi.fn();
|
|
535
|
+
dataTable.addEventListener("neutron-provision", provisionSpy);
|
|
536
|
+
|
|
537
|
+
await waitForEvent(dataTable, "data-table-sort", () => {
|
|
538
|
+
ths[1].dispatchEvent(new CustomEvent("click", { bubbles: true }));
|
|
539
|
+
});
|
|
540
|
+
// the default action runs one macrotask later
|
|
541
|
+
await wait(0);
|
|
542
|
+
expect(dataTable.provision).toEqual({
|
|
543
|
+
totalRows: 3,
|
|
544
|
+
visibleRows: 3,
|
|
545
|
+
sortColumnIndex: 1,
|
|
546
|
+
sortDirection: "asc",
|
|
547
|
+
filterValue: null,
|
|
548
|
+
});
|
|
549
|
+
expect(provisionSpy).toHaveBeenCalledTimes(1);
|
|
550
|
+
|
|
551
|
+
await waitForEvent(dataTable, "data-table-sort", () => {
|
|
552
|
+
ths[1].dispatchEvent(new CustomEvent("click", { bubbles: true }));
|
|
553
|
+
});
|
|
554
|
+
await wait(0);
|
|
555
|
+
expect(dataTable.provision).toEqual(
|
|
556
|
+
expect.objectContaining({ sortColumnIndex: 1, sortDirection: "desc" }),
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
await waitForEvent(dataTable, "data-table-sort", () => {
|
|
560
|
+
ths[0].dispatchEvent(new CustomEvent("click", { bubbles: true }));
|
|
561
|
+
});
|
|
562
|
+
await wait(0);
|
|
563
|
+
expect(dataTable.provision).toEqual(
|
|
564
|
+
expect.objectContaining({ sortColumnIndex: 0, sortDirection: "asc" }),
|
|
565
|
+
);
|
|
566
|
+
expect(provisionSpy).toHaveBeenCalledTimes(3);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it("counts visible rows after a filter and again after clearing it", async () => {
|
|
570
|
+
const dataTable = buildTable();
|
|
571
|
+
|
|
572
|
+
dataTable.filterValue = "doe";
|
|
573
|
+
expect(dataTable.provision).toEqual({
|
|
574
|
+
totalRows: 3,
|
|
575
|
+
visibleRows: 2,
|
|
576
|
+
sortColumnIndex: null,
|
|
577
|
+
sortDirection: null,
|
|
578
|
+
filterValue: "doe",
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
dataTable.filterValue = "ada";
|
|
582
|
+
expect(dataTable.provision).toEqual(
|
|
583
|
+
expect.objectContaining({ visibleRows: 1, filterValue: "ada" }),
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
dataTable.filterCasing = true;
|
|
587
|
+
expect(dataTable.provision).toEqual(
|
|
588
|
+
expect.objectContaining({ visibleRows: 0, filterValue: "ada" }),
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
dataTable.filterValue = "";
|
|
592
|
+
expect(dataTable.provision).toEqual({
|
|
593
|
+
totalRows: 3,
|
|
594
|
+
visibleRows: 3,
|
|
595
|
+
sortColumnIndex: null,
|
|
596
|
+
sortDirection: null,
|
|
597
|
+
filterValue: null,
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it("keeps the same object when nothing changed", async () => {
|
|
602
|
+
const dataTable = buildTable();
|
|
603
|
+
const before = dataTable.provision;
|
|
604
|
+
dataTable.filterValue = "zzz-no-match";
|
|
605
|
+
expect(dataTable.provision).not.toBe(before);
|
|
606
|
+
expect(dataTable.provision?.visibleRows).toBe(0);
|
|
607
|
+
const filtered = dataTable.provision;
|
|
608
|
+
// a filter-casing flip that hides the same rows is a no-op
|
|
609
|
+
dataTable.filterCasing = true;
|
|
610
|
+
expect(dataTable.provision).toBe(filtered);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("tolerates a table without a data-tbody", async () => {
|
|
614
|
+
const dataTable = fixture<HTMLDataTableElement>(
|
|
615
|
+
`<data-table>
|
|
616
|
+
<data-thead><data-tr><data-th>Name</data-th></data-tr></data-thead>
|
|
617
|
+
</data-table>`,
|
|
618
|
+
);
|
|
619
|
+
expect(dataTable.provision).toEqual({
|
|
620
|
+
totalRows: 0,
|
|
621
|
+
visibleRows: 0,
|
|
622
|
+
sortColumnIndex: null,
|
|
623
|
+
sortDirection: null,
|
|
624
|
+
filterValue: null,
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
describe("data-table (filter / export edge cases)", () => {
|
|
630
|
+
afterEach(() => {
|
|
631
|
+
document.body.innerHTML = "";
|
|
632
|
+
vi.restoreAllMocks();
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
const buildTable = () =>
|
|
636
|
+
fixture<HTMLDataTableElement>(
|
|
637
|
+
`<data-table>
|
|
638
|
+
<data-thead>
|
|
639
|
+
<data-tr>
|
|
640
|
+
<data-th column-type="string">Name</data-th>
|
|
641
|
+
<data-th column-type="number">Age</data-th>
|
|
642
|
+
</data-tr>
|
|
643
|
+
</data-thead>
|
|
644
|
+
<data-tbody>
|
|
645
|
+
<data-tr>
|
|
646
|
+
<data-td>John Doe</data-td>
|
|
647
|
+
<data-td>49</data-td>
|
|
648
|
+
</data-tr>
|
|
649
|
+
<data-tr>
|
|
650
|
+
<data-td>Jane Doe</data-td>
|
|
651
|
+
<data-td>45</data-td>
|
|
652
|
+
</data-tr>
|
|
653
|
+
</data-tbody>
|
|
654
|
+
</data-table>`,
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
it("exports in visual sort order, normalises file type and escapes quotes", async () => {
|
|
658
|
+
const dataTable = buildTable();
|
|
659
|
+
const ths = dataTable.querySelectorAll("data-th");
|
|
660
|
+
dataTable.querySelector("data-td")!.textContent = 'John "JD" Doe';
|
|
661
|
+
await waitForEvent(dataTable, "data-table-sort", () => {
|
|
662
|
+
ths[0].dispatchEvent(new CustomEvent("click", { bubbles: true }));
|
|
663
|
+
});
|
|
664
|
+
await wait(0);
|
|
665
|
+
const csv = await captureDownload(() => exportWith(dataTable, { fileType: " CSV ", fileName: " " }));
|
|
666
|
+
expect(csv.download).toMatch(/^export_table_.*\.csv$/);
|
|
667
|
+
const body = decodeURIComponent(
|
|
668
|
+
csv.href.replace("data:text/csv;charset=utf-8,", ""),
|
|
669
|
+
);
|
|
670
|
+
const lines = body.split("\n");
|
|
671
|
+
expect(lines[0]).toBe('"Name","Age"');
|
|
672
|
+
// asc by name: Jane before John, regardless of DOM order
|
|
673
|
+
expect(lines[1]).toBe('"Jane Doe","45"');
|
|
674
|
+
expect(lines[2]).toBe('"John ""JD"" Doe","49"');
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it("exports every row in DOM order with full even when filtered", async () => {
|
|
678
|
+
const dataTable = buildTable();
|
|
679
|
+
dataTable.filterValue = "jane";
|
|
680
|
+
const json = await captureDownload(() => exportWith(dataTable, { full: true, fileType: "json", fileName: "all" }));
|
|
681
|
+
expect(json.download).toBe("all.json");
|
|
682
|
+
expect(
|
|
683
|
+
JSON.parse(
|
|
684
|
+
decodeURIComponent(
|
|
685
|
+
json.href.replace("data:text/json;charset=utf-8,", ""),
|
|
686
|
+
),
|
|
687
|
+
),
|
|
688
|
+
).toEqual([
|
|
689
|
+
{ Name: "John Doe", Age: "49" },
|
|
690
|
+
{ Name: "Jane Doe", Age: "45" },
|
|
691
|
+
]);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it("treats full: false like an omitted full", async () => {
|
|
695
|
+
const dataTable = buildTable();
|
|
696
|
+
dataTable.filterValue = "jane";
|
|
697
|
+
const csv = await captureDownload(() => exportWith(dataTable, { full: false }));
|
|
698
|
+
const body = decodeURIComponent(
|
|
699
|
+
csv.href.replace("data:text/csv;charset=utf-8,", ""),
|
|
700
|
+
);
|
|
701
|
+
expect(body).toContain("Jane Doe");
|
|
702
|
+
expect(body).not.toContain("John Doe");
|
|
703
|
+
});
|
|
704
|
+
});
|