@gradio/dataframe 0.23.1 → 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,21 @@
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
+
3
19
  ## 0.23.1
4
20
 
5
21
  ### Dependency updates
package/Dataframe.test.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import { test, describe, afterEach, expect } from "vitest";
2
- import { cleanup, render, fireEvent, waitFor } 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";
@@ -579,6 +585,67 @@ describe("Add/remove rows and columns", () => {
579
585
  });
580
586
  });
581
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
+
582
649
  describe("Delete/clear cells", () => {
583
650
  afterEach(() => cleanup());
584
651
 
@@ -788,3 +855,167 @@ describe("Copy", () => {
788
855
  });
789
856
  });
790
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);
1020
+ });
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;
@@ -21,13 +21,15 @@
21
21
  i18n,
22
22
  is_dragging = false,
23
23
  wrap_text = false,
24
+ expanded = false,
24
25
  show_selection_buttons = false,
25
26
  coords,
26
27
  on_select_column = null,
27
28
  on_select_row = null,
28
29
  el = $bindable(null),
29
30
  onblur,
30
- onkeydown
31
+ onkeydown,
32
+ pad_left = false
31
33
  }: {
32
34
  edit: boolean;
33
35
  value?: CellValue;
@@ -55,6 +57,7 @@
55
57
  i18n: I18nFormatter;
56
58
  is_dragging?: boolean;
57
59
  wrap_text?: boolean;
60
+ expanded?: boolean;
58
61
  show_selection_buttons?: boolean;
59
62
  coords: [number, number];
60
63
  on_select_column?: ((col: number) => void) | null;
@@ -65,6 +68,7 @@
65
68
  coords: [number, number];
66
69
  }) => void;
67
70
  onkeydown?: (event: KeyboardEvent) => void;
71
+ pad_left: boolean;
68
72
  } = $props();
69
73
 
70
74
  function truncate_text(
@@ -79,7 +83,9 @@
79
83
  return str.slice(0, max_length) + "...";
80
84
  }
81
85
 
82
- let should_truncate = $derived(!edit && max_chars !== null && max_chars > 0);
86
+ let should_truncate = $derived(
87
+ !edit && !expanded && max_chars !== null && max_chars > 0
88
+ );
83
89
 
84
90
  let display_content = $derived(
85
91
  editable ? value : display_value !== null ? display_value : value
@@ -99,6 +105,22 @@
99
105
  return {};
100
106
  }
101
107
 
