@gradio/dataframe 0.23.0 → 0.23.2

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,31 @@
1
1
  # @gradio/dataframe
2
2
 
3
+ ## 0.23.2
4
+
5
+ ### Fixes
6
+
7
+ - [#13250](https://github.com/gradio-app/gradio/pull/13250) [`2e9eb51`](https://github.com/gradio-app/gradio/commit/2e9eb51d4da5e4a997ac2fc18e5814bacf6f818d) - Restore select-all checkbox in boolean column headers of `gr.Dataframe`. Thanks @pngwn!
8
+ - [#13259](https://github.com/gradio-app/gradio/pull/13259) [`b5e6491`](https://github.com/gradio-app/gradio/commit/b5e64918b721550bc3762169fda81c5270904eaa) - Dataframe: allow native context menu and text selection on static cells. Thanks @pngwn!
9
+ - [#13305](https://github.com/gradio-app/gradio/pull/13305) [`1d5994d`](https://github.com/gradio-app/gradio/commit/1d5994da102bd9bf2367f58789de508b1727b1bf) - Dataframe: fix extreme rendering slowdown with `datatype="date"` (and any future dtype with asymmetric string casts) by firing `EditableCell`'s shim-blur only on edit teardown instead of every render. Also makes the hidden sizing-row computation faster by avoiding Date rendering for every entry. Thanks @pngwn!
10
+ - [#13303](https://github.com/gradio-app/gradio/pull/13303) [`79c5776`](https://github.com/gradio-app/gradio/commit/79c577680978adfb5eeaa01944635e14dc67e5f1) - Rework `Dataframe` wrapping, truncation, and column width sizing. Thanks @pngwn!
11
+
12
+ ### Dependency updates
13
+
14
+ - @gradio/atoms@0.24.0
15
+ - @gradio/statustracker@0.14.1
16
+ - @gradio/checkbox@0.7.0
17
+ - @gradio/upload@0.17.9
18
+
19
+ ## 0.23.1
20
+
21
+ ### Dependency updates
22
+
23
+ - @gradio/atoms@0.23.1
24
+ - @gradio/statustracker@0.14.0
25
+ - @gradio/client@2.2.0
26
+ - @gradio/markdown-code@0.7.0
27
+ - @gradio/checkbox@0.6.7
28
+
3
29
  ## 0.23.0
4
30
 
5
31
  ### Features
package/Dataframe.test.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import { test, describe, afterEach, expect } from "vitest";
2
- import { cleanup, render, fireEvent } from "@self/tootils/render";
2
+ import {
3
+ cleanup,
4
+ render,
5
+ fireEvent,
6
+ waitFor,
7
+ within
8
+ } from "@self/tootils/render";
3
9
  import { tick } from "svelte";
4
10
 
5
11
  import Dataframe from "./Index.svelte";
@@ -284,6 +290,85 @@ describe("Cell selection", () => {
284
290
  expect(get_cell(container, 1, 0)!.className).toContain("cell-selected");
285
291
  expect(get_cell(container, 1, 1)!.className).toContain("cell-selected");
286
292
  });
293
+
294
+ // Regression: pressing Ctrl between mousedown and mouseup must not call
295
+ // handle_cell_click a second time via the click event. If it did, the
296
+ // Ctrl+click path would toggle the already-selected cell back off,
297
+ // producing conflicting state updates that froze the interface.
298
+ test("pressing Ctrl between mousedown and mouseup keeps cell selected", async () => {
299
+ const { container } = await render(Dataframe, default_props);
300
+ await wait();
301
+
302
+ const cell = get_cell(container, 0, 0)!;
303
+
304
+ // mousedown (no ctrl) selects the cell
305
+ await fireEvent.mouseDown(cell);
306
+ await wait();
307
+ expect(cell.className).toContain("cell-selected");
308
+
309
+ // The browser fires a click event when the user releases the mouse button.
310
+ // If the user pressed Ctrl before releasing, that click carries ctrlKey:true.
311
+ // A second call to handle_cell_click with ctrlKey would toggle the cell
312
+ // back off (deselect it). Verify that the click event has no effect.
313
+ await fireEvent.click(cell, { ctrlKey: true });
314
+ await wait();
315
+
316
+ expect(cell.className).toContain("cell-selected");
317
+ });
318
+ });
319
+
320
+ describe("Header overflow", () => {
321
+ let wrapper: HTMLDivElement;
322
+
323
+ afterEach(async () => {
324
+ cleanup();
325
+ wrapper?.remove();
326
+ });
327
+
328
+ // Headers must expand to fit their text (no ellipsis truncation).
329
+ // When headers are wider than the scroll container, later columns are
330
+ // hidden by the container's overflow — not by cutting off the text.
331
+ test("long headers are not truncated in the DOM and the 3rd header is hidden by scroll overflow", async () => {
332
+ // Narrow container makes overflow predictable regardless of viewport size.
333
+ wrapper = document.createElement("div");
334
+ wrapper.style.width = "300px";
335
+ document.body.appendChild(wrapper);
336
+
337
+ // Capital W is one of the widest glyphs — 60 of them easily exceeds 300 px.
338
+ const long_header = "W".repeat(60);
339
+
340
+ const { getAllByRole } = await render(
341
+ Dataframe,
342
+ {
343
+ ...default_props,
344
+ value: {
345
+ data: [["a", "b", "c"]],
346
+ headers: [long_header + " A", long_header + " B", long_header + " C"],
347
+ metadata: null
348
+ },
349
+ col_count: [3, "fixed"] as [number, "fixed" | "dynamic"],
350
+ row_count: [1, "fixed"] as [number, "fixed" | "dynamic"]
351
+ },
352
+ { container: wrapper }
353
+ );
354
+
355
+ await wait();
356
+
357
+ const headers = getAllByRole("columnheader");
358
+ expect(headers).toHaveLength(3);
359
+
360
+ // Full text must be present in the DOM — no in-cell truncation.
361
+ expect(headers[2].textContent?.trim()).toContain(long_header + " C");
362
+
363
+ // The 3rd header's left edge must be beyond the scroll container's right
364
+ // edge, meaning it is completely outside the visible area.
365
+ const viewport = wrapper.querySelector(
366
+ ".virtual-table-viewport"
367
+ ) as HTMLElement;
368
+ const viewport_rect = viewport.getBoundingClientRect();
369
+ const third_rect = headers[2].getBoundingClientRect();
370
+ expect(third_rect.left).toBeGreaterThanOrEqual(viewport_rect.right);
371
+ });
287
372
  });
288
373
 
289
374
  describe("Sorting", () => {
@@ -500,6 +585,67 @@ describe("Add/remove rows and columns", () => {
500
585
  });
501
586
  });
502
587
 
588
+ describe("Cell menu visibility on static cells", () => {
589
+ afterEach(() => cleanup());
590
+
591
+ test("context menu does not open on a static column cell", async () => {
592
+ const { container } = await render(Dataframe, {
593
+ ...default_props,
594
+ static_columns: [0]
595
+ });
596
+ await wait();
597
+
598
+ const cell = get_cell(container, 0, 0)!;
599
+ await fireEvent.contextMenu(cell);
600
+ await wait();
601
+
602
+ expect(within(document.body).queryByRole("menu")).toBeNull();
603
+ });
604
+
605
+ test("context menu does not open when interactive is false", async () => {
606
+ const { container } = await render(Dataframe, {
607
+ ...default_props,
608
+ interactive: false,
609
+ editable: false
610
+ });
611
+ await wait();
612
+
613
+ const cell = get_cell(container, 0, 0)!;
614
+ await fireEvent.contextMenu(cell);
615
+ await wait();
616
+
617
+ expect(within(document.body).queryByRole("menu")).toBeNull();
618
+ });
619
+
620
+ test("context menu does open on an editable, non-static cell", async () => {
621
+ const { container } = await render(Dataframe, {
622
+ ...default_props,
623
+ row_count: [3, "dynamic"] as [number, "fixed" | "dynamic"]
624
+ });
625
+ await wait();
626
+
627
+ const cell = get_cell(container, 0, 0)!;
628
+ await fireEvent.contextMenu(cell);
629
+ await wait();
630
+
631
+ expect(within(document.body).queryByRole("menu")).not.toBeNull();
632
+ });
633
+
634
+ test("cell menu button does not render on a static column cell", async () => {
635
+ const { container } = await render(Dataframe, {
636
+ ...default_props,
637
+ static_columns: [0]
638
+ });
639
+ await wait();
640
+
641
+ const cell = get_cell(container, 0, 0)!;
642
+ await fireEvent.mouseDown(cell);
643
+ await wait();
644
+
645
+ expect(cell.querySelector(".cell-menu-button")).toBeNull();
646
+ });
647
+ });
648
+
503
649
  describe("Delete/clear cells", () => {
504
650
  afterEach(() => cleanup());
505
651
 
@@ -701,9 +847,175 @@ describe("Copy", () => {
701
847
 
702
848
  const table_wrap = get_table_wrap(container)!;
703
849
  await fireEvent.keyDown(table_wrap, { key: "c", metaKey: true });
704
- await wait(100);
705
850
 
706
- const clipboard = await navigator.clipboard.readText();
707
- expect(clipboard).toBe("Alice");
851
+ // handle_copy() is async but called without await in the keydown handler,
852
+ // so the clipboard write may not be complete immediately. Poll until it settles.
853
+ await waitFor(async () => {
854
+ expect(await navigator.clipboard.readText()).toBe("Alice");
855
+ });
856
+ });
857
+ });
858
+
859
+ describe("Boolean column select-all header checkbox", () => {
860
+ afterEach(() => cleanup());
861
+
862
+ const bool_props = {
863
+ ...default_props,
864
+ value: {
865
+ data: [
866
+ ["Alice", true, false],
867
+ ["Bob", false, false],
868
+ ["Carol", true, false]
869
+ ],
870
+ headers: ["Name", "Active", "Admin"],
871
+ metadata: null
872
+ },
873
+ datatype: ["str", "bool", "bool"] as any,
874
+ col_count: [3, "fixed"] as [number, "fixed" | "dynamic"]
875
+ };
876
+
877
+ function get_header(container: HTMLElement, col_idx: number): HTMLElement {
878
+ return container.querySelector<HTMLElement>(
879
+ `th[data-heading='${col_idx}']`
880
+ )!;
881
+ }
882
+
883
+ function get_header_checkbox(
884
+ container: HTMLElement,
885
+ col_idx: number
886
+ ): HTMLInputElement {
887
+ return within(get_header(container, col_idx)).getByTestId(
888
+ "checkbox"
889
+ ) as HTMLInputElement;
890
+ }
891
+
892
+ test("renders checkbox in bool column header (interactive mode)", async () => {
893
+ const { container } = await render(Dataframe, bool_props);
894
+ await wait();
895
+
896
+ expect(get_header_checkbox(container, 1)).toBeInTheDocument();
897
+ expect(get_header_checkbox(container, 2)).toBeInTheDocument();
898
+
899
+ expect(
900
+ within(get_header(container, 0)).queryByTestId("checkbox")
901
+ ).not.toBeInTheDocument();
902
+ });
903
+
904
+ test("does not render checkbox when not interactive", async () => {
905
+ const { container } = await render(Dataframe, {
906
+ ...bool_props,
907
+ interactive: false
908
+ });
909
+ await wait();
910
+
911
+ expect(
912
+ within(get_header(container, 1)).queryByTestId("checkbox")
913
+ ).not.toBeInTheDocument();
914
+ });
915
+
916
+ test("header checkbox reflects indeterminate state for mixed column", async () => {
917
+ const { container } = await render(Dataframe, bool_props);
918
+ await wait();
919
+
920
+ const active = get_header_checkbox(container, 1);
921
+ expect(active.indeterminate).toBe(true);
922
+ expect(active.checked).toBe(false);
923
+ });
924
+
925
+ test("header checkbox is checked when all column values are true", async () => {
926
+ const { container } = await render(Dataframe, {
927
+ ...bool_props,
928
+ value: {
929
+ data: [
930
+ ["Alice", true],
931
+ ["Bob", true]
932
+ ],
933
+ headers: ["Name", "Active"],
934
+ metadata: null
935
+ },
936
+ datatype: ["str", "bool"] as any,
937
+ col_count: [2, "fixed"] as [number, "fixed" | "dynamic"]
938
+ });
939
+ await wait();
940
+
941
+ const active = get_header_checkbox(container, 1);
942
+ expect(active.checked).toBe(true);
943
+ expect(active.indeterminate).toBe(false);
944
+ });
945
+
946
+ test("clicking indeterminate header checkbox sets all column cells to true", async () => {
947
+ const { container, listen } = await render(Dataframe, bool_props);
948
+ await wait();
949
+
950
+ const change = listen("change");
951
+ await fireEvent.click(get_header_checkbox(container, 1));
952
+ await wait();
953
+
954
+ const data = change.mock.calls.at(-1)?.[0].data;
955
+ expect(data).toEqual([
956
+ ["Alice", true, false],
957
+ ["Bob", true, false],
958
+ ["Carol", true, false]
959
+ ]);
960
+
961
+ const updated = get_header_checkbox(container, 1);
962
+ expect(updated.checked).toBe(true);
963
+ expect(updated.indeterminate).toBe(false);
964
+ });
965
+
966
+ test("clicking fully-checked header checkbox sets all column cells to false", async () => {
967
+ const { container, listen } = await render(Dataframe, {
968
+ ...bool_props,
969
+ value: {
970
+ data: [
971
+ ["Alice", true, false],
972
+ ["Bob", true, false]
973
+ ],
974
+ headers: ["Name", "Active", "Admin"],
975
+ metadata: null
976
+ }
977
+ });
978
+ await wait();
979
+
980
+ const change = listen("change");
981
+ await fireEvent.click(get_header_checkbox(container, 1));
982
+ await wait();
983
+
984
+ const data = change.mock.calls.at(-1)?.[0].data;
985
+ expect(data).toEqual([
986
+ ["Alice", false, false],
987
+ ["Bob", false, false]
988
+ ]);
989
+ });
990
+
991
+ test("toggling one bool header does not affect cells in other bool columns", async () => {
992
+ const { container, listen } = await render(Dataframe, {
993
+ ...bool_props,
994
+ value: {
995
+ data: [
996
+ ["Alice", false, true],
997
+ ["Bob", false, true],
998
+ ["Carol", false, true]
999
+ ],
1000
+ headers: ["Name", "Active", "Admin"],
1001
+ metadata: null
1002
+ }
1003
+ });
1004
+ await wait();
1005
+
1006
+ const change = listen("change");
1007
+ await fireEvent.click(get_header_checkbox(container, 1));
1008
+ await wait();
1009
+
1010
+ const data = change.mock.calls.at(-1)?.[0].data;
1011
+ expect(data).toEqual([
1012
+ ["Alice", true, true],
1013
+ ["Bob", true, true],
1014
+ ["Carol", true, true]
1015
+ ]);
1016
+
1017
+ const admin = get_header_checkbox(container, 2);
1018
+ expect(admin.checked).toBe(true);
1019
+ expect(admin.indeterminate).toBe(false);
708
1020
  });
709
1021
  });
package/Index.svelte CHANGED
@@ -83,7 +83,6 @@
83
83
  min_width={gradio.shared.min_width}
84
84
  padding={false}
85
85
  container={false}
86
- overflow_behavior="visible"
87
86
  {fullscreen}
88
87
  >
89
88
  <StatusTracker
package/dist/Index.svelte CHANGED
@@ -83,7 +83,6 @@
83
83
  min_width={gradio.shared.min_width}
84
84
  padding={false}
85
85
  container={false}
86
- overflow_behavior="visible"
87
86
  {fullscreen}
88
87
  >
89
88
  <StatusTracker
@@ -34,7 +34,6 @@
34
34
  justify-content: center;
35
35
  width: min-content;
36
36
  height: var(--size-full);
37
- margin: 0 auto;
38
37
  }
39
38
 
40
39
  .bool-cell :global(input:disabled) {
@@ -20,6 +20,7 @@
20
20
  show_menu_button = false,
21
21
  show_selection_buttons = false,
22
22
  is_first_column = false,
23
+ is_solo = false,
23
24
  latex_delimiters,
24
25
  line_breaks = true,
25
26
  editable = true,
@@ -50,6 +51,7 @@
50
51
  show_menu_button?: boolean;
51
52
  show_selection_buttons?: boolean;
52
53
  is_first_column?: boolean;
54
+ is_solo?: boolean;
53
55
  latex_delimiters: { left: string; right: string; display: boolean }[];
54
56
  line_breaks?: boolean;
55
57
  editable?: boolean;
@@ -71,21 +73,38 @@
71
73
  } = $props();
72
74
 
73
75
  let is_selected = $derived(selection_classes !== "");
76
+
77
+ // lock the cell-wrap's height to its pre-selection value so the
78
+ // row doesn't shift when the span goes position: absolute.
79
+ let wrap_el: HTMLDivElement | undefined = $state();
80
+ let locked_height = $state("");
81
+
82
+ // pre is weird but runs _before_ DOM updates
83
+ $effect.pre(() => {
84
+ if (is_solo && wrap_el) {
85
+ locked_height = `height: ${wrap_el.offsetHeight}px;`;
86
+ } else {
87
+ locked_height = "";
88
+ }
89
+ });
74
90
  </script>
75
91
 
76
92
  <div
77
93
  class="body-cell {selection_classes}"
78
94
  class:flash={is_flash}
79
95
  class:first-column={is_first_column}
96
+ class:static={is_static}
97
+ class:cell-solo={is_solo}
80
98
  data-row={row_idx}
81
99
  data-col={col_idx}
82
100
  data-testid={`cell-${row_idx}-${col_idx}`}
101
+ tabindex={is_static ? -1 : undefined}
83
102
  {onmousedown}
84
103
  {ondblclick}
85
104
  {oncontextmenu}
86
105
  style="{col_style} {cell_style}"
87
106
  >
88
- <div class="cell-wrap">
107
+ <div class="cell-wrap" bind:this={wrap_el} style={locked_height}>
89
108
  <EditableCell
90
109
  {value}
91
110
  {display_value}
@@ -105,6 +124,7 @@
105
124
  {on_select_row}
106
125
  {is_dragging}
107
126
  {wrap_text}
127
+ expanded={is_solo}
108
128
  />
109
129
  {#if show_menu_button}
110
130
  <CellMenuButton on_click={on_menu_click} />
@@ -120,8 +140,14 @@
120
140
  inset 1px 0 0 var(--border-color-primary),
121
141
  inset 0 0 0 1px var(--ring-color);
122
142
  padding: 0;
123
- overflow: visible;
143
+ overflow: hidden;
124
144
  box-sizing: border-box;
145
+ user-select: none;
146
+ min-width: 0;
147
+ }
148
+
149
+ .body-cell.static {
150
+ user-select: text;
125
151
  }
126
152
 
127
153
  .body-cell.first-column {
@@ -145,6 +171,65 @@
145
171
  position: relative;
146
172
  }
147
173
 
174
+ .body-cell.cell-solo {
175
+ z-index: 10;
176
+ overflow: visible;
177
+ }
178
+
179
+ .body-cell.cell-solo .cell-wrap {
180
+ overflow: visible;
181
+ }
182
+
183
+ .body-cell.cell-solo :global(.cell-wrap > span) {
184
+ position: absolute;
185
+ top: 0;
186
+ left: 0;
187
+ width: 100%;
188
+ min-height: 100%;
189
+ padding: var(--size-2);
190
+ box-sizing: border-box;
191
+ white-space: normal;
192
+ overflow: visible;
193
+ text-overflow: clip;
194
+ overflow-wrap: break-word;
195
+ word-break: break-word;
196
+ background: var(--background-fill-primary);
197
+ box-shadow:
198
+ inset 0 2px 0 0 var(--color-accent),
199
+ inset 0 -2px 0 0 var(--color-accent),
200
+ inset 2px 0 0 0 var(--color-accent),
201
+ inset -2px 0 0 0 var(--color-accent),
202
+ 0 6px 12px -4px rgba(0, 0, 0, 0.22);
203
+ z-index: 1;
204
+ }
205
+
206
+ .body-cell.cell-solo :global(.cell-wrap > textarea) {
207
+ position: absolute;
208
+ top: 0;
209
+ left: 0;
210
+ width: 100%;
211
+ min-height: 100%;
212
+ padding: var(--size-2);
213
+ padding-left: var(--size-2);
214
+ margin: 0;
215
+ box-sizing: border-box;
216
+ transform: none;
217
+ background: var(--background-fill-primary);
218
+ box-shadow:
219
+ inset 0 2px 0 0 var(--color-accent),
220
+ inset 0 -2px 0 0 var(--color-accent),
221
+ inset 2px 0 0 0 var(--color-accent),
222
+ inset -2px 0 0 0 var(--color-accent),
223
+ 0 6px 12px -4px rgba(0, 0, 0, 0.22);
224
+ z-index: 2;
225
+ field-sizing: content;
226
+ height: auto;
227
+ }
228
+
229
+ .body-cell.cell-solo :global(.selection-button) {
230
+ z-index: 3;
231
+ }
232
+
148
233
  .body-cell.cell-selected.no-top {
149
234
  --sel-top: inset 0 0 0 0 transparent;
150
235
  }
@@ -182,7 +267,7 @@
182
267
  padding: var(--size-2);
183
268
  box-sizing: border-box;
184
269
  gap: var(--size-1);
185
- overflow: visible;
270
+ overflow: hidden;
186
271
  min-width: 0;
187
272
  height: 100%;
188
273
  }
@@ -16,6 +16,7 @@ type $$ComponentProps = {
16
16
  show_menu_button?: boolean;
17
17
  show_selection_buttons?: boolean;
18
18
  is_first_column?: boolean;
19
+ is_solo?: boolean;
19
20
  latex_delimiters: {
20
21
  left: string;
21
22
  right: string;