@gradio/dataframe 0.24.3 → 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,9 +252,20 @@
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);
262
+ let keyboard_header: number | false = $derived(
263
+ selected_header !== false &&
264
+ selected_header >= 0 &&
265
+ selected_header < resolved_headers.length
266
+ ? selected_header
267
+ : false
268
+ );
258
269
  let active_cell_menu: {
259
270
  row: number;
260
271
  col: number;
@@ -270,10 +281,148 @@
270
281
 
271
282
  let parent: HTMLDivElement;
272
283
 
284
+ let keyboard_active = $derived.by((): CellCoordinate | false => {
285
+ if (selected) {
286
+ const selected_row = selected[0];
287
+ const is_visible = rows.some(
288
+ (row) => row.original._index === selected_row
289
+ );
290
+ if (is_visible) return selected;
291
+ }
292
+ if (rows.length > 0 && resolved_headers.length > 0) {
293
+ return [rows[0].original._index, 0];
294
+ }
295
+ return false;
296
+ });
297
+
298
+ function visible_row_position(row_index: number): number {
299
+ return rows.findIndex((row) => row.original._index === row_index);
300
+ }
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
+
308
+ function is_active_cell(row: number, col: number): boolean {
309
+ return !!(
310
+ keyboard_active &&
311
+ keyboard_active[0] === row &&
312
+ keyboard_active[1] === col &&
313
+ keyboard_header === false
314
+ );
315
+ }
316
+
317
+ async function focus_cell(coord: CellCoordinate): Promise<void> {
318
+ await tick();
319
+ parent
320
+ ?.querySelector<HTMLElement>(
321
+ `[data-testid="cell-${coord[0]}-${coord[1]}"]`
322
+ )
323
+ ?.focus();
324
+ }
325
+
326
+ async function focus_header(col: number): Promise<void> {
327
+ await tick();
328
+ parent
329
+ ?.querySelector<HTMLElement>(`[data-testid="header-${col}"]`)
330
+ ?.focus();
331
+ }
332
+
333
+ function set_active_cell(coord: CellCoordinate, move_focus = true): void {
334
+ selected = coord;
335
+ range_anchor = coord;
336
+ selected_cells = [coord];
337
+ selected_header = false;
338
+ header_edit = false;
339
+ const row_position = visible_row_position(coord[0]);
340
+ if (row_position >= 0) {
341
+ virtualizer.instance.scrollToIndex(row_position, { align: "auto" });
342
+ }
343
+ if (move_focus) focus_cell(coord);
344
+ }
345
+
346
+ function handle_cell_focus(row: number, col: number): void {
347
+ if (editing) return;
348
+ if (selected && selected[0] === row && selected[1] === col) return;
349
+ set_active_cell([row, col], false);
350
+ }
351
+
352
+ function handle_header_focus(col: number): void {
353
+ if (header_edit !== false) return;
354
+ selected = false;
355
+ range_anchor = false;
356
+ selected_cells = [];
357
+ selected_header = col;
358
+ }
359
+
360
+ function dispatch_select(row: number, col: number): void {
361
+ onselect?.({
362
+ index: [row, col],
363
+ value: values?.[row]?.[col],
364
+ row_value: values?.[row] ?? [],
365
+ col_value: values?.map((value_row) => value_row[col]) ?? []
366
+ } as any);
367
+ }
368
+
369
+ function adjacent_visible_cell(
370
+ coord: CellCoordinate,
371
+ backwards: boolean
372
+ ): CellCoordinate | false {
373
+ const [row, col] = coord;
374
+ const row_position = visible_row_position(row);
375
+ if (row_position === -1) return false;
376
+
377
+ if (backwards) {
378
+ if (col > 0) return [row, col - 1];
379
+ if (row_position > 0) {
380
+ return [
381
+ rows[row_position - 1].original._index,
382
+ resolved_headers.length - 1
383
+ ];
384
+ }
385
+ return false;
386
+ }
387
+
388
+ if (col < resolved_headers.length - 1) return [row, col + 1];
389
+ if (row_position < rows.length - 1) {
390
+ return [rows[row_position + 1].original._index, 0];
391
+ }
392
+ return false;
393
+ }
394
+
395
+ function focus_outside_grid(backwards: boolean): void {
396
+ tick().then(() => {
397
+ const candidates = Array.from(
398
+ document.querySelectorAll<HTMLElement>(
399
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
400
+ )
401
+ ).filter(
402
+ (element) =>
403
+ !parent.contains(element) && element.getClientRects().length > 0
404
+ );
405
+ const position = backwards
406
+ ? Node.DOCUMENT_POSITION_PRECEDING
407
+ : Node.DOCUMENT_POSITION_FOLLOWING;
408
+ const ordered = backwards ? candidates.reverse() : candidates;
409
+ ordered
410
+ .find((element) => parent.compareDocumentPosition(element) & position)
411
+ ?.focus();
412
+ });
413
+ }
414
+
273
415
  function get_dtype(col: number): Datatype {
274
416
  return Array.isArray(datatype) ? (datatype[col] ?? "str") : datatype;
275
417
  }
276
418
 
419
+ function is_static_column(col: number): boolean {
420
+ return (
421
+ static_columns.includes(col) ||
422
+ static_columns.includes(resolved_headers[col])
423
+ );
424
+ }
425
+
277
426
  type SizingEntry = { val: string; col_idx: number; dtype: Datatype };
278
427
 
279
428
  // heading multipliers for markdown/html block elements that render
@@ -376,7 +525,8 @@
376
525
  function handle_cell_click(
377
526
  event: MouseEvent,
378
527
  row: number,
379
- col: number
528
+ col: number,
529
+ view_index: number
380
530
  ): void {
381
531
  const col_is_static =
382
532
  !editable ||
@@ -388,25 +538,37 @@
388
538
  event.stopPropagation();
389
539
 
390
540
  const coord: CellCoordinate = [row, col];
391
- if (event.shiftKey && selected) {
392
- // range select
393
- const [r1, c1] = selected;
394
- const [r2, c2] = coord;
395
- const new_cells: CellCoordinate[] = [];
396
- for (let r = Math.min(r1, r2); r <= Math.max(r1, r2); r++) {
397
- for (let c = Math.min(c1, c2); c <= Math.max(c1, c2); c++) {
398
- 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
+ }
399
559
  }
560
+ selected_cells = new_cells;
400
561
  }
401
- selected_cells = new_cells;
402
562
  } else if (event.metaKey || event.ctrlKey) {
403
563
  // toggle select
404
564
  const exists = selected_cells.some(([r, c]) => r === row && c === col);
405
565
  selected_cells = exists
406
566
  ? selected_cells.filter(([r, c]) => !(r === row && c === col))
407
567
  : [...selected_cells, coord];
568
+ range_anchor = coord;
408
569
  } else {
409
570
  selected_cells = [coord];
571
+ range_anchor = coord;
410
572
  }
411
573
 
412
574
  selected = coord;
@@ -418,16 +580,8 @@
418
580
  // click selects, does NOT enter edit mode (double-click or typing does)
419
581
  editing = false;
420
582
 
421
- onselect?.({
422
- index: coord,
423
- value: values?.[row]?.[col],
424
- row_value: values?.[row] ?? [],
425
- col_value: values?.map((r) => r[col]) ?? []
426
- } as any);
427
-
428
- if (!col_is_static) {
429
- tick().then(() => parent?.focus());
430
- }
583
+ dispatch_select(row, col);
584
+ focus_cell(coord);
431
585
  }
