@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,246 @@
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
+
23
+ /** A single edge of a cell border. */
24
+ export type BorderSpec = {
25
+ /** Thickness in pixels. Default 2. */
26
+ width?: number
27
+ /** CSS border-style. Default 'solid'. */
28
+ style?: 'solid' | 'dashed' | 'dotted' | 'double'
29
+ /** CSS color. Falls back to currentColor (i.e. text color). */
30
+ color?: string
31
+ }
32
+
33
+ /** A merge declaration. The cell at (rowIndex, columnId) is the ORIGIN;
34
+ * it spans `colspan` columns to the right + `rowspan` rows downward.
35
+ * Covered cells are hidden so the origin visually fills the region. */
36
+ export type MergeSpec = {
37
+ /** Display-row index — the same value the grid puts on
38
+ * `data-svgrid-row`. After sorting/filtering, recompute the spec
39
+ * against the new display order. */
40
+ rowIndex: number
41
+ columnId: string
42
+ /** Default 1. */
43
+ rowspan?: number
44
+ /** Default 1. */
45
+ colspan?: number
46
+ }
47
+
48
+ /** Borders for one cell. Edges left unset render as the default
49
+ * cell border (i.e. no override). */
50
+ export type CellBorderSpec = {
51
+ rowIndex: number
52
+ columnId: string
53
+ top?: BorderSpec
54
+ right?: BorderSpec
55
+ bottom?: BorderSpec
56
+ left?: BorderSpec
57
+ }
58
+
59
+ /** What the Svelte action receives. Pass new values to update; pass
60
+ * `null` / empty arrays to clear. */
61
+ export type SpreadsheetActionOptions = {
62
+ merges?: ReadonlyArray<MergeSpec> | null
63
+ borders?: ReadonlyArray<CellBorderSpec> | null
64
+ /** Column id order the grid uses, in left-to-right order. Required
65
+ * to translate `colspan` into the right set of covered column ids.
66
+ * Pass `columns.map((c) => c.id)` from the consumer. */
67
+ columnOrder: ReadonlyArray<string>
68
+ }
69
+
70
+ const BORDER_KEYS = ['top', 'right', 'bottom', 'left'] as const
71
+ type EdgeKey = (typeof BORDER_KEYS)[number]
72
+ const MARK = 'data-svgrid-sheet'
73
+
74
+ function borderCss(spec: BorderSpec | undefined): string | null {
75
+ if (!spec) return null
76
+ const w = spec.width ?? 2
77
+ const s = spec.style ?? 'solid'
78
+ const c = spec.color ?? 'currentColor'
79
+ return `${w}px ${s} ${c}`
80
+ }
81
+
82
+ /** Look up a body cell by its display-row + column id. Falls back to
83
+ * null if the cell isn't in the current render window (virtualisation,
84
+ * scrolled out, or filtered away). */
85
+ function findCell(
86
+ root: HTMLElement,
87
+ rowIndex: number,
88
+ columnId: string,
89
+ ): HTMLTableCellElement | null {
90
+ return root.querySelector<HTMLTableCellElement>(
91
+ `td[data-svgrid-row="${rowIndex}"][data-col-id="${CSS.escape(columnId)}"]`,
92
+ )
93
+ }
94
+
95
+ const OVERLAY_CLASS = 'sv-cell-border-overlay'
96
+
97
+ /** Apply / re-apply layout decorations. Called once on mount, again on
98
+ * every relevant DOM mutation inside the grid, and whenever the action
99
+ * options update. */
100
+ function apply(root: HTMLElement, opts: SpreadsheetActionOptions): void {
101
+ // ---- 1. Clean up anything the LAST run added -----------------------
102
+ for (const td of root.querySelectorAll<HTMLTableCellElement>(`td[${MARK}]`)) {
103
+ td.removeAttribute('colspan')
104
+ td.removeAttribute('rowspan')
105
+ if (td.style.display === 'none') td.style.display = ''
106
+ // Remove the overlay child we previously injected for borders.
107
+ const overlay = td.querySelector(`:scope > .${OVERLAY_CLASS}`)
108
+ if (overlay) overlay.remove()
109
+ td.classList.remove('sv-merge-edge-right', 'sv-merge-edge-bottom', 'sv-merge-in-range')
110
+ td.removeAttribute(MARK)
111
+ }
112
+
113
+ const colOrder = opts.columnOrder
114
+ const colIndexOf = new Map<string, number>()
115
+ for (let i = 0; i < colOrder.length; i += 1) colIndexOf.set(colOrder[i]!, i)
116
+
117
+ // ---- 2. Merges -----------------------------------------------------
118
+ for (const m of opts.merges ?? []) {
119
+ const startCol = colIndexOf.get(m.columnId)
120
+ if (startCol === undefined) continue
121
+ const rs = Math.max(1, m.rowspan ?? 1)
122
+ const cs = Math.max(1, m.colspan ?? 1)
123
+ const origin = findCell(root, m.rowIndex, m.columnId)
124
+ if (!origin) continue
125
+ if (cs > 1) origin.setAttribute('colspan', String(cs))
126
+ if (rs > 1) origin.setAttribute('rowspan', String(rs))
127
+ origin.setAttribute(MARK, '')
128
+ for (let dr = 0; dr < rs; dr += 1) {
129
+ for (let dc = 0; dc < cs; dc += 1) {
130
+ if (dr === 0 && dc === 0) continue
131
+ const colId = colOrder[startCol + dc]
132
+ if (!colId) continue
133
+ const td = findCell(root, m.rowIndex + dr, colId)
134
+ if (!td) continue
135
+ td.style.display = 'none'
136
+ td.setAttribute(MARK, '')
137
+ }
138
+ }
139
+
140
+ // Selection-edge inheritance:
141
+ // The grid sets `data-range-*` per cell. The merge's RIGHTMOST /
142
+ // BOTTOMMOST covered cells are `display:none`, so the borders
143
+ // they would draw never render. Mirror those edge flags onto the
144
+ // origin via dedicated CSS classes (so we can toggle them
145
+ // independently of Svelte's own data-range-* updates without a
146
+ // ping-pong loop).
147
+ const lastColId = colOrder[startCol + cs - 1]
148
+ const rightCell = lastColId ? findCell(root, m.rowIndex, lastColId) : null
149
+ const bottomCell = findCell(root, m.rowIndex + rs - 1, m.columnId)
150
+ const farCell = lastColId ? findCell(root, m.rowIndex + rs - 1, lastColId) : null
151
+
152
+ const wantRight =
153
+ rightCell?.getAttribute('data-range-right') === 'true' ||
154
+ farCell ?.getAttribute('data-range-right') === 'true'
155
+ const wantBottom =
156
+ bottomCell?.getAttribute('data-range-bottom') === 'true' ||
157
+ farCell ?.getAttribute('data-range-bottom') === 'true'
158
+ const inRange =
159
+ rightCell ?.getAttribute('data-selected-range') === 'true' ||
160
+ bottomCell?.getAttribute('data-selected-range') === 'true' ||
161
+ farCell ?.getAttribute('data-selected-range') === 'true' ||
162
+ origin.getAttribute('data-selected-range') === 'true'
163
+
164
+ origin.classList.toggle('sv-merge-edge-right', wantRight)
165
+ origin.classList.toggle('sv-merge-edge-bottom', wantBottom)
166
+ origin.classList.toggle('sv-merge-in-range', inRange)
167
+ }
168
+
169
+ // ---- 3. Borders ---------------------------------------------------
170
+ // Use an absolute-positioned overlay div so each edge renders
171
+ // independently of the grid's own border-collapse rules. Adjacent
172
+ // TDs no longer "eat" the right / bottom edges of a bordered cell.
173
+ for (const b of opts.borders ?? []) {
174
+ const td = findCell(root, b.rowIndex, b.columnId)
175
+ if (!td) continue
176
+ let any = false
177
+ const overlay = document.createElement('div')
178
+ overlay.className = OVERLAY_CLASS
179
+ overlay.style.cssText =
180
+ 'position:absolute;inset:0;pointer-events:none;box-sizing:border-box;z-index:1;'
181
+ for (const e of BORDER_KEYS) {
182
+ const css = borderCss(b[e as EdgeKey])
183
+ if (!css) continue
184
+ overlay.style.setProperty(`border-${e}`, css)
185
+ any = true
186
+ }
187
+ if (!any) continue
188
+ // Ensure the TD is a positioning context for the overlay.
189
+ if (getComputedStyle(td).position === 'static') td.style.position = 'relative'
190
+ td.appendChild(overlay)
191
+ td.setAttribute(MARK, '')
192
+ }
193
+ }
194
+
195
+ /** Svelte action. Attach to the element that hosts your `<SvGrid>` so
196
+ * the action can watch its DOM for re-renders.
197
+ *
198
+ * ```svelte
199
+ * <div use:spreadsheetLayout={{ merges, borders, columnOrder }}>
200
+ * <SvGrid {data} {columns} ... />
201
+ * </div>
202
+ * ```
203
+ *
204
+ * The action re-applies the layout whenever the grid's body mutates
205
+ * (new rows, column reorder, virtualization scroll) and whenever the
206
+ * options change. */
207
+ export function spreadsheetLayout(node: HTMLElement, opts: SpreadsheetActionOptions) {
208
+ let current = opts
209
+ let frame = 0
210
+ // The MutationObserver fires for EVERY DOM change inside the grid -
211
+ // batch them into one rAF so a big virtualization scroll doesn't run
212
+ // apply() dozens of times in a tick.
213
+ function schedule() {
214
+ if (frame) return
215
+ frame = requestAnimationFrame(() => {
216
+ frame = 0
217
+ apply(node, current)
218
+ })
219
+ }
220
+ const observer = new MutationObserver(schedule)
221
+ observer.observe(node, {
222
+ childList: true,
223
+ subtree: true,
224
+ attributes: true,
225
+ attributeFilter: [
226
+ 'data-svgrid-row', 'data-col-id', 'style',
227
+ // Watch selection-range attrs so we can transfer them from
228
+ // hidden covered cells to merge origins.
229
+ 'data-range-top', 'data-range-bottom', 'data-range-left',
230
+ 'data-range-right', 'data-selected-range',
231
+ ],
232
+ })
233
+ // First-paint pass: schedule the same way so we don't run before the
234
+ // grid's initial render landed.
235
+ schedule()
236
+ return {
237
+ update(next: SpreadsheetActionOptions) {
238
+ current = next
239
+ schedule()
240
+ },
241
+ destroy() {
242
+ observer.disconnect()
243
+ if (frame) cancelAnimationFrame(frame)
244
+ },
245
+ }
246
+ }
@@ -0,0 +1,204 @@
1
+ // summaries handlers extracted from the controller. Imperative event handlers
2
+ // reading/writing controller state via the `ctx` handle; the reactive core
3
+ // ($state/$derived/$effect) stays in the controller.
4
+ import {
5
+ applyExcelFilter,
6
+ normalizeForFilter,
7
+ createColumnVirtualizer,
8
+ createCoreRowModel,
9
+ createExpandedRowModel,
10
+ createFilteredRowModel,
11
+ createGroupedRowModel,
12
+ createPaginatedRowModel,
13
+ createSvelteVirtualizer,
14
+ createSortedRowModel,
15
+ createSvGrid,
16
+ filterFns,
17
+ getGridCellA11yProps,
18
+ getGridCellDomId,
19
+ getGridHeaderA11yProps,
20
+ getGridRootA11yProps,
21
+ getGridRowA11yProps,
22
+ parseEditorValue,
23
+ normalizeEditorOptions,
24
+ sortFns,
25
+ tableFeatures,
26
+ rowSortingFeature,
27
+ columnFilteringFeature,
28
+ columnGroupingFeature,
29
+ type CellContext,
30
+ type EditorContext,
31
+ type CellEditorOption,
32
+ type CellEditorType,
33
+ type CellFormatter,
34
+ type CellFormatConfig,
35
+ type Column,
36
+ type ColumnDef,
37
+ type Row,
38
+ type RowData,
39
+ type SvGridApi,
40
+ type TableFeatures,
41
+ } from "./index";
42
+ import "./sv-grid-scrollbar";
43
+ import type { Snippet } from "svelte";
44
+ import { getKeyboardIntent, getNextActiveCell } from "./keyboard";
45
+ import {
46
+ formatNumericWithConfig,
47
+ getDateFormatter,
48
+ resolveDatePattern,
49
+ } from "./cell-formatting";
50
+ import {
51
+ RenderSnippetConfig,
52
+ RenderComponentConfig,
53
+ } from "./render-component";
54
+ import { buildFillPattern } from "./fill-patterns";
55
+ import { buildSparkline, toSparklineValues } from "./sparkline";
56
+ import {
57
+ resolveCellFormat,
58
+ computeColumnStat,
59
+ formatsNeedingStats,
60
+ type ColumnStat,
61
+ type ResolvedCellFormat,
62
+ } from "./conditional-formatting";
63
+ import SvGridDropdown from "./SvGridDropdown.svelte";
64
+ import type {
65
+ Props,
66
+ SelectionPoint,
67
+ SelectionRange,
68
+ CellEditState,
69
+ FilterOperator,
70
+ FilterOption,
71
+ MenuPosition,
72
+ } from "./SvGrid.types";
73
+ import {
74
+ cfTextStyle,
75
+ fmtStat,
76
+ getCellKey,
77
+ resolveClassList,
78
+ toDateInputValue,
79
+ toDateTimeLocalInputValue,
80
+ getEditableInputValue,
81
+ getEditorInputType,
82
+ toValueArray,
83
+ getOptionLabel,
84
+ getOptionColor,
85
+ colorfulChipStyle,
86
+ getEditorClass,
87
+ asDate,
88
+ clampMenuX,
89
+ cssEscape,
90
+ rawToNumber,
91
+ formatFacetNumber,
92
+ formatFacetDate,
93
+ } from "./SvGrid.helpers";
94
+ import { createMenus } from "./menus";
95
+ import { createCellRender } from "./cell-render";
96
+ import { createEditing } from "./editing";
97
+ import { createSelection } from "./selection";
98
+ import { createColumns } from "./columns";
99
+ import { createGridApi } from "./build-api";
100
+ import { createClipboard } from "./clipboard";
101
+ import {
102
+ filterOperatorOptions,
103
+ fallbackOperatorOption,
104
+ TEXT_OPERATORS,
105
+ NUMBER_OPERATORS,
106
+ DATE_OPERATORS,
107
+ CHECKBOX_OPERATORS,
108
+ operatorOption,
109
+ operatorsForColumn,
110
+ defaultOperatorFor,
111
+ operatorLabelFor,
112
+ } from "./filter-operators";
113
+ import {
114
+ type FacetBucket,
115
+ isBucketableColumn,
116
+ buildBuckets,
117
+ isInBucket,
118
+ } from "./facet-buckets";
119
+ import {
120
+ getColumnBaseValue,
121
+ isGroupRow,
122
+ toolPanelHeaderLabel,
123
+ formatSummaryNumeric,
124
+ getColumnAlign,
125
+ getPinnedCellValue,
126
+ getColumnAccessorValue,
127
+ columnDefMatchesId,
128
+ } from "./cell-values";
129
+
130
+ export function createSummaries<
131
+ TFeatures extends TableFeatures = TableFeatures,
132
+ TData extends RowData = RowData,
133
+ >(ctx: any) {
134
+ /**
135
+ * Aggregate every row into a per-column footer summary (sum for numeric
136
+ * columns, `Count: N` otherwise). This loop is the hottest path on a
137
+ * large grid - it is rows x columns iterations - so two things keep the
138
+ * constant factor down:
139
+ *
140
+ * 1. The edited-cell overlay is only consulted when an edit actually
141
+ * exists. `key in editedCellValues` hits a reactive-proxy `has`
142
+ * trap for every cell otherwise - 5M no-op trap calls on a
143
+ * 100k x 50 grid. Skipping it when the map is empty is the single
144
+ * biggest win here.
145
+ * 2. The column's accessor (`accessorFn` / `field`) is resolved once
146
+ * per column, not re-read off `columnDef` for every cell.
147
+ */
148
+ function computeSummaries(
149
+ rows: ReadonlyArray<Row<TData>>,
150
+ columns: ReadonlyArray<Column<TData>>,
151
+ ): Record<string, string> {
152
+ const summary: Record<string, string> = {};
153
+ const rowCount = rows.length;
154
+ const hasEdits = Object.keys(ctx.editedCellValues).length > 0;
155
+ for (const column of columns) {
156
+ const def = column.columnDef;
157
+ const accessorFn = def.accessorFn;
158
+ const field = def.field;
159
+ const columnId = column.id;
160
+ let numericSum = 0;
161
+ let numericCount = 0;
162
+ for (let i = 0; i < rowCount; i += 1) {
163
+ const row = rows[i]!;
164
+ let value: unknown;
165
+ const base = accessorFn
166
+ ? accessorFn(row.original)
167
+ : field
168
+ ? (row.original as Record<string, unknown>)[field]
169
+ : row.getCellValueByColumnId(columnId);
170
+ if (hasEdits) {
171
+ const key = getCellKey(row.id, columnId);
172
+ value = key in ctx.editedCellValues ? ctx.editedCellValues[key] : base;
173
+ } else {
174
+ value = base;
175
+ }
176
+ const asNumber = Number(value);
177
+ if (Number.isFinite(asNumber)) {
178
+ numericSum += asNumber;
179
+ numericCount += 1;
180
+ }
181
+ }
182
+ summary[columnId] =
183
+ numericCount > 0
184
+ ? formatSummaryNumeric(column, numericSum)
185
+ : `Count: ${rowCount}`;
186
+ }
187
+ return summary;
188
+ }
189
+
190
+ function hasRenderedColumn(entry: {
191
+ item: (typeof ctx.renderedColumnItems)[number];
192
+ column: (typeof ctx.allColumns)[number] | undefined;
193
+ }): entry is {
194
+ item: (typeof ctx.renderedColumnItems)[number];
195
+ column: (typeof ctx.allColumns)[number];
196
+ } {
197
+ return entry.column !== undefined;
198
+ }
199
+
200
+ return {
201
+ computeSummaries,
202
+ hasRenderedColumn,
203
+ };
204
+ }
@@ -34,7 +34,11 @@ const STYLE_TEMPLATE = `
34
34
  display: flex;
35
35
  user-select: none;
36
36
  background: var(--sg-scrollbar-bg, #eef2f8);
37
- box-shadow: inset 0 0 0 1px var(--sg-scrollbar-border, rgba(15, 23, 42, 0.04));
37
+ /* The 1px separator is drawn on the INNER edge only (the edge facing
38
+ the grid body) via the orientation rules below. A full inset box
39
+ here doubled up with the grid's own frame border on the outer edge,
40
+ reading as a 2px border - most visible in dense-gridline themes
41
+ like Excel, which should show a single 1px line. */
38
42
  /* Start hidden + non-interactive - viewport/content sizes are 0 until
39
43
  after the first layout pass, so without this default the scrollbar
40
44
  paints visible for one frame and then JS hides it on mount. That
@@ -48,10 +52,18 @@ const STYLE_TEMPLATE = `
48
52
  :host([orientation="vertical"]) {
49
53
  flex-direction: column;
50
54
  width: ${ARROW_SIZE}px;
55
+ /* LTR: vertical scrollbar sits at the right, inner edge is the left. */
56
+ box-shadow: inset 1px 0 0 0 var(--sg-scrollbar-border, rgba(15, 23, 42, 0.04));
57
+ }
58
+ :host([orientation="vertical"]:dir(rtl)) {
59
+ /* RTL flips the scrollbar to the left, so the inner edge is the right. */
60
+ box-shadow: inset -1px 0 0 0 var(--sg-scrollbar-border, rgba(15, 23, 42, 0.04));
51
61
  }
