@gradio/dataframe 0.22.0 → 0.23.0

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 +14 -0
  2. package/Dataframe.test.ts +697 -32
  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 +10 -9
  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,709 @@
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 } 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);
9
113
  });
10
- afterEach(() => {
11
- cleanup();
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();
181
+ });
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();
12
201
  });
13
202
 
14
- test("changing value triggers change event", async () => {
15
- await setupi18n();
16
- const { component, listen } = await render(Dataframe, {
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
+
289
+ describe("Sorting", () => {
290
+ afterEach(() => cleanup());
291
+
292
+ test("sort ascending via header menu", async () => {
293
+ const { container } = await render(Dataframe, {
294
+ ...default_props,
295
+ col_count: [3, "dynamic"] as [number, "fixed" | "dynamic"]
296
+ });
297
+ await wait();
298
+
299
+ // Click on the "Age" header — find by content containing "Age"
300
+ const headers = get_header_cells(container);
301
+ const age_header = Array.from(headers).find((h) =>
302
+ h.textContent?.includes("Age")
303
+ ) as HTMLElement;
304
+ expect(age_header).toBeTruthy();
305
+
306
+ // Click the menu button on the header
307
+ const menu_btn = age_header.querySelector(
308
+ ".cell-menu-button"
309
+ ) as HTMLElement;
310
+ expect(menu_btn).toBeTruthy();
311
+ await fireEvent.click(menu_btn);
312
+ await wait();
313
+
314
+ // Click "Sort ascending" in the menu
315
+ const sort_asc_btn = Array.from(
316
+ document.querySelectorAll('[role="menuitem"]')
317
+ ).find((el) => el.textContent?.includes("sort_ascending")) as HTMLElement;
318
+ expect(sort_asc_btn).toBeTruthy();
319
+ await fireEvent.click(sort_asc_btn);
320
+ await wait();
321
+
322
+ // After sorting by Age ascending, Bob (25) should be first visible row
323
+ const first_row = container.querySelector(".virtual-row");
324
+ const first_name_cell = first_row?.querySelector("[data-col='0']");
325
+ expect(first_name_cell?.textContent).toContain("Bob");
326
+ });
327
+ });
328
+
329
+ describe("Global search/filter", () => {
330
+ afterEach(() => cleanup());
331
+
332
+ test("search filters visible rows", async () => {
333
+ const { container } = await render(Dataframe, {
334
+ ...default_props,
335
+ show_search: "search"
336
+ });
337
+ await wait();
338
+
339
+ const search_input = container.querySelector(
340
+ "input.search-input"
341
+ ) as HTMLInputElement;
342
+ expect(search_input).not.toBeNull();
343
+
344
+ // Type a search query
345
+ search_input.focus();
346
+ await fireEvent.input(search_input, { target: { value: "Alice" } });
347
+ await wait(100);
348
+
349
+ const rows = get_rows(container);
350
+ expect(rows.length).toBe(1);
351
+ const first_cell = get_cell(container, 0, 0);
352
+ expect(first_cell?.textContent).toContain("Alice");
353
+ });
354
+
355
+ test("clearing search restores all rows", async () => {
356
+ const { container } = await render(Dataframe, {
357
+ ...default_props,
358
+ show_search: "search"
359
+ });
360
+ await wait();
361
+
362
+ const search_input = container.querySelector(
363
+ "input.search-input"
364
+ ) as HTMLInputElement;
365
+
366
+ // Search then clear
367
+ await fireEvent.input(search_input, { target: { value: "Alice" } });
368
+ await wait(100);
369
+ expect(get_rows(container).length).toBe(1);
370
+
371
+ await fireEvent.input(search_input, { target: { value: "" } });
372
+ await wait(100);
373
+ expect(get_rows(container).length).toBe(3);
374
+ });
375
+
376
+ test("filter mode shows apply button when query present", async () => {
377
+ const { container } = await render(Dataframe, {
378
+ ...default_props,
379
+ show_search: "filter"
380
+ });
381
+ await wait();
382
+
383
+ const filter_input = container.querySelector(
384
+ "input.search-input"
385
+ ) as HTMLInputElement;
386
+ await fireEvent.input(filter_input, { target: { value: "Alice" } });
387
+ await wait();
388
+
389
+ const apply_btn = container.querySelector(
390
+ "button[aria-label='Apply filter and update dataframe values']"
391
+ );
392
+ expect(apply_btn).not.toBeNull();
393
+ });
394
+ });
395
+
396
+ describe("Add/remove rows and columns", () => {
397
+ afterEach(() => cleanup());
398
+
399
+ const dynamic_props = {
400
+ ...default_props,
401
+ col_count: [3, "dynamic"] as [number, "fixed" | "dynamic"],
402
+ row_count: [3, "dynamic"] as [number, "fixed" | "dynamic"]
403
+ };
404
+
405
+ test("add row button appends a new row", async () => {
406
+ const { container } = await render(Dataframe, dynamic_props);
407
+ await wait();
408
+
409
+ const initial_rows = get_rows(container).length;
410
+
411
+ // The empty row button should be present for dynamic row_count
412
+ const add_btn = container.querySelector(".empty-row-button") as HTMLElement;
413
+ if (add_btn) {
414
+ await fireEvent.click(add_btn);
415
+ await wait();
416
+
417
+ expect(get_rows(container).length).toBe(initial_rows + 1);
418
+ }
419
+ });
420
+
421
+ // Cell menu add row tests: The CellMenu renders outside the table-wrap parent,
422
+ // so the document click handler (handle_click_outside) unmounts it before
423
+ // the menu button's onclick fires in synthetic event dispatch. These
424
+ // interactions are covered by E2E tests in dataframe_events.spec.ts.
425
+ test.todo("add row above via cell menu");
426
+ test.todo("add row below via cell menu");
427
+
428
+ test("delete row via cell menu", async () => {
429
+ const { container } = await render(Dataframe, dynamic_props);
430
+ await wait();
431
+
432
+ const cell = get_cell(container, 1, 0)!;
433
+ await fireEvent.mouseDown(cell);
434
+ await wait();
435
+
436
+ const menu_btn = cell.querySelector(".cell-menu-button") as HTMLElement;
437
+ expect(menu_btn).toBeTruthy();
438
+ await fireEvent.click(menu_btn);
439
+ await wait();
440
+
441
+ const delete_btn = document.querySelector(
442
+ '[aria-label="Delete row"]'
443
+ ) as HTMLElement;
444
+ expect(delete_btn).toBeTruthy();
445
+ await fireEvent.click(delete_btn);
446
+ await wait();
447
+
448
+ expect(get_rows(container).length).toBe(2);
449
+ });
450
+
451
+ test("add column via header menu", async () => {
452
+ const { container } = await render(Dataframe, dynamic_props);
453
+ await wait();
454
+
455
+ const initial_headers = get_header_cells(container).length;
456
+
457
+ // Click a header to select it and show menu
458
+ const headers = get_header_cells(container);
459
+ const header = headers[0] as HTMLElement;
460
+ const menu_btn = header.querySelector(".cell-menu-button") as HTMLElement;
461
+ if (menu_btn) {
462
+ await fireEvent.click(menu_btn);
463
+ await wait();
464
+
465
+ const add_col_btn = document.querySelector(
466
+ '[aria-label="Add column to the right"]'
467
+ ) as HTMLElement;
468
+ if (add_col_btn) {
469
+ await fireEvent.click(add_col_btn);
470
+ await wait();
471
+
472
+ expect(get_header_cells(container).length).toBe(initial_headers + 1);
473
+ }
474
+ }
475
+ });
476
+
477
+ test("delete column via header menu", async () => {
478
+ const { container } = await render(Dataframe, dynamic_props);
479
+ await wait();
480
+
481
+ const initial_headers = get_header_cells(container).length;
482
+
483
+ const headers = get_header_cells(container);
484
+ const header = headers[0] as HTMLElement;
485
+ const menu_btn = header.querySelector(".cell-menu-button") as HTMLElement;
486
+ if (menu_btn) {
487
+ await fireEvent.click(menu_btn);
488
+ await wait();
489
+
490
+ const delete_col_btn = document.querySelector(
491
+ '[aria-label="Delete column"]'
492
+ ) as HTMLElement;
493
+ if (delete_col_btn) {
494
+ await fireEvent.click(delete_col_btn);
495
+ await wait();
496
+
497
+ expect(get_header_cells(container).length).toBe(initial_headers - 1);
498
+ }
499
+ }
500
+ });
501
+ });
502
+
503
+ describe("Delete/clear cells", () => {
504
+ afterEach(() => cleanup());
505
+
506
+ test("Delete key clears selected cell value", async () => {
507
+ const { container } = await render(Dataframe, default_props);
508
+ await wait();
509
+
510
+ const cell = get_cell(container, 0, 0)!;
511
+ await fireEvent.mouseDown(cell);
512
+ await wait();
513
+
514
+ const table_wrap = get_table_wrap(container)!;
515
+ // Press Escape first to ensure we're in selection mode, not edit mode
516
+ await fireEvent.keyDown(table_wrap, { key: "Escape" });
517
+ await fireEvent.keyDown(table_wrap, { key: "Delete" });
518
+ await wait();
519
+
520
+ // Cell should be empty
521
+ const cell_text = get_cell(container, 0, 0)?.textContent?.trim();
522
+ expect(cell_text === "" || cell_text === "⋮").toBe(true);
523
+ });
524
+
525
+ test("Backspace key clears selected cell value", async () => {
526
+ const { container } = await render(Dataframe, default_props);
527
+ await wait();
528
+
529
+ const cell = get_cell(container, 0, 1)!;
530
+ await fireEvent.mouseDown(cell);
531
+ await wait();
532
+
533
+ const table_wrap = get_table_wrap(container)!;
534
+ await fireEvent.keyDown(table_wrap, { key: "Escape" });
535
+ await fireEvent.keyDown(table_wrap, { key: "Backspace" });
536
+ await wait();
537
+
538
+ const cell_text = get_cell(container, 0, 1)?.textContent?.trim();
539
+ expect(cell_text === "" || cell_text === "⋮").toBe(true);
540
+ });
541
+
542
+ test("Delete key does not clear static column cells", async () => {
543
+ const { container } = await render(Dataframe, {
544
+ ...default_props,
545
+ static_columns: [0]
546
+ });
547
+ await wait();
548
+
549
+ const cell = get_cell(container, 0, 0)!;
550
+ await fireEvent.mouseDown(cell);
551
+ await wait();
552
+
553
+ const table_wrap = get_table_wrap(container)!;
554
+ await fireEvent.keyDown(table_wrap, { key: "Escape" });
555
+ await fireEvent.keyDown(table_wrap, { key: "Delete" });
556
+ await wait();
557
+
558
+ // Static cell should still have its value
559
+ expect(get_cell(container, 0, 0)?.textContent).toContain("Alice");
560
+ });
561
+ });
562
+
563
+ describe("Events", () => {
564
+ afterEach(() => cleanup());
565
+
566
+ test("change: emitted when value changes from outside via set_data", async () => {
567
+ const { listen, set_data } = await render(Dataframe, default_props);
568
+ await wait();
569
+
570
+ const change = listen("change");
571
+
572
+ await set_data({
17
573
  value: {
18
574
  data: [
575
+ ["X", "Y", "Z"],
19
576
  ["1", "2", "3"],
20
577
  ["4", "5", "6"]
21
578
  ],
22
- headers: ["A", "B", "C"],
579
+ headers: ["Name", "Age", "Role"],
23
580
  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);
581
+ }
582
+ });
583
+ await wait();
584
+
585
+ expect(change).toHaveBeenCalled();
586
+ });
587
+
588
+ test("select: emitted when cell is clicked", async () => {
589
+ const { container, listen } = await render(Dataframe, default_props);
590
+ await wait();
591
+
592
+ const select = listen("select");
593
+
594
+ const cell = get_cell(container, 0, 0)!;
595
+ await fireEvent.mouseDown(cell);
596
+ await wait();
597
+
598
+ expect(select).toHaveBeenCalled();
599
+ });
600
+
601
+ test("change: emitted when cell is edited", async () => {
602
+ const { container, listen } = await render(Dataframe, default_props);
603
+ await wait();
604
+
605
+ const change = listen("change");
606
+
607
+ // Double-click to enter edit mode
608
+ const cell = get_cell(container, 0, 0)!;
609
+ await fireEvent.mouseDown(cell);
610
+ await tick();
611
+ await fireEvent.dblClick(cell);
612
+ await wait();
613
+
614
+ const textarea = container.querySelector(
615
+ "textarea[aria-label='Edit cell']"
616
+ ) as HTMLTextAreaElement;
617
+ if (textarea) {
618
+ textarea.value = "NewValue";
619
+ await fireEvent.input(textarea);
620
+ await tick();
621
+
622
+ // Press Enter to confirm
623
+ const table_wrap = get_table_wrap(container)!;
624
+ await fireEvent.keyDown(table_wrap, { key: "Enter" });
625
+ await wait();
626
+
627
+ expect(change).toHaveBeenCalled();
628
+ }
629
+ });
630
+
631
+ test("input: emitted on cell edit", async () => {
632
+ const { container, listen } = await render(Dataframe, default_props);
633
+ await wait();
634
+
635
+ const input = listen("input");
636
+
637
+ const cell = get_cell(container, 0, 0)!;
638
+ await fireEvent.mouseDown(cell);
639
+ await tick();
640
+ await fireEvent.dblClick(cell);
641
+ await wait();
642
+
643
+ const textarea = container.querySelector(
644
+ "textarea[aria-label='Edit cell']"
645
+ ) as HTMLTextAreaElement;
646
+ if (textarea) {
647
+ textarea.value = "Changed";
648
+ await fireEvent.input(textarea);
649
+ await tick();
650
+
651
+ const table_wrap = get_table_wrap(container)!;
652
+ await fireEvent.keyDown(table_wrap, { key: "Enter" });
653
+ await wait();
654
+
655
+ expect(input).toHaveBeenCalled();
656
+ }
657
+ });
658
+
659
+ test("edit: emitted with old and new value on cell edit", async () => {
660
+ const { container, listen } = await render(Dataframe, default_props);
661
+ await wait();
662
+
663
+ const edit = listen("edit");
664
+
665
+ const cell = get_cell(container, 0, 0)!;
666
+ await fireEvent.mouseDown(cell);
667
+ await tick();
668
+ await fireEvent.dblClick(cell);
669
+ await wait();
670
+
671
+ const textarea = container.querySelector(
672
+ "textarea[aria-label='Edit cell']"
673
+ ) as HTMLTextAreaElement;
674
+ if (textarea) {
675
+ textarea.value = "Zara";
676
+ await fireEvent.input(textarea);
677
+ await tick();
678
+
679
+ const table_wrap = get_table_wrap(container)!;
680
+ await fireEvent.keyDown(table_wrap, { key: "Enter" });
681
+ await wait();
682
+
683
+ expect(edit).toHaveBeenCalled();
684
+ const call_args = edit.mock.calls[0][0];
685
+ expect(call_args.value).toBe("Zara");
686
+ expect(call_args.previous_value).toBe("Alice");
687
+ }
688
+ });
689
+ });
690
+
691
+ describe("Copy", () => {
692
+ afterEach(() => cleanup());
693
+
694
+ test("Cmd+C copies selected cell to clipboard", async () => {
695
+ const { container } = await render(Dataframe, default_props);
696
+ await wait();
697
+
698
+ const cell = get_cell(container, 0, 0)!;
699
+ await fireEvent.mouseDown(cell);
700
+ await wait();
701
+
702
+ const table_wrap = get_table_wrap(container)!;
703
+ await fireEvent.keyDown(table_wrap, { key: "c", metaKey: true });
704
+ await wait(100);
705
+
706
+ const clipboard = await navigator.clipboard.readText();
707
+ expect(clipboard).toBe("Alice");
43
708
  });
44
709
  });