432
586
 
433
587
  function handle_cell_dblclick(
@@ -475,22 +629,27 @@
475
629
  if (event.target instanceof HTMLAnchorElement) return;
476
630
  event.preventDefault();
477
631
  event.stopPropagation();
478
- if (!editable) return;
479
632
 
480
633
  editing = false;
481
634
  selected = false;
635
+ range_anchor = false;
482
636
  selected_cells = [];
483
637
  active_cell_menu = null;
484
638
  active_header_menu = null;
485
639
  selected_header = col;
486
- header_edit = editable ? col : false;
487
- parent?.focus();
640
+ header_edit = editable && !is_static_column(col) ? col : false;
641
+ if (header_edit === false) focus_header(col);
488
642
  }
489
643
 
490
- function end_header_edit(key: string): void {
491
- if (["Escape", "Enter", "Tab"].includes(key)) {
644
+ function end_header_edit(event: KeyboardEvent): void {
645
+ if (["Escape", "Enter", "Tab"].includes(event.key)) {
646
+ event.preventDefault();
492
647
  header_edit = false;
493
- parent?.focus();
648
+ if (event.key === "Tab") {
649
+ focus_outside_grid(event.shiftKey);
650
+ } else if (selected_header !== false) {
651
+ focus_header(selected_header);
652
+ }
494
653
  }
495
654
  }
496
655
 
@@ -565,8 +724,7 @@
565
724
  }
566
725
  values = new_values;
567
726
  push_change(new_values);
568
- selected = [index ?? new_values.length - 1, 0];
569
- parent?.focus();
727
+ set_active_cell([index ?? new_values.length - 1, 0]);
570
728
  }
