@gradio/dataframe 0.24.4 → 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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @gradio/dataframe
2
2
 
3
+ ## 0.24.5
4
+
5
+ ### Fixes
6
+
7
+ - [#13829](https://github.com/gradio-app/gradio/pull/13829) [`908b427`](https://github.com/gradio-app/gradio/commit/908b427466c2ac1d2950ca3b9fb39371f167fba0) - Fix `gr.Dataframe` shift+click range selecting rows hidden by the search. Thanks @hysts!
8
+ - [#13831](https://github.com/gradio-app/gradio/pull/13831) [`d99a49a`](https://github.com/gradio-app/gradio/commit/d99a49a1456dd11efbec75b74c29657466b12b30) - Keep the empty Dataframe's add row button on screen in fullscreen. Thanks @hysts!
9
+
10
+ ### Dependency updates
11
+
12
+ - @gradio/utils@0.14.2
13
+ - @gradio/client@2.7.0
14
+
3
15
  ## 0.24.4
4
16
 
5
17
  ### Fixes
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,
@@ -71,6 +71,29 @@ async function wait(ms = 50) {
71
71
  await tick();
72
72
  }
73
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
+
74
97
  describe("Dataframe rendering", () => {
75
98
  afterEach(() => cleanup());
76
99
 
@@ -308,7 +331,12 @@ describe("Cell editing", () => {
308
331
  });
309
332
 
310
333
  describe("Cell selection", () => {
311
- afterEach(() => cleanup());
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
+ });
312
340
 
313
341
  test("click selects a cell", async () => {
314
342
  const { container } = await render(Dataframe, default_props);
@@ -357,6 +385,457 @@ describe("Cell selection", () => {
357
385
  expect(get_cell(container, 1, 1)!.className).toContain("cell-selected");
358
386
  });
359
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
+
360
839
  // Regression: pressing Ctrl between mousedown and mouseup must not call
361
840
  // handle_cell_click a second time via the click event. If it did, the
362
841
  // Ctrl+click path would toggle the already-selected cell back off,
@@ -814,9 +1293,13 @@ describe("Header overflow", () => {
814
1293
  const viewport = wrapper.querySelector(
815
1294
  ".virtual-table-viewport"
816
1295
  ) as HTMLElement;
817
- const viewport_rect = viewport.getBoundingClientRect();
818
- const third_rect = headers[2].getBoundingClientRect();
819
- expect(third_rect.left).toBeGreaterThanOrEqual(viewport_rect.right);
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
+ });
820
1303
  });
821
1304
  });
822
1305
 
@@ -830,28 +1313,7 @@ describe("Sorting", () => {
830
1313
  });
831
1314
  await wait();
832
1315
 
833
- // Click on the "Age" header — find by content containing "Age"
834
- const headers = get_header_cells(container);
835
- const age_header = Array.from(headers).find((h) =>
836
- h.textContent?.includes("Age")
837
- ) as HTMLElement;
838
- expect(age_header).toBeTruthy();
839
-
840
- // Click the menu button on the header
841
- const menu_btn = age_header.querySelector(
842
- ".cell-menu-button"
843
- ) as HTMLElement;
844
- expect(menu_btn).toBeTruthy();
845
- await fireEvent.click(menu_btn);
846
- await wait();
847
-
848
- // Click "Sort ascending" in the menu
849
- const sort_asc_btn = Array.from(
850
- document.querySelectorAll('[role="menuitem"]')
851
- ).find((el) => el.textContent?.includes("sort_ascending")) as HTMLElement;
852
- expect(sort_asc_btn).toBeTruthy();
853
- await fireEvent.click(sort_asc_btn);
854
- await wait();
1316
+ await sort_column(container, "Age", "ascending");
855
1317
 
856
1318
  // After sorting by Age ascending, Bob (25) should be first visible row
857
1319
  const first_row = container.querySelector(".virtual-row");
@@ -936,16 +1398,21 @@ describe("Add/remove rows and columns", () => {
936
1398
  row_count: [3, "dynamic"] as [number, "fixed" | "dynamic"]
937
1399
  };
938
1400
 
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
+
939
1411
  test("add row button appends a row and focuses its first cell", async () => {
940
- const { container, getByRole, getByTestId } = await render(Dataframe, {
941
- ...dynamic_props,
942
- value: {
943
- data: [],
944
- headers: default_props.value.headers,
945
- metadata: null
946
- },
947
- row_count: [0, "dynamic"] as [number, "dynamic"]
948
- });
1412
+ const { container, getByRole, getByTestId } = await render(
1413
+ Dataframe,
1414
+ empty_dynamic_props
1415
+ );
949
1416
  await wait();
950
1417
 
951
1418
  await fireEvent.click(getByRole("button", { name: "Add row" }));
@@ -955,6 +1422,93 @@ describe("Add/remove rows and columns", () => {
955
1422
  expect(added_cell).toHaveFocus();
956
1423
  });
957
1424
 
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);
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);
1510
+ });
1511
+
958
1512
  // Cell menu add row tests: The CellMenu renders outside the table-wrap parent,
959
1513
  // so the document click handler (handle_click_outside) unmounts it before
960
1514
  // the menu button's onclick fires in synthetic event dispatch. These
@@ -16,7 +16,6 @@
16
16
  color: var(--body-text-color);
17
17
  cursor: pointer;
18
18
  transition: all 150ms;
19
- margin-top: var(--size-2);
20
19
  z-index: 10;
21
20
  position: relative;
22
21
  pointer-events: auto;