@gradio/dataframe 0.22.0 → 0.23.1

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.
Files changed (128) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/Dataframe.test.ts +777 -31
  3. package/Index.svelte +107 -106
  4. package/dist/Index.svelte +107 -106
  5. package/dist/Index.svelte.d.ts +0 -1
  6. package/dist/shared/BooleanCell.svelte +12 -6
  7. package/dist/shared/BooleanCell.svelte.d.ts +4 -18
  8. package/dist/shared/CellMenu.svelte +74 -49
  9. package/dist/shared/CellMenu.svelte.d.ts +5 -19
  10. package/dist/shared/CellMenuButton.svelte +3 -3
  11. package/dist/shared/CellMenuButton.svelte.d.ts +4 -18
  12. package/dist/shared/CellMenuIcons.svelte +1 -1
  13. package/dist/shared/CellMenuIcons.svelte.d.ts +4 -18
  14. package/dist/shared/DataCell.svelte +189 -0
  15. package/dist/shared/DataCell.svelte.d.ts +44 -0
  16. package/dist/shared/EditableCell.svelte +88 -64
  17. package/dist/shared/EditableCell.svelte.d.ts +8 -26
  18. package/dist/shared/EmptyRowButton.svelte +2 -2
  19. package/dist/shared/EmptyRowButton.svelte.d.ts +4 -18
  20. package/dist/shared/FilterMenu.svelte +26 -18
  21. package/dist/shared/FilterMenu.svelte.d.ts +5 -21
  22. package/dist/shared/HeaderCell.svelte +218 -0
  23. package/dist/shared/HeaderCell.svelte.d.ts +30 -0
  24. package/dist/shared/RowNumber.svelte +4 -2
  25. package/dist/shared/RowNumber.svelte.d.ts +4 -18
  26. package/dist/shared/Table.svelte +1037 -949
  27. package/dist/shared/Table.svelte.d.ts +12 -30
  28. package/dist/shared/Toolbar.svelte +32 -22
  29. package/dist/shared/Toolbar.svelte.d.ts +6 -21
  30. package/dist/shared/column_measurement.svelte.d.ts +20 -0
  31. package/dist/shared/column_measurement.svelte.js +79 -0
  32. package/dist/shared/icons/FilterIcon.svelte +14 -6
  33. package/dist/shared/icons/FilterIcon.svelte.d.ts +14 -6
  34. package/dist/shared/icons/Padlock.svelte +6 -2
  35. package/dist/shared/icons/Padlock.svelte.d.ts +4 -25
  36. package/dist/shared/icons/SelectionButtons.svelte +23 -8
  37. package/dist/shared/icons/SelectionButtons.svelte.d.ts +4 -18
  38. package/dist/shared/icons/SortButtonDown.svelte +15 -4
  39. package/dist/shared/icons/SortButtonDown.svelte.d.ts +4 -25
  40. package/dist/shared/icons/SortButtonUp.svelte +15 -5
  41. package/dist/shared/icons/SortButtonUp.svelte.d.ts +4 -25
  42. package/dist/shared/icons/SortIcon.svelte +13 -7
  43. package/dist/shared/icons/SortIcon.svelte.d.ts +7 -21
  44. package/dist/shared/tanstack/index.d.ts +3 -0
  45. package/dist/shared/tanstack/index.js +3 -0
  46. package/dist/shared/tanstack/table.svelte.d.ts +26 -0
  47. package/dist/shared/tanstack/table.svelte.js +102 -0
  48. package/dist/shared/tanstack/virtual.svelte.d.ts +14 -0
  49. package/dist/shared/tanstack/virtual.svelte.js +76 -0
  50. package/dist/shared/types.d.ts +2 -0
  51. package/dist/shared/utils/filter.d.ts +5 -0
  52. package/dist/shared/utils/filter.js +62 -0
  53. package/dist/shared/utils/selection_utils.js +1 -1
  54. package/dist/shared/utils/table_utils.d.ts +1 -14
  55. package/dist/shared/utils/table_utils.js +0 -61
  56. package/dist/standalone/Index.svelte +71 -46
  57. package/dist/standalone/Index.svelte.d.ts +4 -23
  58. package/dist/types.d.ts +32 -0
  59. package/dist/types.js +1 -0
  60. package/package.json +12 -11
  61. package/shared/BooleanCell.svelte +12 -6
  62. package/shared/CellMenu.svelte +74 -49
  63. package/shared/CellMenuButton.svelte +3 -3
  64. package/shared/CellMenuIcons.svelte +1 -1
  65. package/shared/DataCell.svelte +189 -0
  66. package/shared/EditableCell.svelte +88 -64
  67. package/shared/EmptyRowButton.svelte +2 -2
  68. package/shared/FilterMenu.svelte +26 -18
  69. package/shared/HeaderCell.svelte +218 -0
  70. package/shared/RowNumber.svelte +4 -2
  71. package/shared/Table.svelte +1037 -949
  72. package/shared/Toolbar.svelte +32 -22
  73. package/shared/column_measurement.svelte.ts +103 -0
  74. package/shared/icons/FilterIcon.svelte +14 -6
  75. package/shared/icons/Padlock.svelte +6 -2
  76. package/shared/icons/SelectionButtons.svelte +23 -8
  77. package/shared/icons/SortButtonDown.svelte +15 -4
  78. package/shared/icons/SortButtonUp.svelte +15 -5
  79. package/shared/icons/SortIcon.svelte +13 -7
  80. package/shared/tanstack/index.ts +23 -0
  81. package/shared/tanstack/table.svelte.ts +117 -0
  82. package/shared/tanstack/virtual.svelte.ts +105 -0
  83. package/shared/types.ts +3 -0
  84. package/shared/utils/filter.ts +66 -0
  85. package/shared/utils/selection_utils.ts +1 -1
  86. package/shared/utils/table_utils.ts +1 -100
  87. package/standalone/Index.svelte +71 -46
  88. package/test/filter.test.ts +350 -0
  89. package/test/selection_utils.test.ts +416 -0
  90. package/test/table_utils.test.ts +45 -112
  91. package/types.ts +34 -0
  92. package/dist/shared/TableCell.svelte +0 -330
  93. package/dist/shared/TableCell.svelte.d.ts +0 -66
  94. package/dist/shared/TableHeader.svelte +0 -344
  95. package/dist/shared/TableHeader.svelte.d.ts +0 -61
  96. package/dist/shared/VirtualTable.svelte +0 -379
  97. package/dist/shared/VirtualTable.svelte.d.ts +0 -42
  98. package/dist/shared/context/dataframe_context.d.ts +0 -180
  99. package/dist/shared/context/dataframe_context.js +0 -412
  100. package/dist/shared/icons/SortArrowDown.svelte +0 -25
  101. package/dist/shared/icons/SortArrowDown.svelte.d.ts +0 -20
  102. package/dist/shared/icons/SortArrowUp.svelte +0 -25
  103. package/dist/shared/icons/SortArrowUp.svelte.d.ts +0 -20
  104. package/dist/shared/utils/data_processing.d.ts +0 -14
  105. package/dist/shared/utils/data_processing.js +0 -47
  106. package/dist/shared/utils/drag_utils.d.ts +0 -15
  107. package/dist/shared/utils/drag_utils.js +0 -67
  108. package/dist/shared/utils/filter_utils.d.ts +0 -29
  109. package/dist/shared/utils/filter_utils.js +0 -123
  110. package/dist/shared/utils/keyboard_utils.d.ts +0 -3
  111. package/dist/shared/utils/keyboard_utils.js +0 -236
  112. package/dist/shared/utils/menu_utils.d.ts +0 -42
  113. package/dist/shared/utils/menu_utils.js +0 -58
  114. package/dist/shared/utils/sort_utils.d.ts +0 -26
  115. package/dist/shared/utils/sort_utils.js +0 -59
  116. package/shared/TableCell.svelte +0 -330
  117. package/shared/TableHeader.svelte +0 -344
  118. package/shared/VirtualTable.svelte +0 -379
  119. package/shared/context/dataframe_context.ts +0 -701
  120. package/shared/icons/SortArrowDown.svelte +0 -25
  121. package/shared/icons/SortArrowUp.svelte +0 -25
  122. package/shared/utils/data_processing.ts +0 -77
  123. package/shared/utils/drag_utils.ts +0 -105
  124. package/shared/utils/filter_utils.ts +0 -208
  125. package/shared/utils/keyboard_utils.ts +0 -309
  126. package/shared/utils/menu_utils.ts +0 -115
  127. package/shared/utils/sort_utils.ts +0 -91
  128. package/test/sort_utils.test.ts +0 -323