571
729
 
572
730
  function add_col(index?: number): void {
@@ -583,20 +741,33 @@
583
741
  values = new_values;
584
742
  headers = new_headers;
585
743
  push_change(new_values, new_headers);
586
- parent?.focus();
744
+ const new_col = index ?? new_headers.length - 1;
745
+ if (selected) {
746
+ set_active_cell([selected[0], new_col]);
747
+ } else {
748
+ selected_header = new_col;
749
+ header_edit = false;
750
+ focus_header(new_col);
751
+ }
587
752
  }
588
753
 
589
754
  function delete_row_at(index: number): void {
590
755
  if (values.length <= 1) return;
756
+ const target_col = selected ? selected[1] : 0;
591
757
  values = [...values.slice(0, index), ...values.slice(index + 1)];
592
758
  push_change(values);
593
759
  active_cell_menu = null;
594
760
  active_header_menu = null;
761
+ set_active_cell([
762
+ Math.min(index, values.length - 1),
763
+ Math.min(target_col, resolved_headers.length - 1)
764
+ ]);
595
765
  }
596
766
 
597
767
  function delete_col_at(index: number): void {
598
768
  if (col_count[1] !== "dynamic") return;
599
769
  if ((values[0]?.length ?? 0) <= 1) return;
770
+ const target_row = selected ? selected[0] : (rows[0]?.original._index ?? 0);
600
771
  values = values.map((row) => [
601
772
  ...row.slice(0, index),
602
773
  ...row.slice(index + 1)
@@ -608,9 +779,8 @@
608
779
  push_change(values, headers as string[]);
609
780
  active_cell_menu = null;
610
781
  active_header_menu = null;
611
- selected = false;
612
- selected_cells = [];
613
782
  editing = false;
783
+ set_active_cell([target_row, Math.min(index, headers.length - 1)]);
614
784
  }
615
785
 
616
786
  function add_row_at(index: number, position: "above" | "below"): void {
@@ -659,20 +829,40 @@
659
829
  push_change(filtered_values);
660
830
  }
661
831
 
662
- 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
+
663
845
  const data_for_copy = values.map((row) =>
664
846
  row.map((val, j) => ({ id: `${j}`, value: val }))
665
847
  );
666
- const cells_to_copy = selected_cells.length > 0 ? selected_cells : null;
667
- 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
+ }
668
856
  copy_flash = true;
669
857
  setTimeout(() => (copy_flash = false), 800);
858
+ return true;
670
859
  }
671
860
 
