@svgrid/grid 1.0.2 → 1.1.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 (143) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +137 -39
  3. package/dist/GridFooter.svelte +164 -0
  4. package/dist/GridFooter.svelte.d.ts +29 -0
  5. package/dist/GridMenus.svelte +570 -0
  6. package/dist/GridMenus.svelte.d.ts +31 -0
  7. package/dist/SvGrid.controller.svelte.d.ts +429 -0
  8. package/dist/SvGrid.controller.svelte.js +1732 -0
  9. package/dist/SvGrid.css +1709 -0
  10. package/dist/SvGrid.helpers.d.ts +35 -0
  11. package/dist/SvGrid.helpers.js +160 -0
  12. package/dist/SvGrid.svelte +344 -7043
  13. package/dist/SvGrid.svelte.d.ts +4 -357
  14. package/dist/SvGrid.types.d.ts +436 -0
  15. package/dist/SvGrid.types.js +1 -0
  16. package/dist/SvGridChart.svelte +1060 -23
  17. package/dist/SvGridChart.svelte.d.ts +17 -0
  18. package/dist/build-api.d.ts +5 -0
  19. package/dist/build-api.js +527 -0
  20. package/dist/cell-render.d.ts +15 -0
  21. package/dist/cell-render.js +246 -0
  22. package/dist/cell-values.d.ts +28 -0
  23. package/dist/cell-values.js +89 -0
  24. package/dist/chart.d.ts +370 -3
  25. package/dist/chart.js +1135 -42
  26. package/dist/clipboard.d.ts +15 -0
  27. package/dist/clipboard.js +356 -0
  28. package/dist/columns.d.ts +30 -0
  29. package/dist/columns.js +277 -0
  30. package/dist/core.d.ts +1 -1
  31. package/dist/css.d.ts +3 -0
  32. package/dist/editing.d.ts +24 -0
  33. package/dist/editing.js +343 -0
  34. package/dist/editors/cell-editors.d.ts +1 -1
  35. package/dist/facet-buckets.d.ts +13 -0
  36. package/dist/facet-buckets.js +54 -0
  37. package/dist/features.d.ts +5 -0
  38. package/dist/features.js +30 -0
  39. package/dist/filter-operators.d.ts +16 -0
  40. package/dist/filter-operators.js +69 -0
  41. package/dist/hyperformula-adapter.d.ts +82 -0
  42. package/dist/hyperformula-adapter.js +73 -0
  43. package/dist/index.d.ts +5 -2
  44. package/dist/index.js +5 -2
  45. package/dist/keyboard-handlers.d.ts +7 -0
  46. package/dist/keyboard-handlers.js +197 -0
  47. package/dist/menus.d.ts +40 -0
  48. package/dist/menus.js +389 -0
  49. package/dist/named-views.d.ts +27 -0
  50. package/dist/named-views.js +39 -0
  51. package/dist/row-resize.d.ts +43 -0
  52. package/dist/row-resize.js +158 -0
  53. package/dist/scroll-sync.d.ts +9 -0
  54. package/dist/scroll-sync.js +86 -0
  55. package/dist/selection.d.ts +26 -0
  56. package/dist/selection.js +387 -0
  57. package/dist/spreadsheet.d.ts +80 -0
  58. package/dist/spreadsheet.js +194 -0
  59. package/dist/summaries.d.ts +12 -0
  60. package/dist/summaries.js +65 -0
  61. package/dist/svgrid-wrapper.types.d.ts +12 -1
  62. package/dist/svgrid.comments-autocomplete.test.d.ts +1 -0
  63. package/dist/svgrid.comments-autocomplete.test.js +96 -0
  64. package/dist/svgrid.context-menu.test.d.ts +1 -0
  65. package/dist/svgrid.context-menu.test.js +102 -0
  66. package/dist/svgrid.new-features.wrapper.test.js +30 -4
  67. package/dist/svgrid.wrapper.test.js +27 -1
  68. package/dist/virtualization/column-virtualizer.d.ts +2 -0
  69. package/dist/virtualization/scroll-scaling.d.ts +28 -0
  70. package/dist/virtualization/scroll-scaling.js +64 -0
  71. package/dist/virtualization/scroll-scaling.test.d.ts +1 -0
  72. package/dist/virtualization/scroll-scaling.test.js +86 -0
  73. package/dist/virtualization/svelte-virtualizer.svelte.d.ts +2 -0
  74. package/dist/virtualization/svelte-virtualizer.svelte.js +2 -0
  75. package/dist/virtualization/virtualizer.d.ts +7 -0
  76. package/dist/virtualization/virtualizer.js +30 -0
  77. package/package.json +1 -1
  78. package/src/GridFooter.svelte +164 -0
  79. package/src/GridMenus.svelte +570 -0
  80. package/src/SvGrid.controller.svelte.ts +2195 -0
  81. package/src/SvGrid.css +1747 -0
  82. package/src/SvGrid.helpers.test.ts +415 -0
  83. package/src/SvGrid.helpers.ts +185 -0
  84. package/src/SvGrid.svelte +348 -7043
  85. package/src/SvGrid.types.ts +456 -0
  86. package/src/SvGridChart.svelte +1060 -23
  87. package/src/build-api.coverage.test.ts +532 -0
  88. package/src/build-api.ts +663 -0
  89. package/src/cell-render.test.ts +451 -0
  90. package/src/cell-render.ts +426 -0
  91. package/src/cell-values.ts +114 -0
  92. package/src/chart-export.test.ts +370 -0
  93. package/src/chart.coverage.test.ts +814 -0
  94. package/src/chart.ts +1352 -47
  95. package/src/clipboard.test.ts +731 -0
  96. package/src/clipboard.ts +524 -0
  97. package/src/collaboration.coverage.test.ts +220 -0
  98. package/src/columns.test.ts +702 -0
  99. package/src/columns.ts +419 -0
  100. package/src/core.ts +8 -0
  101. package/src/css.d.ts +3 -0
  102. package/src/editing.test.ts +837 -0
  103. package/src/editing.ts +513 -0
  104. package/src/editors/cell-editors.coverage.test.ts +156 -0
  105. package/src/editors/cell-editors.ts +1 -0
  106. package/src/facet-buckets.test.ts +353 -0
  107. package/src/facet-buckets.ts +67 -0
  108. package/src/features.ts +128 -0
  109. package/src/filter-operators.test.ts +174 -0
  110. package/src/filter-operators.ts +87 -0
  111. package/src/hyperformula-adapter.test.ts +256 -0
  112. package/src/hyperformula-adapter.ts +124 -0
  113. package/src/index.ts +37 -0
  114. package/src/keyboard-handlers.coverage.test.ts +560 -0
  115. package/src/keyboard-handlers.ts +353 -0
  116. package/src/keyboard.ts +97 -97
  117. package/src/menus.test.ts +620 -0
  118. package/src/menus.ts +554 -0
  119. package/src/named-views.coverage.test.ts +210 -0
  120. package/src/named-views.ts +48 -0
  121. package/src/row-resize.test.ts +369 -0
  122. package/src/row-resize.ts +171 -0
  123. package/src/scroll-sync.test.ts +330 -0
  124. package/src/scroll-sync.ts +216 -0
  125. package/src/selection.test.ts +722 -0
  126. package/src/selection.ts +545 -0
  127. package/src/server-data-source.coverage.test.ts +180 -0
  128. package/src/spreadsheet.test.ts +445 -0
  129. package/src/spreadsheet.ts +246 -0
  130. package/src/summaries.ts +204 -0
  131. package/src/sv-grid-scrollbar.ts +13 -1
  132. package/src/svgrid-wrapper.types.ts +12 -1
  133. package/src/svgrid.behavior.test.ts +22 -0
  134. package/src/svgrid.comments-autocomplete.test.ts +112 -0
  135. package/src/svgrid.context-menu.test.ts +126 -0
  136. package/src/svgrid.interaction.test.ts +30 -0
  137. package/src/svgrid.new-features.wrapper.test.ts +65 -4
  138. package/src/svgrid.wrapper.test.ts +27 -1
  139. package/src/test-setup.ts +9 -6
  140. package/src/virtualization/scroll-scaling.test.ts +148 -0
  141. package/src/virtualization/scroll-scaling.ts +121 -0
  142. package/src/virtualization/svelte-virtualizer.svelte.ts +2 -0
  143. package/src/virtualization/virtualizer.ts +26 -0
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Spreadsheet-style layout helpers — cell merging + per-cell borders.
3
+ *
4
+ * These are *layout-only* augmentations on top of `<SvGrid>`. They don't
5
+ * change the grid's data model, sorting, or filtering — they just decorate
6
+ * the rendered body cells. Bind the action to the wrapper around your
7
+ * SvGrid and pass merge / border specs; the helper observes DOM mutations
8
+ * and re-applies the layout whenever the grid re-renders.
9
+ *
10
+ * Limitations (worth knowing up front):
11
+ * - Merges + borders index into the CURRENTLY-DISPLAYED rows (after
12
+ * sort / filter), via the `data-svgrid-row` index the grid sets on
13
+ * every body cell. If your data sorts or filters, recompute the
14
+ * specs against the new display order.
15
+ * - Merges use real `colspan` / `rowspan` plus `display:none` on
16
+ * covered TDs. Column widths come from the inline styles SvGrid
17
+ * emits on each TD; we don't fight those.
18
+ * - For long row-merges across a virtualised window, only rows that
19
+ * are actually rendered get the rowspan applied. Disable
20
+ * virtualisation on grids that need a continuous merge.
21
+ */
22
+ const BORDER_KEYS = ['top', 'right', 'bottom', 'left'];
23
+ const MARK = 'data-svgrid-sheet';
24
+ function borderCss(spec) {
25
+ if (!spec)
26
+ return null;
27
+ const w = spec.width ?? 2;
28
+ const s = spec.style ?? 'solid';
29
+ const c = spec.color ?? 'currentColor';
30
+ return `${w}px ${s} ${c}`;
31
+ }
32
+ /** Look up a body cell by its display-row + column id. Falls back to
33
+ * null if the cell isn't in the current render window (virtualisation,
34
+ * scrolled out, or filtered away). */
35
+ function findCell(root, rowIndex, columnId) {
36
+ return root.querySelector(`td[data-svgrid-row="${rowIndex}"][data-col-id="${CSS.escape(columnId)}"]`);
37
+ }
38
+ const OVERLAY_CLASS = 'sv-cell-border-overlay';
39
+ /** Apply / re-apply layout decorations. Called once on mount, again on
40
+ * every relevant DOM mutation inside the grid, and whenever the action
41
+ * options update. */
42
+ function apply(root, opts) {
43
+ // ---- 1. Clean up anything the LAST run added -----------------------
44
+ for (const td of root.querySelectorAll(`td[${MARK}]`)) {
45
+ td.removeAttribute('colspan');
46
+ td.removeAttribute('rowspan');
47
+ if (td.style.display === 'none')
48
+ td.style.display = '';
49
+ // Remove the overlay child we previously injected for borders.
50
+ const overlay = td.querySelector(`:scope > .${OVERLAY_CLASS}`);
51
+ if (overlay)
52
+ overlay.remove();
53
+ td.classList.remove('sv-merge-edge-right', 'sv-merge-edge-bottom', 'sv-merge-in-range');
54
+ td.removeAttribute(MARK);
55
+ }
56
+ const colOrder = opts.columnOrder;
57
+ const colIndexOf = new Map();
58
+ for (let i = 0; i < colOrder.length; i += 1)
59
+ colIndexOf.set(colOrder[i], i);
60
+ // ---- 2. Merges -----------------------------------------------------
61
+ for (const m of opts.merges ?? []) {
62
+ const startCol = colIndexOf.get(m.columnId);
63
+ if (startCol === undefined)
64
+ continue;
65
+ const rs = Math.max(1, m.rowspan ?? 1);
66
+ const cs = Math.max(1, m.colspan ?? 1);
67
+ const origin = findCell(root, m.rowIndex, m.columnId);
68
+ if (!origin)
69
+ continue;
70
+ if (cs > 1)
71
+ origin.setAttribute('colspan', String(cs));
72
+ if (rs > 1)
73
+ origin.setAttribute('rowspan', String(rs));
74
+ origin.setAttribute(MARK, '');
75
+ for (let dr = 0; dr < rs; dr += 1) {
76
+ for (let dc = 0; dc < cs; dc += 1) {
77
+ if (dr === 0 && dc === 0)
78
+ continue;
79
+ const colId = colOrder[startCol + dc];
80
+ if (!colId)
81
+ continue;
82
+ const td = findCell(root, m.rowIndex + dr, colId);
83
+ if (!td)
84
+ continue;
85
+ td.style.display = 'none';
86
+ td.setAttribute(MARK, '');
87
+ }
88
+ }
89
+ // Selection-edge inheritance:
90
+ // The grid sets `data-range-*` per cell. The merge's RIGHTMOST /
91
+ // BOTTOMMOST covered cells are `display:none`, so the borders
92
+ // they would draw never render. Mirror those edge flags onto the
93
+ // origin via dedicated CSS classes (so we can toggle them
94
+ // independently of Svelte's own data-range-* updates without a
95
+ // ping-pong loop).
96
+ const lastColId = colOrder[startCol + cs - 1];
97
+ const rightCell = lastColId ? findCell(root, m.rowIndex, lastColId) : null;
98
+ const bottomCell = findCell(root, m.rowIndex + rs - 1, m.columnId);
99
+ const farCell = lastColId ? findCell(root, m.rowIndex + rs - 1, lastColId) : null;
100
+ const wantRight = rightCell?.getAttribute('data-range-right') === 'true' ||
101
+ farCell?.getAttribute('data-range-right') === 'true';
102
+ const wantBottom = bottomCell?.getAttribute('data-range-bottom') === 'true' ||
103
+ farCell?.getAttribute('data-range-bottom') === 'true';
104
+ const inRange = rightCell?.getAttribute('data-selected-range') === 'true' ||
105
+ bottomCell?.getAttribute('data-selected-range') === 'true' ||
106
+ farCell?.getAttribute('data-selected-range') === 'true' ||
107
+ origin.getAttribute('data-selected-range') === 'true';
108
+ origin.classList.toggle('sv-merge-edge-right', wantRight);
109
+ origin.classList.toggle('sv-merge-edge-bottom', wantBottom);
110
+ origin.classList.toggle('sv-merge-in-range', inRange);
111
+ }
112
+ // ---- 3. Borders ---------------------------------------------------
113
+ // Use an absolute-positioned overlay div so each edge renders
114
+ // independently of the grid's own border-collapse rules. Adjacent
115
+ // TDs no longer "eat" the right / bottom edges of a bordered cell.
116
+ for (const b of opts.borders ?? []) {
117
+ const td = findCell(root, b.rowIndex, b.columnId);
118
+ if (!td)
119
+ continue;
120
+ let any = false;
121
+ const overlay = document.createElement('div');
122
+ overlay.className = OVERLAY_CLASS;
123
+ overlay.style.cssText =
124
+ 'position:absolute;inset:0;pointer-events:none;box-sizing:border-box;z-index:1;';
125
+ for (const e of BORDER_KEYS) {
126
+ const css = borderCss(b[e]);
127
+ if (!css)
128
+ continue;
129
+ overlay.style.setProperty(`border-${e}`, css);
130
+ any = true;
131
+ }
132
+ if (!any)
133
+ continue;
134
+ // Ensure the TD is a positioning context for the overlay.
135
+ if (getComputedStyle(td).position === 'static')
136
+ td.style.position = 'relative';
137
+ td.appendChild(overlay);
138
+ td.setAttribute(MARK, '');
139
+ }
140
+ }
141
+ /** Svelte action. Attach to the element that hosts your `<SvGrid>` so
142
+ * the action can watch its DOM for re-renders.
143
+ *
144
+ * ```svelte
145
+ * <div use:spreadsheetLayout={{ merges, borders, columnOrder }}>
146
+ * <SvGrid {data} {columns} ... />
147
+ * </div>
148
+ * ```
149
+ *
150
+ * The action re-applies the layout whenever the grid's body mutates
151
+ * (new rows, column reorder, virtualization scroll) and whenever the
152
+ * options change. */
153
+ export function spreadsheetLayout(node, opts) {
154
+ let current = opts;
155
+ let frame = 0;
156
+ // The MutationObserver fires for EVERY DOM change inside the grid -
157
+ // batch them into one rAF so a big virtualization scroll doesn't run
158
+ // apply() dozens of times in a tick.
159
+ function schedule() {
160
+ if (frame)
161
+ return;
162
+ frame = requestAnimationFrame(() => {
163
+ frame = 0;
164
+ apply(node, current);
165
+ });
166
+ }
167
+ const observer = new MutationObserver(schedule);
168
+ observer.observe(node, {
169
+ childList: true,
170
+ subtree: true,
171
+ attributes: true,
172
+ attributeFilter: [
173
+ 'data-svgrid-row', 'data-col-id', 'style',
174
+ // Watch selection-range attrs so we can transfer them from
175
+ // hidden covered cells to merge origins.
176
+ 'data-range-top', 'data-range-bottom', 'data-range-left',
177
+ 'data-range-right', 'data-selected-range',
178
+ ],
179
+ });
180
+ // First-paint pass: schedule the same way so we don't run before the
181
+ // grid's initial render landed.
182
+ schedule();
183
+ return {
184
+ update(next) {
185
+ current = next;
186
+ schedule();
187
+ },
188
+ destroy() {
189
+ observer.disconnect();
190
+ if (frame)
191
+ cancelAnimationFrame(frame);
192
+ },
193
+ };
194
+ }
@@ -0,0 +1,12 @@
1
+ import { type Column, type Row, type RowData, type TableFeatures } from "./index";
2
+ import "./sv-grid-scrollbar";
3
+ export declare function createSummaries<TFeatures extends TableFeatures = TableFeatures, TData extends RowData = RowData>(ctx: any): {
4
+ computeSummaries: (rows: ReadonlyArray<Row<TData>>, columns: ReadonlyArray<Column<TData>>) => Record<string, string>;
5
+ hasRenderedColumn: (entry: {
6
+ item: (typeof ctx.renderedColumnItems)[number];
7
+ column: (typeof ctx.allColumns)[number] | undefined;
8
+ }) => entry is {
9
+ item: (typeof ctx.renderedColumnItems)[number];
10
+ column: (typeof ctx.allColumns)[number];
11
+ };
12
+ };
@@ -0,0 +1,65 @@
1
+ import "./sv-grid-scrollbar";
2
+ import { getCellKey, } from "./SvGrid.helpers";
3
+ import { formatSummaryNumeric, } from "./cell-values";
4
+ export function createSummaries(ctx) {
5
+ /**
6
+ * Aggregate every row into a per-column footer summary (sum for numeric
7
+ * columns, `Count: N` otherwise). This loop is the hottest path on a
8
+ * large grid - it is rows x columns iterations - so two things keep the
9
+ * constant factor down:
10
+ *
11
+ * 1. The edited-cell overlay is only consulted when an edit actually
12
+ * exists. `key in editedCellValues` hits a reactive-proxy `has`
13
+ * trap for every cell otherwise - 5M no-op trap calls on a
14
+ * 100k x 50 grid. Skipping it when the map is empty is the single
15
+ * biggest win here.
16
+ * 2. The column's accessor (`accessorFn` / `field`) is resolved once
17
+ * per column, not re-read off `columnDef` for every cell.
18
+ */
19
+ function computeSummaries(rows, columns) {
20
+ const summary = {};
21
+ const rowCount = rows.length;
22
+ const hasEdits = Object.keys(ctx.editedCellValues).length > 0;
23
+ for (const column of columns) {
24
+ const def = column.columnDef;
25
+ const accessorFn = def.accessorFn;
26
+ const field = def.field;
27
+ const columnId = column.id;
28
+ let numericSum = 0;
29
+ let numericCount = 0;
30
+ for (let i = 0; i < rowCount; i += 1) {
31
+ const row = rows[i];
32
+ let value;
33
+ const base = accessorFn
34
+ ? accessorFn(row.original)
35
+ : field
36
+ ? row.original[field]
37
+ : row.getCellValueByColumnId(columnId);
38
+ if (hasEdits) {
39
+ const key = getCellKey(row.id, columnId);
40
+ value = key in ctx.editedCellValues ? ctx.editedCellValues[key] : base;
41
+ }
42
+ else {
43
+ value = base;
44
+ }
45
+ const asNumber = Number(value);
46
+ if (Number.isFinite(asNumber)) {
47
+ numericSum += asNumber;
48
+ numericCount += 1;
49
+ }
50
+ }
51
+ summary[columnId] =
52
+ numericCount > 0
53
+ ? formatSummaryNumeric(column, numericSum)
54
+ : `Count: ${rowCount}`;
55
+ }
56
+ return summary;
57
+ }
58
+ function hasRenderedColumn(entry) {
59
+ return entry.column !== undefined;
60
+ }
61
+ return {
62
+ computeSummaries,
63
+ hasRenderedColumn,
64
+ };
65
+ }
@@ -164,6 +164,15 @@ export type SvGridApi<TFeatures extends TableFeatures, TData extends RowData> =
164
164
  * default. Useful for "save view" + URL persistence.
