@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/Dataframe.test.ts CHANGED
@@ -1070,18 +1070,30 @@ describe("Keyboard accessibility", () => {
1070
1070
  expect(first_cell).toHaveFocus();
1071
1071
  });
1072
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
1073
+ test("Enter, F2, Tab and Escape move between grid navigation and cell editing", async () => {
1074
+ // The editor focuses itself in a requestAnimationFrame, which the browser
1075
+ // defers under full-suite load. Running frames as plain timers keeps the
1076
+ // focus hand-off deterministic without changing what the component does.
1077
+ vi.stubGlobal(
1078
+ "requestAnimationFrame",
1079
+ (callback: FrameRequestCallback): number =>
1080
+ window.setTimeout(() => callback(performance.now()), 0)
1077
1081
  );
1082
+ vi.stubGlobal("cancelAnimationFrame", (id: number): void =>
1083
+ window.clearTimeout(id)
1084
+ );
1085
+ onTestFinished(() => vi.unstubAllGlobals());
1086
+
1087
+ const { findByRole, getByRole, getByTestId, queryByRole, listen } =
1088
+ await render(Dataframe, navigation_props);
1078
1089
  const select = listen("select");
1079
1090
  const first_cell = await waitFor(() => getByTestId("cell-0-0"));
1091
+ const second_cell = getByTestId("cell-0-1");
1080
1092
 
1081
1093
  first_cell.focus();
1082
1094
  await event.keyboard("{Enter}");
1083
1095
 
1084
- const editor = getByRole("textbox", { name: "Edit cell" });
1096
+ const editor = await findByRole("textbox", { name: "Edit cell" });
1085
1097
  await waitFor(() => expect(editor).toHaveFocus());
1086
1098
  expect(select).toHaveBeenCalledWith({
1087
1099
  index: [0, 0],
@@ -1091,10 +1103,38 @@ describe("Keyboard accessibility", () => {
1091
1103
  });
1092
1104
 
1093
1105
  await event.keyboard("{Enter}");
1094
- expect(
1095
- queryByRole("textbox", { name: "Edit cell" })
1096
- ).not.toBeInTheDocument();
1097
- expect(first_cell).toHaveFocus();
1106
+ await waitFor(() =>
1107
+ expect(
1108
+ queryByRole("textbox", { name: "Edit cell" })
1109
+ ).not.toBeInTheDocument()
1110
+ );
1111
+ await waitFor(() => expect(first_cell).toHaveFocus());
1112
+
1113
+ await event.keyboard("{F2}");
1114
+ await waitFor(() =>
1115
+ expect(
1116
+ within(first_cell).getByRole("textbox", { name: "Edit cell" })
1117
+ ).toHaveFocus()
1118
+ );
1119
+
1120
+ await event.tab();
1121
+ await waitFor(() =>
1122
+ expect(
1123
+ within(second_cell).getByRole("textbox", { name: "Edit cell" })
1124
+ ).toHaveFocus()
1125
+ );
1126
+ expect(second_cell).toHaveAttribute("tabindex", "0");
1127
+
1128
+ await event.keyboard("{Escape}");
1129
+ await waitFor(() =>
1130
+ expect(
1131
+ queryByRole("textbox", { name: "Edit cell" })
1132
+ ).not.toBeInTheDocument()
1133
+ );
1134
+ await waitFor(() => expect(second_cell).toHaveFocus());
1135
+ expect(getByRole("grid").querySelectorAll('[tabindex="0"]')).toHaveLength(
1136
+ 1
1137
+ );
1098
1138
  });
1099
1139
 
1100
1140
  test("Space activates a cell without entering edit mode", async () => {
@@ -1137,71 +1177,6 @@ describe("Keyboard accessibility", () => {
1137
1177
  expect(cell).toHaveFocus();
1138
1178
  });
1139
1179
 
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
1180
  test("arrow navigation follows visible filtered rows while select reports original indices", async () => {
1206
1181
  const filtered_props = {
1207
1182
  ...navigation_props,
@@ -1239,10 +1214,6 @@ describe("Keyboard accessibility", () => {
1239
1214
  col_value: ["match one", "skip", "match two"]
1240
1215
  });
1241
1216
  });
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
1217
  });
1247
1218
 
1248
1219
  describe("Header overflow", () => {
@@ -1474,47 +1445,10 @@ describe("Add/remove rows and columns", () => {
1474
1445
  expect_button_under_header();
1475
1446
  });
1476
1447
 
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
-
1512
1448
  // Cell menu add row tests: The CellMenu renders outside the table-wrap parent,
1513
1449
  // so the document click handler (handle_click_outside) unmounts it before
1514
1450
  // the menu button's onclick fires in synthetic event dispatch. These
1515
1451
  // interactions are covered by E2E tests in dataframe_events.spec.ts.
1516
- test.todo("add row above via cell menu");
1517
- test.todo("add row below via cell menu");
1518
1452
 
1519
1453
  test("delete row via cell menu", async () => {
1520
1454
  const { container, getByTestId } = await render(Dataframe, dynamic_props);
@@ -2027,3 +1961,595 @@ describe("Boolean column select-all header checkbox", () => {
2027
1961
  expect(admin.indeterminate).toBe(false);
2028
1962
  });
2029
1963
  });
1964
+
1965
+ describe("Dataframe CSV drop", () => {
1966
+ afterEach(() => cleanup());
1967
+
1968
+ const drop_props = {
1969
+ ...default_props,
1970
+ value: {
1971
+ data: [["", ""]],
1972
+ headers: ["a", "b"],
1973
+ metadata: null
1974
+ },
1975
+ col_count: [2, "dynamic"] as [number, "dynamic"],
1976
+ row_count: [1, "dynamic"] as [number, "dynamic"]
1977
+ };
1978
+
1979
+ // dispatched natively rather than through fireEvent: testing-library builds
1980
+ // its own DataTransfer and copies only own properties across, which leaves
1981
+ // the file list empty
1982
+ function drop_csv(
1983
+ container: HTMLElement,
1984
+ text: string,
1985
+ name = "data.csv"
1986
+ ): void {
1987
+ const data_transfer = new DataTransfer();
1988
+ data_transfer.items.add(new File([text], name, { type: "text/csv" }));
1989
+ const upload_container = container.querySelector(
1990
+ ".upload-container"
1991
+ ) as HTMLElement;
1992
+ upload_container.dispatchEvent(
1993
+ new DragEvent("drop", {
1994
+ bubbles: true,
1995
+ cancelable: true,
1996
+ dataTransfer: data_transfer
1997
+ })
1998
+ );
1999
+ }
2000
+
2001
+ function header_texts(container: HTMLElement): string[] {
2002
+ return Array.from(
2003
+ container.querySelectorAll("th.header-cell .header-content")
2004
+ ).map((h) => h.textContent?.trim() ?? "");
2005
+ }
2006
+
2007
+ test("imports a dropped CSV as headers and values", async () => {
2008
+ const { container, listen } = await render(Dataframe, drop_props);
2009
+ await wait();
2010
+ const change = listen("change");
2011
+
2012
+ drop_csv(container, "name,age\nAlice,30\nBob,25\n");
2013
+ await wait();
2014
+
2015
+ expect(header_texts(container)).toEqual(["name", "age"]);
2016
+ expect(get_cell(container, 0, 0)?.textContent).toContain("Alice");
2017
+ expect(get_cell(container, 1, 1)?.textContent).toContain("25");
2018
+ expect(change.mock.calls.at(-1)?.[0].data).toEqual([
2019
+ ["Alice", "30"],
2020
+ ["Bob", "25"]
2021
+ ]);
2022
+ });
2023
+
2024
+ test("imports a dropped TSV as headers and values", async () => {
2025
+ const { container } = await render(Dataframe, drop_props);
2026
+ await wait();
2027
+
2028
+ drop_csv(container, "name\tage\nAlice\t30\nBob\t25\n", "data.tsv");
2029
+ await wait();
2030
+
2031
+ expect(header_texts(container)).toEqual(["name", "age"]);
2032
+ expect(get_cell(container, 0, 0)?.textContent).toContain("Alice");
2033
+ expect(get_cell(container, 1, 1)?.textContent).toContain("25");
2034
+ });
2035
+
2036
+ // counting raw tabs misses this: the quoted one makes the counts disagree
2037
+ // between the two lines, so nothing looks consistent
2038
+ test("imports a dropped TSV whose field contains a quoted tab", async () => {
2039
+ const { container } = await render(Dataframe, drop_props);
2040
+ await wait();
2041
+
2042
+ drop_csv(container, 'name\tnote\nAlice\t"hello\tworld"\n', "data.tsv");
2043
+ await wait();
2044
+
2045
+ expect(header_texts(container)).toEqual(["name", "note"]);
2046
+ expect(get_cell(container, 0, 0)?.textContent).toContain("Alice");
2047
+ expect(get_cell(container, 0, 1)?.textContent).toContain("hello\tworld");
2048
+ });
2049
+
2050
+ test("reports a dropped file that is neither CSV nor TSV", async () => {
2051
+ const { container, listen } = await render(Dataframe, drop_props);
2052
+ await wait();
2053
+ const change = listen("change");
2054
+ const error = listen("error");
2055
+
2056
+ drop_csv(container, "name,age\nAlice,30\n", "data.png");
2057
+ await wait();
2058
+
2059
+ expect(header_texts(container)).toEqual(["a", "b"]);
2060
+ expect(change).not.toHaveBeenCalled();
2061
+ expect(error).toHaveBeenCalled();
2062
+ });
2063
+
2064
+ test("imports a dropped single-column CSV", async () => {
2065
+ const { container } = await render(Dataframe, drop_props);
2066
+ await wait();
2067
+
2068
+ drop_csv(container, "name\nAlice\nBob\n");
2069
+ await wait();
2070
+
2071
+ expect(header_texts(container)).toEqual(["name"]);
2072
+ expect(get_cell(container, 0, 0)?.textContent).toContain("Alice");
2073
+ expect(get_cell(container, 1, 0)?.textContent).toContain("Bob");
2074
+ });
2075
+
2076
+ function drag_over(target: Element, type: "dragenter" | "dragleave"): void {
2077
+ target.dispatchEvent(
2078
+ new DragEvent(type, { bubbles: true, cancelable: true })
2079
+ );
2080
+ }
2081
+
2082
+ function drop_target(container: HTMLElement): Element {
2083
+ return container.querySelector(".upload-container")!;
2084
+ }
2085
+
2086
+ test("highlights the table while a file is dragged over it", async () => {
2087
+ const { container } = await render(Dataframe, drop_props);
2088
+ await wait();
2089
+ const table_wrap = get_table_wrap(container);
2090
+
2091
+ drag_over(drop_target(container), "dragenter");
2092
+ await wait();
2093
+ expect(table_wrap).toHaveClass("file-dragging");
2094
+
2095
+ drag_over(drop_target(container), "dragleave");
2096
+ await wait();
2097
+ expect(table_wrap).not.toHaveClass("file-dragging");
2098
+ });
2099
+
2100
+ test.todo(
2101
+ "VISUAL: the file drag outline is drawn inside the table's border and follows its corner radius, needs Playwright visual regression screenshot comparison"
2102
+ );
2103
+
2104
+ // #13729 moved this component off hidden sr-only text and onto ARIA attributes
2105
+ // on the grid, so the drop hint goes there too rather than into <Upload>, which
2106
+ // discards a label on its div branch
2107
+ test("announces the drop target on an editable table", async () => {
2108
+ const { container } = await render(Dataframe, drop_props);
2109
+ await wait();
2110
+
2111
+ const described_by =
2112
+ get_table_wrap(container).getAttribute("aria-describedby");
2113
+ expect(described_by).toBeTruthy();
2114
+ // tootils stubs i18n as a passthrough, so the key stands in for the string
2115
+ // it resolves to (en.json: "Drop CSV or TSV files here to import data into
2116
+ // dataframe")
2117
+ expect(document.getElementById(described_by!)?.textContent).toBe(
2118
+ "dataframe.drop_to_upload"
2119
+ );
2120
+ });
2121
+
2122
+ test("does not announce a drop target on a read-only table", async () => {
2123
+ const { container } = await render(Dataframe, {
2124
+ ...drop_props,
2125
+ interactive: false
2126
+ });
2127
+ await wait();
2128
+
2129
+ expect(get_table_wrap(container)).not.toHaveAttribute("aria-describedby");
2130
+ expect(container.querySelector(".drop-hint")).toBeNull();
2131
+ });
2132
+
2133
+ test("does not highlight the table when it is not interactive", async () => {
2134
+ const { container } = await render(Dataframe, {
2135
+ ...drop_props,
2136
+ interactive: false
2137
+ });
2138
+ await wait();
2139
+
2140
+ drag_over(drop_target(container), "dragenter");
2141
+ await wait();
2142
+
2143
+ expect(get_table_wrap(container)).not.toHaveClass("file-dragging");
2144
+ });
2145
+
2146
+ test("ignores a dropped CSV when the dataframe is not interactive", async () => {
2147
+ const { container, listen } = await render(Dataframe, {
2148
+ ...drop_props,
2149
+ interactive: false
2150
+ });
2151
+ await wait();
2152
+ const change = listen("change");
2153
+ const input = listen("input");
2154
+
2155
+ drop_csv(container, "name,age\nAlice,30\nBob,25\n");
2156
+ await wait();
2157
+
2158
+ expect(header_texts(container)).toEqual(["a", "b"]);
2159
+ expect(change).not.toHaveBeenCalled();
2160
+ expect(input).not.toHaveBeenCalled();
2161
+ });
2162
+
2163
+ const dynamic_3x3_props = {
2164
+ ...drop_props,
2165
+ value: {
2166
+ data: [
2167
+ ["a1", "b1", "c1"],
2168
+ ["a2", "b2", "c2"],
2169
+ ["a3", "b3", "c3"]
2170
+ ],
2171
+ headers: ["A", "B", "C"],
2172
+ metadata: null
2173
+ },
2174
+ col_count: [3, "dynamic"] as [number, "dynamic"],
2175
+ row_count: [3, "dynamic"] as [number, "dynamic"]
2176
+ };
2177
+
2178
+ const fixed_props = {
2179
+ ...drop_props,
2180
+ value: {
2181
+ data: [
2182
+ ["", ""],
2183
+ ["", ""]
2184
+ ],
2185
+ headers: ["a", "b"],
2186
+ metadata: null
2187
+ },
2188
+ col_count: [2, "fixed"] as [number, "fixed"],
2189
+ row_count: [2, "fixed"] as [number, "fixed"]
2190
+ };
2191
+
2192
+ // both delimiters split every line into the same number of fields, so the
2193
+ // extension breaks the tie
2194
+ test("imports a dropped TSV whose fields contain commas", async () => {
2195
+ const { container } = await render(Dataframe, drop_props);
2196
+ await wait();
2197
+
2198
+ drop_csv(container, "first,last\tage\nAlice,Smith\t30\n", "data.tsv");
2199
+ await wait();
2200
+
2201
+ expect(header_texts(container)).toEqual(["first,last", "age"]);
2202
+ expect(get_cell(container, 0, 0)?.textContent).toContain("Alice,Smith");
2203
+ expect(get_cell(container, 0, 1)?.textContent).toContain("30");
2204
+ });
2205
+
2206
+ // the name says tab, but every tab-split of this file is one field wide, so it
2207
+ // has to be read as the comma-separated file it actually is
2208
+ test("imports a mislabeled TSV whose field holds a quoted comma", async () => {
2209
+ const { container } = await render(Dataframe, drop_props);
2210
+ await wait();
2211
+
2212
+ drop_csv(container, 'name,note\nAlice,"x,y"\n', "data.tsv");
2213
+ await wait();
2214
+
2215
+ expect(header_texts(container)).toEqual(["name", "note"]);
2216
+ expect(get_cell(container, 0, 0)?.textContent).toContain("Alice");
2217
+ expect(get_cell(container, 0, 1)?.textContent).toContain("x,y");
2218
+ });
2219
+
2220
+ test("rejects a dropped file whose rows do not match its header", async () => {
2221
+ const { container, listen } = await render(Dataframe, drop_props);
2222
+ await wait();
2223
+ const change = listen("change");
2224
+ const error = listen("error");
2225
+
2226
+ drop_csv(container, "name,age\nAlice,30,Engineer\n");
2227
+ await wait();
2228
+
2229
+ expect(header_texts(container)).toEqual(["a", "b"]);
2230
+ expect(change).not.toHaveBeenCalled();
2231
+ expect(error).toHaveBeenCalledTimes(1);
2232
+
2233
+ drop_csv(container, "name,age\nAlice\n");
2234
+ await wait();
2235
+
2236
+ expect(header_texts(container)).toEqual(["a", "b"]);
2237
+ expect(change).not.toHaveBeenCalled();
2238
+ expect(error).toHaveBeenCalledTimes(2);
2239
+ });
2240
+
2241
+ test("reports an error and keeps the table when the file is blank", async () => {
2242
+ const { container, listen } = await render(Dataframe, drop_props);
2243
+ await wait();
2244
+ const change = listen("change");
2245
+ const error = listen("error");
2246
+
2247
+ drop_csv(container, " \n");
2248
+ await wait();
2249
+
2250
+ expect(header_texts(container)).toEqual(["a", "b"]);
2251
+ expect(change).not.toHaveBeenCalled();
2252
+ expect(error).toHaveBeenCalled();
2253
+ });
2254
+
2255
+ // separators alone parse into a header of blank names, which would replace
2256
+ // the table with unnamed columns instead of reporting anything
2257
+ test("reports an error and keeps the table when the file has no column names", async () => {
2258
+ const { container, listen } = await render(Dataframe, drop_props);
2259
+ await wait();
2260
+ const change = listen("change");
2261
+ const error = listen("error");
2262
+
2263
+ drop_csv(container, ",\n");
2264
+ await wait();
2265
+
2266
+ expect(header_texts(container)).toEqual(["a", "b"]);
2267
+ expect(change).not.toHaveBeenCalled();
2268
+ expect(error).toHaveBeenCalled();
2269
+ });
2270
+
2271
+ test("imports a dropped CSV that ends with a blank line", async () => {
2272
+ const { container, listen } = await render(Dataframe, drop_props);
2273
+ await wait();
2274
+ const change = listen("change");
2275
+ const error = listen("error");
2276
+
2277
+ drop_csv(container, "name,age\nAlice,30\n\n");
2278
+ await wait();
2279
+
2280
+ expect(header_texts(container)).toEqual(["name", "age"]);
2281
+ expect(error).not.toHaveBeenCalled();
2282
+ expect(change.mock.calls.at(-1)?.[0].data).toEqual([["Alice", "30"]]);
2283
+ });
2284
+
2285
+ // the name says comma and the header does split on one, but only the tab
2286
+ // splits every line, so the name loses to the file's own shape
2287
+ test("imports a mislabeled CSV whose header holds a tab", async () => {
2288
+ const { container, listen } = await render(Dataframe, drop_props);
2289
+ await wait();
2290
+ const error = listen("error");
2291
+
2292
+ drop_csv(container, "first,last\tage\nAlice\t30\n");
2293
+ await wait();
2294
+
2295
+ expect(header_texts(container)).toEqual(["first,last", "age"]);
2296
+ expect(get_cell(container, 0, 0)?.textContent).toContain("Alice");
2297
+ expect(error).not.toHaveBeenCalled();
2298
+ });
2299
+
2300
+ // a dropped file the upload rejects would otherwise report a type error on a
2301
+ // table that ignores dropped files in the first place
2302
+ test("reports nothing for a file dropped on a read-only table", async () => {
2303
+ const { container, listen } = await render(Dataframe, {
2304
+ ...drop_props,
2305
+ interactive: false
2306
+ });
2307
+ await wait();
2308
+ const error = listen("error");
2309
+
2310
+ drop_csv(container, "name,age\nAlice,30\n", "data.png");
2311
+ await wait();
2312
+
2313
+ expect(error).not.toHaveBeenCalled();
2314
+ });
2315
+
2316
+ // a value can reach the frontend ragged, and a cell past the end of its own
2317
+ // row still renders and still takes an edit
2318
+ test("keeps an edit to a cell in a row shorter than the header", async () => {
2319
+ const { container, listen } = await render(Dataframe, {
2320
+ ...drop_props,
2321
+ value: {
2322
+ data: [["a1"], ["a2", "b2"]],
2323
+ headers: ["A", "B"],
2324
+ metadata: null
2325
+ }
2326
+ });
2327
+ await wait();
2328
+ const edit = listen("edit");
2329
+
2330
+ const cell = get_cell(container, 0, 1)!;
2331
+ await fireEvent.mouseDown(cell);
2332
+ await fireEvent.dblClick(cell);
2333
+ await wait();
2334
+
2335
+ const textarea = container.querySelector(
2336
+ "textarea[aria-label='Edit cell']"
2337
+ ) as HTMLTextAreaElement;
2338
+ textarea.value = "typed";
2339
+ await fireEvent.input(textarea);
2340
+ await fireEvent.blur(textarea);
2341
+ await wait();
2342
+
2343
+ expect(edit).toHaveBeenCalled();
2344
+ expect(get_cell(container, 0, 1)?.textContent).toContain("typed");
2345
+ });
2346
+
2347
+ // the cell unmounts while still in edit mode, and EditableCell commits on
2348
+ // teardown, so the stale coordinates land in the table that replaced it
2349
+ test("drops an in-flight cell edit when the imported table is narrower", async () => {
2350
+ const { container, listen } = await render(Dataframe, dynamic_3x3_props);
2351
+ await wait();
2352
+ const change = listen("change");
2353
+ const edit = listen("edit");
2354
+
2355
+ await fireEvent.dblClick(get_cell(container, 0, 2)!);
2356
+ await wait();
2357
+
2358
+ drop_csv(container, "name,age\nAlice,30\n");
2359
+ await wait();
2360
+
2361
+ expect(edit).not.toHaveBeenCalled();
2362
+ expect(change.mock.calls.at(-1)?.[0].data).toEqual([["Alice", "30"]]);
2363
+ });
2364
+
2365
+ test("drops an in-flight cell edit when the imported table is shorter", async () => {
2366
+ const { container, listen } = await render(Dataframe, dynamic_3x3_props);
2367
+ await wait();
2368
+ const change = listen("change");
2369
+ const edit = listen("edit");
2370
+
2371
+ await fireEvent.dblClick(get_cell(container, 2, 0)!);
2372
+ await wait();
2373
+
2374
+ drop_csv(container, "name,age,role\nAlice,30,Engineer\n");
2375
+ await wait();
2376
+
2377
+ expect(edit).not.toHaveBeenCalled();
2378
+ expect(change.mock.calls.at(-1)?.[0].data).toEqual([
2379
+ ["Alice", "30", "Engineer"]
2380
+ ]);
2381
+ });
2382
+
2383
+ // the shorter case above is caught by the row guard in handle_blur. this one
2384
+ // is not: the edited cell is still in range after the import, so the
2385
+ // teardown commit lands on a row that exists
2386
+ test("drops an in-flight cell edit when the imported table is the same shape", async () => {
2387
+ const { container, listen } = await render(Dataframe, dynamic_3x3_props);
2388
+ await wait();
2389
+ const change = listen("change");
2390
+ const edit = listen("edit");
2391
+
2392
+ const cell = get_cell(container, 0, 0)!;
2393
+ await fireEvent.mouseDown(cell);
2394
+ await fireEvent.dblClick(cell);
2395
+ await wait();
2396
+
2397
+ const textarea = container.querySelector(
2398
+ "textarea[aria-label='Edit cell']"
2399
+ ) as HTMLTextAreaElement;
2400
+ textarea.value = "typed";
2401
+ await fireEvent.input(textarea);
2402
+ await wait();
2403
+
2404
+ drop_csv(container, "x,y,z\n1,2,3\n4,5,6\n7,8,9\n");
2405
+ await wait();
2406
+
2407
+ expect(edit).not.toHaveBeenCalled();
2408
+ expect(get_cell(container, 0, 0)?.textContent).toContain("1");
2409
+ expect(change.mock.calls.at(-1)?.[0].data).toEqual([
2410
+ ["1", "2", "3"],
2411
+ ["4", "5", "6"],
2412
+ ["7", "8", "9"]
2413
+ ]);
2414
+ });
2415
+
2416
+ // a coordinate kept from the old table reaches handle_copy, which reads it
2417
+ // out of the new one. asserted against an import of the same shape, so the
2418
+ // cell is still in the DOM and its class means something
2419
+ test("clears the cell selection when a file is imported", async () => {
2420
+ const { container } = await render(Dataframe, dynamic_3x3_props);
2421
+ await wait();
2422
+
2423
+ await fireEvent.mouseDown(get_cell(container, 2, 2)!);
2424
+ await wait();
2425
+ expect(get_cell(container, 2, 2)?.className).toContain("cell-selected");
2426
+
2427
+ drop_csv(container, "x,y,z\n1,2,3\n4,5,6\n7,8,9\n");
2428
+ await wait();
2429
+
2430
+ expect(get_cell(container, 2, 2)?.className).not.toContain("cell-selected");
2431
+ });
2432
+
2433
+ // tanstack keys sorting and column filters by positional col_N, and a search
2434
+ // left over from the old table hides imported rows, which commit_filter then
2435
+ // drops from the value entirely
2436
+ test("clears the search when a file is imported", async () => {
2437
+ const { container } = await render(Dataframe, {
2438
+ ...dynamic_3x3_props,
2439
+ show_search: "search"
2440
+ });
2441
+ await wait();
2442
+
2443
+ const search = container.querySelector(
2444
+ "input.search-input"
2445
+ ) as HTMLInputElement;
2446
+ search.value = "a1";
2447
+ await fireEvent.input(search);
2448
+ await wait();
2449
+ expect(get_cell(container, 1, 0)).toBeNull();
2450
+
2451
+ drop_csv(container, "x,y,z\n1,2,3\n4,5,6\n7,8,9\n");
2452
+ await wait();
2453
+
2454
+ expect(get_cell(container, 0, 0)?.textContent).toContain("1");
2455
+ expect(get_cell(container, 2, 0)?.textContent).toContain("7");
2456
+ });
2457
+
2458
+ // dragenter on the cell being entered arrives before dragleave on the one
2459
+ // being left, and both bubble to the drop target
2460
+ test("keeps the highlight while the file moves between cells", async () => {
2461
+ const { container } = await render(Dataframe, dynamic_3x3_props);
2462
+ await wait();
2463
+ const table_wrap = get_table_wrap(container);
2464
+ const first = get_cell(container, 0, 0)!;
2465
+ const second = get_cell(container, 0, 1)!;
2466
+
2467
+ drag_over(first, "dragenter");
2468
+ await wait();
2469
+ expect(table_wrap).toHaveClass("file-dragging");
2470
+
2471
+ drag_over(second, "dragenter");
2472
+ drag_over(first, "dragleave");
2473
+ await wait();
2474
+ expect(table_wrap).toHaveClass("file-dragging");
2475
+
2476
+ drag_over(second, "dragleave");
2477
+ await wait();
2478
+ expect(table_wrap).not.toHaveClass("file-dragging");
2479
+ });
2480
+
2481
+ test("imports into a fixed-shape dataframe when the file matches", async () => {
2482
+ const { container } = await render(Dataframe, fixed_props);
2483
+ await wait();
2484
+
2485
+ drop_csv(container, "name,age\nAlice,30\nBob,25\n");
2486
+ await wait();
2487
+
2488
+ expect(header_texts(container)).toEqual(["name", "age"]);
2489
+ expect(get_cell(container, 1, 0)?.textContent).toContain("Bob");
2490
+ });
2491
+
2492
+ test("rejects a dropped file that does not fit a fixed column count", async () => {
2493
+ const { container, listen } = await render(Dataframe, fixed_props);
2494
+ await wait();
2495
+ const change = listen("change");
2496
+ const error = listen("error");
2497
+
2498
+ drop_csv(container, "name,age,role\nAlice,30,Engineer\nBob,25,Designer\n");
2499
+ await wait();
2500
+
2501
+ expect(header_texts(container)).toEqual(["a", "b"]);
2502
+ expect(change).not.toHaveBeenCalled();
2503
+ expect(error).toHaveBeenCalled();
2504
+ });
2505
+
2506
+ // the passthrough i18n stub returns the bare key, which has no placeholders
2507
+ // to fill, so this one swaps in a translation of its own
2508
+ test("reports an import error through i18n with its counts filled in", async () => {
2509
+ const { container, listen } = await render(Dataframe, {
2510
+ ...fixed_props,
2511
+ i18n: (key: string) =>
2512
+ key === "dataframe.import_fixed_columns"
2513
+ ? "want {expected} columns, got {actual}"
2514
+ : key
2515
+ });
2516
+ await wait();
2517
+ const error = listen("error");
2518
+
2519
+ drop_csv(container, "name,age,role\nAlice,30,Engineer\nBob,25,Designer\n");
2520
+ await wait();
2521
+
2522
+ expect(error).toHaveBeenCalledWith("want 2 columns, got 3");
2523
+ });
2524
+
2525
+ test("rejects a dropped file that does not fit a fixed row count", async () => {
2526
+ const { container, listen } = await render(Dataframe, fixed_props);
2527
+ await wait();
2528
+ const change = listen("change");
2529
+ const error = listen("error");
2530
+
2531
+ drop_csv(container, "name,age\nAlice,30\n");
2532
+ await wait();
2533
+
2534
+ expect(header_texts(container)).toEqual(["a", "b"]);
2535
+ expect(change).not.toHaveBeenCalled();
2536
+ expect(error).toHaveBeenCalled();
2537
+ });
2538
+
2539
+ test("rejects a dropped file when a column is read-only", async () => {
2540
+ const { container, listen } = await render(Dataframe, {
2541
+ ...drop_props,
2542
+ static_columns: [0]
2543
+ });
2544
+ await wait();
2545
+ const change = listen("change");
2546
+ const error = listen("error");
2547
+
2548
+ drop_csv(container, "name,age\nAlice,30\n");
2549
+ await wait();
2550
+
2551
+ expect(header_texts(container)).toEqual(["a", "b"]);
2552
+ expect(change).not.toHaveBeenCalled();
2553
+ expect(error).toHaveBeenCalled();
2554
+ });
2555
+ });