@gradio/dataframe 0.24.5 → 0.24.7

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/Index.svelte CHANGED
@@ -124,5 +124,6 @@
124
124
  oninput={handle_input}
125
125
  onselect={handle_select}
126
126
  onedit={handle_edit}
127
+ onerror={(message) => gradio.dispatch("error", message)}
127
128
  />
128
129
  </Block>
package/dist/Index.svelte CHANGED
@@ -124,5 +124,6 @@
124
124
  oninput={handle_input}
125
125
  onselect={handle_select}
126
126
  onedit={handle_edit}
127
+ onerror={(message) => gradio.dispatch("error", message)}
127
128
  />
128
129
  </Block>
@@ -35,7 +35,7 @@
35
35
  is_cell_selected,
36
36
  handle_click_outside as handle_click_outside_util
37
37
  } from "./utils/selection_utils";
38
- import { copy_table_data, handle_file_upload } from "./utils/table_utils";
38
+ import { copy_table_data, parse_table_file } from "./utils/table_utils";
39
39
  import { gradio_filter_fn } from "./utils/filter";
40
40
  import { create_column_measurement } from "./column_measurement.svelte.js";
41
41
 
@@ -73,7 +73,8 @@
73
73
  onselect,
74
74
  onedit,
75
75
  onsearch,
76
- onfullscreen
76
+ onfullscreen,
77
+ onerror
77
78
  }: {
78
79
  datatype: Datatype | Datatype[];
79
80
  label?: string | null;
@@ -109,6 +110,7 @@
109
110
  onedit?: (detail: EditData) => void;
110
111
  onsearch?: (detail: string | null) => void;
111
112
  onfullscreen?: () => void;
113
+ onerror?: (message: string) => void;
112
114
  } = $props();
113
115
 
114
116
  type GradioRow = Record<string, CellValue> & { _index: number };
@@ -277,7 +279,11 @@
277
279
  let copy_flash = $state(false);
278
280
  let is_dragging = $state(false);
279
281
  let show_scroll_button = $state(false);
280
- let dragging = $state(false); // file drag
282
+ let file_dragging = $state(false);
283
+ let discard_pending_edit = false;
284
+ // document-unique so several dataframes on a page do not describe each
285
+ // other's grids
286
+ const drop_hint_id = $props.id();
281
287
 
282
288
  let parent: HTMLDivElement;
283
289
 
@@ -603,12 +609,21 @@
603
609
  blur_event: FocusEvent;
604
610
  coords: [number, number];
605
611
  }): void {
612
+ // an import replaces the table under an open editor, and EditableCell
613
+ // commits when it leaves edit mode either way. that half-typed value
614
+ // belongs to the table that just went away
615
+ if (discard_pending_edit) return;
616
+
606
617
  const { coords } = detail;
607
618
  const input_el = detail.blur_event.target as HTMLTextAreaElement;
608
619
  if (!input_el || input_el.value === undefined) return;
609
620
 
610
621
  const [row, col] = coords;
611
- const old_value = values?.[row]?.[col];
622
+ // and deleting a row leaves the editor's row behind. a column short of
623
+ // the header is not the same thing: a value can arrive ragged, and the
624
+ // cell past the end of its own row still renders and still takes an edit
625
+ if (!values?.[row]) return;
626
+ const old_value = values[row][col];
612
627
  const new_value = input_el.value;
613
628
 
614
629
  if (String(old_value) !== String(new_value)) {
@@ -858,16 +873,22 @@
858
873
  return true;
859
874
  }
860
875
 
876
+ // every one of these holds a row or column index, so they have to go
877
+ // whenever the table stops being the one they were recorded against
878
+ function reset_interaction_state(): void {
879
+ selected_cells = [];
880
+ selected = false;
881
+ range_anchor = false;
882
+ editing = false;
883
+ header_edit = false;
884
+ selected_header = false;
885
+ active_cell_menu = null;
886
+ active_header_menu = null;
887
+ }
888
+
861
889
  function handle_click_outside(event: Event): void {
862
890
  if (handle_click_outside_util(event, parent)) {
863
- selected_cells = [];
864
- selected = false;
865
- range_anchor = false;
866
- editing = false;
867
- header_edit = false;
868
- selected_header = false;
869
- active_cell_menu = null;
870
- active_header_menu = null;
891
+ reset_interaction_state();
871
892
  }
872
893
  }
873
894
 
@@ -1113,21 +1134,87 @@
1113
1134
  }