165
165
  */
166
166
  getColumnWidths(): Record<string, number>;
167
+ /**
168
+ * Snap one column's width to its widest visible cell (header text +
169
+ * any rendered body cell). Equivalent to double-clicking the column's
170
+ * resize handle. The grid also exposes this through the column menu's
171
+ * "Autosize" item.
172
+ */
173
+ autosizeColumn(columnId: string): void;
174
+ /** Run `autosizeColumn` on every column. */
175
+ autosizeAllColumns(): void;
167
176
  /**
168
177
  * Replace the column-pinning state in one call. Each entry is a
169
178
  * column id; the order in the array becomes the visible order along
@@ -325,7 +334,9 @@ export type SvGridWrapperProps<TFeatures extends TableFeatures, TData extends Ro
325
334
  showRowSelection?: boolean;
326
335
  showPagination?: boolean;
327
336
  virtualization?: boolean;
328
- rowHeight?: number;
337
+ /** Row height in pixels. Pass a function `(rowIndex) => px` for per-row
338
+ * variable heights (e.g. when wiring up an interactive row-resize). */
339
+ rowHeight?: number | ((rowIndex: number) => number);
329
340
  overscan?: number;
330
341
  containerHeight?: number;
331
342
  columnVirtualization?: boolean;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Track C: free-text autocomplete cell type + editable cell comments.