52
62
  :host([orientation="horizontal"]) {
53
63
  flex-direction: row;
54
64
  height: ${ARROW_SIZE}px;
65
+ /* Horizontal scrollbar sits at the bottom, inner edge is the top. */
66
+ box-shadow: inset 0 1px 0 0 var(--sg-scrollbar-border, rgba(15, 23, 42, 0.04));
55
67
  }
56
68
  .arrow {
57
69
  flex: none;
@@ -196,6 +196,15 @@ export type SvGridApi<
196
196
  * default. Useful for "save view" + URL persistence.
197
197
  */
198
198
  getColumnWidths(): Record<string, number>
199
+ /**
200
+ * Snap one column's width to its widest visible cell (header text +
201
+ * any rendered body cell). Equivalent to double-clicking the column's
202
+ * resize handle. The grid also exposes this through the column menu's
203
+ * "Autosize" item.
204
+ */
205
+ autosizeColumn(columnId: string): void
206
+ /** Run `autosizeColumn` on every column. */
207
+ autosizeAllColumns(): void
199
208
  /**
200
209
  * Replace the column-pinning state in one call. Each entry is a
201
210
  * column id; the order in the array becomes the visible order along
@@ -366,7 +375,9 @@ export type SvGridWrapperProps<
366
375
  showRowSelection?: boolean
367
376
  showPagination?: boolean
368
377
  virtualization?: boolean
369
- rowHeight?: number
378
+ /** Row height in pixels. Pass a function `(rowIndex) => px` for per-row
379
+ * variable heights (e.g. when wiring up an interactive row-resize). */
380
+ rowHeight?: number | ((rowIndex: number) => number)
370
381
  overscan?: number
371
382
  containerHeight?: number
372
383
  columnVirtualization?: boolean
@@ -441,6 +441,28 @@ describe('SvGrid - column add / remove / visibility', () => {
441
441
  destroy()
442
442
  }
443
443
  })