108
+ // grow the edit-mode textarea to fit its content so the overlay
109
+ // expands the same way the display span does.
110
+ function use_autosize(node: HTMLTextAreaElement): any {
111
+ function resize(): void {
112
+ node.style.height = "auto";
113
+ node.style.height = `${node.scrollHeight}px`;
114
+ }
115
+ node.addEventListener("input", resize);
116
+ requestAnimationFrame(resize);
117
+ return {
118
+ destroy() {
119
+ node.removeEventListener("input", resize);
120
+ }
121
+ };
122
+ }
123
+
102
124
  function handle_blur(event: FocusEvent): void {
103
125
  onblur?.({
104
126
  blur_event: event,
@@ -114,10 +136,12 @@
114
136
  handle_blur({ target: { value } } as unknown as FocusEvent);
115
137
  }
116
138
 
139
+ // returning cleanup from the effect fires the blur only when leaving edit mode, not on every render
117
140
  $effect(() => {
118
- if (!edit) {
119
- // Shim blur on removal for Safari and Firefox
120
- handle_blur({ target: { value } } as unknown as FocusEvent);
141
+ if (edit) {
142
+ return () => {
143
+ handle_blur({ target: { value } } as unknown as FocusEvent);
144
+ };
121
145
  }
122
146
  });
123
147
  </script>
@@ -135,7 +159,9 @@
135
159
  onmousedown={(e: MouseEvent) => e.stopPropagation()}
136
160
  onclick={(e: MouseEvent) => e.stopPropagation()}
137
161
  use:use_focus
162
+ use:use_autosize
138
163
  onkeydown={handle_keydown}
164
+ class:pad_left
139
165
  />
140
166
  {/if}
141
167
 
@@ -149,7 +175,7 @@
149
175
  role="button"
150
176
  class:edit
151
177
  class:expanded={edit}
152
- class:multiline={header}
178
+ class:header
153
179
  onfocus={(e) => e.preventDefault()}
154
180
  style={styling}
155
181
  data-editable={editable}
@@ -205,7 +231,7 @@
205
231
  textarea {
206
232
  position: absolute;
207
233
  flex: 1 1 0%;
208
- transform: translateX(-0.1px);
234
+ transform: translate(0.2px, -0.5px);
209
235
  outline: none;
210
236
  border: none;
211
237
  background: transparent;
@@ -217,24 +243,33 @@
217
243
  font-size: inherit;
218
244
  font-weight: inherit;
219
245
  line-height: var(--line-lg);
246
+ left: var(--size-2);
247
+ user-select: text;
220
248
  }
221
249
 
222
250
  textarea:focus {
223
251
  outline: none;
224
252
  }
225
253
 
254
+ textarea.pad_left {
255
+ margin-left: var(--size-7);
256
+ }
257
+
226
258
  span {
227
259
  flex: 1 1 0%;
228
260
  position: relative;
229
261
  display: block;
230
262
  outline: none;
231
- -webkit-user-select: text;
232
- -moz-user-select: text;
233
- -ms-user-select: text;
234
- user-select: text;
235
263
  cursor: text;
236
264
  width: 100%;
237
- overflow-wrap: break-word;
265
+ min-width: 0;
266
+ white-space: nowrap;
267
+ overflow: hidden;
268
+ text-overflow: ellipsis;
269
+ }
270
+
271
+ .header {
272
+ font-weight: var(--weight-bold);
238
273
  }
239
274
 
240
275
  span.text.expanded {
@@ -245,21 +280,6 @@
245
280
  overflow: visible;
246
281
  }
247
282
 
248
- .multiline {
249
- white-space: pre;
250
- overflow: hidden;
251
- text-overflow: ellipsis;
252
- }
253
-
254
- .header {
255
- transform: translateX(0);
256
- font-weight: var(--weight-bold);
257
- white-space: nowrap;
258
- overflow: hidden;
259
- text-overflow: ellipsis;
260
- margin-left: var(--size-1);
261
- }
262
-
263
283
  .edit {
264
284
  opacity: 0;
265
285
  pointer-events: none;
@@ -267,6 +287,8 @@
267
287
 
268
288
  span :global(img) {
269
289
  max-height: 100px;
290
+ max-width: 100%;
291
+ height: auto;
270
292
  width: auto;
271
293
  object-fit: contain;
272
294
  }
@@ -278,8 +300,9 @@
278
300
  .wrap,
279
301
  .wrap.expanded {
280
302
  white-space: normal;
281
- word-wrap: break-word;
303
+ overflow: visible;
304
+ text-overflow: clip;
282
305
  overflow-wrap: break-word;
283
- word-wrap: break-word;
306
+ word-break: break-word;
284
307
  }
285
308
  </style>
@@ -20,6 +20,7 @@ type $$ComponentProps = {
20
20
  i18n: I18nFormatter;
21
21
  is_dragging?: boolean;
22
22
  wrap_text?: boolean;
23
+ expanded?: boolean;
23
24
  show_selection_buttons?: boolean;
24
25
  coords: [number, number];
25
26
  on_select_column?: ((col: number) => void) | null;
@@ -30,6 +31,7 @@ type $$ComponentProps = {
30
31
  coords: [number, number];
31
32
  }) => void;
32
33
  onkeydown?: (event: KeyboardEvent) => void;
34
+ pad_left: boolean;
33
35
  };
34
36
  declare const EditableCell: import("svelte").Component<$$ComponentProps, {}, "value" | "el">;
35
37
  type EditableCell = ReturnType<typeof EditableCell>;