3
+ */
4
+ import { describe, expect, it } from 'vitest';
5
+ import { mount, unmount } from 'svelte';
6
+ import SvGrid from './SvGrid.svelte';
7
+ import { createCoreRowModel, tableFeatures } from './index';
8
+ const features = tableFeatures({});
9
+ const rows = [
10
+ { id: 1, name: 'Ann', city: 'Paris' },
11
+ { id: 2, name: 'Bo', city: 'Berlin' },
12
+ ];
13
+ const tick = () => new Promise((r) => queueMicrotask(r));
14
+ function mountGrid(extraCols, props = {}) {
15
+ return new Promise((res) => {
16
+ const target = document.createElement('div');
17
+ document.body.appendChild(target);
18
+ const app = mount(SvGrid, {
19
+ target,
20
+ props: {
21
+ data: rows,
22
+ columns: [{ field: 'name', header: 'Name', width: 160, editorType: 'text' }, ...extraCols],
23
+ features,
24
+ _rowModels: { coreRowModel: createCoreRowModel() },
25
+ rowHeight: 32,
26
+ containerHeight: 360,
27
+ virtualization: false,
28
+ enableInlineEditing: true,
29
+ enableCellSelection: true,
30
+ onApiReady(api) {
31
+ res({ api, target, destroy: () => { unmount(app); target.remove(); } });
32
+ },
33
+ ...props,
34
+ },
35
+ });
36
+ });
37
+ }
38
+ function dataCell(target, col) {
39
+ return target.querySelectorAll('.sv-grid-cell[data-svgrid-row]')[col];
40
+ }
41
+ describe('autocomplete cell type', () => {
42
+ it('filters suggestions as you type and commits the picked option', async () => {
43
+ const { api, target, destroy } = await mountGrid([
44
+ {
45
+ field: 'city',
46
+ header: 'City',
47
+ width: 200,
48
+ editorType: 'autocomplete',
49
+ editorOptions: ['Paris', 'Berlin', 'Boston', 'Brussels'],
50
+ },
51
+ ]);
52
+ await tick();
53
+ // city is the 2nd column => index 1 in the first row's cells
54
+ const cell = target.querySelectorAll('.sv-grid-cell[data-svgrid-row="0"]')[1];
55
+ cell.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
56
+ await tick();
57
+ const input = target.querySelector('.sv-grid-cell-editor-autocomplete');
58
+ expect(input).not.toBeNull();
59
+ input.value = 'B';
60
+ input.dispatchEvent(new Event('input', { bubbles: true }));
61
+ await tick();
62
+ const opts = [...target.querySelectorAll('.sv-grid-autocomplete-option')].map((b) => b.textContent?.trim());
63
+ // 'B' matches Berlin, Boston, Brussels - not Paris
64
+ expect(opts).toEqual(['Berlin', 'Boston', 'Brussels']);
65
+ const berlin = [...target.querySelectorAll('.sv-grid-autocomplete-option')].find((b) => b.textContent?.trim() === 'Boston');
66
+ berlin.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
67
+ await tick();
68
+ expect(api.getCellValue(0, 'city')).toBe('Boston');
69
+ destroy();
70
+ });
71
+ });
72
+ describe('editable comments', () => {
73
+ it('adds a comment via the context menu and emits onNoteChange', async () => {
74
+ const changes = [];
75
+ const { target, destroy } = await mountGrid([{ field: 'city', header: 'City', width: 200, editorType: 'text' }], { contextMenu: true, editableComments: true, onNoteChange: (e) => changes.push(e) });
76
+ await tick();
77
+ const cell = dataCell(target, 0);
78
+ cell.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }));
79
+ await tick();
80
+ const editItem = [...target.querySelectorAll('.sv-grid-context-menu .sv-grid-menu-item')].find((b) => b.textContent?.trim() === 'Edit comment');
81
+ expect(editItem).toBeTruthy();
82
+ editItem.click();
83
+ await tick();
84
+ const ta = target.querySelector('.sv-grid-comment-textarea');
85
+ expect(ta).not.toBeNull();
86
+ ta.value = 'Needs review';
87
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
88
+ await tick();
89
+ target.querySelector('.sv-grid-comment-save').click();
90
+ await tick();
91
+ expect(changes.length).toBe(1);
92
+ expect(changes[0].note).toBe('Needs review');
93
+ expect(target.querySelector('.sv-grid-comment-editor')).toBeNull();
94
+ destroy();
95
+ });
96
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Right-click context menu (Track A). Mounts a grid with `contextMenu`,
3
+ * fires a synthetic `contextmenu` event on a cell, and asserts the menu
4
+ * opens with the expected items and that actions/close work.
5
+ */
6
+ import { describe, expect, it } from 'vitest';
7
+ import { mount, unmount } from 'svelte';
8
+ import SvGrid from './SvGrid.svelte';
9
+ import { createCoreRowModel, createFilteredRowModel, createSortedRowModel, rowSelectionFeature, rowSortingFeature, sortFns, tableFeatures, } from './index';
10
+ const features = tableFeatures({ rowSortingFeature, rowSelectionFeature });
11
+ const rows = Array.from({ length: 6 }, (_, i) => ({
12
+ id: i + 1,
13
+ name: `Person ${i + 1}`,
14
+ team: ['A', 'B'][i % 2],
15
+ }));
16
+ const cols = [
17
+ { field: 'name', header: 'Name', width: 200, editorType: 'text' },
18
+ { field: 'team', header: 'Team', width: 160, editorType: 'text' },
19
+ ];
20
+ const tick = () => new Promise((r) => queueMicrotask(r));
21
+ function mountGrid(contextMenu) {
22
+ return new Promise((res, rej) => {
23
+ const target = document.createElement('div');
24
+ document.body.appendChild(target);
25
+ const app = mount(SvGrid, {
26
+ target,
27
+ props: {
28
+ data: rows,
29
+ columns: cols,
30
+ features,
31
+ _rowModels: {
32
+ coreRowModel: createCoreRowModel(),
33
+ filteredRowModel: createFilteredRowModel(),
34
+ sortedRowModel: createSortedRowModel(sortFns),
35
+ },
36
+ rowHeight: 32,
37
+ containerHeight: 400,
38
+ virtualization: false,
39
+ enableCellSelection: true,
40
+ contextMenu,
41
+ onApiReady(api) {
42
+ res({ api, target, destroy: () => { unmount(app); target.remove(); } });
43
+ },
44
+ },
45
+ });
46
+ queueMicrotask(() => { if (!target.querySelector('[role="grid"]'))
47
+ rej(new Error('no grid')); });
48
+ });
49
+ }
50
+ function rightClickFirstCell(target) {
51
+ const cell = target.querySelector('.sv-grid-cell[data-svgrid-row]');
52
+ const r = cell.getBoundingClientRect();
53
+ cell.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: r.x + 4, clientY: r.y + 4 }));
54
+ return cell;
55
+ }
56
+ describe('SvGrid context menu', () => {
57
+ it('opens the default menu on right-click with the built-in items', async () => {
58
+ const { target, destroy } = await mountGrid(true);
59
+ await tick();
60
+ rightClickFirstCell(target);
61
+ await tick();
62
+ const menu = target.querySelector('.sv-grid-context-menu');
63
+ expect(menu).not.toBeNull();
64
+ const labels = [...menu.querySelectorAll('.sv-grid-menu-item')].map((b) => b.textContent?.trim());
65
+ expect(labels).toContain('Copy');
66
+ expect(labels).toContain('Paste');
67
+ expect(labels).toContain('Remove row');
68
+ expect(labels).toContain('Remove column');
69
+ destroy();
70
+ });
71
+ it('does not open when contextMenu is omitted (native menu shows)', async () => {
72
+ const { target, destroy } = await mountGrid(undefined);
73
+ await tick();
74
+ const cell = target.querySelector('.sv-grid-cell[data-svgrid-row]');
75
+ const ev = new MouseEvent('contextmenu', { bubbles: true, cancelable: true });
76
+ cell.dispatchEvent(ev);
77
+ await tick();
78
+ expect(target.querySelector('.sv-grid-context-menu')).toBeNull();
79
+ expect(ev.defaultPrevented).toBe(false);
80
+ destroy();
81
+ });
82
+ it('renders only the configured custom items and runs the action', async () => {
83
+ let ran = 0;
84
+ const items = [
85
+ 'copy',
86
+ 'separator',
87
+ { key: 'flag', label: 'Flag row', action: () => { ran += 1; } },
88
+ ];
89
+ const { target, destroy } = await mountGrid(items);
90
+ await tick();
91
+ rightClickFirstCell(target);
92
+ await tick();
93
+ const labels = [...target.querySelectorAll('.sv-grid-context-menu .sv-grid-menu-item')].map((b) => b.textContent?.trim());
94
+ expect(labels).toEqual(['Copy', 'Flag row']);
95
+ const flag = [...target.querySelectorAll('.sv-grid-context-menu .sv-grid-menu-item')].find((b) => b.textContent?.trim() === 'Flag row');
96
+ flag.click();
97
+ expect(ran).toBe(1);
98
+ await tick();
99
+ expect(target.querySelector('.sv-grid-context-menu')).toBeNull();
100
+ destroy();
101
+ });
102
+ });
@@ -15,8 +15,34 @@
15
15
  import { readFileSync } from 'node:fs';