package/Dataframe.test.ts CHANGED
@@ -1,44 +1,790 @@
1
- import { test, describe, assert, afterEach, beforeEach } from "vitest";
2
- import { cleanup, render } from "@self/tootils/render";
3
- import { setupi18n } from "../core/src/i18n";
4
- // import Dataframe from "./Index.svelte";
1
+ import { test, describe, afterEach, expect } from "vitest";
2
+ import { cleanup, render, fireEvent, waitFor } from "@self/tootils/render";
3
+ import { tick } from "svelte";
5
4
 
6
- describe.skip("Dataframe", () => {
7
- beforeEach(async () => {
8
- await setupi18n();
5
+ import Dataframe from "./Index.svelte";
6
+
7
+ const default_props = {
8
+ value: {
9
+ data: [
10
+ ["Alice", "30", "Engineer"],
11
+ ["Bob", "25", "Designer"],
12
+ ["Carol", "35", "Manager"]
13
+ ],
14
+ headers: ["Name", "Age", "Role"],
15
+ metadata: null
16
+ },
17
+ col_count: [3, "fixed"] as [number, "fixed" | "dynamic"],
18
+ row_count: [3, "fixed"] as [number, "fixed" | "dynamic"],
19
+ editable: true,
20
+ datatype: "str" as const,
21
+ root: "",
22
+ static_columns: [] as (string | number)[],
23
+ latex_delimiters: [] as { left: string; right: string; display: boolean }[],
24
+ line_breaks: true,
25
+ wrap: false,
26
+ column_widths: [] as string[],
27
+ show_row_numbers: false,
28
+ show_search: "none" as "none" | "search" | "filter",
29
+ pinned_columns: 0,
30
+ fullscreen: false,
31
+ max_height: 500
32
+ };
33
+
34
+ function get_cell(container: HTMLElement, row: number, col: number) {
35
+ return container.querySelector(
36
+ `[data-row='${row}'][data-col='${col}']`
37
+ ) as HTMLElement | null;
38
+ }
39
+
40
+ function get_header_cells(container: HTMLElement) {
41
+ return container.querySelectorAll("th.header-cell");
42
+ }
43
+
44
+ function get_rows(container: HTMLElement) {
45
+ return container.querySelectorAll(".virtual-row");
46
+ }
47
+
48
+ function get_table_wrap(container: HTMLElement) {
49
+ return container.querySelector(".table-wrap") as HTMLElement;
50
+ }
51
+
52
+ async function wait(ms = 50) {
53
+ await new Promise((r) => setTimeout(r, ms));
54
+ await tick();
55
+ await tick();
56
+ }
57
+
58
+ describe("Dataframe rendering", () => {
59
+ afterEach(() => cleanup());
60
+
61
+ test("renders provided headers", async () => {
62
+ const { container } = await render(Dataframe, default_props);
63
+ await wait();
64
+
65
+ const headers = get_header_cells(container);
66
+ const header_texts = Array.from(headers).map(
67
+ (h) => h.textContent?.trim() ?? ""
68
+ );
69
+ expect(header_texts).toContain("Name");
70
+ expect(header_texts).toContain("Age");
71
+ expect(header_texts).toContain("Role");
72
+ });
73
+
74
+ test("renders provided cell values", async () => {
75
+ const { container } = await render(Dataframe, default_props);
76
+ await wait();
77
+
78
+ const alice_cell = get_cell(container, 0, 0);
79
+ expect(alice_cell?.textContent).toContain("Alice");
80
+
81
+ const bob_cell = get_cell(container, 1, 0);
82
+ expect(bob_cell?.textContent).toContain("Bob");
83
+ });
84
+
85
+ test("renders correct number of rows", async () => {
86
+ const { container } = await render(Dataframe, default_props);
87
+ await wait();
88
+
89
+ const rows = get_rows(container);
90
+ expect(rows.length).toBe(3);
91
+ });
92
+
93
+ test("renders label when show_label is true", async () => {
94
+ const { container } = await render(Dataframe, {
95
+ ...default_props,
96
+ label: "Test Table",
97
+ show_label: true
98
+ });
99
+ await wait();
100
+
101
+ expect(container.textContent).toContain("Test Table");
102
+ });
103
+
104
+ test("renders row numbers when show_row_numbers is true", async () => {
105
+ const { container } = await render(Dataframe, {
106
+ ...default_props,
107
+ show_row_numbers: true
108
+ });
109
+ await wait();
110
+
111
+ const row_number_cells = container.querySelectorAll(".row-number-cell");
112
+ expect(row_number_cells.length).toBeGreaterThan(0);
113
+ });
114
+
115
+ test("renders search input when show_search is 'search'", async () => {
116
+ const { container } = await render(Dataframe, {
117
+ ...default_props,
118
+ show_search: "search"
119
+ });
120
+ await wait();
121
+
122
+ const search_input = container.querySelector(
123
+ "input.search-input"
124
+ ) as HTMLInputElement;
125
+ expect(search_input).not.toBeNull();
126
+ expect(search_input.placeholder).toBe("Search...");
127
+ });
128
+
129
+ test("renders filter input when show_search is 'filter'", async () => {
130
+ const { container } = await render(Dataframe, {
131
+ ...default_props,
132
+ show_search: "filter"
133
+ });
134
+ await wait();
135
+
136
+ const filter_input = container.querySelector(
137
+ "input.search-input"
138
+ ) as HTMLInputElement;
139
+ expect(filter_input).not.toBeNull();
140
+ expect(filter_input.placeholder).toBe("Filter...");
141
+ });
142
+ });
143
+
144
+ describe("Cell editing", () => {
145
+ afterEach(() => cleanup());
146
+
147
+ test("double-click enters edit mode (shows textarea)", async () => {
148
+ const { container } = await render(Dataframe, default_props);
149
+ await wait();
150
+
151
+ const cell = get_cell(container, 0, 0)!;
152
+ await fireEvent.mouseDown(cell);
153
+ await tick();
154
+ await fireEvent.dblClick(cell);
155
+ await wait();
156
+
157
+ const textarea = container.querySelector(
158
+ "textarea[aria-label='Edit cell']"
159
+ );
160
+ expect(textarea).not.toBeNull();
161
+ });
162
+
163
+ test("Escape key cancels edit", async () => {
164
+ const { container } = await render(Dataframe, default_props);
165
+ await wait();
166
+
167
+ const cell = get_cell(container, 0, 0)!;
168
+ await fireEvent.mouseDown(cell);
169
+ await tick();
170
+ await fireEvent.dblClick(cell);
171
+ await wait();
172
+
173
+ const table_wrap = get_table_wrap(container)!;
174
+ await fireEvent.keyDown(table_wrap, { key: "Escape" });
175
+ await wait();
176
+
177
+ const textarea = container.querySelector(
178
+ "textarea[aria-label='Edit cell']"
179
+ );
180
+ expect(textarea).toBeNull();
9
181
  });
10
- afterEach(() => {
182
+
183
+ test("non-editable dataframe prevents editing on double-click", async () => {
184
+ const { container } = await render(Dataframe, {
185
+ ...default_props,
186
+ interactive: false,
187
+ editable: false
188
+ });
189
+ await wait();
190
+
191
+ const cell = get_cell(container, 0, 0)!;
192
+ await fireEvent.mouseDown(cell);
193
+ await tick();
194
+ await fireEvent.dblClick(cell);
195
+ await wait();
196
+
197
+ const textarea = container.querySelector(
198
+ "textarea[aria-label='Edit cell']"
199
+ );
200
+ expect(textarea).toBeNull();
201
+ });
202
+
203
+ test("static columns cannot be edited", async () => {
204
+ const { container } = await render(Dataframe, {
205
+ ...default_props,
206
+ static_columns: [0]
207
+ });
208
+ await wait();
209
+
210
+ const static_cell = get_cell(container, 0, 0)!;
211
+ await fireEvent.mouseDown(static_cell);
212
+ await tick();
213
+ await fireEvent.dblClick(static_cell);
214
+ await wait();
215
+
216
+ // static cell should not have an editable textarea
217
+ const textarea = static_cell.querySelector("textarea");
218
+ expect(textarea).toBeNull();
219
+ });
220
+ });
221
+
222
+ describe("Cell selection", () => {
223
+ afterEach(() => cleanup());
224
+
225
+ test("click selects a cell", async () => {
226
+ const { container } = await render(Dataframe, default_props);
227
+ await wait();
228
+
229
+ const cell = get_cell(container, 1, 1)!;
230
+ await fireEvent.mouseDown(cell);
231
+ await wait();
232
+
233
+ // cell should have selection styling
234
+ expect(cell.className).toContain("cell-selected");
235
+ });
236
+
237
+ test("arrow keys move selection", async () => {
238
+ const { container } = await render(Dataframe, default_props);
239
+ await wait();
240
+
241
+ const cell = get_cell(container, 0, 0)!;
242
+ await fireEvent.mouseDown(cell);
243
+ await wait();
244
+
245
+ const table_wrap = get_table_wrap(container)!;
246
+ await fireEvent.keyDown(table_wrap, { key: "ArrowRight" });
247
+ await wait();
248
+
249
+ const next_cell = get_cell(container, 0, 1)!;
250
+ expect(next_cell.className).toContain("cell-selected");
251
+ });
252
+
253
+ test("Tab moves to next cell", async () => {
254
+ const { container } = await render(Dataframe, default_props);
255
+ await wait();
256
+
257
+ const cell = get_cell(container, 0, 0)!;
258
+ await fireEvent.mouseDown(cell);
259
+ await wait();
260
+
261
+ const table_wrap = get_table_wrap(container)!;
262
+ await fireEvent.keyDown(table_wrap, { key: "Tab" });
263
+ await wait();
264
+
265
+ const next_cell = get_cell(container, 0, 1)!;
266
+ expect(next_cell.className).toContain("cell-selected");
267
+ });
268
+
269
+ test("shift+click selects a range", async () => {
270
+ const { container } = await render(Dataframe, default_props);
271
+ await wait();
272
+
273
+ const first_cell = get_cell(container, 0, 0)!;
274
+ await fireEvent.mouseDown(first_cell);
275
+ await wait();
276
+
277
+ const last_cell = get_cell(container, 1, 1)!;
278
+ await fireEvent.mouseDown(last_cell, { shiftKey: true });
279
+ await wait();
280
+
281
+ // all 4 cells in the 2x2 range should be selected
282
+ expect(get_cell(container, 0, 0)!.className).toContain("cell-selected");
283
+ expect(get_cell(container, 0, 1)!.className).toContain("cell-selected");
284
+ expect(get_cell(container, 1, 0)!.className).toContain("cell-selected");
285
+ expect(get_cell(container, 1, 1)!.className).toContain("cell-selected");
286
+ });
287
+
288
+ // Regression: pressing Ctrl between mousedown and mouseup must not call
289
+ // handle_cell_click a second time via the click event. If it did, the
290
+ // Ctrl+click path would toggle the already-selected cell back off,
291
+ // producing conflicting state updates that froze the interface.
292
+ test("pressing Ctrl between mousedown and mouseup keeps cell selected", async () => {
293
+ const { container } = await render(Dataframe, default_props);
294
+ await wait();
295
+
296
+ const cell = get_cell(container, 0, 0)!;
297
+
298
+ // mousedown (no ctrl) selects the cell
299
+ await fireEvent.mouseDown(cell);
300
+ await wait();
301
+ expect(cell.className).toContain("cell-selected");
302
+
303
+ // The browser fires a click event when the user releases the mouse button.
304
+ // If the user pressed Ctrl before releasing, that click carries ctrlKey:true.
305
+ // A second call to handle_cell_click with ctrlKey would toggle the cell
306
+ // back off (deselect it). Verify that the click event has no effect.
307
+ await fireEvent.click(cell, { ctrlKey: true });
308
+ await wait();
309
+
310
+ expect(cell.className).toContain("cell-selected");
311
+ });
312
+ });
313
+
314
+ describe("Header overflow", () => {
315
+ let wrapper: HTMLDivElement;
316
+
317
+ afterEach(async () => {
11
318
  cleanup();
319
+ wrapper?.remove();
320
+ });
321
+
322
+ // Headers must expand to fit their text (no ellipsis truncation).
323
+ // When headers are wider than the scroll container, later columns are
324
+ // hidden by the container's overflow — not by cutting off the text.
325
+ test("long headers are not truncated in the DOM and the 3rd header is hidden by scroll overflow", async () => {
326
+ // Narrow container makes overflow predictable regardless of viewport size.
327
+ wrapper = document.createElement("div");
328
+ wrapper.style.width = "300px";
329
+ document.body.appendChild(wrapper);
330
+
331
+ // Capital W is one of the widest glyphs — 60 of them easily exceeds 300 px.
332
+ const long_header = "W".repeat(60);
333
+
334
+ const { getAllByRole } = await render(
335
+ Dataframe,
336
+ {
337
+ ...default_props,
338
+ value: {
339
+ data: [["a", "b", "c"]],
340
+ headers: [long_header + " A", long_header + " B", long_header + " C"],
341
+ metadata: null
342
+ },
343
+ col_count: [3, "fixed"] as [number, "fixed" | "dynamic"],
344
+ row_count: [1, "fixed"] as [number, "fixed" | "dynamic"]
345
+ },
346
+ { container: wrapper }
347
+ );
348
+
349
+ await wait();
350
+
351
+ const headers = getAllByRole("columnheader");
352
+ expect(headers).toHaveLength(3);
353
+
354
+ // Full text must be present in the DOM — no in-cell truncation.
355
+ expect(headers[2].textContent?.trim()).toContain(long_header + " C");
356
+
357
+ // The 3rd header's left edge must be beyond the scroll container's right
358
+ // edge, meaning it is completely outside the visible area.
359
+ const viewport = wrapper.querySelector(
360
+ ".virtual-table-viewport"
361
+ ) as HTMLElement;
362
+ const viewport_rect = viewport.getBoundingClientRect();
363
+ const third_rect = headers[2].getBoundingClientRect();
364
+ expect(third_rect.left).toBeGreaterThanOrEqual(viewport_rect.right);
365
+ });
366
+ });
367
+
368
+ describe("Sorting", () => {
369
+ afterEach(() => cleanup());
370
+
371
+ test("sort ascending via header menu", async () => {
372
+ const { container } = await render(Dataframe, {
373
+ ...default_props,
374
+ col_count: [3, "dynamic"] as [number, "fixed" | "dynamic"]
375
+ });
376
+ await wait();
377
+
378
+ // Click on the "Age" header — find by content containing "Age"
379
+ const headers = get_header_cells(container);
380
+ const age_header = Array.from(headers).find((h) =>
381
+ h.textContent?.includes("Age")
382
+ ) as HTMLElement;
383
+ expect(age_header).toBeTruthy();
384
+
385
+ // Click the menu button on the header
386
+ const menu_btn = age_header.querySelector(
387
+ ".cell-menu-button"
388
+ ) as HTMLElement;
389
+ expect(menu_btn).toBeTruthy();
390
+ await fireEvent.click(menu_btn);
391
+ await wait();
392
+
393
+ // Click "Sort ascending" in the menu
394
+ const sort_asc_btn = Array.from(
395
+ document.querySelectorAll('[role="menuitem"]')
396
+ ).find((el) => el.textContent?.includes("sort_ascending")) as HTMLElement;
397
+ expect(sort_asc_btn).toBeTruthy();
398
+ await fireEvent.click(sort_asc_btn);
399
+ await wait();
400
+
401
+ // After sorting by Age ascending, Bob (25) should be first visible row
402
+ const first_row = container.querySelector(".virtual-row");
403
+ const first_name_cell = first_row?.querySelector("[data-col='0']");
404
+ expect(first_name_cell?.textContent).toContain("Bob");
405
+ });
406
+ });
407
+
408
+ describe("Global search/filter", () => {
409
+ afterEach(() => cleanup());
410
+
411
+ test("search filters visible rows", async () => {
412
+ const { container } = await render(Dataframe, {
413
+ ...default_props,
414
+ show_search: "search"
415
+ });
416
+ await wait();
417
+
418
+ const search_input = container.querySelector(
419
+ "input.search-input"
420
+ ) as HTMLInputElement;
421
+ expect(search_input).not.toBeNull();
422
+
423
+ // Type a search query
424
+ search_input.focus();
425
+ await fireEvent.input(search_input, { target: { value: "Alice" } });
426
+ await wait(100);
427
+
428
+ const rows = get_rows(container);
429
+ expect(rows.length).toBe(1);
430
+ const first_cell = get_cell(container, 0, 0);
431
+ expect(first_cell?.textContent).toContain("Alice");
432
+ });
433
+
434
+ test("clearing search restores all rows", async () => {
435
+ const { container } = await render(Dataframe, {
436
+ ...default_props,
437
+ show_search: "search"
438
+ });
439
+ await wait();
440
+
441
+ const search_input = container.querySelector(
442
+ "input.search-input"
443
+ ) as HTMLInputElement;
444
+
445
+ // Search then clear
446
+ await fireEvent.input(search_input, { target: { value: "Alice" } });
447
+ await wait(100);
448
+ expect(get_rows(container).length).toBe(1);
449
+
450
+ await fireEvent.input(search_input, { target: { value: "" } });
451
+ await wait(100);
452
+ expect(get_rows(container).length).toBe(3);
453
+ });
454
+
455
+ test("filter mode shows apply button when query present", async () => {
456
+ const { container } = await render(Dataframe, {
457
+ ...default_props,
458
+ show_search: "filter"
459
+ });
460
+ await wait();
461
+
462
+ const filter_input = container.querySelector(
463
+ "input.search-input"
464
+ ) as HTMLInputElement;
465
+ await fireEvent.input(filter_input, { target: { value: "Alice" } });
466
+ await wait();
467
+
468
+ const apply_btn = container.querySelector(
469
+ "button[aria-label='Apply filter and update dataframe values']"
470
+ );
471
+ expect(apply_btn).not.toBeNull();
472
+ });
473
+ });
474
+
475
+ describe("Add/remove rows and columns", () => {
476
+ afterEach(() => cleanup());
477
+
478
+ const dynamic_props = {
479
+ ...default_props,
480
+ col_count: [3, "dynamic"] as [number, "fixed" | "dynamic"],
481
+ row_count: [3, "dynamic"] as [number, "fixed" | "dynamic"]
482
+ };
483
+
484
+ test("add row button appends a new row", async () => {
485
+ const { container } = await render(Dataframe, dynamic_props);
486
+ await wait();
487
+
488
+ const initial_rows = get_rows(container).length;
489
+
490
+ // The empty row button should be present for dynamic row_count
491
+ const add_btn = container.querySelector(".empty-row-button") as HTMLElement;
492
+ if (add_btn) {
493
+ await fireEvent.click(add_btn);
494
+ await wait();
495
+
496
+ expect(get_rows(container).length).toBe(initial_rows + 1);
497
+ }
498
+ });
499
+
500
+ // Cell menu add row tests: The CellMenu renders outside the table-wrap parent,
501
+ // so the document click handler (handle_click_outside) unmounts it before
502
+ // the menu button's onclick fires in synthetic event dispatch. These
503
+ // interactions are covered by E2E tests in dataframe_events.spec.ts.
504
+ test.todo("add row above via cell menu");
505
+ test.todo("add row below via cell menu");
506
+
507
+ test("delete row via cell menu", async () => {
508
+ const { container } = await render(Dataframe, dynamic_props);
509
+ await wait();
510
+
511
+ const cell = get_cell(container, 1, 0)!;
512
+ await fireEvent.mouseDown(cell);
513
+ await wait();
514
+
515
+ const menu_btn = cell.querySelector(".cell-menu-button") as HTMLElement;
516
+ expect(menu_btn).toBeTruthy();
517
+ await fireEvent.click(menu_btn);
518
+ await wait();
519
+
520
+ const delete_btn = document.querySelector(
521
+ '[aria-label="Delete row"]'
522
+ ) as HTMLElement;
523
+ expect(delete_btn).toBeTruthy();
524
+ await fireEvent.click(delete_btn);
525
+ await wait();
526
+
527
+ expect(get_rows(container).length).toBe(2);
528
+ });
529
+
530
+ test("add column via header menu", async () => {
531
+ const { container } = await render(Dataframe, dynamic_props);
532
+ await wait();
533
+
534
+ const initial_headers = get_header_cells(container).length;
535
+
536
+ // Click a header to select it and show menu
537
+ const headers = get_header_cells(container);
538
+ const header = headers[0] as HTMLElement;
539
+ const menu_btn = header.querySelector(".cell-menu-button") as HTMLElement;
540
+ if (menu_btn) {
541
+ await fireEvent.click(menu_btn);
542
+ await wait();
543
+
544
+ const add_col_btn = document.querySelector(
545
+ '[aria-label="Add column to the right"]'
546
+ ) as HTMLElement;
547
+ if (add_col_btn) {
548
+ await fireEvent.click(add_col_btn);
549
+ await wait();
550
+
551
+ expect(get_header_cells(container).length).toBe(initial_headers + 1);
552
+ }
553
+ }
554
+ });
555
+
556
+ test("delete column via header menu", async () => {
557
+ const { container } = await render(Dataframe, dynamic_props);
558
+ await wait();
559
+
560
+ const initial_headers = get_header_cells(container).length;
561
+
562
+ const headers = get_header_cells(container);
563
+ const header = headers[0] as HTMLElement;
564
+ const menu_btn = header.querySelector(".cell-menu-button") as HTMLElement;
565
+ if (menu_btn) {
566
+ await fireEvent.click(menu_btn);
567
+ await wait();
568
+
569
+ const delete_col_btn = document.querySelector(
570
+ '[aria-label="Delete column"]'
571
+ ) as HTMLElement;
572
+ if (delete_col_btn) {
573
+ await fireEvent.click(delete_col_btn);
574
+ await wait();
575
+
576
+ expect(get_header_cells(container).length).toBe(initial_headers - 1);
577
+ }
578
+ }
12
579
  });
580
+ });
581
+
582
+ describe("Delete/clear cells", () => {
583
+ afterEach(() => cleanup());
584
+
585
+ test("Delete key clears selected cell value", async () => {
586
+ const { container } = await render(Dataframe, default_props);
587
+ await wait();
588
+
589
+ const cell = get_cell(container, 0, 0)!;
590
+ await fireEvent.mouseDown(cell);
591
+ await wait();
592
+
593
+ const table_wrap = get_table_wrap(container)!;
594
+ // Press Escape first to ensure we're in selection mode, not edit mode
595
+ await fireEvent.keyDown(table_wrap, { key: "Escape" });
596
+ await fireEvent.keyDown(table_wrap, { key: "Delete" });
597
+ await wait();
598
+
599
+ // Cell should be empty
600
+ const cell_text = get_cell(container, 0, 0)?.textContent?.trim();
601
+ expect(cell_text === "" || cell_text === "⋮").toBe(true);
602
+ });
603
+
604
+ test("Backspace key clears selected cell value", async () => {
605
+ const { container } = await render(Dataframe, default_props);
606
+ await wait();
13
607
 
14
- test("changing value triggers change event", async () => {
15
- await setupi18n();
16
- const { component, listen } = await render(Dataframe, {
608
+ const cell = get_cell(container, 0, 1)!;
609
+ await fireEvent.mouseDown(cell);
610
+ await wait();
611
+
612
+ const table_wrap = get_table_wrap(container)!;
613
+ await fireEvent.keyDown(table_wrap, { key: "Escape" });
614
+ await fireEvent.keyDown(table_wrap, { key: "Backspace" });
615
+ await wait();
616
+
617
+ const cell_text = get_cell(container, 0, 1)?.textContent?.trim();
618
+ expect(cell_text === "" || cell_text === "⋮").toBe(true);
619
+ });
620
+
621
+ test("Delete key does not clear static column cells", async () => {
622
+ const { container } = await render(Dataframe, {
623
+ ...default_props,
624
+ static_columns: [0]
625
+ });
626
+ await wait();
627
+
628
+ const cell = get_cell(container, 0, 0)!;
629
+ await fireEvent.mouseDown(cell);
630
+ await wait();
631
+
632
+ const table_wrap = get_table_wrap(container)!;
633
+ await fireEvent.keyDown(table_wrap, { key: "Escape" });
634
+ await fireEvent.keyDown(table_wrap, { key: "Delete" });
635
+ await wait();
636
+
637
+ // Static cell should still have its value
638
+ expect(get_cell(container, 0, 0)?.textContent).toContain("Alice");
639
+ });
640
+ });
641
+
642
+ describe("Events", () => {
643
+ afterEach(() => cleanup());
644
+
645
+ test("change: emitted when value changes from outside via set_data", async () => {
646
+ const { listen, set_data } = await render(Dataframe, default_props);
647
+ await wait();
648
+
649
+ const change = listen("change");
650
+
651
+ await set_data({
17
652
  value: {
18
653
  data: [
654
+ ["X", "Y", "Z"],
19
655
  ["1", "2", "3"],
20
656
  ["4", "5", "6"]
21
657
  ],
22
- headers: ["A", "B", "C"],
658
+ headers: ["Name", "Age", "Role"],
23
659
  metadata: null
24
- },
25
- col_count: [3, "fixed"],
26
- row_count: [2, "fixed"],
27
- editable: true,
28
- datatype: "str",
29
- root: "",
30
- static_columns: []
31
- });
32
- const mock = listen("change");
33
- component.value = {
34
- data: [
35
- ["11", "22", "33"],
36
- ["44", "55", "66"]
37
- ],
38
- headers: ["A", "B", "C"],
39
- metadata: null,
40
- static_columns: []
41
- };
42
- assert.equal(mock.callCount, 1);
660
+ }
661
+ });
662
+ await wait();
663
+
664
+ expect(change).toHaveBeenCalled();
665
+ });
666
+
667
+ test("select: emitted when cell is clicked", async () => {
668
+ const { container, listen } = await render(Dataframe, default_props);
669
+ await wait();
670
+
671
+ const select = listen("select");
672
+
673
+ const cell = get_cell(container, 0, 0)!;
674
+ await fireEvent.mouseDown(cell);
675
+ await wait();
676
+
677
+ expect(select).toHaveBeenCalled();
678
+ });
679
+
680
+ test("change: emitted when cell is edited", async () => {
681
+ const { container, listen } = await render(Dataframe, default_props);
682
+ await wait();
683
+
684
+ const change = listen("change");
685
+
686
+ // Double-click to enter edit mode
687
+ const cell = get_cell(container, 0, 0)!;
688
+ await fireEvent.mouseDown(cell);
689
+ await tick();
690
+ await fireEvent.dblClick(cell);
691
+ await wait();
692
+
693
+ const textarea = container.querySelector(
694
+ "textarea[aria-label='Edit cell']"
695
+ ) as HTMLTextAreaElement;
696
+ if (textarea) {
697
+ textarea.value = "NewValue";
698
+ await fireEvent.input(textarea);
699
+ await tick();
700
+
701
+ // Press Enter to confirm
702
+ const table_wrap = get_table_wrap(container)!;
703
+ await fireEvent.keyDown(table_wrap, { key: "Enter" });
704
+ await wait();
705
+
706
+ expect(change).toHaveBeenCalled();
707
+ }
708
+ });
709
+
710
+ test("input: emitted on cell edit", async () => {
711
+ const { container, listen } = await render(Dataframe, default_props);
712
+ await wait();
713
+
714
+ const input = listen("input");
715
+
716
+ const cell = get_cell(container, 0, 0)!;
717
+ await fireEvent.mouseDown(cell);
718
+ await tick();
719
+ await fireEvent.dblClick(cell);
720
+ await wait();
721
+
722
+ const textarea = container.querySelector(
723
+ "textarea[aria-label='Edit cell']"
724
+ ) as HTMLTextAreaElement;
725
+ if (textarea) {
726
+ textarea.value = "Changed";
727
+ await fireEvent.input(textarea);
728
+ await tick();
729
+
730
+ const table_wrap = get_table_wrap(container)!;
731
+ await fireEvent.keyDown(table_wrap, { key: "Enter" });
732
+ await wait();
733
+
734
+ expect(input).toHaveBeenCalled();
735
+ }
736
+ });
737
+
738
+ test("edit: emitted with old and new value on cell edit", async () => {
739
+ const { container, listen } = await render(Dataframe, default_props);
740
+ await wait();
741
+
742
+ const edit = listen("edit");
743
+
744
+ const cell = get_cell(container, 0, 0)!;
745
+ await fireEvent.mouseDown(cell);
746
+ await tick();
747
+ await fireEvent.dblClick(cell);
748
+ await wait();
749
+
750
+ const textarea = container.querySelector(
751
+ "textarea[aria-label='Edit cell']"
752
+ ) as HTMLTextAreaElement;
753
+ if (textarea) {
754
+ textarea.value = "Zara";
755
+ await fireEvent.input(textarea);
756
+ await tick();
757
+
758
+ const table_wrap = get_table_wrap(container)!;
759
+ await fireEvent.keyDown(table_wrap, { key: "Enter" });
760
+ await wait();
761
+
762
+ expect(edit).toHaveBeenCalled();
763
+ const call_args = edit.mock.calls[0][0];
764
+ expect(call_args.value).toBe("Zara");
765
+ expect(call_args.previous_value).toBe("Alice");
766
+ }
767
+ });
768
+ });
769
+
770
+ describe("Copy", () => {
771
+ afterEach(() => cleanup());
772
+
773
+ test("Cmd+C copies selected cell to clipboard", async () => {
774
+ const { container } = await render(Dataframe, default_props);
775
+ await wait();
776
+
777
+ const cell = get_cell(container, 0, 0)!;
778
+ await fireEvent.mouseDown(cell);
779
+ await wait();
780
+
781
+ const table_wrap = get_table_wrap(container)!;
782
+ await fireEvent.keyDown(table_wrap, { key: "c", metaKey: true });
783
+
784
+ // handle_copy() is async but called without await in the keydown handler,
785
+ // so the clipboard write may not be complete immediately. Poll until it settles.
786
+ await waitFor(async () => {
787
+ expect(await navigator.clipboard.readText()).toBe("Alice");
788
+ });
43
789
  });
44
790
  });