@gradio/dataframe 0.24.4 → 0.24.5

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.
@@ -252,6 +252,10 @@
252
252
 
253
253
  let selected_cells: CellCoordinate[] = $state([]);
254
254
  let selected: CellCoordinate | false = $state(false);
255
+ // where a shift+click range starts. `selected` cannot serve, because it
256
+ // follows every click, the shift+clicks that extend the range included.
257
+ // nothing renders from it, so it does not need to be reactive
258
+ let range_anchor: CellCoordinate | false = false;
255
259
  let editing: CellCoordinate | false = $state(false);
256
260
  let header_edit: number | false = $state(false);
257
261
  let selected_header: number | false = $state(false);
@@ -295,6 +299,12 @@
295
299
  return rows.findIndex((row) => row.original._index === row_index);
296
300
  }
297
301
 
302
+ // for the whole-selection sweeps, where one findIndex per cell would make
303
+ // Delete and copy quadratic on a column selection
304
+ function visible_row_set(): Set<number> {
305
+ return new Set(rows.map((row) => row.original._index));
306
+ }
307
+
298
308
  function is_active_cell(row: number, col: number): boolean {
299
309
  return !!(
300
310
  keyboard_active &&
@@ -322,6 +332,7 @@
322
332
 
323
333
  function set_active_cell(coord: CellCoordinate, move_focus = true): void {
324
334
  selected = coord;
335
+ range_anchor = coord;
325
336
  selected_cells = [coord];
326
337
  selected_header = false;
327
338
  header_edit = false;
@@ -341,6 +352,7 @@
341
352
  function handle_header_focus(col: number): void {
342
353
  if (header_edit !== false) return;
343
354
  selected = false;
355
+ range_anchor = false;
344
356
  selected_cells = [];
345
357
  selected_header = col;
346
358
  }
@@ -513,7 +525,8 @@
513
525
  function handle_cell_click(
514
526
  event: MouseEvent,
515
527
  row: number,
516
- col: number
528
+ col: number,
529
+ view_index: number
517
530
  ): void {
518
531
  const col_is_static =
519
532
  !editable ||
@@ -525,25 +538,37 @@
525
538
  event.stopPropagation();
526
539
 
527
540
  const coord: CellCoordinate = [row, col];
528
- if (event.shiftKey && selected) {
529
- // range select
530
- const [r1, c1] = selected;
531
- const [r2, c2] = coord;
532
- const new_cells: CellCoordinate[] = [];
533
- for (let r = Math.min(r1, r2); r <= Math.max(r1, r2); r++) {
534
- for (let c = Math.min(c1, c2); c <= Math.max(c1, c2); c++) {
535
- new_cells.push([r, c]);
541
+ if (event.shiftKey && range_anchor) {
542
+ // the range runs from the anchor, and the search may have hidden its
543
+ // row since
544
+ const from_row = visible_row_position(range_anchor[0]);
545
+ if (from_row === -1) {
546
+ selected_cells = [coord];
547
+ range_anchor = coord;
548
+ } else {
549
+ const c1 = range_anchor[1];
550
+ const new_cells: CellCoordinate[] = [];
551
+ for (
552
+ let p = Math.min(from_row, view_index);
553
+ p <= Math.max(from_row, view_index);
554
+ p++
555
+ ) {
556
+ for (let c = Math.min(c1, col); c <= Math.max(c1, col); c++) {
557
+ new_cells.push([rows[p].original._index, c]);
558
+ }
536
559
  }
560
+ selected_cells = new_cells;
537
561
  }
538
- selected_cells = new_cells;
539
562
  } else if (event.metaKey || event.ctrlKey) {
540
563
  // toggle select
541
564
  const exists = selected_cells.some(([r, c]) => r === row && c === col);
542
565
  selected_cells = exists
543
566
  ? selected_cells.filter(([r, c]) => !(r === row && c === col))
544
567
  : [...selected_cells, coord];
568
+ range_anchor = coord;
545
569
  } else {
546
570
  selected_cells = [coord];
571
+ range_anchor = coord;
547
572
  }
548
573
 
549
574
  selected = coord;
@@ -607,6 +632,7 @@
607
632
 
608
633
  editing = false;
609
634
  selected = false;
635
+ range_anchor = false;
610
636
  selected_cells = [];
611
637
  active_cell_menu = null;
612
638
  active_header_menu = null;
@@ -803,20 +829,40 @@
803
829
  push_change(filtered_values);
804
830
  }
805
831
 
806
- async function handle_copy(): Promise<void> {
832
+ // false when nothing reached the clipboard, so the toolbar can hold back its
833
+ // "Copied to clipboard" state
834
+ async function handle_copy(): Promise<boolean> {
835
+ const visible = visible_row_set();
836
+ const cells =
837
+ selected_cells.length > 0
838
+ ? selected_cells.filter(([r]) => visible.has(r))
839
+ : rows.flatMap((row) => {
840
+ const r = row.original._index;
841
+ return (values[r] ?? []).map((_, c) => [r, c] as CellCoordinate);
842
+ });
843
+ if (cells.length === 0) return false;
844
+
807
845
  const data_for_copy = values.map((row) =>
808
846
  row.map((val, j) => ({ id: `${j}`, value: val }))
809
847
  );
810
- const cells_to_copy = selected_cells.length > 0 ? selected_cells : null;
811
- await copy_table_data(data_for_copy, cells_to_copy);
848
+ try {
849
+ await copy_table_data(data_for_copy, cells);
850
+ } catch (err) {
851
+ // the write can be refused outright, on an insecure origin or without
852
+ // permission. logged because this catches anything else thrown too
853
+ console.error(err);
854
+ return false;
855
+ }
812
856
  copy_flash = true;
813
857
  setTimeout(() => (copy_flash = false), 800);
858
+ return true;
814
859
  }
815
860
 
816
861
  function handle_click_outside(event: Event): void {
817
862
  if (handle_click_outside_util(event, parent)) {
818
863
  selected_cells = [];
819
864
  selected = false;
865
+ range_anchor = false;
820
866
  editing = false;
821
867
  header_edit = false;
822
868
  selected_header = false;
@@ -895,6 +941,7 @@
895
941
  set_active_cell([rows[row_position - 1].original._index, col]);
896
942
  } else {
897
943
  selected = false;
944
+ range_anchor = false;
898
945
  selected_cells = [];
899
946
  selected_header = col;
900
947
  focus_header(col);
@@ -1001,13 +1048,28 @@
1001
1048
  if (!editing && editable) {
1002
1049
  e.preventDefault();
1003
1050
  const new_values = values.map((value_row) => [...value_row]);
1051
+ const visible = visible_row_set();
1052
+ let cleared = false;
1004
1053
  selected_cells.forEach(([selected_row, selected_col]) => {
1005
- if (!is_static_column(selected_col)) {
1054
+ // undefined is a cell past the end of a short row, or a row a
1055
+ // shrunk table no longer has; a null is a value, so it is cleared
1056
+ const current = new_values[selected_row]?.[selected_col];
1057
+ if (
1058
+ !is_static_column(selected_col) &&
1059
+ visible.has(selected_row) &&
1060
+ current !== "" &&
1061
+ current !== undefined
1062
+ ) {
1006
1063
  new_values[selected_row][selected_col] = "";
1064
+ cleared = true;
1007
1065
  }
1008
1066
  });
1009
- values = new_values;
1010
- push_change(new_values);
1067
+ // pushing with nothing written would fire change and input for an
1068
+ // identical table
1069
+ if (cleared) {
1070
+ values = new_values;
1071
+ push_change(new_values);
1072
+ }
1011
1073
  }
1012
1074
  break;
1013
1075
  default:
@@ -1133,7 +1195,11 @@
1133
1195
  }}
1134
1196
  />
1135
1197
 
1136
- <div class="table-container" class:fullscreen>
1198
+ <div
1199
+ class="table-container"
1200
+ class:fullscreen
1201
+ class:no-rows={values.length === 0}
1202
+ >
1137
1203
  {#if (label && label.length !== 0 && show_label) || (buttons === null ? true : buttons.includes("fullscreen")) || (buttons === null ? true : buttons.includes("copy")) || show_search !== "none"}
1138
1204
  <div class="header-row">
1139
1205
  {#if label && label.length !== 0 && show_label}
@@ -1281,6 +1347,10 @@
1281
1347
  {#each virtual_items as virtual_row (virtual_row.key)}
1282
1348
  {@const row = rows[virtual_row.index]}
1283
1349
  {@const row_idx = row?.original._index ?? virtual_row.index}
1350
+ {@const row_above =
1351
+ rows[virtual_row.index - 1]?.original._index ?? null}
1352
+ {@const row_below =
1353
+ rows[virtual_row.index + 1]?.original._index ?? null}
1284
1354
  {#if row}
1285
1355
  <div
1286
1356
  class="virtual-row"
@@ -1325,7 +1395,9 @@
1325
1395
  cell_style={get_styling(row_idx, col_idx)}
1326
1396
  selection_classes={is_cell_selected(
1327
1397
  [row_idx, col_idx],
1328
- selected_cells
1398
+ selected_cells,
1399
+ row_above,
1400
+ row_below
1329
1401
  )}
1330
1402
  is_active={is_active_cell(row_idx, col_idx)}
1331
1403
  aria_row_index={virtual_row.index + 2}
@@ -1357,7 +1429,8 @@
1357
1429
  {components}
1358
1430
  {is_dragging}
1359
1431
  wrap_text={wrap}
1360
- onmousedown={(e) => handle_cell_click(e, row_idx, col_idx)}
1432
+ onmousedown={(e) =>
1433
+ handle_cell_click(e, row_idx, col_idx, virtual_row.index)}
1361
1434
  ondblclick={(e) =>
1362
1435
  handle_cell_dblclick(e, row_idx, col_idx)}
1363
1436
  oncontextmenu={(e) => {
@@ -1396,6 +1469,10 @@
1396
1469
  <button class="scroll-top-button" onclick={scroll_to_top}>&uarr;</button>
1397
1470
  {/if}
1398
1471
  </div>
1472
+
1473
+ {#if values.length === 0 && editable && row_count[1] === "dynamic"}
1474
+ <EmptyRowButton on_click={() => add_row()} />
1475
+ {/if}
1399
1476
  </div>
1400
1477
 
1401
1478
  {#if active_cell_menu || active_header_menu}
@@ -1462,10 +1539,6 @@
1462
1539
  />
1463
1540
  {/if}
1464
1541
 
1465
- {#if values.length === 0 && editable && row_count[1] === "dynamic"}
1466
- <EmptyRowButton on_click={() => add_row()} />
1467
- {/if}
1468
-
1469
1542
  <style>
1470
1543
  .table-container {
1471
1544
  display: flex;
@@ -1502,6 +1575,14 @@
1502
1575
  min-height: 0;
1503
1576
  }
1504
1577
 
1578
+ /* An empty body would otherwise fill the screen and carry the add-row button off it.
1579
+ Gated on the data alone, so empty tables that show no button stop stretching too. */
1580
+ .table-container.fullscreen.no-rows .table-wrap,
1581
+ .table-container.fullscreen.no-rows .table-wrap > :global(*),
1582
+ .table-container.fullscreen.no-rows .virtual-table-viewport {
1583
+ flex: 0 0 auto;
1584
+ }
1585
+
1505
1586
  .table-wrap {
1506
1587
  position: relative;
1507
1588
  transition: 150ms;
@@ -18,7 +18,7 @@
18
18
  show_copy_button?: boolean;
19
19
  show_search?: "none" | "search" | "filter";
20
20
  fullscreen?: boolean;
21
- on_copy: () => Promise<void>;
21
+ on_copy: () => Promise<boolean>;
22
22
  on_commit_filter: () => void;
23
23
  current_search_query?: string | null;
24
24
  onsearch?: (query: string | null) => void;
@@ -48,8 +48,7 @@
48
48
  }
49
49
 
50
50
  async function handle_copy(): Promise<void> {
51
- await on_copy();
52
- copy_feedback();
51
+ if (await on_copy()) copy_feedback();
53
52
  }
54
53
 
55
54
  $effect(() => {
@@ -3,7 +3,7 @@ type $$ComponentProps = {
3
3
  show_copy_button?: boolean;
4
4
  show_search?: "none" | "search" | "filter";
5
5
  fullscreen?: boolean;
6
- on_copy: () => Promise<void>;
6
+ on_copy: () => Promise<boolean>;
7
7
  on_commit_filter: () => void;
8
8
  current_search_query?: string | null;
9
9
  onsearch?: (query: string | null) => void;
@@ -4,7 +4,7 @@ export type CellData = {
4
4
  value: CellValue;
5
5
  };
6
6
  export declare function is_cell_in_selection(coords: [number, number], selected_cells: [number, number][]): boolean;
7
- export declare function is_cell_selected(cell: CellCoordinate, selected_cells: CellCoordinate[]): string;
7
+ export declare function is_cell_selected(cell: CellCoordinate, selected_cells: CellCoordinate[], row_above: number | null, row_below: number | null): string;
8
8
  export declare function get_range_selection(start: CellCoordinate, end: CellCoordinate): CellCoordinate[];
9
9
  export declare function handle_selection(current: CellCoordinate, selected_cells: CellCoordinate[], event: {
10
10
  shiftKey: boolean;
@@ -2,12 +2,15 @@ export function is_cell_in_selection(coords, selected_cells) {
2
2
  const [row, col] = coords;
3
3
  return selected_cells.some(([r, c]) => r === row && c === col);
4
4
  }
5
- export function is_cell_selected(cell, selected_cells) {
5
+ // row_above/row_below are the data indices of the rows the caller is rendering
6
+ // on either side of `cell`, or null at an edge of the view. A search or a sort
7
+ // makes them differ from row ± 1, so only the caller knows them.
8
+ export function is_cell_selected(cell, selected_cells, row_above, row_below) {
6
9
  const [row, col] = cell;
7
10
  if (!selected_cells.some(([r, c]) => r === row && c === col))
8
11
  return "";
9
- const up = selected_cells.some(([r, c]) => r === row - 1 && c === col);
10
- const down = selected_cells.some(([r, c]) => r === row + 1 && c === col);
12
+ const up = selected_cells.some(([r, c]) => r === row_above && c === col);
13
+ const down = selected_cells.some(([r, c]) => r === row_below && c === col);
11
14
  const left = selected_cells.some(([r, c]) => r === row && c === col - 1);
12
15
  const right = selected_cells.some(([r, c]) => r === row && c === col + 1);
13
16
  return `cell-selected${up ? " no-top" : ""}${down ? " no-bottom" : ""}${left ? " no-left" : ""}${right ? " no-right" : ""}`;
@@ -1,7 +1,7 @@
1
1
  import type { CellValue, Headers, HeadersWithIDs, TableData } from "../types";
2
2
  export declare function make_cell_id(row: number, col: number): string;
3
3
  export declare function make_header_id(col: number): string;
4
- export declare function copy_table_data(data: TableData, selected_cells: [number, number][] | null): Promise<void>;
4
+ export declare function copy_table_data(data: TableData, selected_cells: [number, number][]): Promise<void>;
5
5
  export declare function guess_delimiter(text: string, possibleDelimiters: string[]): string[];
6
6
  export declare function data_uri_to_blob(data_uri: string): Blob;
7
7
  export declare function handle_file_upload(data_uri: string, update_headers: (headers: Headers) => HeadersWithIDs[], update_values: (values: CellValue[][]) => void): void;
@@ -8,11 +8,12 @@ export function make_header_id(col) {
8
8
  export async function copy_table_data(data, selected_cells) {
9
9
  if (!data || !data.length)
10
10
  return;
11
- const cells_to_copy = selected_cells ||
12
- data.flatMap((row, r) => row.map((_, c) => [r, c]));
13
- const csv = cells_to_copy.reduce((acc, [row, col]) => {
11
+ const csv = selected_cells.reduce((acc, [row, col]) => {
14
12
  acc[row] = acc[row] || {};
15
- const value = String(data[row][col].value);
13
+ // only the headers are padded out to the column count on the way in, so
14
+ // a selection can name a cell a short row does not have
15
+ const cell = data[row]?.[col];
16
+ const value = cell ? String(cell.value) : "";
16
17
  acc[row][col] =
17
18
  value.includes(",") || value.includes('"') || value.includes("\n")
18
19
  ? `"${value.replace(/"/g, '""')}"`
@@ -22,7 +23,9 @@ export async function copy_table_data(data, selected_cells) {
22
23
  const rows = Object.keys(csv).sort((a, b) => +a - +b);
23
24
  if (!rows.length)
24
25
  return;
25
- const cols = Object.keys(csv[rows[0]]).sort((a, b) => +a - +b);
26
+ // every column any selected row contributes: taking them from the first row
27
+ // alone drops the rest of a selection that is not rectangular there
28
+ const cols = Array.from(new Set(rows.flatMap((r) => Object.keys(csv[r])))).sort((a, b) => +a - +b);
26
29
  const text = rows
27
30
  .map((r) => cols.map((c) => csv[r][c] || "").join(","))
28
31
  .join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gradio/dataframe",
3
- "version": "0.24.4",
3
+ "version": "0.24.5",
4
4
  "description": "Gradio UI packages",
5
5
  "type": "module",
6
6
  "author": "",
@@ -18,12 +18,12 @@
18
18
  "@gradio/atoms": "^0.26.2",
19
19
  "@gradio/checkbox": "^0.8.3",
20
20
  "@gradio/button": "^0.8.2",
21
- "@gradio/icons": "^0.16.0",
22
- "@gradio/client": "^2.6.0",
21
+ "@gradio/client": "^2.7.0",
23
22
  "@gradio/markdown-code": "^0.10.1",
23
+ "@gradio/icons": "^0.16.0",
24
24
  "@gradio/upload": "^0.18.3",
25
25
  "@gradio/statustracker": "^0.15.3",
26
- "@gradio/utils": "^0.14.1"
26
+ "@gradio/utils": "^0.14.2"
27
27
  },
28
28
  "exports": {
29
29
  ".": {
@@ -42,7 +42,7 @@
42
42
  "svelte": "^5.48.0"
43
43
  },
44
44
  "devDependencies": {
45
- "@gradio/preview": "^0.17.0"
45
+ "@gradio/preview": "^0.17.1"
46
46
  },
47
47
  "repository": {
48
48
  "type": "git",
@@ -16,7 +16,6 @@
16
16
  color: var(--body-text-color);
17
17
  cursor: pointer;
18
18
  transition: all 150ms;
19
- margin-top: var(--size-2);
20
19
  z-index: 10;
21
20
  position: relative;
22
21
  pointer-events: auto;
@@ -252,6 +252,10 @@
252
252
 
253
253
  let selected_cells: CellCoordinate[] = $state([]);
254
254
  let selected: CellCoordinate | false = $state(false);
255
+ // where a shift+click range starts. `selected` cannot serve, because it
256
+ // follows every click, the shift+clicks that extend the range included.
257
+ // nothing renders from it, so it does not need to be reactive
258
+ let range_anchor: CellCoordinate | false = false;
255
259
  let editing: CellCoordinate | false = $state(false);
256
260
  let header_edit: number | false = $state(false);
257
261
  let selected_header: number | false = $state(false);
@@ -295,6 +299,12 @@
295
299
  return rows.findIndex((row) => row.original._index === row_index);
296
300
  }
297
301
 
302
+ // for the whole-selection sweeps, where one findIndex per cell would make
303
+ // Delete and copy quadratic on a column selection
304
+ function visible_row_set(): Set<number> {
305
+ return new Set(rows.map((row) => row.original._index));
306
+ }
307
+
298
308
  function is_active_cell(row: number, col: number): boolean {
299
309
  return !!(
300
310
  keyboard_active &&
@@ -322,6 +332,7 @@
322
332
 
323
333
  function set_active_cell(coord: CellCoordinate, move_focus = true): void {
324
334
  selected = coord;
335
+ range_anchor = coord;
325
336
  selected_cells = [coord];
326
337
  selected_header = false;
327
338
  header_edit = false;
@@ -341,6 +352,7 @@
341
352
  function handle_header_focus(col: number): void {
342
353
  if (header_edit !== false) return;
343
354
  selected = false;
355
+ range_anchor = false;
344
356
  selected_cells = [];
345
357
  selected_header = col;
346
358
  }
@@ -513,7 +525,8 @@
513
525
  function handle_cell_click(
514
526
  event: MouseEvent,
515
527
  row: number,
516
- col: number
528
+ col: number,
529
+ view_index: number
517
530
  ): void {
518
531
  const col_is_static =
519
532
  !editable ||
@@ -525,25 +538,37 @@
525
538
  event.stopPropagation();
526
539
 
527
540
  const coord: CellCoordinate = [row, col];
528
- if (event.shiftKey && selected) {
529
- // range select
530
- const [r1, c1] = selected;
531
- const [r2, c2] = coord;
532
- const new_cells: CellCoordinate[] = [];
533
- for (let r = Math.min(r1, r2); r <= Math.max(r1, r2); r++) {
534
- for (let c = Math.min(c1, c2); c <= Math.max(c1, c2); c++) {
535
- new_cells.push([r, c]);
541
+ if (event.shiftKey && range_anchor) {
542
+ // the range runs from the anchor, and the search may have hidden its
543
+ // row since
544
+ const from_row = visible_row_position(range_anchor[0]);
545
+ if (from_row === -1) {
546
+ selected_cells = [coord];
547
+ range_anchor = coord;
548
+ } else {
549
+ const c1 = range_anchor[1];
550
+ const new_cells: CellCoordinate[] = [];
551
+ for (
552
+ let p = Math.min(from_row, view_index);
553
+ p <= Math.max(from_row, view_index);
554
+ p++
555
+ ) {
556
+ for (let c = Math.min(c1, col); c <= Math.max(c1, col); c++) {
557
+ new_cells.push([rows[p].original._index, c]);
558
+ }
536
559
  }
560
+ selected_cells = new_cells;
537
561
  }
538
- selected_cells = new_cells;
539
562
  } else if (event.metaKey || event.ctrlKey) {
540
563
  // toggle select
541
564
  const exists = selected_cells.some(([r, c]) => r === row && c === col);
542
565
  selected_cells = exists
543
566
  ? selected_cells.filter(([r, c]) => !(r === row && c === col))
544
567
  : [...selected_cells, coord];
568
+ range_anchor = coord;
545
569
  } else {
546
570
  selected_cells = [coord];
571
+ range_anchor = coord;
547
572
  }
548
573
 
549
574
  selected = coord;
@@ -607,6 +632,7 @@
607
632
 
608
633
  editing = false;
609
634
  selected = false;
635
+ range_anchor = false;
610
636
  selected_cells = [];
611
637
  active_cell_menu = null;
612
638
  active_header_menu = null;
@@ -803,20 +829,40 @@
803
829
  push_change(filtered_values);
804
830
  }
805
831
 
806
- async function handle_copy(): Promise<void> {
832
+ // false when nothing reached the clipboard, so the toolbar can hold back its
833
+ // "Copied to clipboard" state
834
+ async function handle_copy(): Promise<boolean> {
835
+ const visible = visible_row_set();
836
+ const cells =
837
+ selected_cells.length > 0
838
+ ? selected_cells.filter(([r]) => visible.has(r))
839
+ : rows.flatMap((row) => {
840
+ const r = row.original._index;
841
+ return (values[r] ?? []).map((_, c) => [r, c] as CellCoordinate);
842
+ });
843
+ if (cells.length === 0) return false;
844
+
807
845
  const data_for_copy = values.map((row) =>
808
846
  row.map((val, j) => ({ id: `${j}`, value: val }))
809
847
  );
810
- const cells_to_copy = selected_cells.length > 0 ? selected_cells : null;
811
- await copy_table_data(data_for_copy, cells_to_copy);
848
+ try {
849
+ await copy_table_data(data_for_copy, cells);
850
+ } catch (err) {
851
+ // the write can be refused outright, on an insecure origin or without
852
+ // permission. logged because this catches anything else thrown too
853
+ console.error(err);
854
+ return false;
855
+ }
812
856
  copy_flash = true;
813
857
  setTimeout(() => (copy_flash = false), 800);
858
+ return true;
814
859
  }
815
860
 
816
861
  function handle_click_outside(event: Event): void {
817
862
  if (handle_click_outside_util(event, parent)) {
818
863
  selected_cells = [];
819
864
  selected = false;
865
+ range_anchor = false;
820
866
  editing = false;
821
867
  header_edit = false;
822
868
  selected_header = false;
@@ -895,6 +941,7 @@
895
941
  set_active_cell([rows[row_position - 1].original._index, col]);
896
942
  } else {
897
943
  selected = false;
944
+ range_anchor = false;
898
945
  selected_cells = [];
899
946
  selected_header = col;
900
947
  focus_header(col);
@@ -1001,13 +1048,28 @@
1001
1048
  if (!editing && editable) {
1002
1049
  e.preventDefault();
1003
1050
  const new_values = values.map((value_row) => [...value_row]);
1051
+ const visible = visible_row_set();
1052
+ let cleared = false;
1004
1053
  selected_cells.forEach(([selected_row, selected_col]) => {
1005
- if (!is_static_column(selected_col)) {
1054
+ // undefined is a cell past the end of a short row, or a row a
1055
+ // shrunk table no longer has; a null is a value, so it is cleared
1056
+ const current = new_values[selected_row]?.[selected_col];
1057
+ if (
1058
+ !is_static_column(selected_col) &&
1059
+ visible.has(selected_row) &&
1060
+ current !== "" &&
1061
+ current !== undefined
1062
+ ) {
1006
1063
  new_values[selected_row][selected_col] = "";
1064
+ cleared = true;
1007
1065
  }
1008
1066
  });
1009
- values = new_values;
1010
- push_change(new_values);
1067
+ // pushing with nothing written would fire change and input for an
1068
+ // identical table
1069
+ if (cleared) {
1070
+ values = new_values;
1071
+ push_change(new_values);
1072
+ }
1011
1073
  }
1012
1074
  break;
1013
1075
  default:
@@ -1133,7 +1195,11 @@
1133
1195
  }}
1134
1196
  />
1135
1197
 
1136
- <div class="table-container" class:fullscreen>
1198
+ <div
1199
+ class="table-container"
1200
+ class:fullscreen
1201
+ class:no-rows={values.length === 0}
1202
+ >
1137
1203
  {#if (label && label.length !== 0 && show_label) || (buttons === null ? true : buttons.includes("fullscreen")) || (buttons === null ? true : buttons.includes("copy")) || show_search !== "none"}
1138
1204
  <div class="header-row">
1139
1205
  {#if label && label.length !== 0 && show_label}
@@ -1281,6 +1347,10 @@
1281
1347
  {#each virtual_items as virtual_row (virtual_row.key)}
1282
1348
  {@const row = rows[virtual_row.index]}
1283
1349
  {@const row_idx = row?.original._index ?? virtual_row.index}
1350
+ {@const row_above =
1351
+ rows[virtual_row.index - 1]?.original._index ?? null}
1352
+ {@const row_below =
1353
+ rows[virtual_row.index + 1]?.original._index ?? null}
1284
1354
  {#if row}
1285
1355
  <div
1286
1356
  class="virtual-row"
@@ -1325,7 +1395,9 @@
1325
1395
  cell_style={get_styling(row_idx, col_idx)}
1326
1396
  selection_classes={is_cell_selected(
1327
1397
  [row_idx, col_idx],
1328
- selected_cells
1398
+ selected_cells,
1399
+ row_above,
1400
+ row_below
1329
1401
  )}
1330
1402
  is_active={is_active_cell(row_idx, col_idx)}
1331
1403
  aria_row_index={virtual_row.index + 2}
@@ -1357,7 +1429,8 @@
1357
1429
  {components}
1358
1430
  {is_dragging}
1359
1431
  wrap_text={wrap}
1360
- onmousedown={(e) => handle_cell_click(e, row_idx, col_idx)}
1432
+ onmousedown={(e) =>
1433
+ handle_cell_click(e, row_idx, col_idx, virtual_row.index)}
1361
1434
  ondblclick={(e) =>
1362
1435
  handle_cell_dblclick(e, row_idx, col_idx)}
1363
1436
  oncontextmenu={(e) => {
@@ -1396,6 +1469,10 @@
1396
1469
  <button class="scroll-top-button" onclick={scroll_to_top}>&uarr;</button>
1397
1470
  {/if}
1398
1471
  </div>
1472
+
1473
+ {#if values.length === 0 && editable && row_count[1] === "dynamic"}
1474
+ <EmptyRowButton on_click={() => add_row()} />
1475
+ {/if}
1399
1476
  </div>
1400
1477
 
1401
1478
  {#if active_cell_menu || active_header_menu}
@@ -1462,10 +1539,6 @@
1462
1539
  />
1463
1540
  {/if}
1464
1541
 
1465
- {#if values.length === 0 && editable && row_count[1] === "dynamic"}
1466
- <EmptyRowButton on_click={() => add_row()} />
1467
- {/if}
1468
-
1469
1542
  <style>
1470
1543
  .table-container {
1471
1544
  display: flex;
@@ -1502,6 +1575,14 @@
1502
1575
  min-height: 0;
1503
1576
  }
1504
1577
 
1578
+ /* An empty body would otherwise fill the screen and carry the add-row button off it.
1579
+ Gated on the data alone, so empty tables that show no button stop stretching too. */
1580
+ .table-container.fullscreen.no-rows .table-wrap,
1581
+ .table-container.fullscreen.no-rows .table-wrap > :global(*),
1582
+ .table-container.fullscreen.no-rows .virtual-table-viewport {
1583
+ flex: 0 0 auto;
1584
+ }
1585
+
1505
1586
  .table-wrap {
1506
1587
  position: relative;
1507
1588
  transition: 150ms;