16
16
  import { resolve } from 'node:path';
17
17
  import { describe, expect, it } from 'vitest';
18
- const sourcePath = resolve(__dirname, './SvGrid.svelte');
19
- const source = readFileSync(sourcePath, 'utf-8');
18
+ // The wrapper implementation spans SvGrid.svelte plus its extracted
19
+ // sibling modules (types + pure helpers). Concatenate them so these
20
+ // "the wrapper declares/exposes X" assertions hold wherever the code
21
+ // physically lives.
22
+ const source = [
23
+ './SvGrid.svelte',
24
+ './SvGrid.controller.svelte.ts',
25
+ './GridMenus.svelte',
26
+ './GridFooter.svelte',
27
+ './SvGrid.types.ts',
28
+ './SvGrid.helpers.ts',
29
+ './filter-operators.ts',
30
+ './facet-buckets.ts',
31
+ './cell-values.ts',
32
+ './clipboard.ts',
33
+ './build-api.ts',
34
+ './columns.ts',
35
+ './selection.ts',
36
+ './editing.ts',
37
+ './cell-render.ts',
38
+ './menus.ts',
39
+ './summaries.ts',
40
+ './keyboard-handlers.ts',
41
+ './scroll-sync.ts',
42
+ './features.ts',
43
+ ]
44
+ .map((p) => readFileSync(resolve(__dirname, p), 'utf-8'))
45
+ .join('\n');
20
46
  const typesPath = resolve(__dirname, './svgrid-wrapper.types.ts');