1114
1135
  }
1115
1136
 
1116
- function on_file_upload(file_data: any): void {
1117
- handle_file_upload(
1118
- typeof file_data === "string" ? file_data : (file_data?.data ?? ""),
1119
- (head) => {
1120
- headers = head.map((h: any) => h ?? "");
1121
- return (headers as string[]).map((h: string, i: number) => ({
1122
- id: `h_${i}`,
1123
- value: h
1124
- }));
1125
- },
1126
- (vals) => {
1127
- values = vals;
1128
- push_change(vals, headers as string[]);
1129
- }
1137
+ function interpolate(
1138
+ message: string,
1139
+ values: Record<string, string | number>
1140
+ ): string {
1141
+ return message.replace(/\{(\w+)\}/g, (_, key) => String(values[key]));
1142
+ }
1143
+
1144
+ function apply_imported_table(
1145
+ new_headers: (string | null)[],
1146
+ new_values: CellValue[][]
1147
+ ): void {
1148
+ if (!new_headers.length) {
1149
+ throw new Error(i18n("dataframe.import_empty_file"));
1150
+ }
1151
+ // a file of separators alone parses into a header of blank names, and
1152
+ // importing it would replace the table with unnamed columns
1153
+ if (new_headers.every((h) => !h?.trim())) {
1154
+ throw new Error(i18n("dataframe.import_no_column_names"));
1155
+ }
1156
+ // a row the header cannot account for would reach the backend as a ragged
1157
+ // value, and the column checks below only see the header
1158
+ const ragged = new_values.findIndex(
1159
+ (row) => row.length !== new_headers.length
1130
1160
  );
1161
+ if (ragged !== -1) {
1162
+ throw new Error(
1163
+ interpolate(i18n("dataframe.import_ragged_row"), {
1164
+ line: ragged + 2,
1165
+ fields: new_values[ragged].length,
1166
+ columns: new_headers.length
1167
+ })
1168
+ );
1169
+ }
1170
+ // the menu paths already refuse to change a fixed shape or write to a
1171
+ // read-only column, so an import must not be the way around them
1172
+ if (static_columns.length > 0) {
1173
+ throw new Error(i18n("dataframe.import_static_columns"));
1174
+ }
1175
+ if (col_count[1] === "fixed" && new_headers.length !== col_count[0]) {
1176
+ throw new Error(
1177
+ interpolate(i18n("dataframe.import_fixed_columns"), {
1178
+ expected: col_count[0],
1179
+ actual: new_headers.length
1180
+ })
1181
+ );
1182
+ }
1183
+ if (row_count[1] === "fixed" && new_values.length !== row_count[0]) {
1184
+ throw new Error(
1185
+ interpolate(i18n("dataframe.import_fixed_rows"), {
1186
+ expected: row_count[0],
1187
+ actual: new_values.length
1188
+ })
1189
+ );
1190
+ }
1191
+
1192
+ discard_pending_edit = true;
1193
+ headers = new_headers;
1194
+ values = new_values;
1195
+ reset_interaction_state();
1196
+ // tanstack keys these by positional col_N, so they would land on whichever
1197
+ // column now sits at that index. a search is worse than misplaced: it hides
1198
+ // imported rows, and commit_filter then drops the hidden ones from the value
1199
+ sorting = [];
1200
+ column_filters = [];
1201
+ global_filter = "";
1202
+ push_change(new_values, headers as string[]);
1203
+ tick().then(() => (discard_pending_edit = false));
1204
+ }
1205
+
1206
+ // undefined when every dropped file was filtered out by `filetype`
1207
+ function on_file_upload(file: File | undefined): void {
1208
+ if (!editable || !file) return;
1209
+ parse_table_file(file)
1210
+ .then(({ headers: new_headers, values: new_values }) =>
1211
+ apply_imported_table(new_headers, new_values)
1212
+ )
1213
+ .catch((e) => {
1214
+ const message = e instanceof Error ? e.message : String(e);
1215
+ if (onerror) onerror(message);
1216
+ else console.error(message);
1217
+ });
1131
1218
  }
1132
1219
 
1133
1220
  onMount(() => {
@@ -1225,6 +1312,7 @@
1225
1312
  bind:this={parent}
1226
1313
  class="table-wrap"
1227
1314
  class:dragging={is_dragging}
1315
+ class:file-dragging={file_dragging && editable}
1228
1316
  class:menu-open={active_cell_menu || active_header_menu}
1229
1317
  onkeydown={handle_keydown}
1230
1318
  role="grid"
@@ -1232,6 +1320,7 @@
1232
1320
  aria-rowcount={rows.length + 1}
1233
1321
  aria-colcount={resolved_headers.length + Number(show_row_numbers)}
1234
1322
  aria-readonly={!editable}
1323
+ aria-describedby={editable ? drop_hint_id : undefined}
1235
1324
  tabindex={rows.length === 0 ? 0 : -1}
1236
1325
  style="--df-max-col-width: {viewport_width}px;"
1237
1326
  >
@@ -1242,10 +1331,12 @@
1242
1331
  center={false}
1243
1332
  boundedheight={false}
1244
1333
  disable_click={true}
1334
+ format="blob"
1335
+ filetype={[".csv", ".tsv"]}
1245
1336
  {root}
1246
1337
  onload={on_file_upload}
1247
- bind:dragging
1248
- aria_label={i18n("dataframe.drop_to_upload")}
1338
+ onerror={editable ? onerror : undefined}
1339
+ bind:dragging={file_dragging}
1249
1340
  tab_index={-1}
1250
1341
  container_element="div"
1251
1342
  >
@@ -1470,6 +1561,15 @@
1470
1561
  {/if}
1471
1562
  </div>
1472
1563
 
1564
+ <!-- outside the grid: #13729 took the label off a span like this one and put it
1565
+ on the grid as an attribute, and a test pins that there is no sr-only text
1566
+ inside. a description has no equally well supported attribute form -->
1567
+ {#if editable}
1568
+ <span id={drop_hint_id} class="drop-hint"
1569
+ >{i18n("dataframe.drop_to_upload")}</span
1570
+ >
1571
+ {/if}
1572
+
1473
1573
  {#if values.length === 0 && editable && row_count[1] === "dynamic"}
1474
1574
  <EmptyRowButton on_click={() => add_row()} />
1475
1575
  {/if}
@@ -1585,8 +1685,13 @@
1585
1685
 
1586
1686
  .table-wrap {
1587
1687
  position: relative;
1588
- transition: 150ms;
1589
1688
  width: 100%;
1689
+ /* the duration used to stand alone, which means every property. that
1690
+ swept the drag outline in from `currentcolor`, near-white in dark
1691
+ mode, before it reached the accent. the list is what the fullscreen
1692
+ rules above actually change */
1693
+ transition-property: flex-grow, flex-shrink, flex-basis, min-height;
1694
+ transition-duration: 150ms;
1590
1695
  }
1591
1696
 
1592
1697
  /* Constrain Upload component wrapper */
@@ -1602,6 +1707,27 @@
1602
1707
  outline: none;
1603
1708
  }
1604
1709
 
1710
+ /* after :focus-within, which is equally specific and would otherwise
1711
+ clear the outline whenever a cell is focused. drawn inside the box so
1712
+ hovering a file over the table shifts nothing */
1713
+ .table-wrap.file-dragging {
1714
+ outline: 2px solid var(--color-accent);
1715
+ outline-offset: -2px;
1716
+ /* matches the .upload-container border the outline covers */
1717
+ border-radius: var(--table-radius);
1718
+ }
1719
+
1720
+ /* an aria-describedby target, so it has to stay in the accessibility tree
1721
+ rather than be hidden with display: none */
1722
+ .drop-hint {
1723
+ position: absolute;
1724
+ clip-path: inset(50%);
1725
+ width: 1px;
1726
+ height: 1px;
1727
+ overflow: hidden;
1728
+ white-space: nowrap;
1729
+ }
1730
+
1605
1731
  .table-wrap.dragging {
1606
1732
  cursor: crosshair !important;
1607
1733
  user-select: none;
@@ -42,6 +42,7 @@ type $$ComponentProps = {
42
42
  onedit?: (detail: EditData) => void;
43
43
  onsearch?: (detail: string | null) => void;
44
44
  onfullscreen?: () => void;
45
+ onerror?: (message: string) => void;
45
46
  };
46
47
  declare const Table: import("svelte").Component<$$ComponentProps, {}, "headers" | "values" | "value_is_output">;
47
48
  type Table = ReturnType<typeof Table>;
@@ -1,10 +1,6 @@
1
1
  export type CellCoordinate = [number, number];
2
2
  export type EditingState = CellCoordinate | false;
3
3
  export type Headers = (string | null)[];
4
- export interface HeadersWithIDs {
5
- id: string;
6
- value: string;
7
- }
8
4
  export type CellValue = string | number | boolean;
9
5
  export interface TableCell {
10
6
  id: string;
@@ -1,2 +1 @@
1
- [];
2
1
  export {};
@@ -1,7 +1,8 @@
1
- import type { CellValue, Headers, HeadersWithIDs, TableData } from "../types";
1
+ import type { CellValue, Headers, TableData } from "../types";
2
2
  export declare function make_cell_id(row: number, col: number): string;
3
3
  export declare function make_header_id(col: number): string;
4
4
  export declare function copy_table_data(data: TableData, selected_cells: [number, number][]): Promise<void>;
5
- export declare function guess_delimiter(text: string, possibleDelimiters: string[]): string[];
6
- export declare function data_uri_to_blob(data_uri: string): Blob;
7
- export declare function handle_file_upload(data_uri: string, update_headers: (headers: Headers) => HeadersWithIDs[], update_values: (values: CellValue[][]) => void): void;
5
+ export declare function parse_table_file(file: File): Promise<{
6
+ headers: Headers;
7
+ values: CellValue[][];
8
+ }>;
@@ -36,41 +36,43 @@ export async function copy_table_data(data, selected_cells) {
36
36
  throw new Error("Failed to copy to clipboard: " + err.message);
37
37
  }
38
38
  }
39
- export function guess_delimiter(text, possibleDelimiters) {
40
- return possibleDelimiters.filter(weedOut);
41
- function weedOut(delimiter) {
42
- var cache = -1;
43
- return text.split("\n").every(checkLength);
44
- function checkLength(line) {
45
- if (!line)
46
- return true;
47
- var length = line.split(delimiter).length;
48
- if (cache < 0)
49
- cache = length;
50
- return cache === length && length > 1;
39
+ export async function parse_table_file(file) {
40
+ const text = await file.text();
41
+ if (!text.trim())
42
+ return { headers: [], values: [] };
43
+ // the drop zone only accepts .csv and .tsv, so the extension goes first and
44
+ // wins any tie, but a mislabeled file is still read by its content: a
45
+ // separator only takes the file if it splits every line the same way, which
46
+ // is what tells a real separator from one that sits in a header name. the
47
+ // split is d3's rather than a raw count, so a separator inside a quoted
48
+ // field does not throw the detection off.
49
+ const by_extension = file.name.toLowerCase().endsWith(".tsv") ? "\t" : ",";
50
+ const candidates = by_extension === "\t" ? ["\t", ","] : [",", "\t"];
51
+ let consistent;
52
+ let ragged;
53
+ let unsplit;
54
+ for (const delimiter of candidates) {
55
+ // a blank line parses to a single empty field, which both the width
56
+ // checks below and the caller's ragged check would read as a short row
57
+ const parsed = dsvFormat(delimiter)
58
+ .parseRows(text)
59
+ .filter((row) => row.length > 1 || row[0] !== "");
60
+ const width = parsed[0]?.length ?? 0;
61
+ // the extension is first in `candidates`, so this keeps its parse for the
62
+ // single-column case rather than running it again at the end
63
+ unsplit ??= parsed;
64
+ if (width < 2)
65
+ continue;
66
+ if (parsed.every((row) => row.length === width)) {
67
+ consistent = parsed;
68
+ break;
51
69
  }
70
+ // a separator that splits the header but leaves the rows uneven is
71
+ // usually the one that just happens to appear in a header name. keep it
72
+ // only in case the other does not split at all, where a ragged table is
73
+ // still better to report on than an unsplit one
74
+ ragged ??= parsed;
52
75
  }
53
- }
54
- export function data_uri_to_blob(data_uri) {
55
- const byte_str = atob(data_uri.split(",")[1]);
56
- const mime_str = data_uri.split(",")[0].split(":")[1].split(";")[0];
57
- const ab = new ArrayBuffer(byte_str.length);
58
- const ia = new Uint8Array(ab);
59
- for (let i = 0; i < byte_str.length; i++) {
60
- ia[i] = byte_str.charCodeAt(i);
61
- }
62
- return new Blob([ab], { type: mime_str });
63
- }
64
- export function handle_file_upload(data_uri, update_headers, update_values) {
65
- const blob = data_uri_to_blob(data_uri);
66
- const reader = new FileReader();
67
- reader.addEventListener("loadend", (e) => {
68
- if (!e?.target?.result || typeof e.target.result !== "string")
69
- return;
70
- const [delimiter] = guess_delimiter(e.target.result, [",", "\t"]);
71
- const [head, ...rest] = dsvFormat(delimiter).parseRows(e.target.result);
72
- update_headers(head);
73
- update_values(rest);
74
- });
75
- reader.readAsText(blob);
76
+ const [head = [], ...rest] = consistent ?? ragged ?? unsplit ?? [];
77
+ return { headers: head.map((h) => h ?? ""), values: rest };
76
78
  }
@@ -5,10 +5,6 @@ export type Datatype = "str" | "number" | "bool" | "date" | "markdown" | "html"
5
5
  export type Metadata = {
6
6
  [key: string]: string[][] | null;
7
7
  } | null;
8
- export type HeadersWithIDs = {
9
- value: string;
10
- id: string;
11
- }[];
12
8
  export type DataframeValue = {
13
9
  data: Data;
14
10
  headers: Headers;
@@ -5,6 +5,13 @@ export const default_i18n = {
5
5
  "dataframe.add_column_left": "Add column left",
6
6
  "dataframe.add_column_right": "Add column right",
7
7
  "dataframe.delete_column": "Delete column",
8
+ "dataframe.drop_to_upload": "Drop CSV or TSV files here to import data into dataframe",
9
+ "dataframe.import_empty_file": "The dropped file is empty.",
10
+ "dataframe.import_no_column_names": "The dropped file has no column names.",
11
+ "dataframe.import_ragged_row": "Line {line} of the file has {fields} fields, the header has {columns}.",
12
+ "dataframe.import_static_columns": "Cannot import into a table with read-only columns.",
13
+ "dataframe.import_fixed_columns": "This table takes exactly {expected} columns, the file has {actual}.",
14
+ "dataframe.import_fixed_rows": "This table takes exactly {expected} rows, the file has {actual}.",
8
15
  "dataframe.sort_asc": "Sort ascending",
9
16
  "dataframe.sort_desc": "Sort descending",
10
17
  "dataframe.sort_ascending": "Sort ascending",
package/dist/types.d.ts CHANGED
@@ -6,6 +6,7 @@ export interface DataframeEvents {
6
6
  input: never;
7
7
  select: SelectData;
8
8
  edit: EditData;
9
+ error: string;
9
10
  clear_status: LoadingStatus;
10
11
  }
11
12
  export interface DataframeProps {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gradio/dataframe",
3
- "version": "0.24.5",
3
+ "version": "0.24.7",
4
4
  "description": "Gradio UI packages",
5
5
  "type": "module",
6
6
  "author": "",
@@ -15,15 +15,15 @@
15
15
  "dequal": "^2.0.3",
16
16
  "@tanstack/table-core": "^8.21.3",
17
17
  "@tanstack/virtual-core": "^3.13.6",
18
- "@gradio/atoms": "^0.26.2",
19
- "@gradio/checkbox": "^0.8.3",
20
- "@gradio/button": "^0.8.2",
21
- "@gradio/client": "^2.7.0",
22
- "@gradio/markdown-code": "^0.10.1",
18
+ "@gradio/atoms": "^0.26.3",
19
+ "@gradio/checkbox": "^0.8.4",
20
+ "@gradio/client": "^2.7.2",
21
+ "@gradio/markdown-code": "^0.10.3",
22
+ "@gradio/button": "^0.8.4",
23
+ "@gradio/statustracker": "^0.16.0",
24
+ "@gradio/utils": "^0.15.0",
23
25
  "@gradio/icons": "^0.16.0",
24
- "@gradio/upload": "^0.18.3",
25
- "@gradio/statustracker": "^0.15.3",
26
- "@gradio/utils": "^0.14.2"
26
+ "@gradio/upload": "^0.19.1"
27
27
  },
28
28
  "exports": {
29
29
  ".": {