@gradio/dataframe 0.15.0 → 0.16.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 (47) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/Dataframe.stories.svelte +183 -2
  3. package/Example.svelte +7 -0
  4. package/Index.svelte +20 -3
  5. package/dist/Example.svelte +7 -0
  6. package/dist/Index.svelte +16 -4
  7. package/dist/Index.svelte.d.ts +12 -0
  8. package/dist/shared/CellMenu.svelte +1 -1
  9. package/dist/shared/EditableCell.svelte +1 -6
  10. package/dist/shared/Table.svelte +620 -319
  11. package/dist/shared/Table.svelte.d.ts +3 -0
  12. package/dist/shared/Toolbar.svelte +122 -30
  13. package/dist/shared/Toolbar.svelte.d.ts +4 -0
  14. package/dist/shared/VirtualTable.svelte +70 -26
  15. package/dist/shared/VirtualTable.svelte.d.ts +1 -0
  16. package/dist/shared/icons/FilterIcon.svelte +11 -0
  17. package/dist/shared/icons/FilterIcon.svelte.d.ts +16 -0
  18. package/dist/shared/icons/SortIcon.svelte +90 -0
  19. package/dist/shared/icons/SortIcon.svelte.d.ts +20 -0
  20. package/dist/shared/selection_utils.d.ts +12 -2
  21. package/dist/shared/selection_utils.js +33 -5
  22. package/dist/shared/types.d.ts +16 -0
  23. package/dist/shared/types.js +1 -0
  24. package/dist/shared/utils/menu_utils.d.ts +42 -0
  25. package/dist/shared/utils/menu_utils.js +58 -0
  26. package/dist/shared/utils/sort_utils.d.ts +7 -0
  27. package/dist/shared/utils/sort_utils.js +39 -0
  28. package/dist/shared/utils/table_utils.d.ts +12 -0
  29. package/dist/shared/utils/table_utils.js +148 -0
  30. package/package.json +8 -8
  31. package/shared/CellMenu.svelte +1 -1
  32. package/shared/EditableCell.svelte +1 -6
  33. package/shared/Table.svelte +649 -322
  34. package/shared/Toolbar.svelte +125 -30
  35. package/shared/VirtualTable.svelte +73 -26
  36. package/shared/icons/FilterIcon.svelte +12 -0
  37. package/shared/icons/SortIcon.svelte +95 -0
  38. package/shared/selection_utils.ts +51 -9
  39. package/shared/types.ts +27 -0
  40. package/shared/utils/menu_utils.ts +115 -0
  41. package/shared/utils/sort_utils.test.ts +71 -0
  42. package/shared/utils/sort_utils.ts +55 -0
  43. package/shared/utils/table_utils.test.ts +114 -0
  44. package/shared/utils/table_utils.ts +206 -0
  45. package/dist/shared/table_utils.d.ts +0 -12
  46. package/dist/shared/table_utils.js +0 -113
  47. package/shared/table_utils.ts +0 -148
@@ -1,14 +1,25 @@
1
1
  <script lang="ts">
2
- import { Maximize, Minimize, Copy, Check } from "@gradio/icons";
2
+ import { Maximize, Minimize, Copy } from "@gradio/icons";
3
3
  import { onDestroy } from "svelte";
4
+ import { createEventDispatcher } from "svelte";
5
+ import FilterIcon from "./icons/FilterIcon.svelte";
4
6
 
5
7
  export let show_fullscreen_button = false;
6
8
  export let show_copy_button = false;
9
+ export let show_search: "none" | "search" | "filter" = "none";
7
10
  export let is_fullscreen = false;
8
11
  export let on_copy: () => Promise<void>;
12
+ export let on_commit_filter: () => void;
13
+
14
+ const dispatch = createEventDispatcher<{
15
+ search: string | null;
16
+ }>();
9
17
 
10
18
  let copied = false;
11
19
  let timer: ReturnType<typeof setTimeout>;
20
+ export let current_search_query: string | null = null;
21
+
22
+ $: dispatch("search", current_search_query);
12
23
 
