@gradio/dataframe 0.24.3 → 0.24.5
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/CHANGELOG.md +33 -0
- package/Dataframe.test.ts +1048 -68
- package/dist/shared/BooleanCell.svelte +1 -0
- package/dist/shared/CellMenuButton.svelte +8 -1
- package/dist/shared/CellMenuButton.svelte.d.ts +1 -0
- package/dist/shared/DataCell.svelte +26 -2
- package/dist/shared/DataCell.svelte.d.ts +4 -0
- package/dist/shared/EditableCell.svelte +1 -2
- package/dist/shared/EmptyRowButton.svelte +0 -1
- package/dist/shared/HeaderCell.svelte +17 -3
- package/dist/shared/HeaderCell.svelte.d.ts +3 -1
- package/dist/shared/Table.svelte +472 -158
- package/dist/shared/Toolbar.svelte +2 -3
- package/dist/shared/Toolbar.svelte.d.ts +1 -1
- package/dist/shared/icons/SelectionButtons.svelte +1 -0
- package/dist/shared/utils/selection_utils.d.ts +1 -1
- package/dist/shared/utils/selection_utils.js +6 -3
- package/dist/shared/utils/table_utils.d.ts +1 -1
- package/dist/shared/utils/table_utils.js +8 -5
- package/package.json +8 -8
- package/shared/BooleanCell.svelte +1 -0
- package/shared/CellMenuButton.svelte +8 -1
- package/shared/DataCell.svelte +26 -2
- package/shared/EditableCell.svelte +1 -2
- package/shared/EmptyRowButton.svelte +0 -1
- package/shared/HeaderCell.svelte +17 -3
- package/shared/Table.svelte +472 -158
- package/shared/Toolbar.svelte +2 -3
- package/shared/icons/SelectionButtons.svelte +1 -0
- package/shared/utils/selection_utils.ts +8 -3
- package/shared/utils/table_utils.ts +11 -8
- package/test/selection_utils.test.ts +40 -7
package/Dataframe.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { test, describe, afterEach, expect } from "vitest";
|
|
1
|
+
import { test, describe, afterEach, expect, vi, onTestFinished } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
cleanup,
|
|
4
4
|
render,
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
within
|
|
8
8
|
} from "@self/tootils/render";
|
|
9
9
|
import { tick } from "svelte";
|
|
10
|
+
import event from "@testing-library/user-event";
|
|
11
|
+
import { run_shared_prop_tests } from "@self/tootils/shared-prop-tests";
|
|
10
12
|
|
|
11
13
|
import Dataframe from "./Index.svelte";
|
|
12
14
|
|
|
@@ -37,6 +39,14 @@ const default_props = {
|
|
|
37
39
|
max_height: 500
|
|
38
40
|
};
|
|
39
41
|
|
|
42
|
+
run_shared_prop_tests({
|
|
43
|
+
component: Dataframe,
|
|
44
|
+
name: "Dataframe",
|
|
45
|
+
base_props: default_props,
|
|
46
|
+
has_label: false,
|
|
47
|
+
has_validation_error: false
|
|
48
|
+
});
|
|
49
|
+
|
|
40
50
|
function get_cell(container: HTMLElement, row: number, col: number) {
|
|
41
51
|
return container.querySelector(
|
|
42
52
|
`[data-row='${row}'][data-col='${col}']`
|
|
@@ -61,6 +71,29 @@ async function wait(ms = 50) {
|
|
|
61
71
|
await tick();
|
|
62
72
|
}
|
|
63
73
|
|
|
74
|
+
async function sort_column(
|
|
75
|
+
container: HTMLElement,
|
|
76
|
+
header_text: string,
|
|
77
|
+
direction: "ascending" | "descending"
|
|
78
|
+
) {
|
|
79
|
+
const header = Array.from(get_header_cells(container)).find((h) =>
|
|
80
|
+
h.textContent?.includes(header_text)
|
|
81
|
+
) as HTMLElement;
|
|
82
|
+
expect(header).toBeTruthy();
|
|
83
|
+
|
|
84
|
+
const menu_btn = header.querySelector(".cell-menu-button") as HTMLElement;
|
|
85
|
+
expect(menu_btn).toBeTruthy();
|
|
86
|
+
await fireEvent.click(menu_btn);
|
|
87
|
+
await wait();
|
|
88
|
+
|
|
89
|
+
const sort_btn = Array.from(
|
|
90
|
+
document.querySelectorAll('[role="menuitem"]')
|
|
91
|
+
).find((el) => el.textContent?.includes(`sort_${direction}`)) as HTMLElement;
|
|
92
|
+
expect(sort_btn).toBeTruthy();
|
|
93
|
+
await fireEvent.click(sort_btn);
|
|
94
|
+
await wait();
|
|
95
|
+
}
|
|
96
|
+
|
|
64
97
|
describe("Dataframe rendering", () => {
|
|
65
98
|
afterEach(() => cleanup());
|
|
66
99
|
|
|
@@ -125,7 +158,7 @@ describe("Dataframe rendering", () => {
|
|
|
125
158
|
});
|
|
126
159
|
|
|
127
160
|
test("renders label when show_label is true", async () => {
|
|
128
|
-
const { container } = await render(Dataframe, {
|
|
161
|
+
const { container, getByRole } = await render(Dataframe, {
|
|
129
162
|
...default_props,
|
|
130
163
|
label: "Test Table",
|
|
131
164
|
show_label: true
|
|
@@ -133,6 +166,9 @@ describe("Dataframe rendering", () => {
|
|
|
133
166
|
await wait();
|
|
134
167
|
|
|
135
168
|
expect(container.textContent).toContain("Test Table");
|
|
169
|
+
const grid = getByRole("grid");
|
|
170
|
+
expect(grid).toHaveAttribute("aria-label", "Test Table");
|
|
171
|
+
expect(grid.querySelector(".sr-only")).not.toBeInTheDocument();
|
|
136
172
|
});
|
|
137
173
|
|
|
138
174
|
test("renders row numbers when show_row_numbers is true", async () => {
|
|
@@ -144,6 +180,47 @@ describe("Dataframe rendering", () => {
|
|
|
144
180
|
|
|
145
181
|
const row_number_cells = container.querySelectorAll(".row-number-cell");
|
|
146
182
|
expect(row_number_cells.length).toBeGreaterThan(0);
|
|
183
|
+
expect(get_table_wrap(container)).toHaveAttribute("aria-colcount", "4");
|
|
184
|
+
expect(container.querySelector(".row-number-header")).toHaveAttribute(
|
|
185
|
+
"aria-colindex",
|
|
186
|
+
"1"
|
|
187
|
+
);
|
|
188
|
+
expect(get_header_cells(container)[0]).toHaveAttribute(
|
|
189
|
+
"aria-colindex",
|
|
190
|
+
"2"
|
|
191
|
+
);
|
|
192
|
+
const row_header = container.querySelector('[role="rowheader"]');
|
|
193
|
+
expect(row_header).toHaveAttribute("aria-colindex", "1");
|
|
194
|
+
expect(get_cell(container, 0, 0)).toHaveAttribute("aria-colindex", "2");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("exposes grid rows through presentational layout wrappers", async () => {
|
|
198
|
+
const { getByRole } = await render(Dataframe, default_props);
|
|
199
|
+
await wait();
|
|
200
|
+
|
|
201
|
+
const grid = getByRole("grid");
|
|
202
|
+
const upload_container = grid.querySelector<HTMLElement>(
|
|
203
|
+
":scope > .upload-container"
|
|
204
|
+
)!;
|
|
205
|
+
const viewport = upload_container.querySelector<HTMLElement>(
|
|
206
|
+
":scope > .virtual-table-viewport"
|
|
207
|
+
)!;
|
|
208
|
+
const header_table = viewport.querySelector<HTMLElement>(
|
|
209
|
+
":scope > .header-table"
|
|
210
|
+
)!;
|
|
211
|
+
const header_rowgroup = header_table.querySelector(":scope > thead");
|
|
212
|
+
const virtual_body = viewport.querySelector<HTMLElement>(
|
|
213
|
+
":scope > .virtual-body"
|
|
214
|
+
)!;
|
|
215
|
+
|
|
216
|
+
expect(upload_container).toHaveAttribute("role", "none");
|
|
217
|
+
expect(upload_container).not.toHaveAttribute("tabindex");
|
|
218
|
+
expect(upload_container).not.toHaveAttribute("aria-label");
|
|
219
|
+
expect(upload_container).not.toHaveAttribute("aria-dropeffect");
|
|
220
|
+
expect(viewport).toHaveAttribute("role", "none");
|
|
221
|
+
expect(header_table).toHaveAttribute("role", "none");
|
|
222
|
+
expect(header_rowgroup).toHaveAttribute("role", "rowgroup");
|
|
223
|
+
expect(virtual_body).toHaveAttribute("role", "none");
|
|
147
224
|
});
|
|
148
225
|
|
|
149
226
|
test("renders search input when show_search is 'search'", async () => {
|
|
@@ -254,7 +331,12 @@ describe("Cell editing", () => {
|
|
|
254
331
|
});
|
|
255
332
|
|
|
256
333
|
describe("Cell selection", () => {
|
|
257
|
-
afterEach(() =>
|
|
334
|
+
afterEach(() => {
|
|
335
|
+
cleanup();
|
|
336
|
+
// a test that stubs the clipboard and then fails would otherwise leave the
|
|
337
|
+
// stub in place for everything after it
|
|
338
|
+
vi.restoreAllMocks();
|
|
339
|
+
});
|
|
258
340
|
|
|
259
341
|
test("click selects a cell", async () => {
|
|
260
342
|
const { container } = await render(Dataframe, default_props);
|
|
@@ -284,22 +366,6 @@ describe("Cell selection", () => {
|
|
|
284
366
|
expect(next_cell.className).toContain("cell-selected");
|
|
285
367
|
});
|
|
286
368
|
|
|
287
|
-
test("Tab moves to next cell", async () => {
|
|
288
|
-
const { container } = await render(Dataframe, default_props);
|
|
289
|
-
await wait();
|
|
290
|
-
|
|
291
|
-
const cell = get_cell(container, 0, 0)!;
|
|
292
|
-
await fireEvent.mouseDown(cell);
|
|
293
|
-
await wait();
|
|
294
|
-
|
|
295
|
-
const table_wrap = get_table_wrap(container)!;
|
|
296
|
-
await fireEvent.keyDown(table_wrap, { key: "Tab" });
|
|
297
|
-
await wait();
|
|
298
|
-
|
|
299
|
-
const next_cell = get_cell(container, 0, 1)!;
|
|
300
|
-
expect(next_cell.className).toContain("cell-selected");
|
|
301
|
-
});
|
|
302
|
-
|
|
303
369
|
test("shift+click selects a range", async () => {
|
|
304
370
|
const { container } = await render(Dataframe, default_props);
|
|
305
371
|
await wait();
|
|
@@ -319,6 +385,457 @@ describe("Cell selection", () => {
|
|
|
319
385
|
expect(get_cell(container, 1, 1)!.className).toContain("cell-selected");
|
|
320
386
|
});
|
|
321
387
|
|
|
388
|
+
test("a second shift+click extends from the first click, not the last", async () => {
|
|
389
|
+
const { container } = await render(Dataframe, default_props);
|
|
390
|
+
await wait();
|
|
391
|
+
|
|
392
|
+
await fireEvent.mouseDown(get_cell(container, 0, 0)!);
|
|
393
|
+
await wait();
|
|
394
|
+
await fireEvent.mouseDown(get_cell(container, 1, 0)!, { shiftKey: true });
|
|
395
|
+
await wait();
|
|
396
|
+
await fireEvent.mouseDown(get_cell(container, 2, 0)!, { shiftKey: true });
|
|
397
|
+
await wait();
|
|
398
|
+
|
|
399
|
+
expect(get_cell(container, 0, 0)!.className).toContain("cell-selected");
|
|
400
|
+
expect(get_cell(container, 1, 0)!.className).toContain("cell-selected");
|
|
401
|
+
expect(get_cell(container, 2, 0)!.className).toContain("cell-selected");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// only the even rows match "target", so every row between two visible
|
|
405
|
+
// endpoints is one the search hides
|
|
406
|
+
const search_props = {
|
|
407
|
+
...default_props,
|
|
408
|
+
value: {
|
|
409
|
+
data: Array.from({ length: 40 }, (_, i) => [
|
|
410
|
+
`row ${i}`,
|
|
411
|
+
i % 2 === 0 ? "target" : `keep-${i}`
|
|
412
|
+
]),
|
|
413
|
+
headers: ["Name", "Tag"],
|
|
414
|
+
metadata: null
|
|
415
|
+
},
|
|
416
|
+
col_count: [2, "fixed"] as [number, "fixed" | "dynamic"],
|
|
417
|
+
row_count: [40, "fixed"] as [number, "fixed" | "dynamic"],
|
|
418
|
+
show_search: "search" as const
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
function get_search_input(container: HTMLElement) {
|
|
422
|
+
return container.querySelector("input.search-input") as HTMLInputElement;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// The hidden rows are never rendered, so what Delete touches is the only way
|
|
426
|
+
// to see whether they were in the range.
|
|
427
|
+
test("shift+click range skips rows the search is hiding", async () => {
|
|
428
|
+
const { container } = await render(Dataframe, search_props);
|
|
429
|
+
await wait();
|
|
430
|
+
|
|
431
|
+
const search_input = get_search_input(container);
|
|
432
|
+
await fireEvent.input(search_input, { target: { value: "target" } });
|
|
433
|
+
await wait(100);
|
|
434
|
+
|
|
435
|
+
await fireEvent.mouseDown(get_cell(container, 0, 1)!);
|
|
436
|
+
await wait();
|
|
437
|
+
await fireEvent.mouseDown(get_cell(container, 4, 1)!, { shiftKey: true });
|
|
438
|
+
await wait();
|
|
439
|
+
|
|
440
|
+
// rows 0, 2 and 4 sit next to each other on screen, so the selection ring
|
|
441
|
+
// around them has to merge into one block
|
|
442
|
+
const middle = get_cell(container, 2, 1)!.className;
|
|
443
|
+
expect(middle).toContain("no-top");
|
|
444
|
+
expect(middle).toContain("no-bottom");
|
|
445
|
+
|
|
446
|
+
await fireEvent.keyDown(get_table_wrap(container), { key: "Delete" });
|
|
447
|
+
await wait();
|
|
448
|
+
|
|
449
|
+
await fireEvent.input(search_input, { target: { value: "" } });
|
|
450
|
+
await wait(100);
|
|
451
|
+
|
|
452
|
+
for (const row of [0, 2, 4]) {
|
|
453
|
+
expect(get_cell(container, row, 1)?.textContent?.trim()).toBe("");
|
|
454
|
+
}
|
|
455
|
+
expect(get_cell(container, 1, 1)?.textContent).toContain("keep-1");
|
|
456
|
+
expect(get_cell(container, 3, 1)?.textContent).toContain("keep-3");
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// the range itself, not just what Delete goes on to touch: clearing the
|
|
460
|
+
// query renders the skipped rows again, so the selection becomes visible
|
|
461
|
+
test("shift+click range holds only the rows the search was showing", async () => {
|
|
462
|
+
const { container } = await render(Dataframe, search_props);
|
|
463
|
+
await wait();
|
|
464
|
+
|
|
465
|
+
const search_input = get_search_input(container);
|
|
466
|
+
await fireEvent.input(search_input, { target: { value: "target" } });
|
|
467
|
+
await wait(100);
|
|
468
|
+
|
|
469
|
+
await fireEvent.mouseDown(get_cell(container, 0, 1)!);
|
|
470
|
+
await wait();
|
|
471
|
+
await fireEvent.mouseDown(get_cell(container, 4, 1)!, { shiftKey: true });
|
|
472
|
+
await wait();
|
|
473
|
+
|
|
474
|
+
await fireEvent.input(search_input, { target: { value: "" } });
|
|
475
|
+
await wait(100);
|
|
476
|
+
|
|
477
|
+
for (const row of [0, 2, 4]) {
|
|
478
|
+
expect(get_cell(container, row, 1)!.className).toContain("cell-selected");
|
|
479
|
+
}
|
|
480
|
+
for (const row of [1, 3]) {
|
|
481
|
+
expect(get_cell(container, row, 1)!.className).not.toContain(
|
|
482
|
+
"cell-selected"
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// the same data loss with the two steps swapped: the range is built while
|
|
488
|
+
// everything is on screen, and the search hides part of it afterwards
|
|
489
|
+
test("Delete leaves rows the search hid after they were selected", async () => {
|
|
490
|
+
const { container } = await render(Dataframe, search_props);
|
|
491
|
+
await wait();
|
|
492
|
+
|
|
493
|
+
await fireEvent.mouseDown(get_cell(container, 2, 1)!);
|
|
494
|
+
await wait();
|
|
495
|
+
await fireEvent.mouseDown(get_cell(container, 0, 1)!, { shiftKey: true });
|
|
496
|
+
await wait();
|
|
497
|
+
|
|
498
|
+
const search_input = get_search_input(container);
|
|
499
|
+
await fireEvent.input(search_input, { target: { value: "target" } });
|
|
500
|
+
await wait(100);
|
|
501
|
+
|
|
502
|
+
await fireEvent.keyDown(get_table_wrap(container), { key: "Delete" });
|
|
503
|
+
await wait();
|
|
504
|
+
|
|
505
|
+
await fireEvent.input(search_input, { target: { value: "" } });
|
|
506
|
+
await wait(100);
|
|
507
|
+
|
|
508
|
+
for (const row of [0, 2]) {
|
|
509
|
+
expect(get_cell(container, row, 1)?.textContent?.trim()).toBe("");
|
|
510
|
+
}
|
|
511
|
+
expect(get_cell(container, 1, 1)?.textContent).toContain("keep-1");
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("copy leaves out rows the search hid after they were selected", async () => {
|
|
515
|
+
const { container } = await render(Dataframe, search_props);
|
|
516
|
+
await wait();
|
|
517
|
+
|
|
518
|
+
await fireEvent.mouseDown(get_cell(container, 2, 1)!);
|
|
519
|
+
await wait();
|
|
520
|
+
await fireEvent.mouseDown(get_cell(container, 0, 1)!, { shiftKey: true });
|
|
521
|
+
await wait();
|
|
522
|
+
|
|
523
|
+
await fireEvent.input(get_search_input(container), {
|
|
524
|
+
target: { value: "target" }
|
|
525
|
+
});
|
|
526
|
+
await wait(100);
|
|
527
|
+
|
|
528
|
+
await fireEvent.keyDown(get_table_wrap(container), {
|
|
529
|
+
key: "c",
|
|
530
|
+
metaKey: true
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// rows 0 and 2 are on screen, row 1 holds "keep-1" and is not
|
|
534
|
+
await waitFor(async () => {
|
|
535
|
+
expect(await navigator.clipboard.readText()).toBe("target\ntarget");
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("Delete fires no events when every selected row is hidden", async () => {
|
|
540
|
+
const { container, listen } = await render(Dataframe, search_props);
|
|
541
|
+
await wait();
|
|
542
|
+
|
|
543
|
+
// build a selection holding only row 1, with the anchor left on row 0 so
|
|
544
|
+
// `handle_keydown` still runs
|
|
545
|
+
await fireEvent.mouseDown(get_cell(container, 1, 1)!, { ctrlKey: true });
|
|
546
|
+
await wait();
|
|
547
|
+
await fireEvent.mouseDown(get_cell(container, 0, 1)!, { ctrlKey: true });
|
|
548
|
+
await wait();
|
|
549
|
+
await fireEvent.mouseDown(get_cell(container, 0, 1)!, { ctrlKey: true });
|
|
550
|
+
await wait();
|
|
551
|
+
|
|
552
|
+
await fireEvent.input(get_search_input(container), {
|
|
553
|
+
target: { value: "target" }
|
|
554
|
+
});
|
|
555
|
+
await wait(100);
|
|
556
|
+
|
|
557
|
+
const change = listen("change");
|
|
558
|
+
const input = listen("input");
|
|
559
|
+
await fireEvent.keyDown(get_table_wrap(container), { key: "Delete" });
|
|
560
|
+
await wait(100);
|
|
561
|
+
|
|
562
|
+
expect(change).not.toHaveBeenCalled();
|
|
563
|
+
expect(input).not.toHaveBeenCalled();
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
// [0, 0] is already "" and [1, 1] is past the end of a short row, so neither
|
|
567
|
+
// is written and the table comes out identical
|
|
568
|
+
test("Delete fires no events when the selected cells are blank", async () => {
|
|
569
|
+
const { container, listen } = await render(Dataframe, {
|
|
570
|
+
...default_props,
|
|
571
|
+
value: {
|
|
572
|
+
data: [["", "b"], ["c"]],
|
|
573
|
+
headers: ["1", "2"],
|
|
574
|
+
metadata: null
|
|
575
|
+
},
|
|
576
|
+
col_count: [2, "fixed"] as [number, "fixed" | "dynamic"],
|
|
577
|
+
row_count: [2, "fixed"] as [number, "fixed" | "dynamic"]
|
|
578
|
+
});
|
|
579
|
+
await wait();
|
|
580
|
+
|
|
581
|
+
await fireEvent.mouseDown(get_cell(container, 0, 0)!);
|
|
582
|
+
await wait();
|
|
583
|
+
await fireEvent.mouseDown(get_cell(container, 1, 1)!, { ctrlKey: true });
|
|
584
|
+
await wait();
|
|
585
|
+
|
|
586
|
+
const change = listen("change");
|
|
587
|
+
const input = listen("input");
|
|
588
|
+
await fireEvent.keyDown(get_table_wrap(container), { key: "Delete" });
|
|
589
|
+
await wait(100);
|
|
590
|
+
|
|
591
|
+
expect(change).not.toHaveBeenCalled();
|
|
592
|
+
expect(input).not.toHaveBeenCalled();
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// `null` renders blank like "" does, but it is a value the backend sent, so
|
|
596
|
+
// Delete clears it rather than treating it as nothing to do
|
|
597
|
+
test("Delete clears a null cell", async () => {
|
|
598
|
+
const { container, listen } = await render(Dataframe, {
|
|
599
|
+
...default_props,
|
|
600
|
+
value: {
|
|
601
|
+
data: [
|
|
602
|
+
[null, "b"],
|
|
603
|
+
["c", "d"]
|
|
604
|
+
] as any,
|
|
605
|
+
headers: ["1", "2"],
|
|
606
|
+
metadata: null
|
|
607
|
+
},
|
|
608
|
+
col_count: [2, "fixed"] as [number, "fixed" | "dynamic"],
|
|
609
|
+
row_count: [2, "fixed"] as [number, "fixed" | "dynamic"]
|
|
610
|
+
});
|
|
611
|
+
await wait();
|
|
612
|
+
|
|
613
|
+
await fireEvent.mouseDown(get_cell(container, 0, 0)!);
|
|
614
|
+
await wait();
|
|
615
|
+
|
|
616
|
+
const change = listen("change");
|
|
617
|
+
await fireEvent.keyDown(get_table_wrap(container), { key: "Delete" });
|
|
618
|
+
await wait(100);
|
|
619
|
+
|
|
620
|
+
expect(change).toHaveBeenCalled();
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// the selection is data-space and survives a value update, so it can name a
|
|
624
|
+
// row that no longer exists
|
|
625
|
+
test("Delete skips selected rows a shrunk table no longer has", async () => {
|
|
626
|
+
const { container, set_data } = await render(Dataframe, search_props);
|
|
627
|
+
await wait();
|
|
628
|
+
|
|
629
|
+
// anchor on row 0, which survives the shrink, so `handle_keydown` runs
|
|
630
|
+
await fireEvent.mouseDown(get_cell(container, 4, 1)!);
|
|
631
|
+
await wait();
|
|
632
|
+
await fireEvent.mouseDown(get_cell(container, 0, 1)!, { shiftKey: true });
|
|
633
|
+
await wait();
|
|
634
|
+
|
|
635
|
+
await set_data({
|
|
636
|
+
value: {
|
|
637
|
+
data: [
|
|
638
|
+
["row 0", "target"],
|
|
639
|
+
["row 1", "keep-1"]
|
|
640
|
+
],
|
|
641
|
+
headers: ["Name", "Tag"],
|
|
642
|
+
metadata: null
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
await wait();
|
|
646
|
+
|
|
647
|
+
await fireEvent.keyDown(get_table_wrap(container), { key: "Delete" });
|
|
648
|
+
await wait(100);
|
|
649
|
+
|
|
650
|
+
// rows 2-4 are gone; reading `new_values[2]` before the visibility check
|
|
651
|
+
// threw here and left rows 0 and 1 untouched
|
|
652
|
+
expect(get_cell(container, 0, 1)?.textContent?.trim()).toBe("");
|
|
653
|
+
expect(get_cell(container, 1, 1)?.textContent?.trim()).toBe("");
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test("Ctrl+C copies a selection with a gap in its first row", async () => {
|
|
657
|
+
const { container } = await render(Dataframe, {
|
|
658
|
+
...default_props,
|
|
659
|
+
value: {
|
|
660
|
+
data: [
|
|
661
|
+
["a", "b"],
|
|
662
|
+
["c", "d"]
|
|
663
|
+
],
|
|
664
|
+
headers: ["1", "2"],
|
|
665
|
+
metadata: null
|
|
666
|
+
},
|
|
667
|
+
col_count: [2, "fixed"] as [number, "fixed" | "dynamic"],
|
|
668
|
+
row_count: [2, "fixed"] as [number, "fixed" | "dynamic"]
|
|
669
|
+
});
|
|
670
|
+
await wait();
|
|
671
|
+
|
|
672
|
+
await fireEvent.mouseDown(get_cell(container, 0, 0)!, { ctrlKey: true });
|
|
673
|
+
await wait();
|
|
674
|
+
await fireEvent.mouseDown(get_cell(container, 1, 1)!, { ctrlKey: true });
|
|
675
|
+
await wait();
|
|
676
|
+
|
|
677
|
+
await fireEvent.keyDown(get_table_wrap(container), {
|
|
678
|
+
key: "c",
|
|
679
|
+
metaKey: true
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
// row 0 holds only column 0, so taking the columns from it alone dropped
|
|
683
|
+
// "d" from the clipboard
|
|
684
|
+
await waitFor(async () => {
|
|
685
|
+
expect(await navigator.clipboard.readText()).toBe("a,\n,d");
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
// every row renders a cell per header, so the phantom cells of a short row
|
|
690
|
+
// are clickable and a selection can name one
|
|
691
|
+
test("Ctrl+C copies a selection that runs past the end of a short row", async () => {
|
|
692
|
+
const { container } = await render(Dataframe, {
|
|
693
|
+
...default_props,
|
|
694
|
+
value: {
|
|
695
|
+
data: [["a", "b"], ["c"]],
|
|
696
|
+
headers: ["1", "2"],
|
|
697
|
+
metadata: null
|
|
698
|
+
},
|
|
699
|
+
col_count: [2, "fixed"] as [number, "fixed" | "dynamic"],
|
|
700
|
+
row_count: [2, "fixed"] as [number, "fixed" | "dynamic"]
|
|
701
|
+
});
|
|
702
|
+
await wait();
|
|
703
|
+
|
|
704
|
+
await fireEvent.mouseDown(get_cell(container, 1, 0)!);
|
|
705
|
+
await wait();
|
|
706
|
+
await fireEvent.mouseDown(get_cell(container, 1, 1)!, { shiftKey: true });
|
|
707
|
+
await wait();
|
|
708
|
+
|
|
709
|
+
await fireEvent.keyDown(get_table_wrap(container), {
|
|
710
|
+
key: "c",
|
|
711
|
+
metaKey: true
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
// cell [1, 1] has no value behind it, so it copies as empty rather than
|
|
715
|
+
// throwing inside `copy_table_data`
|
|
716
|
+
await waitFor(async () => {
|
|
717
|
+
expect(await navigator.clipboard.readText()).toBe("c,");
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// the toolbar button is the reachable path here: `handle_keydown` bails out
|
|
722
|
+
// before Ctrl+C whenever the row `selected` points at is off screen
|
|
723
|
+
test("the copy button does not report success with nothing on screen to copy", async () => {
|
|
724
|
+
const { container } = await render(Dataframe, search_props);
|
|
725
|
+
await wait();
|
|
726
|
+
|
|
727
|
+
// row 1 is the only selected row, and the search is about to hide it
|
|
728
|
+
await fireEvent.mouseDown(get_cell(container, 1, 1)!);
|
|
729
|
+
await wait();
|
|
730
|
+
await fireEvent.input(get_search_input(container), {
|
|
731
|
+
target: { value: "target" }
|
|
732
|
+
});
|
|
733
|
+
await wait(100);
|
|
734
|
+
|
|
735
|
+
const copy_button = container.querySelector(
|
|
736
|
+
'[aria-label="Copy table data"]'
|
|
737
|
+
) as HTMLElement;
|
|
738
|
+
expect(copy_button).toBeTruthy();
|
|
739
|
+
await fireEvent.click(copy_button);
|
|
740
|
+
await wait(150);
|
|
741
|
+
|
|
742
|
+
expect(
|
|
743
|
+
container.querySelector('[aria-label="Copied to clipboard"]')
|
|
744
|
+
).toBeNull();
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
// a refused write already left the copied state alone, by rejecting through
|
|
748
|
+
// `Toolbar.handle_copy` before it could run; what the boolean adds is that
|
|
749
|
+
// the rejection stops there instead of floating away unhandled
|
|
750
|
+
test("a refused clipboard write is reported, not left unhandled", async () => {
|
|
751
|
+
const { container } = await render(Dataframe, default_props);
|
|
752
|
+
await wait();
|
|
753
|
+
|
|
754
|
+
const write_text = vi
|
|
755
|
+
.spyOn(navigator.clipboard, "writeText")
|
|
756
|
+
.mockRejectedValue(new Error("denied"));
|
|
757
|
+
const unhandled = vi.fn();
|
|
758
|
+
window.addEventListener("unhandledrejection", unhandled);
|
|
759
|
+
// not at the end of the body: an assertion below would skip it and leak
|
|
760
|
+
// the listener into every later test
|
|
761
|
+
onTestFinished(() =>
|
|
762
|
+
window.removeEventListener("unhandledrejection", unhandled)
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
const copy_button = container.querySelector(
|
|
766
|
+
'[aria-label="Copy table data"]'
|
|
767
|
+
) as HTMLElement;
|
|
768
|
+
await fireEvent.click(copy_button);
|
|
769
|
+
await wait(150);
|
|
770
|
+
|
|
771
|
+
expect(write_text).toHaveBeenCalled();
|
|
772
|
+
expect(
|
|
773
|
+
container.querySelector('[aria-label="Copied to clipboard"]')
|
|
774
|
+
).toBeNull();
|
|
775
|
+
expect(unhandled).not.toHaveBeenCalled();
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test("shift+click falls back to one cell when the anchor is filtered out", async () => {
|
|
779
|
+
const { container } = await render(Dataframe, search_props);
|
|
780
|
+
await wait();
|
|
781
|
+
|
|
782
|
+
// row 1 does not match "target", so the search is about to hide it
|
|
783
|
+
await fireEvent.mouseDown(get_cell(container, 1, 1)!);
|
|
784
|
+
await wait();
|
|
785
|
+
|
|
786
|
+
await fireEvent.input(get_search_input(container), {
|
|
787
|
+
target: { value: "target" }
|
|
788
|
+
});
|
|
789
|
+
await wait(100);
|
|
790
|
+
|
|
791
|
+
await fireEvent.mouseDown(get_cell(container, 4, 1)!, { shiftKey: true });
|
|
792
|
+
await wait();
|
|
793
|
+
|
|
794
|
+
expect(container.querySelectorAll(".cell-selected")).toHaveLength(1);
|
|
795
|
+
expect(get_cell(container, 4, 1)!.className).toContain("cell-selected");
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
test("shift+click range follows the sorted order, not the data order", async () => {
|
|
799
|
+
const { container } = await render(Dataframe, {
|
|
800
|
+
...default_props,
|
|
801
|
+
value: {
|
|
802
|
+
data: [
|
|
803
|
+
["A", "1"],
|
|
804
|
+
["B", "3"],
|
|
805
|
+
["C", "2"],
|
|
806
|
+
["D", "4"]
|
|
807
|
+
],
|
|
808
|
+
headers: ["Name", "Age"],
|
|
809
|
+
metadata: null
|
|
810
|
+
},
|
|
811
|
+
// the header menu, and so the sort control, only renders for dynamic columns
|
|
812
|
+
col_count: [2, "dynamic"] as [number, "fixed" | "dynamic"],
|
|
813
|
+
row_count: [4, "fixed"] as [number, "fixed" | "dynamic"]
|
|
814
|
+
});
|
|
815
|
+
await wait();
|
|
816
|
+
|
|
817
|
+
await sort_column(container, "Age", "descending");
|
|
818
|
+
|
|
819
|
+
// screen order is now D(4) B(3) C(2) A(1), so the span from D to B holds
|
|
820
|
+
// only those two even though data rows 3 and 1 straddle row 2
|
|
821
|
+
await fireEvent.mouseDown(get_cell(container, 3, 0)!);
|
|
822
|
+
await wait();
|
|
823
|
+
await fireEvent.mouseDown(get_cell(container, 1, 0)!, { shiftKey: true });
|
|
824
|
+
await wait();
|
|
825
|
+
|
|
826
|
+
// D and B are the top and bottom of the block on screen, so the outer
|
|
827
|
+
// edges are drawn and the two facing each other are suppressed
|
|
828
|
+
expect(get_cell(container, 3, 0)!.className).not.toContain("no-top");
|
|
829
|
+
expect(get_cell(container, 3, 0)!.className).toContain("no-bottom");
|
|
830
|
+
expect(get_cell(container, 1, 0)!.className).toContain("no-top");
|
|
831
|
+
expect(get_cell(container, 1, 0)!.className).not.toContain("no-bottom");
|
|
832
|
+
|
|
833
|
+
expect(container.querySelectorAll(".cell-selected")).toHaveLength(2);
|
|
834
|
+
expect(get_cell(container, 3, 0)!.className).toContain("cell-selected");
|
|
835
|
+
expect(get_cell(container, 1, 0)!.className).toContain("cell-selected");
|
|
836
|
+
expect(get_cell(container, 2, 0)!.className).not.toContain("cell-selected");
|
|
837
|
+
});
|
|
838
|
+
|
|
322
839
|
// Regression: pressing Ctrl between mousedown and mouseup must not call
|
|
323
840
|
// handle_cell_click a second time via the click event. If it did, the
|
|
324
841
|
// Ctrl+click path would toggle the already-selected cell back off,
|
|
@@ -345,6 +862,389 @@ describe("Cell selection", () => {
|
|
|
345
862
|
});
|
|
346
863
|
});
|
|
347
864
|
|
|
865
|
+
describe("Keyboard accessibility", () => {
|
|
866
|
+
const external_buttons: HTMLButtonElement[] = [];
|
|
867
|
+
|
|
868
|
+
function append_external_button(text: string): HTMLButtonElement {
|
|
869
|
+
const button = document.createElement("button");
|
|
870
|
+
button.textContent = text;
|
|
871
|
+
document.body.appendChild(button);
|
|
872
|
+
external_buttons.push(button);
|
|
873
|
+
return button;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
afterEach(() => {
|
|
877
|
+
cleanup();
|
|
878
|
+
external_buttons.forEach((button) => button.remove());
|
|
879
|
+
external_buttons.length = 0;
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
const navigation_props = {
|
|
883
|
+
...default_props,
|
|
884
|
+
buttons: [] as string[]
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
test("Tab bypasses the grid container and enters on a single active cell", async () => {
|
|
888
|
+
const before = append_external_button("Before dataframe");
|
|
889
|
+
|
|
890
|
+
const { getByRole, getByTestId } = await render(
|
|
891
|
+
Dataframe,
|
|
892
|
+
navigation_props
|
|
893
|
+
);
|
|
894
|
+
const first_cell = await waitFor(() => getByTestId("cell-0-0"));
|
|
895
|
+
const second_cell = getByTestId("cell-0-1");
|
|
896
|
+
|
|
897
|
+
before.focus();
|
|
898
|
+
await event.tab();
|
|
899
|
+
|
|
900
|
+
expect(first_cell).toHaveFocus();
|
|
901
|
+
expect(first_cell).toHaveAttribute("tabindex", "0");
|
|
902
|
+
expect(second_cell).toHaveAttribute("tabindex", "-1");
|
|
903
|
+
const grid = getByRole("grid");
|
|
904
|
+
expect(grid).toHaveAttribute("tabindex", "-1");
|
|
905
|
+
expect(grid).not.toHaveFocus();
|
|
906
|
+
expect(grid).toHaveAttribute("aria-rowcount", "4");
|
|
907
|
+
expect(grid).toHaveAttribute("aria-colcount", "3");
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
test("Tab and Shift+Tab leave the grid in navigation mode", async () => {
|
|
911
|
+
const before = append_external_button("Before dataframe");
|
|
912
|
+
|
|
913
|
+
const { getByTestId } = await render(Dataframe, navigation_props);
|
|
914
|
+
const after = append_external_button("After dataframe");
|
|
915
|
+
const first_cell = await waitFor(() => getByTestId("cell-0-0"));
|
|
916
|
+
|
|
917
|
+
first_cell.focus();
|
|
918
|
+
await event.tab();
|
|
919
|
+
expect(after).toHaveFocus();
|
|
920
|
+
|
|
921
|
+
first_cell.focus();
|
|
922
|
+
await event.tab({ shift: true });
|
|
923
|
+
expect(before).toHaveFocus();
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
test("arrow keys move DOM focus between cells and headers", async () => {
|
|
927
|
+
const { getByTestId } = await render(Dataframe, navigation_props);
|
|
928
|
+
const first_cell = await waitFor(() => getByTestId("cell-0-0"));
|
|
929
|
+
const second_cell = getByTestId("cell-0-1");
|
|
930
|
+
const second_header = getByTestId("header-1");
|
|
931
|
+
|
|
932
|
+
first_cell.focus();
|
|
933
|
+
await event.keyboard("{ArrowRight}");
|
|
934
|
+
expect(second_cell).toHaveFocus();
|
|
935
|
+
|
|
936
|
+
await event.keyboard("{ArrowUp}");
|
|
937
|
+
expect(second_header).toHaveFocus();
|
|
938
|
+
|
|
939
|
+
await event.keyboard("{ArrowDown}");
|
|
940
|
+
expect(second_cell).toHaveFocus();
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
test("Space does not change a static boolean column from its header", async () => {
|
|
944
|
+
const { getByTestId, listen } = await render(Dataframe, {
|
|
945
|
+
...navigation_props,
|
|
946
|
+
value: {
|
|
947
|
+
data: [[true], [false], [true]],
|
|
948
|
+
headers: ["Flag"],
|
|
949
|
+
metadata: null
|
|
950
|
+
},
|
|
951
|
+
datatype: ["bool"] as const,
|
|
952
|
+
static_columns: [0],
|
|
953
|
+
col_count: [1, "fixed"] as [number, "fixed"],
|
|
954
|
+
row_count: [3, "fixed"] as [number, "fixed"]
|
|
955
|
+
});
|
|
956
|
+
const change = listen("change");
|
|
957
|
+
const cell = await waitFor(() => getByTestId("cell-0-0"));
|
|
958
|
+
const header = getByTestId("header-0");
|
|
959
|
+
|
|
960
|
+
cell.focus();
|
|
961
|
+
await event.keyboard("{ArrowUp}");
|
|
962
|
+
expect(header).toHaveFocus();
|
|
963
|
+
await event.keyboard(" ");
|
|
964
|
+
|
|
965
|
+
expect(change).not.toHaveBeenCalled();
|
|
966
|
+
expect(header).toHaveFocus();
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
test("static headers named by label never enter edit mode", async () => {
|
|
970
|
+
const { getByTestId, queryByRole } = await render(Dataframe, {
|
|
971
|
+
...navigation_props,
|
|
972
|
+
static_columns: ["Name"]
|
|
973
|
+
});
|
|
974
|
+
const cell = await waitFor(() => getByTestId("cell-0-0"));
|
|
975
|
+
const header = getByTestId("header-0");
|
|
976
|
+
|
|
977
|
+
cell.focus();
|
|
978
|
+
await event.keyboard("{ArrowUp}{Enter}");
|
|
979
|
+
expect(queryByRole("textbox", { name: "Cell is read-only" })).toBeNull();
|
|
980
|
+
expect(header).toHaveFocus();
|
|
981
|
+
|
|
982
|
+
await fireEvent.click(header);
|
|
983
|
+
await waitFor(() => expect(header).toHaveFocus());
|
|
984
|
+
expect(queryByRole("textbox", { name: "Cell is read-only" })).toBeNull();
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
test("clicking a read-only header selects and focuses it", async () => {
|
|
988
|
+
const { getByTestId, queryByRole } = await render(Dataframe, {
|
|
989
|
+
...navigation_props,
|
|
990
|
+
interactive: false,
|
|
991
|
+
editable: false
|
|
992
|
+
});
|
|
993
|
+
const header = await waitFor(() => getByTestId("header-0"));
|
|
994
|
+
|
|
995
|
+
await fireEvent.click(header);
|
|
996
|
+
|
|
997
|
+
await waitFor(() => expect(header).toHaveFocus());
|
|
998
|
+
expect(header).toHaveAttribute("tabindex", "0");
|
|
999
|
+
expect(queryByRole("textbox")).toBeNull();
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
test("restores a cell tab stop when the selected header is removed", async () => {
|
|
1003
|
+
const result = await render(Dataframe, navigation_props);
|
|
1004
|
+
const last_cell = await waitFor(() => result.getByTestId("cell-0-2"));
|
|
1005
|
+
|
|
1006
|
+
last_cell.focus();
|
|
1007
|
+
await event.keyboard("{ArrowUp}");
|
|
1008
|
+
expect(result.getByTestId("header-2")).toHaveFocus();
|
|
1009
|
+
|
|
1010
|
+
await result.set_data({
|
|
1011
|
+
value: {
|
|
1012
|
+
data: [["Alice"], ["Bob"], ["Carol"]],
|
|
1013
|
+
headers: ["Name"],
|
|
1014
|
+
metadata: null
|
|
1015
|
+
},
|
|
1016
|
+
col_count: [1, "fixed"]
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
const first_cell = await waitFor(() => result.getByTestId("cell-0-0"));
|
|
1020
|
+
expect(first_cell).toHaveAttribute("tabindex", "0");
|
|
1021
|
+
expect(
|
|
1022
|
+
result.getByRole("grid").querySelectorAll('[tabindex="0"]')
|
|
1023
|
+
).toHaveLength(1);
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
test("link key events are handled by the link instead of grid navigation", async () => {
|
|
1027
|
+
const { getByTestId, queryByRole } = await render(Dataframe, {
|
|
1028
|
+
...navigation_props,
|
|
1029
|
+
value: {
|
|
1030
|
+
data: [['<a href="#model">Model</a>', "Details"]],
|
|
1031
|
+
headers: ["Model", "Details"],
|
|
1032
|
+
metadata: null
|
|
1033
|
+
},
|
|
1034
|
+
datatype: ["markdown", "str"] as const,
|
|
1035
|
+
col_count: [2, "fixed"] as [number, "fixed"],
|
|
1036
|
+
row_count: [1, "fixed"] as [number, "fixed"]
|
|
1037
|
+
});
|
|
1038
|
+
const first_cell = await waitFor(() => getByTestId("cell-0-0"));
|
|
1039
|
+
const second_cell = getByTestId("cell-0-1");
|
|
1040
|
+
const link = await waitFor(() => within(first_cell).getByRole("link"));
|
|
1041
|
+
|
|
1042
|
+
first_cell.focus();
|
|
1043
|
+
await event.tab();
|
|
1044
|
+
expect(link).toHaveFocus();
|
|
1045
|
+
|
|
1046
|
+
await fireEvent.keyDown(link, { key: "Enter" });
|
|
1047
|
+
expect(queryByRole("textbox", { name: "Edit cell" })).toBeNull();
|
|
1048
|
+
expect(link).toHaveFocus();
|
|
1049
|
+
|
|
1050
|
+
await fireEvent.keyDown(link, { key: "ArrowRight" });
|
|
1051
|
+
expect(link).toHaveFocus();
|
|
1052
|
+
expect(second_cell).not.toHaveFocus();
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
test("Home and End move to row and grid boundaries", async () => {
|
|
1056
|
+
const { getByTestId } = await render(Dataframe, navigation_props);
|
|
1057
|
+
const first_cell = await waitFor(() => getByTestId("cell-0-0"));
|
|
1058
|
+
|
|
1059
|
+
first_cell.focus();
|
|
1060
|
+
await event.keyboard("{End}");
|
|
1061
|
+
expect(getByTestId("cell-0-2")).toHaveFocus();
|
|
1062
|
+
|
|
1063
|
+
await event.keyboard("{Control>}{End}{/Control}");
|
|
1064
|
+
expect(getByTestId("cell-2-2")).toHaveFocus();
|
|
1065
|
+
|
|
1066
|
+
await event.keyboard("{Home}");
|
|
1067
|
+
expect(getByTestId("cell-2-0")).toHaveFocus();
|
|
1068
|
+
|
|
1069
|
+
await event.keyboard("{Control>}{Home}{/Control}");
|
|
1070
|
+
expect(first_cell).toHaveFocus();
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
test("Enter activates the selected cell, enters editing, and commits back to grid navigation", async () => {
|
|
1074
|
+
const { getByRole, getByTestId, queryByRole, listen } = await render(
|
|
1075
|
+
Dataframe,
|
|
1076
|
+
navigation_props
|
|
1077
|
+
);
|
|
1078
|
+
const select = listen("select");
|
|
1079
|
+
const first_cell = await waitFor(() => getByTestId("cell-0-0"));
|
|
1080
|
+
|
|
1081
|
+
first_cell.focus();
|
|
1082
|
+
await event.keyboard("{Enter}");
|
|
1083
|
+
|
|
1084
|
+
const editor = getByRole("textbox", { name: "Edit cell" });
|
|
1085
|
+
await waitFor(() => expect(editor).toHaveFocus());
|
|
1086
|
+
expect(select).toHaveBeenCalledWith({
|
|
1087
|
+
index: [0, 0],
|
|
1088
|
+
value: "Alice",
|
|
1089
|
+
row_value: ["Alice", "30", "Engineer"],
|
|
1090
|
+
col_value: ["Alice", "Bob", "Carol"]
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
await event.keyboard("{Enter}");
|
|
1094
|
+
expect(
|
|
1095
|
+
queryByRole("textbox", { name: "Edit cell" })
|
|
1096
|
+
).not.toBeInTheDocument();
|
|
1097
|
+
expect(first_cell).toHaveFocus();
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
test("Space activates a cell without entering edit mode", async () => {
|
|
1101
|
+
const { getByTestId, queryByRole, listen } = await render(
|
|
1102
|
+
Dataframe,
|
|
1103
|
+
navigation_props
|
|
1104
|
+
);
|
|
1105
|
+
const select = listen("select");
|
|
1106
|
+
const first_cell = await waitFor(() => getByTestId("cell-0-0"));
|
|
1107
|
+
|
|
1108
|
+
first_cell.focus();
|
|
1109
|
+
await event.keyboard(" ");
|
|
1110
|
+
|
|
1111
|
+
expect(select).toHaveBeenCalledTimes(1);
|
|
1112
|
+
expect(
|
|
1113
|
+
queryByRole("textbox", { name: "Edit cell" })
|
|
1114
|
+
).not.toBeInTheDocument();
|
|
1115
|
+
expect(first_cell).toHaveFocus();
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
test("Space toggles an editable boolean cell", async () => {
|
|
1119
|
+
const { getByTestId } = await render(Dataframe, {
|
|
1120
|
+
...navigation_props,
|
|
1121
|
+
value: {
|
|
1122
|
+
data: [[false]],
|
|
1123
|
+
headers: ["Enabled"],
|
|
1124
|
+
metadata: null
|
|
1125
|
+
},
|
|
1126
|
+
datatype: ["bool"] as const,
|
|
1127
|
+
col_count: [1, "fixed"] as [number, "fixed"],
|
|
1128
|
+
row_count: [1, "fixed"] as [number, "fixed"]
|
|
1129
|
+
});
|
|
1130
|
+
const cell = await waitFor(() => getByTestId("cell-0-0"));
|
|
1131
|
+
const checkbox = within(cell).getByTestId("checkbox");
|
|
1132
|
+
|
|
1133
|
+
cell.focus();
|
|
1134
|
+
await event.keyboard(" ");
|
|
1135
|
+
|
|
1136
|
+
await waitFor(() => expect(checkbox).toBeChecked());
|
|
1137
|
+
expect(cell).toHaveFocus();
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
test("F2 toggles edit mode and Escape returns to grid navigation", async () => {
|
|
1141
|
+
const { getByRole, getByTestId, queryByRole } = await render(
|
|
1142
|
+
Dataframe,
|
|
1143
|
+
navigation_props
|
|
1144
|
+
);
|
|
1145
|
+
const first_cell = await waitFor(() => getByTestId("cell-0-0"));
|
|
1146
|
+
|
|
1147
|
+
first_cell.focus();
|
|
1148
|
+
await event.keyboard("{F2}");
|
|
1149
|
+
await waitFor(() =>
|
|
1150
|
+
expect(getByRole("textbox", { name: "Edit cell" })).toHaveFocus()
|
|
1151
|
+
);
|
|
1152
|
+
await event.keyboard("{F2}");
|
|
1153
|
+
expect(
|
|
1154
|
+
queryByRole("textbox", { name: "Edit cell" })
|
|
1155
|
+
).not.toBeInTheDocument();
|
|
1156
|
+
expect(first_cell).toHaveFocus();
|
|
1157
|
+
|
|
1158
|
+
await event.keyboard("{F2}");
|
|
1159
|
+
await waitFor(() =>
|
|
1160
|
+
expect(getByRole("textbox", { name: "Edit cell" })).toHaveFocus()
|
|
1161
|
+
);
|
|
1162
|
+
|
|
1163
|
+
await event.keyboard("{Escape}");
|
|
1164
|
+
expect(
|
|
1165
|
+
queryByRole("textbox", { name: "Edit cell" })
|
|
1166
|
+
).not.toBeInTheDocument();
|
|
1167
|
+
expect(first_cell).toHaveFocus();
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
test("Tab moves between cells while editing and leaves at the grid boundary", async () => {
|
|
1171
|
+
const before = append_external_button("Before dataframe");
|
|
1172
|
+
const { getByRole, getByTestId } = await render(
|
|
1173
|
+
Dataframe,
|
|
1174
|
+
navigation_props
|
|
1175
|
+
);
|
|
1176
|
+
const after = append_external_button("After dataframe");
|
|
1177
|
+
const first_cell = await waitFor(() => getByTestId("cell-0-0"));
|
|
1178
|
+
const last_cell = getByTestId("cell-2-2");
|
|
1179
|
+
|
|
1180
|
+
first_cell.focus();
|
|
1181
|
+
await event.keyboard("{Enter}");
|
|
1182
|
+
await waitFor(() =>
|
|
1183
|
+
expect(getByRole("textbox", { name: "Edit cell" })).toHaveFocus()
|
|
1184
|
+
);
|
|
1185
|
+
await event.tab();
|
|
1186
|
+
await waitFor(() =>
|
|
1187
|
+
expect(getByRole("textbox", { name: "Edit cell" })).toHaveFocus()
|
|
1188
|
+
);
|
|
1189
|
+
expect(getByTestId("cell-0-1")).toHaveAttribute("tabindex", "0");
|
|
1190
|
+
|
|
1191
|
+
last_cell.focus();
|
|
1192
|
+
await event.keyboard("{Enter}");
|
|
1193
|
+
await event.tab();
|
|
1194
|
+
expect(after).toHaveFocus();
|
|
1195
|
+
|
|
1196
|
+
first_cell.focus();
|
|
1197
|
+
await event.keyboard("{Enter}");
|
|
1198
|
+
await waitFor(() =>
|
|
1199
|
+
expect(getByRole("textbox", { name: "Edit cell" })).toHaveFocus()
|
|
1200
|
+
);
|
|
1201
|
+
await event.tab({ shift: true });
|
|
1202
|
+
expect(before).toHaveFocus();
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
test("arrow navigation follows visible filtered rows while select reports original indices", async () => {
|
|
1206
|
+
const filtered_props = {
|
|
1207
|
+
...navigation_props,
|
|
1208
|
+
value: {
|
|
1209
|
+
data: [["match one"], ["skip"], ["match two"]],
|
|
1210
|
+
headers: ["Value"],
|
|
1211
|
+
metadata: null
|
|
1212
|
+
},
|
|
1213
|
+
col_count: [1, "fixed"] as [number, "fixed"],
|
|
1214
|
+
row_count: [3, "fixed"] as [number, "fixed"],
|
|
1215
|
+
show_search: "search" as const
|
|
1216
|
+
};
|
|
1217
|
+
const { getByPlaceholderText, getByTestId, queryByTestId, listen } =
|
|
1218
|
+
await render(Dataframe, filtered_props);
|
|
1219
|
+
const search = getByPlaceholderText("Search...");
|
|
1220
|
+
|
|
1221
|
+
await event.type(search, "match");
|
|
1222
|
+
await waitFor(() => {
|
|
1223
|
+
expect(queryByTestId("cell-1-0")).not.toBeInTheDocument();
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
const first_match = getByTestId("cell-0-0");
|
|
1227
|
+
const second_match = getByTestId("cell-2-0");
|
|
1228
|
+
first_match.focus();
|
|
1229
|
+
await event.keyboard("{ArrowDown}");
|
|
1230
|
+
expect(second_match).toHaveFocus();
|
|
1231
|
+
expect(second_match).toHaveAttribute("aria-rowindex", "3");
|
|
1232
|
+
|
|
1233
|
+
const select = listen("select");
|
|
1234
|
+
await event.keyboard(" ");
|
|
1235
|
+
expect(select).toHaveBeenCalledWith({
|
|
1236
|
+
index: [2, 0],
|
|
1237
|
+
value: "match two",
|
|
1238
|
+
row_value: ["match two"],
|
|
1239
|
+
col_value: ["match one", "skip", "match two"]
|
|
1240
|
+
});
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
test.todo(
|
|
1244
|
+
"VISUAL: keyboard focus shows a visible outline around the active Dataframe cell — needs Playwright visual regression screenshot comparison"
|
|
1245
|
+
);
|
|
1246
|
+
});
|
|
1247
|
+
|
|
348
1248
|
describe("Header overflow", () => {
|
|
349
1249
|
let wrapper: HTMLDivElement;
|
|
350
1250
|
|
|
@@ -393,9 +1293,13 @@ describe("Header overflow", () => {
|
|
|
393
1293
|
const viewport = wrapper.querySelector(
|
|
394
1294
|
".virtual-table-viewport"
|
|
395
1295
|
) as HTMLElement;
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
1296
|
+
// Column widths are measured asynchronously; until that lands the columns
|
|
1297
|
+
// share the container equally and the 3rd one is still in view.
|
|
1298
|
+
await waitFor(() => {
|
|
1299
|
+
const viewport_rect = viewport.getBoundingClientRect();
|
|
1300
|
+
const third_rect = headers[2].getBoundingClientRect();
|
|
1301
|
+
expect(third_rect.left).toBeGreaterThanOrEqual(viewport_rect.right);
|
|
1302
|
+
});
|
|
399
1303
|
});
|
|
400
1304
|
});
|
|
401
1305
|
|
|
@@ -409,28 +1313,7 @@ describe("Sorting", () => {
|
|
|
409
1313
|
});
|
|
410
1314
|
await wait();
|
|
411
1315
|
|
|
412
|
-
|
|
413
|
-
const headers = get_header_cells(container);
|
|
414
|
-
const age_header = Array.from(headers).find((h) =>
|
|
415
|
-
h.textContent?.includes("Age")
|
|
416
|
-
) as HTMLElement;
|
|
417
|
-
expect(age_header).toBeTruthy();
|
|
418
|
-
|
|
419
|
-
// Click the menu button on the header
|
|
420
|
-
const menu_btn = age_header.querySelector(
|
|
421
|
-
".cell-menu-button"
|
|
422
|
-
) as HTMLElement;
|
|
423
|
-
expect(menu_btn).toBeTruthy();
|
|
424
|
-
await fireEvent.click(menu_btn);
|
|
425
|
-
await wait();
|
|
426
|
-
|
|
427
|
-
// Click "Sort ascending" in the menu
|
|
428
|
-
const sort_asc_btn = Array.from(
|
|
429
|
-
document.querySelectorAll('[role="menuitem"]')
|
|
430
|
-
).find((el) => el.textContent?.includes("sort_ascending")) as HTMLElement;
|
|
431
|
-
expect(sort_asc_btn).toBeTruthy();
|
|
432
|
-
await fireEvent.click(sort_asc_btn);
|
|
433
|
-
await wait();
|
|
1316
|
+
await sort_column(container, "Age", "ascending");
|
|
434
1317
|
|
|
435
1318
|
// After sorting by Age ascending, Bob (25) should be first visible row
|
|
436
1319
|
const first_row = container.querySelector(".virtual-row");
|
|
@@ -515,20 +1398,115 @@ describe("Add/remove rows and columns", () => {
|
|
|
515
1398
|
row_count: [3, "dynamic"] as [number, "fixed" | "dynamic"]
|
|
516
1399
|
};
|
|
517
1400
|
|
|
518
|
-
|
|
519
|
-
|
|
1401
|
+
const empty_dynamic_props = {
|
|
1402
|
+
...dynamic_props,
|
|
1403
|
+
value: {
|
|
1404
|
+
data: [],
|
|
1405
|
+
headers: default_props.value.headers,
|
|
1406
|
+
metadata: null
|
|
1407
|
+
},
|
|
1408
|
+
row_count: [0, "dynamic"] as [number, "dynamic"]
|
|
1409
|
+
};
|
|
1410
|
+
|
|
1411
|
+
test("add row button appends a row and focuses its first cell", async () => {
|
|
1412
|
+
const { container, getByRole, getByTestId } = await render(
|
|
1413
|
+
Dataframe,
|
|
1414
|
+
empty_dynamic_props
|
|
1415
|
+
);
|
|
520
1416
|
await wait();
|
|
521
1417
|
|
|
522
|
-
|
|
1418
|
+
await fireEvent.click(getByRole("button", { name: "Add row" }));
|
|
523
1419
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
await wait();
|
|
1420
|
+
const added_cell = await waitFor(() => getByTestId("cell-0-0"));
|
|
1421
|
+
expect(get_rows(container)).toHaveLength(1);
|
|
1422
|
+
expect(added_cell).toHaveFocus();
|
|
1423
|
+
});
|
|
529
1424
|
|
|
530
|
-
|
|
1425
|
+
test("empty table keeps its add row button on screen in fullscreen", async () => {
|
|
1426
|
+
const { getByRole } = await render(Dataframe, empty_dynamic_props);
|
|
1427
|
+
await wait();
|
|
1428
|
+
|
|
1429
|
+
const toggle = (): HTMLElement =>
|
|
1430
|
+
getByRole("button", { name: /fullscreen/i });
|
|
1431
|
+
|
|
1432
|
+
// A sibling that follows `.table-container` is laid out past the bottom of
|
|
1433
|
+
// the fixed fullscreen viewport, so the button has to be inside it.
|
|
1434
|
+
expect(
|
|
1435
|
+
document.querySelector(".table-container .add-row-button")
|
|
1436
|
+
).not.toBeNull();
|
|
1437
|
+
|
|
1438
|
+
function expect_button_under_header(): void {
|
|
1439
|
+
const box = getByRole("button", {
|
|
1440
|
+
name: "Add row"
|
|
1441
|
+
}).getBoundingClientRect();
|
|
1442
|
+
const header = (
|
|
1443
|
+
document.querySelector("thead") as HTMLElement
|
|
1444
|
+
).getBoundingClientRect();
|
|
1445
|
+
// getByRole already rules out a button that has left the accessibility
|
|
1446
|
+
// tree, but a zero-sized one would still satisfy the bounds below, and
|
|
1447
|
+
// an upper bound on its own also admits a button above the header.
|
|
1448
|
+
expect(box.height).toBeGreaterThan(0);
|
|
1449
|
+
expect(box.top).toBeGreaterThanOrEqual(header.bottom);
|
|
1450
|
+
expect(box.bottom).toBeLessThanOrEqual(window.innerHeight);
|
|
1451
|
+
expect(box.top - header.bottom).toBeLessThan(50);
|
|
531
1452
|
}
|
|
1453
|
+
|
|
1454
|
+
expect_button_under_header();
|
|
1455
|
+
|
|
1456
|
+
// Block animates into fullscreen over 0.1s and the container reaches its
|
|
1457
|
+
// final size a frame before the layout inside it does, so there is no DOM
|
|
1458
|
+
// state to wait on that is not still mid-settle. Measuring early reads the
|
|
1459
|
+
// pre-fullscreen geometry and passes against any layout.
|
|
1460
|
+
await fireEvent.click(toggle());
|
|
1461
|
+
await waitFor(() =>
|
|
1462
|
+
expect(
|
|
1463
|
+
document.querySelector(".table-container.fullscreen")
|
|
1464
|
+
).not.toBeNull()
|
|
1465
|
+
);
|
|
1466
|
+
await wait(300);
|
|
1467
|
+
expect_button_under_header();
|
|
1468
|
+
|
|
1469
|
+
await fireEvent.click(toggle());
|
|
1470
|
+
await waitFor(() =>
|
|
1471
|
+
expect(document.querySelector(".table-container.fullscreen")).toBeNull()
|
|
1472
|
+
);
|
|
1473
|
+
await wait(300);
|
|
1474
|
+
expect_button_under_header();
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
test("table with rows still fills the screen in fullscreen", async () => {
|
|
1478
|
+
// Without this the gate is unguarded: letting every table collapse leaves
|
|
1479
|
+
// the suite green while populated bodies quietly lose their height.
|
|
1480
|
+
const { getByRole } = await render(Dataframe, dynamic_props);
|
|
1481
|
+
await wait();
|
|
1482
|
+
|
|
1483
|
+
// The body, not the wrap: `.table-wrap` keeps `flex: 1 1 auto` from the
|
|
1484
|
+
// rule above and stays tall even when the viewport inside it collapses.
|
|
1485
|
+
function viewport_height(): number {
|
|
1486
|
+
const el = document.querySelector(
|
|
1487
|
+
".table-container .virtual-table-viewport"
|
|
1488
|
+
);
|
|
1489
|
+
expect(el).not.toBeNull();
|
|
1490
|
+
return (el as HTMLElement).getBoundingClientRect().height;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
const before = viewport_height();
|
|
1494
|
+
expect(before).toBeGreaterThan(0);
|
|
1495
|
+
|
|
1496
|
+
await fireEvent.click(getByRole("button", { name: /fullscreen/i }));
|
|
1497
|
+
await waitFor(() =>
|
|
1498
|
+
expect(
|
|
1499
|
+
document.querySelector(".table-container.fullscreen")
|
|
1500
|
+
).not.toBeNull()
|
|
1501
|
+
);
|
|
1502
|
+
await wait(300);
|
|
1503
|
+
|
|
1504
|
+
expect(
|
|
1505
|
+
document.querySelector(".table-container.fullscreen.no-rows")
|
|
1506
|
+
).toBeNull();
|
|
1507
|
+
// Its own pre-fullscreen height: on a short screen a fixed fraction of
|
|
1508
|
+
// the window is a weak bound.
|
|
1509
|
+
expect(viewport_height()).toBeGreaterThan(before * 2);
|
|
532
1510
|
});
|
|
533
1511
|
|
|
534
1512
|
// Cell menu add row tests: The CellMenu renders outside the table-wrap parent,
|
|
@@ -539,7 +1517,7 @@ describe("Add/remove rows and columns", () => {
|
|
|
539
1517
|
test.todo("add row below via cell menu");
|
|
540
1518
|
|
|
541
1519
|
test("delete row via cell menu", async () => {
|
|
542
|
-
const { container } = await render(Dataframe, dynamic_props);
|
|
1520
|
+
const { container, getByTestId } = await render(Dataframe, dynamic_props);
|
|
543
1521
|
await wait();
|
|
544
1522
|
|
|
545
1523
|
const cell = get_cell(container, 1, 0)!;
|
|
@@ -559,10 +1537,11 @@ describe("Add/remove rows and columns", () => {
|
|
|
559
1537
|
await wait();
|
|
560
1538
|
|
|
561
1539
|
expect(get_rows(container).length).toBe(2);
|
|
1540
|
+
await waitFor(() => expect(getByTestId("cell-1-0")).toHaveFocus());
|
|
562
1541
|
});
|
|
563
1542
|
|
|
564
1543
|
test("add column via header menu", async () => {
|
|
565
|
-
const { container } = await render(Dataframe, dynamic_props);
|
|
1544
|
+
const { container, getByTestId } = await render(Dataframe, dynamic_props);
|
|
566
1545
|
await wait();
|
|
567
1546
|
|
|
568
1547
|
const initial_headers = get_header_cells(container).length;
|
|
@@ -571,24 +1550,24 @@ describe("Add/remove rows and columns", () => {
|
|
|
571
1550
|
const headers = get_header_cells(container);
|
|
572
1551
|
const header = headers[0] as HTMLElement;
|
|
573
1552
|
const menu_btn = header.querySelector(".cell-menu-button") as HTMLElement;
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
1553
|
+
expect(menu_btn).toBeTruthy();
|
|
1554
|
+
await fireEvent.click(menu_btn);
|
|
1555
|
+
await wait();
|
|
577
1556
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
await wait();
|
|
1557
|
+
const add_col_btn = document.querySelector(
|
|
1558
|
+
'[aria-label="Add column to the right"]'
|
|
1559
|
+
) as HTMLElement;
|
|
1560
|
+
expect(add_col_btn).toBeTruthy();
|
|
1561
|
+
await fireEvent.click(add_col_btn);
|
|
584
1562
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
1563
|
+
await waitFor(() =>
|
|
1564
|
+
expect(get_header_cells(container)).toHaveLength(initial_headers + 1)
|
|
1565
|
+
);
|
|
1566
|
+
expect(getByTestId("header-1")).toHaveFocus();
|
|
588
1567
|
});
|
|
589
1568
|
|
|
590
1569
|
test("delete column via header menu", async () => {
|
|
591
|
-
const { container } = await render(Dataframe, dynamic_props);
|
|
1570
|
+
const { container, getByTestId } = await render(Dataframe, dynamic_props);
|
|
592
1571
|
await wait();
|
|
593
1572
|
|
|
594
1573
|
const initial_headers = get_header_cells(container).length;
|
|
@@ -608,6 +1587,7 @@ describe("Add/remove rows and columns", () => {
|
|
|
608
1587
|
await wait();
|
|
609
1588
|
|
|
610
1589
|
expect(get_header_cells(container).length).toBe(initial_headers - 1);
|
|
1590
|
+
await waitFor(() => expect(getByTestId("cell-0-0")).toHaveFocus());
|
|
611
1591
|
}
|
|
612
1592
|
}
|
|
613
1593
|
});
|