444
+
445
+ it('columns marked `visible: false` start hidden but stay listed', async () => {
446
+ const columns: ColumnDef<typeof fullFeatures, Person>[] = personColumns.map(
447
+ (c) => (c.field === 'team' ? { ...c, visible: false } : c),
448
+ )
449
+ const { api, destroy } = await mountGrid({ columns })
450
+ try {
451
+ // Starts hidden...
452
+ expect(api.isColumnVisible('team')).toBe(false)
453
+ // ...but is still listed in getColumns() (so a Choose Columns UI
454
+ // can offer it), just with visible=false.
455
+ const team = api.getColumns().find((c) => c.id === 'team')
456
+ expect(team).toBeDefined()
457
+ expect(team?.visible).toBe(false)
458
+ // A later user toggle wins over the initial flag.
459
+ api.setColumnVisible('team', true)
460
+ await tick()
461
+ expect(api.isColumnVisible('team')).toBe(true)
462
+ } finally {
463
+ destroy()
464
+ }
465
+ })
444
466
  })
445
467
 
446
468
  describe('SvGrid - row add / remove', () => {
@@ -0,0 +1,112 @@
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
+ import type { ColumnDef, SvGridApi } from './index'
9
+
10
+ type Row = { id: number; name: string; city: string }
11
+ const features = tableFeatures({})
12
+ const rows: Row[] = [
13
+ { id: 1, name: 'Ann', city: 'Paris' },
14
+ { id: 2, name: 'Bo', city: 'Berlin' },
15
+ ]
16
+ const tick = () => new Promise<void>((r) => queueMicrotask(r))
17
+
18
+ function mountGrid(extraCols: ColumnDef<typeof features, Row>[], props: Record<string, unknown> = {}) {
19
+ return new Promise<{ api: SvGridApi<typeof features, Row>; target: HTMLElement; destroy: () => void }>(
20
+ (res) => {
21
+ const target = document.createElement('div')
22
+ document.body.appendChild(target)
23
+ const app = mount(SvGrid, {
24
+ target,
25
+ props: {
26
+ data: rows,
27
+ columns: [{ field: 'name', header: 'Name', width: 160, editorType: 'text' }, ...extraCols],
28
+ features,
29
+ _rowModels: { coreRowModel: createCoreRowModel() },
30
+ rowHeight: 32,
31
+ containerHeight: 360,
32
+ virtualization: false,
33
+ enableInlineEditing: true,
34
+ enableCellSelection: true,
35
+ onApiReady(api: SvGridApi<typeof features, Row>) {
36
+ res({ api, target, destroy: () => { unmount(app); target.remove() } })
37
+ },
38
+ ...props,
39
+ } as any,
40
+ })
41
+ },
42
+ )
43
+ }
44
+
45
+ function dataCell(target: HTMLElement, col: number) {
46
+ return target.querySelectorAll('.sv-grid-cell[data-svgrid-row]')[col] as HTMLElement
47
+ }
48
+
49
+ describe('autocomplete cell type', () => {
50
+ it('filters suggestions as you type and commits the picked option', async () => {
51
+ const { api, target, destroy } = await mountGrid([
52
+ {
53
+ field: 'city',
54
+ header: 'City',
55
+ width: 200,
56
+ editorType: 'autocomplete',
57
+ editorOptions: ['Paris', 'Berlin', 'Boston', 'Brussels'],
58
+ },
59
+ ])
60
+ await tick()
61
+ // city is the 2nd column => index 1 in the first row's cells
62
+ const cell = target.querySelectorAll('.sv-grid-cell[data-svgrid-row="0"]')[1] as HTMLElement
63
+ cell.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
64
+ await tick()
65
+ const input = target.querySelector('.sv-grid-cell-editor-autocomplete') as HTMLInputElement
66
+ expect(input).not.toBeNull()
67
+ input.value = 'B'
68
+ input.dispatchEvent(new Event('input', { bubbles: true }))
69
+ await tick()
70
+ const opts = [...target.querySelectorAll('.sv-grid-autocomplete-option')].map((b) => b.textContent?.trim())
71
+ // 'B' matches Berlin, Boston, Brussels - not Paris
72
+ expect(opts).toEqual(['Berlin', 'Boston', 'Brussels'])
73
+ const berlin = [...target.querySelectorAll('.sv-grid-autocomplete-option')].find(
74
+ (b) => b.textContent?.trim() === 'Boston',
75
+ ) as HTMLElement
76
+ berlin.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
77
+ await tick()
78
+ expect(api.getCellValue(0, 'city')).toBe('Boston')
79
+ destroy()
80
+ })
81
+ })
82
+
83
+ describe('editable comments', () => {
84
+ it('adds a comment via the context menu and emits onNoteChange', async () => {
85
+ const changes: Array<{ rowId: string; columnId: string; note: string }> = []
86
+ const { target, destroy } = await mountGrid(
87
+ [{ field: 'city', header: 'City', width: 200, editorType: 'text' }],
88
+ { contextMenu: true, editableComments: true, onNoteChange: (e: any) => changes.push(e) },
89
+ )
90
+ await tick()
91
+ const cell = dataCell(target, 0)
92
+ cell.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
93
+ await tick()
94
+ const editItem = [...target.querySelectorAll('.sv-grid-context-menu .sv-grid-menu-item')].find(
95
+ (b) => b.textContent?.trim() === 'Edit comment',
96
+ ) as HTMLElement
97
+ expect(editItem).toBeTruthy()
98
+ editItem.click()
99
+ await tick()
100
+ const ta = target.querySelector('.sv-grid-comment-textarea') as HTMLTextAreaElement
101
+ expect(ta).not.toBeNull()
102
+ ta.value = 'Needs review'
103
+ ta.dispatchEvent(new Event('input', { bubbles: true }))
104
+ await tick()
105
+ ;(target.querySelector('.sv-grid-comment-save') as HTMLElement).click()
106
+ await tick()
107
+ expect(changes.length).toBe(1)
108
+ expect(changes[0]!.note).toBe('Needs review')
109
+ expect(target.querySelector('.sv-grid-comment-editor')).toBeNull()
110
+ destroy()
111
+ })
112
+ })