13
24
  function copy_feedback(): void {
14
25
  copied = true;
@@ -29,41 +40,70 @@
29
40
  </script>
30
41
 
31
42
  <div class="toolbar" role="toolbar" aria-label="Table actions">
32
- {#if show_copy_button}
33
- <button
34
- class="toolbar-button"
35
- on:click={handle_copy}
36
- aria-label={copied ? "Copied to clipboard" : "Copy table data"}
37
- title={copied ? "Copied to clipboard" : "Copy table data"}
38
- >
39
- {#if copied}
40
- <Check />
41
- {:else}
42
- <Copy />
43
- {/if}
44
- </button>
45
- {/if}
46
- {#if show_fullscreen_button}
47
- <button
48
- class="toolbar-button"
49
- on:click
50
- aria-label={is_fullscreen ? "Exit fullscreen" : "Enter fullscreen"}
51
- title={is_fullscreen ? "Exit fullscreen" : "Enter fullscreen"}
52
- >
53
- {#if is_fullscreen}
54
- <Minimize />
55
- {:else}
56
- <Maximize />
57
- {/if}
58
- </button>
59
- {/if}
43
+ <div class="toolbar-buttons">
44
+ {#if show_search !== "none"}
45
+ <div class="search-container">
46
+ <input
47
+ type="text"
48
+ bind:value={current_search_query}
49
+ placeholder="Search..."
50
+ class="search-input"
51
+ />
52
+ {#if current_search_query && show_search === "filter"}
53
+ <button
54
+ class="toolbar-button check-button"
55
+ on:click={on_commit_filter}
56
+ aria-label="Apply filter and update dataframe values"
57
+ title="Apply filter and update dataframe values"
58
+ >
59
+ <FilterIcon />
60
+ </button>
61
+ {/if}
62
+ </div>
63
+ {/if}
64
+ {#if show_copy_button}
65
+ <button
66
+ class="toolbar-button"
67
+ on:click={handle_copy}
68
+ aria-label={copied ? "Copied to clipboard" : "Copy table data"}
69
+ title={copied ? "Copied to clipboard" : "Copy table data"}
70
+ >
71
+ {#if copied}
72
+ <FilterIcon />
73
+ {:else}
74
+ <Copy />
75
+ {/if}
76
+ </button>
77
+ {/if}
78
+ {#if show_fullscreen_button}
79
+ <button
80
+ class="toolbar-button"
81
+ on:click
82
+ aria-label={is_fullscreen ? "Exit fullscreen" : "Enter fullscreen"}
83
+ title={is_fullscreen ? "Exit fullscreen" : "Enter fullscreen"}
84
+ >
85
+ {#if is_fullscreen}
86
+ <Minimize />
87
+ {:else}
88
+ <Maximize />
89
+ {/if}
90
+ </button>
91
+ {/if}
92
+ </div>
60
93
  </div>
61
94
 
62
95
  <style>
63
96
  .toolbar {
64
97
  display: flex;
65
- justify-content: flex-end;
98
+ align-items: center;
99
+ gap: var(--size-2);
100
+ flex: 0 0 auto;
101
+ }
102
+
103
+ .toolbar-buttons {
104
+ display: flex;
66
105
  gap: var(--size-1);
106
+ flex-wrap: nowrap;
67
107
  }
68
108
 
69
109
  .toolbar-button {
@@ -90,4 +130,59 @@
90
130
  width: var(--size-4);
91
131
  height: var(--size-4);
92
132
  }
133
+
134
+ .search-container {
135
+ position: relative;
136
+ }
137
+
138
+ .search-input {
139
+ width: var(--size-full);
140
+ height: var(--size-6);
141
+ padding: var(--size-2);
142
+ padding-right: var(--size-8);
143
+ border: 1px solid var(--border-color-primary);
144
+ border-radius: var(--table-radius);
145
+ font-size: var(--text-sm);
146
+ color: var(--body-text-color);
147
+ background: var(--background-fill-secondary);
148
+ transition: all 0.2s ease;
149
+ }
150
+
151
+ .search-input:hover {
152
+ border-color: var(--border-color-secondary);
153
+ background: var(--background-fill-primary);
154
+ }
155
+
156
+ .search-input:focus {
157
+ outline: none;
158
+ border-color: var(--color-accent);
159
+ background: var(--background-fill-primary);
160
+ box-shadow: 0 0 0 1px var(--color-accent);
161
+ }
162
+
163
+ .check-button {
164
+ position: absolute;
165
+ right: var(--size-1);
166
+ top: 50%;
167
+ transform: translateY(-50%);
168
+ background: var(--color-accent);
169
+ color: white;
170
+ border: none;
171
+ width: var(--size-4);
172
+ height: var(--size-4);
173
+ border-radius: var(--radius-sm);
174
+ display: flex;
175
+ align-items: center;
176
+ justify-content: center;
177
+ padding: var(--size-1);
178
+ }
179
+
180
+ .check-button :global(svg) {
181
+ width: var(--size-3);
182
+ height: var(--size-3);
183
+ }
184
+
185
+ .check-button:hover {
186
+ background: var(--color-accent-soft);
187
+ }
93
188
  </style>
@@ -10,6 +10,7 @@
10
10
  export let start = 0;
11
11
  export let end = 20;
12
12
  export let selected: number | false;
13
+ export let disable_scroll = false;
13
14
  let height = "100%";
14
15
 
15
16
  let average_height = 30;
@@ -41,6 +42,11 @@
41
42
  return;
42
43
  }
43
44
 
45
+ // force header height calculation first
46
+ head_height =
47
+ viewport.querySelector(".thead")?.getBoundingClientRect().height || 0;
48
+ await tick();
49
+
44
50
  const { scrollTop } = viewport;
45
51
  table_scrollbar_width = viewport.offsetWidth - viewport.clientWidth;
46
52
 
@@ -256,29 +262,32 @@
256
262
  </script>
257
263
 
258
264
  <svelte-virtual-table-viewport>
259
- <table
260
- class="table"
261
- bind:this={viewport}
262
- bind:contentRect={viewport_box}
263
- on:scroll={handle_scroll}
264
- style="height: {height}; --bw-svt-p-top: {top}px; --bw-svt-p-bottom: {bottom}px; --bw-svt-head-height: {head_height}px; --bw-svt-foot-height: {foot_height}px; --bw-svt-avg-row-height: {average_height}px"
265
- >
266
- <thead class="thead" bind:offsetHeight={head_height}>
267
- <slot name="thead" />
268
- </thead>
269
- <tbody bind:this={contents} class="tbody">
270
- {#if visible.length && visible[0].data.length}
271
- {#each visible as item (item.data[0].id)}
272
- <slot name="tbody" item={item.data} index={item.index}>
273
- Missing Table Row
274
- </slot>
275
- {/each}
276
- {/if}
277
- </tbody>
278
- <tfoot class="tfoot" bind:offsetHeight={foot_height}>
279
- <slot name="tfoot" />
280
- </tfoot>
281
- </table>
265
+ <div>
266
+ <table
267
+ class="table"
268
+ class:disable-scroll={disable_scroll}
269
+ bind:this={viewport}
270
+ bind:contentRect={viewport_box}
271
+ on:scroll={handle_scroll}
272
+ style="height: {height}; --bw-svt-p-top: {top}px; --bw-svt-p-bottom: {bottom}px; --bw-svt-head-height: {head_height}px; --bw-svt-foot-height: {foot_height}px; --bw-svt-avg-row-height: {average_height}px; --max-height: {max_height}px"
273
+ >
274
+ <thead class="thead" bind:offsetHeight={head_height}>
275
+ <slot name="thead" />
276
+ </thead>
277
+ <tbody bind:this={contents} class="tbody">
278
+ {#if visible.length && visible[0].data.length}
279
+ {#each visible as item (item.data[0].id)}
280
+ <slot name="tbody" item={item.data} index={item.index}>
281
+ Missing Table Row
282
+ </slot>
283
+ {/each}
284
+ {/if}
285
+ </tbody>
286
+ <tfoot class="tfoot" bind:offsetHeight={foot_height}>
287
+ <slot name="tfoot" />
288
+ </tfoot>
289
+ </table>
290
+ </div>
282
291
  </svelte-virtual-table-viewport>
283
292
 
284
293
  <style type="text/css">
@@ -287,7 +296,7 @@
287
296
  overflow-y: scroll;
288
297
  overflow-x: scroll;
289
298
  -webkit-overflow-scrolling: touch;
290
- max-height: 100vh;
299
+ max-height: var(--max-height);
291
300
  box-sizing: border-box;
292
301
  display: block;
293
302
  padding: 0;
@@ -335,11 +344,49 @@
335
344
  background: var(--table-even-background-fill);
336
345
  }
337
346
 
347
+ tbody :global(td.frozen-column) {
348
+ position: sticky;
349
+ z-index: var(--layer-2);
350
+ }
351
+
352
+ tbody :global(tr:nth-child(odd)) :global(td.frozen-column) {
353
+ background: var(--table-odd-background-fill);
354
+ }
355
+
356
+ tbody :global(tr:nth-child(even)) :global(td.frozen-column) {
357
+ background: var(--table-even-background-fill);
358
+ }
359
+
360
+ tbody :global(td.always-frozen) {
361
+ z-index: var(--layer-3);
362
+ }
363
+
364
+ tbody :global(td.last-frozen) {
365
+ border-right: 2px solid var(--border-color-primary);
366
+ }
367
+
338
368
  thead {
339
369
  position: sticky;
340
370
  top: 0;
341
371
  left: 0;
342
- z-index: var(--layer-1);
343
- overflow: hidden;
372
+ z-index: var(--layer-3);
373
+ background: var(--background-fill-primary);
374
+ }
375
+
376
+ thead :global(th) {
377
+ background: var(--table-even-background-fill) !important;
378
+ }
379
+
380
+ thead :global(th.frozen-column) {
381
+ position: sticky;
382
+ z-index: var(--layer-4);
383
+ }
384
+
385
+ thead :global(th.always-frozen) {
386
+ z-index: var(--layer-5);
387
+ }
388
+
389
+ .table.disable-scroll {
390
+ overflow: hidden !important;
344
391
  }
345
392
  </style>
@@ -0,0 +1,12 @@
1
+ <script lang="ts">
2
+ </script>
3
+
4
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
5
+ <path
6
+ d="M4 4h16v2.67l-6.67 6.67v8L9.33 19v-5.66L2.67 6.67V4h1.33z"
7
+ stroke="currentColor"
8
+ stroke-width="2"
9
+ stroke-linecap="round"
10
+ stroke-linejoin="round"
11
+ />
12
+ </svg>
@@ -0,0 +1,95 @@
1
+ <script lang="ts">
2
+ import { createEventDispatcher } from "svelte";
3
+ import type { I18nFormatter } from "@gradio/utils";
4
+
5
+ type SortDirection = "asc" | "des";
6
+ export let direction: SortDirection | null = null;
7
+ export let i18n: I18nFormatter;
8
+
9
+ const dispatch = createEventDispatcher<{ sort: SortDirection }>();
10
+ </script>
11
+
12
+ <div class="sort-icons" role="group" aria-label={i18n("dataframe.sort_column")}>
13
+ <button
14
+ class="sort-button up"
15
+ class:active={direction === "asc"}
16
+ on:click={() => dispatch("sort", "asc")}
17
+ aria-label={i18n("dataframe.sort_ascending")}
18
+ aria-pressed={direction === "asc"}
19
+ >
20
+ <svg
21
+ viewBox="0 0 24 24"
22
+ fill="none"
23
+ xmlns="http://www.w3.org/2000/svg"
24
+ aria-hidden="true"
25
+ focusable="false"
26
+ >
27
+ <path
28
+ d="M7 14l5-5 5 5"
29
+ stroke="currentColor"
30
+ stroke-width="2"
31
+ stroke-linecap="round"
32
+ stroke-linejoin="round"
33
+ />
34
+ </svg>
35
+ </button>
36
+ <button
37
+ class="sort-button down"
38
+ class:active={direction === "des"}
39
+ on:click={() => dispatch("sort", "des")}
40
+ aria-label={i18n("dataframe.sort_descending")}
41
+ aria-pressed={direction === "des"}
42
+ >
43
+ <svg
44
+ viewBox="0 0 24 24"
45
+ fill="none"
46
+ xmlns="http://www.w3.org/2000/svg"
47
+ aria-hidden="true"
48
+ focusable="false"
49
+ >
50
+ <path
51
+ d="M7 10l5 5 5-5"
52
+ stroke="currentColor"
53
+ stroke-width="2"
54
+ stroke-linecap="round"
55
+ stroke-linejoin="round"
56
+ />
57
+ </svg>
58
+ </button>
59
+ </div>
60
+
61
+ <style>
62
+ .sort-icons {
63
+ display: flex;
64
+ flex-direction: column;
65
+ gap: 0;
66
+ margin-right: var(--spacing-md);
67
+ }
68
+
69
+ .sort-button {
70
+ display: flex;
71
+ align-items: center;
72
+ justify-content: center;
73
+ padding: 0;
74
+ background: none;
75
+ border: none;
76
+ cursor: pointer;
77
+ opacity: 0.5;
78
+ transition: opacity 150ms;
79
+ }
80
+
81
+ .sort-button:hover {
82
+ opacity: 0.8;
83
+ }
84
+
85
+ .sort-button.active {
86
+ opacity: 1;
87
+ color: var(--color-accent);
88
+ }
89
+
90
+ svg {
91
+ width: var(--size-3);
92
+ height: var(--size-3);
93
+ display: block;
94
+ }
95
+ </style>
@@ -1,6 +1,6 @@
1
- export type CellCoordinate = [number, number];
1
+ import type { CellCoordinate, EditingState } from "./types";
2
+
2
3
  export type CellData = { id: string; value: string | number };
3
- export type EditingState = false | CellCoordinate;
4
4
 
5
5
  export function is_cell_selected(
6
6
  cell: CellCoordinate,
@@ -50,13 +50,12 @@ export function handle_selection(
50
50
  }
51
51
 
52
52
  if (event.metaKey || event.ctrlKey) {
53
- const index = selected_cells.findIndex(
54
- ([r, c]) => r === current[0] && c === current[1]
55
- );
56
- if (index === -1) {
57
- return [...selected_cells, current];
58
- }
59
- return selected_cells.filter((_, i) => i !== index);
53
+ const is_cell_match = ([r, c]: CellCoordinate): boolean =>
54
+ r === current[0] && c === current[1];
55
+ const index = selected_cells.findIndex(is_cell_match);
56
+ return index === -1
57
+ ? [...selected_cells, current]
58
+ : selected_cells.filter((_, i) => i !== index);
60
59
  }
61
60
 
62
61
  return [current];
@@ -186,3 +185,46 @@ export function handle_click_outside(
186
185
  const [trigger] = event.composedPath() as HTMLElement[];
187
186
  return !parent.contains(trigger);
188
187
  }
188
+
189
+ export function select_column(data: any[][], col: number): CellCoordinate[] {
190
+ return Array.from({ length: data.length }, (_, i) => [i, col]);
191
+ }
192
+
193
+ export function select_row(data: any[][], row: number): CellCoordinate[] {
194
+ return Array.from({ length: data[0].length }, (_, i) => [row, i]);
195
+ }
196
+
197
+ export function calculate_selection_positions(
198
+ selected: CellCoordinate,
199
+ data: { id: string; value: string | number }[][],
200
+ els: Record<string, { cell: HTMLTableCellElement | null }>,
201
+ parent: HTMLElement,
202
+ table: HTMLElement
203
+ ): { col_pos: string; row_pos: string | undefined } {
204
+ const [row, col] = selected;
205
+ if (!data[row]?.[col]) {
206
+ return { col_pos: "0px", row_pos: undefined };
207
+ }
208
+
209
+ let offset = 0;
210
+ for (let i = 0; i < col; i++) {
211
+ offset += parseFloat(
212
+ getComputedStyle(parent).getPropertyValue(`--cell-width-${i}`)
213
+ );
214
+ }
215
+
216
+ const cell_id = data[row][col].id;
217
+ const cell_el = els[cell_id]?.cell;
218
+
219
+ if (!cell_el) {
220
+ // if we cant get the row position, just return the column position which is static
221
+ return { col_pos: "0px", row_pos: undefined };
222
+ }
223
+
224
+ const cell_rect = cell_el.getBoundingClientRect();
225
+ const table_rect = table.getBoundingClientRect();
226
+ const col_pos = `${cell_rect.left - table_rect.left + cell_rect.width / 2}px`;
227
+ const relative_top = cell_rect.top - table_rect.top;
228
+ const row_pos = `${relative_top + cell_rect.height / 2}px`;
229
+ return { col_pos, row_pos };
230
+ }
package/shared/types.ts CHANGED
@@ -1,2 +1,29 @@
1
1
  export type CellCoordinate = [number, number];
2
2
  export type EditingState = CellCoordinate | false;
3
+
4
+ export type Headers = (string | null)[];
5
+
6
+ export interface HeadersWithIDs {
7
+ id: string;
8
+ value: string;
9
+ }
10
+ [];
11
+
12
+ export interface TableCell {
13
+ id: string;
14
+ value: string | number;
15
+ }
16
+
17
+ export type TableData = TableCell[][];
18
+
19
+ export type CountConfig = [number, "fixed" | "dynamic"];
20
+
21
+ export type ElementRefs = Record<
22
+ string,
23
+ {
24
+ cell: null | HTMLTableCellElement;
25
+ input: null | HTMLInputElement;
26
+ }
27
+ >;
28
+
29
+ export type DataBinding = Record<string, TableCell>;
@@ -0,0 +1,115 @@
1
+ export function toggle_header_menu(
2
+ event: MouseEvent,
3
+ col: number,
4
+ active_header_menu: { col: number; x: number; y: number } | null,
5
+ set_active_header_menu: (
6
+ menu: { col: number; x: number; y: number } | null
7
+ ) => void
8
+ ): void {
9
+ event.stopPropagation();
10
+ if (active_header_menu && active_header_menu.col === col) {
11
+ set_active_header_menu(null);
12
+ } else {
13
+ const header = (event.target as HTMLElement).closest("th");
14
+ if (header) {
15
+ const rect = header.getBoundingClientRect();
16
+ set_active_header_menu({ col, x: rect.right, y: rect.bottom });
17
+ }
18
+ }
19
+ }
20
+
21
+ export function toggle_cell_menu(
22
+ event: MouseEvent,
23
+ row: number,
24
+ col: number,
25
+ active_cell_menu: { row: number; col: number; x: number; y: number } | null,
26
+ set_active_cell_menu: (
27
+ menu: { row: number; col: number; x: number; y: number } | null
28
+ ) => void
29
+ ): void {
30
+ event.stopPropagation();
31
+ if (
32
+ active_cell_menu &&
33
+ active_cell_menu.row === row &&
34
+ active_cell_menu.col === col
35
+ ) {
36
+ set_active_cell_menu(null);
37
+ } else {
38
+ const cell = (event.target as HTMLElement).closest("td");
39
+ if (cell) {
40
+ const rect = cell.getBoundingClientRect();
41
+ set_active_cell_menu({ row, col, x: rect.right, y: rect.bottom });
42
+ }
43
+ }
44
+ }
45
+
46
+ export function add_row_at(
47
+ index: number,
48
+ position: "above" | "below",
49
+ add_row: (index?: number) => void,
50
+ clear_menus: () => void
51
+ ): void {
52
+ const row_index = position === "above" ? index : index + 1;
53
+ add_row(row_index);
54
+ clear_menus();
55
+ }
56
+
57
+ export function add_col_at(
58
+ index: number,
59
+ position: "left" | "right",
60
+ add_col: (index?: number) => void,
61
+ clear_menus: () => void
62
+ ): void {
63
+ const col_index = position === "left" ? index : index + 1;
64
+ add_col(col_index);
65
+ clear_menus();
66
+ }
67
+
68
+ export function delete_row_at(
69
+ index: number,
70
+ delete_row: (index: number) => void,
71
+ clear_menus: () => void
72
+ ): void {
73
+ delete_row(index);
74
+ clear_menus();
75
+ }
76
+
77
+ export function delete_col_at(
78
+ index: number,
79
+ delete_col: (index: number) => void,
80
+ clear_menus: () => void
81
+ ): void {
82
+ delete_col(index);
83
+ clear_menus();
84
+ }
85
+
86
+ export function toggle_header_button(
87
+ col: number,
88
+ active_button: { type: "header" | "cell"; row?: number; col: number } | null,
89
+ set_active_button: (
90
+ button: { type: "header" | "cell"; row?: number; col: number } | null
91
+ ) => void
92
+ ): void {
93
+ set_active_button(
94
+ active_button?.type === "header" && active_button.col === col
95
+ ? null
96
+ : { type: "header", col }
97
+ );
98
+ }
99
+
100
+ export function toggle_cell_button(
101
+ row: number,
102
+ col: number,
103
+ active_button: { type: "header" | "cell"; row?: number; col: number } | null,
104
+ set_active_button: (
105
+ button: { type: "header" | "cell"; row?: number; col: number } | null
106
+ ) => void
107
+ ): void {
108
+ set_active_button(
109
+ active_button?.type === "cell" &&
110
+ active_button.row === row &&
111
+ active_button.col === col
112
+ ? null
113
+ : { type: "cell", row, col }
114
+ );
115
+ }