21
47
  const types = readFileSync(typesPath, 'utf-8');
22
48
  describe('SvGrid wrapper - getRowId prop', () => {
@@ -120,7 +146,7 @@ describe('SvGrid wrapper - multi-cell paste + cut + delete', () => {
120
146
  });
121
147
  it('wires Ctrl/Cmd+X to cutSelectionToClipboard', () => {
122
148
  expect(source).toMatch(/lower === "x"/);
123
- expect(source).toMatch(/void cutSelectionToClipboard\(\)/);
149
+ expect(source).toMatch(/void (?:ctx\.)?cutSelectionToClipboard\(\)/);
124
150
  expect(source).toMatch(/async function cutSelectionToClipboard/);
125
151
  // Confirm both calls live inside the cutSelectionToClipboard body
126
152
  // (copy first, then clear). A comment between them is fine.
@@ -131,6 +157,6 @@ describe('SvGrid wrapper - multi-cell paste + cut + delete', () => {
131
157
  });
132
158
  it('wires Delete + Backspace to clearSelectedCells (without clipboard interaction)', () => {
133
159
  expect(source).toMatch(/event\.key === "Delete" \|\| event\.key === "Backspace"/);
134
- expect(source).toMatch(/if \(clearSelectedCells\(\)\)\s*\{/);
160
+ expect(source).toMatch(/if \((?:ctx\.)?clearSelectedCells\(\)\)\s*\{/);
135
161
  });
136
162
  });
@@ -7,7 +7,33 @@ describe('SvGrid wrapper', () => {
7
7
  expect(SvGrid).toBeDefined();
8
8
  });
9
9
  it('contains full-wrapper controls and aria-activedescendant wiring', () => {
10
- const source = readFileSync(resolve(__dirname, './SvGrid.svelte'), 'utf-8');
10
+ // The wrapper spans SvGrid.svelte plus its extracted sibling modules
11
+ // (types + pure helpers); read them together so these "contains X"
12
+ // assertions hold wherever the code physically lives.
13
+ const source = [
14
+ './SvGrid.svelte',
15
+ './SvGrid.controller.svelte.ts',
16
+ './GridMenus.svelte',
17
+ './GridFooter.svelte',
18
+ './SvGrid.types.ts',
19
+ './SvGrid.helpers.ts',
20
+ './filter-operators.ts',
21
+ './facet-buckets.ts',
22
+ './cell-values.ts',
23
+ './clipboard.ts',
24
+ './build-api.ts',
25
+ './columns.ts',
26
+ './selection.ts',
27
+ './editing.ts',
28
+ './cell-render.ts',
29
+ './menus.ts',
30
+ './summaries.ts',
31
+ './keyboard-handlers.ts',
32
+ './scroll-sync.ts',
33
+ './features.ts',
34
+ ]
35
+ .map((p) => readFileSync(resolve(__dirname, p), 'utf-8'))
36
+ .join('\n');
11
37
  expect(source).toContain('Filter all rows');
12
38
  expect(source).toContain('Previous');
13
39
  expect(source).toContain('role="status"');
@@ -15,6 +15,8 @@ export declare function createColumnVirtualizer(input: {
15
15
  scrollToIndex(index: number): void;
16
16
  getVirtualItems(): import("./types").VirtualItem[];
17
17
  getTotalSize(): number;
18
+ getOffsetForIndex(index: number): number;
19
+ getSizeForIndex(index: number): number;
18
20
  getState(): import("./types").VirtualizerState;
19
21
  subscribe(listener: () => void): () => boolean;
20
22
  };
@@ -0,0 +1,28 @@
1
+ export type RowScrollScaling = {
2
+ /** True when the true content height exceeds the cap and mapping applies. */
3
+ readonly active: boolean;
4
+ /** Height (px) the DOM scroll spacer should occupy - capped at maxDomHeight. */
5
+ readonly domTotal: number;
6
+ /** Map a DOM scrollTop into the virtualizer's logical scroll offset. */
7
+ domToLogical(domTop: number): number;
8
+ /** Map a virtualizer logical offset back into a DOM scrollTop. */
9
+ logicalToDom(logical: number): number;
10
+ };
11
+ /**
12
+ * Build the scaling mapping for one axis.
13
+ *
14
+ * @param trueTotal The real content height in logical px (count * rowH, or
15
+ * the cumulative offset total for variable rows).
16
+ * @param maxDomHeight The browser's max element height (detected at runtime).
17
+ * @param viewport The scroll viewport height in px.
18
+ *
19
+ * Invariants (verified in scroll-scaling.test.ts):
20
+ * - Inert when `trueTotal <= maxDomHeight`: both maps are the identity.
21
+ * - `domTotal <= maxDomHeight` always, so the spacer never exceeds the cap.
22
+ * - Endpoints line up: domTop 0 -> logical 0, and the DOM max
23
+ * (`domTotal - viewport`) -> the logical max (`trueTotal - viewport`), so
24
+ * the very last row is always reachable.
25
+ * - `logicalToDom` is the inverse of `domToLogical` across the range.
26
+ * - Both maps are monotonic non-decreasing and clamp out-of-range inputs.
27
+ */
28
+ export declare function createRowScrollScaling(trueTotal: number, maxDomHeight: number, viewport: number): RowScrollScaling;