672
861
  function handle_click_outside(event: Event): void {
673
862
  if (handle_click_outside_util(event, parent)) {
674
863
  selected_cells = [];
675
864
  selected = false;
865
+ range_anchor = false;
676
866
  editing = false;
677
867
  header_edit = false;
678
868
  selected_header = false;
@@ -682,137 +872,222 @@
682
872
  }
683
873
 
684
874
  function handle_keydown(e: KeyboardEvent): void {
685
- if (!selected && selected_header === false) return;
875
+ if (e.target instanceof HTMLAnchorElement) return;
876
+ if (!selected && keyboard_header === false) return;
686
877
 
687
- const num_rows = rows.length;
688
878
  const num_cols = resolved_headers.length;
689
879
 
690
- if (selected) {
691
- const [row, col] = selected;
692
-
693
- if (
694
- editing &&
695
- (e.key === "ArrowUp" ||
696
- e.key === "ArrowDown" ||
697
- e.key === "ArrowLeft" ||
698
- e.key === "ArrowRight")
699
- ) {
700
- return;
701
- }
702
-
880
+ if (keyboard_header !== false) {
881
+ if (header_edit !== false) return;
882
+ const col = keyboard_header;
703
883
  switch (e.key) {
704
- case "ArrowUp":
884
+ case "ArrowDown": {
705
885
  e.preventDefault();
706
- if (row > 0) {
707
- selected = [row - 1, col];
708
- selected_cells = [selected];
709
- virtualizer.instance.scrollToIndex(row - 1, { align: "auto" });
710
- }
711
- break;
712
- case "ArrowDown":
713
- e.preventDefault();
714
- if (row < num_rows - 1) {
715
- selected = [row + 1, col];
716
- selected_cells = [selected];
717
- virtualizer.instance.scrollToIndex(row + 1, { align: "auto" });
718
- }
886
+ const first_row = rows[0]?.original._index;
887
+ if (first_row !== undefined) set_active_cell([first_row, col]);
719
888
  break;
889
+ }
720
890
  case "ArrowLeft":
721
891
  e.preventDefault();
722
892
  if (col > 0) {
723
- selected = [row, col - 1];
724
- selected_cells = [selected];
893
+ selected_header = col - 1;
894
+ focus_header(col - 1);
725
895
  }
726
896
  break;
727
897
  case "ArrowRight":
728
898
  e.preventDefault();
729
899
  if (col < num_cols - 1) {
730
- selected = [row, col + 1];
731
- selected_cells = [selected];
900
+ selected_header = col + 1;
901
+ focus_header(col + 1);
732
902
  }
733
903
  break;
734
- case "Tab": {
904
+ case "Enter":
905
+ case "F2":
735
906
  e.preventDefault();
736
- const was_editing = !!editing;
737
- if (e.shiftKey) {
738
- if (col > 0) selected = [row, col - 1];
739
- else if (row > 0) selected = [row - 1, num_cols - 1];
740
- } else {
741
- if (col < num_cols - 1) selected = [row, col + 1];
742
- else if (row < num_rows - 1) selected = [row + 1, 0];
743
- }
744
- selected_cells = [selected];
745
- if (was_editing) {
746
- const tab_col = (selected as CellCoordinate)[1];
747
- const tab_static =
748
- static_columns.includes(tab_col) ||
749
- static_columns.includes(resolved_headers[tab_col]);
750
- editing = editable && !tab_static ? selected : false;
751
- } else {
752
- editing = false;
907
+ if (editable && !is_static_column(col)) header_edit = col;
908
+ break;
909
+ case " ":
910
+ case "Spacebar":
911
+ e.preventDefault();
912
+ if (editable && !is_static_column(col) && get_dtype(col) === "bool") {
913
+ handle_select_all(col, get_select_all_state(col) !== "checked");
753
914
  }
754
- if (!editing) tick().then(() => parent?.focus());
755
915
  break;
916
+ }
917
+ return;
918
+ }
919
+
920
+ if (!selected) return;
921
+ const [row, col] = selected;
922
+ const row_position = visible_row_position(row);
923
+ if (row_position === -1) return;
924
+
925
+ if (
926
+ editing &&
927
+ (e.key === "ArrowUp" ||
928
+ e.key === "ArrowDown" ||
929
+ e.key === "ArrowLeft" ||
930
+ e.key === "ArrowRight" ||
931
+ e.key === "Home" ||
932
+ e.key === "End")
933
+ ) {
934
+ return;
935
+ }
936
+
937
+ switch (e.key) {
938
+ case "ArrowUp": {
939
+ e.preventDefault();
940
+ if (row_position > 0) {
941
+ set_active_cell([rows[row_position - 1].original._index, col]);
942
+ } else {
943
+ selected = false;
944
+ range_anchor = false;
945
+ selected_cells = [];
946
+ selected_header = col;
947
+ focus_header(col);
756
948
  }
757
- case "Enter":
758
- if (editing && e.shiftKey) {
759
- // shift+enter inserts newline in textarea — don't intercept
760
- return;
949
+ break;
950
+ }
951
+ case "ArrowDown":
952
+ e.preventDefault();
953
+ if (row_position < rows.length - 1) {
954
+ set_active_cell([rows[row_position + 1].original._index, col]);
955
+ }
956
+ break;
957
+ case "ArrowLeft":
958
+ e.preventDefault();
959
+ if (col > 0) set_active_cell([row, col - 1]);
960
+ break;
961
+ case "ArrowRight":
962
+ e.preventDefault();
963
+ if (col < num_cols - 1) set_active_cell([row, col + 1]);
964
+ break;
965
+ case "Home":
966
+ e.preventDefault();
967
+ set_active_cell(
968
+ e.ctrlKey || e.metaKey ? [rows[0].original._index, 0] : [row, 0]
969
+ );
970
+ break;
971
+ case "End":
972
+ e.preventDefault();
973
+ set_active_cell(
974
+ e.ctrlKey || e.metaKey
975
+ ? [rows[rows.length - 1].original._index, num_cols - 1]
976
+ : [row, num_cols - 1]
977
+ );
978
+ break;
979
+ case "Tab": {
980
+ if (!editing) return;
981
+ e.preventDefault();
982
+ const next_cell = adjacent_visible_cell([row, col], e.shiftKey);
983
+ editing = false;
984
+ if (!next_cell) {
985
+ focus_outside_grid(e.shiftKey);
986
+ break;
987
+ }
988
+ set_active_cell(next_cell, false);
989
+ const next_col = next_cell[1];
990
+ const next_is_static = is_static_column(next_col);
991
+ if (editable && !next_is_static && get_dtype(next_col) !== "bool") {
992
+ editing = next_cell;
993
+ } else {
994
+ focus_cell(next_cell);
995
+ }
996
+ break;
997
+ }
998
+ case "Enter":
999
+ if (editing && e.shiftKey) return;
1000
+ e.preventDefault();
1001
+ if (editing) {
1002
+ editing = false;
1003
+ focus_cell([row, col]);
1004
+ } else {
1005
+ dispatch_select(row, col);
1006
+ const enter_static = is_static_column(col);
1007
+ if (editable && !enter_static && get_dtype(col) !== "bool") {
1008
+ editing = [row, col];
761
1009
  }
1010
+ }
1011
+ break;
1012
+ case "F2":
1013
+ e.preventDefault();
1014
+ if (editing) {
1015
+ editing = false;
1016
+ focus_cell([row, col]);
1017
+ } else if (
1018
+ editable &&
1019
+ !is_static_column(col) &&
1020
+ get_dtype(col) !== "bool"
1021
+ ) {
1022
+ editing = [row, col];
1023
+ }
1024
+ break;
1025
+ case " ":
1026
+ case "Spacebar":
1027
+ if (!editing) {
762
1028
  e.preventDefault();
763
- if (editing) {
764
- editing = false;
765
- if (row < num_rows - 1) {
766
- selected = [row + 1, col];
767
- selected_cells = [selected];
768
- }
769
- tick().then(() => parent?.focus());
770
- } else if (editable) {
771
- const enter_static =
772
- static_columns.includes(col) ||
773
- static_columns.includes(resolved_headers[col]);
774
- if (!enter_static) {
775
- editing = [row, col];
776
- }
1029
+ const col_is_static = is_static_column(col);
1030
+ if (editable && !col_is_static && get_dtype(col) === "bool") {
1031
+ const new_values = values.map((value_row) => [...value_row]);
1032
+ new_values[row][col] = !new_values[row][col];
1033
+ values = new_values;
1034
+ push_change(new_values);
777
1035
  }
778
- break;
779
- case "Escape":
1036
+ dispatch_select(row, col);
1037
+ }
1038
+ break;
1039
+ case "Escape":
1040
+ if (editing) {
1041
+ e.preventDefault();
780
1042
  editing = false;
781
- tick().then(() => parent?.focus());
782
- break;
783
- case "Delete":
784
- case "Backspace":
785
- if (!editing && editable) {
786
- e.preventDefault();
787
- const new_values = values.map((r) => [...r]);
788
- selected_cells.forEach(([r, c]) => {
789
- if (!static_columns.includes(c)) {
790
- new_values[r][c] = "";
791
- }
792
- });
1043
+ focus_cell([row, col]);
1044
+ }
1045
+ break;
1046
+ case "Delete":
1047
+ case "Backspace":
1048
+ if (!editing && editable) {
1049
+ e.preventDefault();
1050
+ const new_values = values.map((value_row) => [...value_row]);
1051
+ const visible = visible_row_set();
1052
+ let cleared = false;
1053
+ selected_cells.forEach(([selected_row, 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
+ ) {
1063
+ new_values[selected_row][selected_col] = "";
1064
+ cleared = true;
1065
+ }
1066
+ });
1067
+ // pushing with nothing written would fire change and input for an
1068
+ // identical table
1069
+ if (cleared) {
793
1070
  values = new_values;
794
1071
  push_change(new_values);
795
1072
  }
796
- break;
797
- default:
798
- // start editing on printable character
799
- if (
800
- editable &&
801
- !editing &&
802
- e.key.length === 1 &&
803
- !e.ctrlKey &&
804
- !e.metaKey &&
805
- !static_columns.includes(col)
806
- ) {
807
- editing = [row, col];
808
- }
809
- break;
810
- }
811
-
812
- if ((e.ctrlKey || e.metaKey) && e.key === "c") {
813
- handle_copy();
814
- }
1073
+ }
1074
+ break;
1075
+ default:
1076
+ if (
1077
+ editable &&
1078
+ !editing &&
1079
+ e.key.length === 1 &&
1080
+ !e.ctrlKey &&
1081
+ !e.metaKey &&
1082
+ !is_static_column(col) &&
1083
+ get_dtype(col) !== "bool"
1084
+ ) {
1085
+ editing = [row, col];
1086
+ }
1087
+ break;
815
1088
  }
1089
+
1090
+ if ((e.ctrlKey || e.metaKey) && e.key === "c") handle_copy();
816
1091
  }
817
1092
 
818
1093
  function handle_scroll(): void {
@@ -888,10 +1163,12 @@
888
1163
  let disable_scroll = $derived(
889
1164
  active_cell_menu !== null || active_header_menu !== null
890
1165
  );
891
- let selected_index = $derived(selected !== false ? selected[0] : false);
1166
+ let selected_index = $derived(
1167
+ selected !== false ? visible_row_position(selected[0]) : false
1168
+ );
892
1169
 
893
1170
  $effect(() => {
894
- if (typeof selected_index === "number") {
1171
+ if (typeof selected_index === "number" && selected_index >= 0) {
895
1172
  virtualizer.instance.scrollToIndex(selected_index, { align: "auto" });
896
1173
  }
897
1174
  });
@@ -918,7 +1195,11 @@
918
1195
  }}
919
1196
  />
920
1197
 
921
- <div class="table-container" class:fullscreen>
1198
+ <div
1199
+ class="table-container"
1200
+ class:fullscreen
1201
+ class:no-rows={values.length === 0}
1202
+ >
922
1203
  {#if (label && label.length !== 0 && show_label) || (buttons === null ? true : buttons.includes("fullscreen")) || (buttons === null ? true : buttons.includes("copy")) || show_search !== "none"}
923
1204
  <div class="header-row">
924
1205
  {#if label && label.length !== 0 && show_label}
@@ -947,7 +1228,11 @@
947
1228
  class:menu-open={active_cell_menu || active_header_menu}
948
1229
  onkeydown={handle_keydown}
949
1230
  role="grid"
950
- tabindex="0"
1231
+ aria-label={label || "Dataframe"}
1232
+ aria-rowcount={rows.length + 1}
1233
+ aria-colcount={resolved_headers.length + Number(show_row_numbers)}
1234
+ aria-readonly={!editable}
1235
+ tabindex={rows.length === 0 ? 0 : -1}
951
1236
  style="--df-max-col-width: {viewport_width}px;"
952
1237
  >
953
1238
  <Upload
@@ -961,6 +1246,8 @@
961
1246
  onload={on_file_upload}
962
1247
  bind:dragging
963
1248
  aria_label={i18n("dataframe.drop_to_upload")}
1249
+ tab_index={-1}
1250
+ container_element="div"
964
1251
  >
965
1252
  <div
966
1253
  class="virtual-table-viewport"
@@ -968,18 +1255,19 @@
968
1255
  bind:this={scroll_container}
969
1256
  bind:clientWidth={viewport_width}
970
1257
  onscroll={handle_scroll}
1258
+ role="none"
971
1259
  style="max-height: {max_height}px;"
972
- role="grid"
973
1260
  >
974
- {#if label && label.length !== 0}
975
- <span class="sr-only">{label}</span>
976
- {/if}
977
1261
  <!-- header row: uses table layout to auto-size columns by content -->
978
- <table class="header-table" bind:this={header_table_el}>
979
- <thead>
980
- <tr bind:this={header_row_el}>
1262
+ <table class="header-table" bind:this={header_table_el} role="none">
1263
+ <thead role="rowgroup">
1264
+ <tr bind:this={header_row_el} role="row" aria-rowindex="1">
981
1265
  {#if show_row_numbers}
982
- <th class="row-number-header">&nbsp;</th>
1266
+ <th
1267
+ class="row-number-header"
1268
+ role="columnheader"
1269
+ aria-colindex="1">&nbsp;</th
1270
+ >
983
1271
  {/if}
984
1272
  {#each header_groups as headerGroup (headerGroup.id)}
985
1273
  {#each headerGroup.headers as header (header.id)}
@@ -988,8 +1276,9 @@
988
1276
  <HeaderCell
989
1277
  value={String(header.column.columnDef.header ?? "")}
990
1278
  {col_idx}
1279
+ aria_col_index={col_idx + (show_row_numbers ? 2 : 1)}
991
1280
  is_editing={header_edit === col_idx}
992
- is_selected={selected_header === col_idx}
1281
+ is_selected={keyboard_header === col_idx}
993
1282
  is_static={!!(header.column.columnDef.meta as any)
994
1283
  ?.isStatic}
995
1284
  is_bool={get_dtype(col_idx) === "bool"}
@@ -1007,6 +1296,7 @@
1007
1296
  {i18n}
1008
1297
  wrap_text={wrap}
1009
1298
  onclick={handle_header_click}
1299
+ onfocus={handle_header_focus}
1010
1300
  on_menu_click={toggle_header_menu}
1011
1301
  on_end_edit={end_header_edit}
1012
1302
  on_select_all={handle_select_all}
@@ -1049,6 +1339,7 @@
1049
1339
  <!-- table body: absolutely positioned rows (standard tanstack virtual pattern) -->
1050
1340
  <div
1051
1341
  class="virtual-body"
1342
+ role="none"
1052
1343
  style="height: {total_size}px; position: relative; flex-shrink: 0; width: {measurement.total_header_width
1053
1344
  ? `${measurement.total_header_width}px`
1054
1345
  : '100%'};"
@@ -1056,11 +1347,17 @@
1056
1347
  {#each virtual_items as virtual_row (virtual_row.key)}
1057
1348
  {@const row = rows[virtual_row.index]}
1058
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}
1059
1354
  {#if row}
1060
1355
  <div
1061
1356
  class="virtual-row"
1062
1357
  class:row-odd={virtual_row.index % 2 !== 0}
1063
1358
  data-index={virtual_row.index}
1359
+ role="row"
1360
+ aria-rowindex={virtual_row.index + 2}
1064
1361
  style="position: absolute; top: 0; left: 0; width: 100%; transform: translateY({virtual_row.start}px);{selected_cells.some(
1065
1362
  ([r]) => r === row_idx
1066
1363
  )
@@ -1073,6 +1370,8 @@
1073
1370
  class="row-number-cell"
1074
1371
  data-row={row_idx}
1075
1372
  data-col="row-number"
1373
+ role="rowheader"
1374
+ aria-colindex="1"
1076
1375
  style="flex: 0 0 {measurement.row_num_width}px; width: {measurement.row_num_width}px;"
1077
1376
  >
1078
1377
  {row_idx + 1}
@@ -1091,12 +1390,17 @@
1091
1390
  datatype={get_dtype(col_idx)}
1092
1391
  {row_idx}
1093
1392
  {col_idx}
1393
+ aria_col_index={col_idx + (show_row_numbers ? 2 : 1)}
1094
1394
  col_style={measurement.get_col_style(ci)}
1095
1395
  cell_style={get_styling(row_idx, col_idx)}
1096
1396
  selection_classes={is_cell_selected(
1097
1397
  [row_idx, col_idx],
1098
- selected_cells
1398
+ selected_cells,
1399
+ row_above,
1400
+ row_below
1099
1401
  )}
1402
+ is_active={is_active_cell(row_idx, col_idx)}
1403
+ aria_row_index={virtual_row.index + 2}
1100
1404
  is_editing={!!(
1101
1405
  editing &&
1102
1406
  editing[0] === row_idx &&
@@ -1125,7 +1429,8 @@
1125
1429
  {components}
1126
1430
  {is_dragging}
1127
1431
  wrap_text={wrap}
1128
- 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)}
1129
1434
  ondblclick={(e) =>
1130
1435
  handle_cell_dblclick(e, row_idx, col_idx)}
1131
1436
  oncontextmenu={(e) => {
@@ -1136,11 +1441,12 @@
1136
1441
  e.preventDefault();
1137
1442
  toggle_cell_menu(e, row_idx, col_idx);
1138
1443
  }}
1444
+ onfocus={() => handle_cell_focus(row_idx, col_idx)}
1139
1445
  onblur={handle_blur}
1140
1446
  on_menu_click={(e) => toggle_cell_menu(e, row_idx, col_idx)}
1141
1447
  on_select_column={(c) => {
1142
1448
  selected_cells = rows.map(
1143
- (_, r) => [r, c] as CellCoordinate
1449
+ (row) => [row.original._index, c] as CellCoordinate
1144
1450
  );
1145
1451
  selected = selected_cells[0];
1146
1452
  }}
@@ -1163,6 +1469,10 @@
1163
1469
  <button class="scroll-top-button" onclick={scroll_to_top}>&uarr;</button>
1164
1470
  {/if}
1165
1471
  </div>
1472
+
1473
+ {#if values.length === 0 && editable && row_count[1] === "dynamic"}
1474
+ <EmptyRowButton on_click={() => add_row()} />
1475
+ {/if}
1166
1476
  </div>
1167
1477
 
1168
1478
  {#if active_cell_menu || active_header_menu}
@@ -1229,10 +1539,6 @@
1229
1539
  />
1230
1540
  {/if}
1231
1541
 
1232
- {#if values.length === 0 && editable && row_count[1] === "dynamic"}
1233
- <EmptyRowButton on_click={() => add_row()} />
1234
- {/if}
1235
-
1236
1542
  <style>
1237
1543
  .table-container {
1238
1544
  display: flex;
@@ -1269,6 +1575,14 @@
1269
1575
  min-height: 0;
1270
1576
  }
1271
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
+
1272
1586
  .table-wrap {
1273
1587
  position: relative;
1274
1588
  transition: 150ms;
@@ -1298,7 +1612,7 @@
1298
1612
  user-select: none;
1299
1613
  }
1300
1614
 
1301
- .table-wrap > :global(button) {
1615
+ .table-wrap > :global(.upload-container) {
1302
1616
  border: 1px solid var(--border-color-primary);
1303
1617
  border-radius: var(--table-radius);
1304
1618
  overflow: hidden;