@svgrid/grid 3.0.5 → 3.0.6

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 (148) hide show
  1. package/README.md +28 -7
  2. package/dist/GridMenus.svelte +34 -4
  3. package/dist/SvGrid.controller.svelte.d.ts +21 -3
  4. package/dist/SvGrid.controller.svelte.js +205 -50
  5. package/dist/SvGrid.css +127 -2
  6. package/dist/SvGrid.svelte +223 -38
  7. package/dist/SvGrid.types.d.ts +77 -4
  8. package/dist/SvGridDropdown.svelte +20 -0
  9. package/dist/SvGridDropdown.svelte.d.ts +12 -0
  10. package/dist/SvModal.svelte +22 -0
  11. package/dist/SvModal.svelte.d.ts +7 -0
  12. package/dist/a11y/dismissable.d.ts +10 -0
  13. package/dist/a11y/dismissable.js +24 -2
  14. package/dist/build-api.js +81 -31
  15. package/dist/cdn/{GridMenus-CID62rlI.js → GridMenus-C-NtjlrJ.js} +150 -141
  16. package/dist/cdn/GridMenus-TssHej3q.js +644 -0
  17. package/dist/cdn/{SvChartMenu-FBSMINA6.js → SvChartMenu-BFSTpvB-.js} +1 -1
  18. package/dist/cdn/{SvChartMenu-Bl6PBbkT.js → SvChartMenu-BZ1Iwv0y.js} +1 -1
  19. package/dist/cdn/{SvDateTimePicker-DQwt4UAs.js → SvDateTimePicker-D9u_cOf8.js} +1 -1
  20. package/dist/cdn/{SvDateTimePicker-vNU6bZ-q.js → SvDateTimePicker-s_6IUpI7.js} +1 -1
  21. package/dist/cdn/{SvGridCellEditor-B1p-vCK5.js → SvGridCellEditor-BlyCSp0q.js} +1 -1
  22. package/dist/cdn/{SvGridCellEditor-D_0q4xAu.js → SvGridCellEditor-ClRRA9lW.js} +1 -1
  23. package/dist/cdn/{SvGridChart-DzLkSwCH.js → SvGridChart-B4EV8U6t.js} +4 -4
  24. package/dist/cdn/{SvGridChart-C3EWAZaM.js → SvGridChart-DsmwTyZF.js} +4 -4
  25. package/dist/cdn/{SvGridChartBuilder-CfII62sZ.js → SvGridChartBuilder-cHloHobN.js} +4 -4
  26. package/dist/cdn/{SvGridChartBuilder-BE2T1ykB.js → SvGridChartBuilder-vOl7C0Za.js} +4 -4
  27. package/dist/cdn/{SvGridChartPanel-xf4sWVTo.js → SvGridChartPanel-AcqF-Ll5.js} +5 -5
  28. package/dist/cdn/{SvGridChartPanel-CCX5_Wgd.js → SvGridChartPanel-biMtSBPA.js} +5 -5
  29. package/dist/cdn/{SvGridChartView-DYwabQWj.js → SvGridChartView-Dx_GmZuL.js} +2 -2
  30. package/dist/cdn/{SvGridChartView-CfuXmY5I.js → SvGridChartView-I3rDHXAq.js} +2 -2
  31. package/dist/cdn/{SvGridDropdown-D0VdjeR8.js → SvGridDropdown-BWeM3lMe.js} +46 -46
  32. package/dist/cdn/{SvGridDropdown-D13MtJ2j.js → SvGridDropdown-CJ6QqTxu.js} +67 -67
  33. package/dist/cdn/{SvModal-CIZWCcad.js → SvModal-ClxGmpAc.js} +187 -186
  34. package/dist/cdn/{SvModal-BAE-pjZX.js → SvModal-DhB_VMFR.js} +148 -147
  35. package/dist/cdn/{cell-formatting-C2Hf5gqd.js → cell-formatting-DVVKOSI9.js} +1 -1
  36. package/dist/cdn/{chart-Dz7SqMXH.js → chart-DEZxwC0i.js} +1 -1
  37. package/dist/cdn/{chart-panel-messages-CUbf2R4i.js → chart-panel-messages-D12XcTZJ.js} +1 -1
  38. package/dist/cdn/{chart-panel-messages-CmNrMdsr.js → chart-panel-messages-DguS7gC5.js} +1 -1
  39. package/dist/cdn/{chart-summary-BJW_tg_X.js → chart-summary-CMAXBGY-.js} +1 -1
  40. package/dist/cdn/{chart-trend-CaN9mDEV.js → chart-trend-BkEp61Gj.js} +1 -1
  41. package/dist/cdn/{dismissable-DAHetSNk.js → dismissable-Brc4EZU1.js} +5 -1
  42. package/dist/cdn/{export-format-CUDLy2yn.js → export-format-YzdwBww2.js} +1 -1
  43. package/dist/cdn/{row-resize-niQCp040.js → row-resize-DtOS1KG6.js} +41 -32
  44. package/dist/cdn/{server-block-cache-CGoWz-87.js → server-block-cache-DIqb3VZn.js} +4 -0
  45. package/dist/cdn/{src-jcFMkBnN.js → src-CgoeSM8k.js} +4697 -4131
  46. package/dist/cdn/{src-Bklofsad.js → src-DfwxB1TH.js} +4727 -4161
  47. package/dist/cdn/svgrid.js +17 -17
  48. package/dist/cdn/svgrid.svelte-external.js +17 -17
  49. package/dist/cdn/{validate-C7Rn_Bns.js → validate-AZoD8BoR.js} +6 -2
  50. package/dist/cell-formatting.js +18 -1
  51. package/dist/cell-render.js +25 -6
  52. package/dist/clipboard.d.ts +2 -2
  53. package/dist/clipboard.js +72 -23
  54. package/dist/columns.js +7 -2
  55. package/dist/conditional-formatting.d.ts +2 -0
  56. package/dist/conditional-formatting.js +2 -0
  57. package/dist/core.d.ts +22 -6
  58. package/dist/core.js +365 -25
  59. package/dist/editing.js +24 -0
  60. package/dist/grid-messages.d.ts +4 -0
  61. package/dist/grid-messages.js +4 -0
  62. package/dist/headless.d.ts +5 -0
  63. package/dist/headless.js +12 -0
  64. package/dist/history.d.ts +21 -0
  65. package/dist/history.js +34 -4
  66. package/dist/index.d.ts +1 -1
  67. package/dist/keyboard-handlers.js +24 -1
  68. package/dist/keyboard.js +4 -0
  69. package/dist/row-drag-touch.d.ts +1 -1
  70. package/dist/row-drag.js +35 -1
  71. package/dist/row-model.d.ts +6 -0
  72. package/dist/row-resize.js +22 -1
  73. package/dist/selection.js +1 -1
  74. package/dist/server-block-cache.d.ts +5 -0
  75. package/dist/server-block-cache.js +8 -0
  76. package/dist/server-data-source.d.ts +35 -3
  77. package/dist/server-data-source.js +11 -2
  78. package/dist/server.d.ts +1 -1
  79. package/dist/sparkline.js +7 -5
  80. package/dist/svgrid-wrapper.types.d.ts +33 -0
  81. package/dist/validate.js +9 -1
  82. package/package.json +6 -1
  83. package/src/GridMenus.svelte +34 -4
  84. package/src/SvGrid.controller.svelte.ts +206 -53
  85. package/src/SvGrid.css +127 -2
  86. package/src/SvGrid.svelte +223 -38
  87. package/src/SvGrid.types.ts +77 -4
  88. package/src/SvGridDropdown.svelte +20 -0
  89. package/src/SvModal.svelte +22 -0
  90. package/src/SvModal.test.ts +30 -1
  91. package/src/a11y/dismissable.test.ts +10 -1
  92. package/src/a11y/dismissable.ts +22 -2
  93. package/src/build-api.ts +85 -27
  94. package/src/cell-formatting.test.ts +17 -0
  95. package/src/cell-formatting.ts +18 -1
  96. package/src/cell-render.test.ts +24 -2
  97. package/src/cell-render.ts +24 -8
  98. package/src/clipboard.test.ts +107 -0
  99. package/src/clipboard.ts +62 -23
  100. package/src/columns.test.ts +4 -4
  101. package/src/columns.ts +7 -2
  102. package/src/conditional-formatting.test.ts +8 -0
  103. package/src/conditional-formatting.ts +3 -0
  104. package/src/core.tick-repair.test.ts +287 -0
  105. package/src/core.ts +374 -26
  106. package/src/editing.test.ts +52 -0
  107. package/src/editing.ts +25 -0
  108. package/src/grid-messages.test.ts +7 -0
  109. package/src/grid-messages.ts +10 -0
  110. package/src/headless.ts +24 -0
  111. package/src/history.test.ts +49 -1
  112. package/src/history.ts +48 -2
  113. package/src/index.ts +2 -0
  114. package/src/keyboard-handlers.coverage.test.ts +65 -0
  115. package/src/keyboard-handlers.ts +26 -1
  116. package/src/keyboard.ts +3 -0
  117. package/src/row-drag-touch.ts +2 -2
  118. package/src/row-drag.test.ts +51 -0
  119. package/src/row-drag.ts +35 -2
  120. package/src/row-model.ts +6 -0
  121. package/src/row-resize.ts +24 -1
  122. package/src/selection.ts +1 -1
  123. package/src/server-block-cache.test.ts +27 -0
  124. package/src/server-block-cache.ts +13 -0
  125. package/src/server-data-source.infinite.test.ts +62 -0
  126. package/src/server-data-source.test.ts +19 -0
  127. package/src/server-data-source.ts +48 -4
  128. package/src/server.ts +2 -0
  129. package/src/sparkline.test.ts +7 -0
  130. package/src/sparkline.ts +6 -4
  131. package/src/svgrid-wrapper.types.ts +32 -0
  132. package/src/svgrid.api.test.ts +21 -0
  133. package/src/svgrid.charting.test.ts +12 -4
  134. package/src/svgrid.context-menu.test.ts +12 -0
  135. package/src/svgrid.detail-rows.svelte.test.ts +187 -0
  136. package/src/svgrid.filter-menu-scroll.test.ts +9 -0
  137. package/src/svgrid.gantt-seam.test.ts +1 -1
  138. package/src/svgrid.live-update-paths.svelte.test.ts +184 -0
  139. package/src/svgrid.menu-scroll-close.test.ts +10 -0
  140. package/src/svgrid.row-model-seam.svelte.test.ts +16 -0
  141. package/src/svgrid.row-pinning.test.ts +15 -1
  142. package/src/svgrid.selection-bar-seam.test.ts +1 -1
  143. package/src/svgrid.sticky-groups.svelte.test.ts +145 -0
  144. package/src/svgrid.upsell-license.test.ts +1 -1
  145. package/src/test-setup.ts +12 -0
  146. package/src/transaction.test.ts +58 -0
  147. package/src/validate.ts +7 -1
  148. package/dist/cdn/GridMenus-DLL-qZeu.js +0 -635
@@ -60,6 +60,14 @@ function mountGrid() {
60
60
  })
61
61
  }
62
62
 
63
+ /**
64
+ * The close-on-scroll listener arms one animation frame after a popover
65
+ * mounts, so a scroll queued before the opening click cannot dismiss what
66
+ * that click opened. Every open helper waits that frame out before a case
67
+ * dispatches its scroll.
68
+ */
69
+ const nextFrame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
70
+
63
71
  async function openFilterMenu(target: HTMLElement) {
64
72
  const btn = target.querySelector('.sv-grid-col-filter-btn') as HTMLButtonElement
65
73
  expect(btn).not.toBeNull()
@@ -74,6 +82,7 @@ async function openFilterMenu(target: HTMLElement) {
74
82
  expect(menu).not.toBeNull()
75
83
  expect(menu!.querySelector('.sv-listbox')).not.toBeNull()
76
84
  })
85
+ await nextFrame()
77
86
  return target.querySelector('.sv-grid-filter-menu') as HTMLElement
78
87
  }
79
88
 
@@ -104,7 +104,7 @@ describe('gantt - without the Enterprise renderer', () => {
104
104
  it('says a license is needed, and links to it', async () => {
105
105
  // Naming the package is what unblocks a developer trying it; saying a key
106
106
  // is needed is what they have to know before shipping. Enterprise is
107
- // SOFT-gated, so the wording must not imply the feature is inert without
107
+ // runs without a key, so the wording must not imply the feature is inert without
108
108
  // one - it says "watermark", which is what actually happens.
109
109
  const { target, destroy } = await mountGrid()
110
110
  await tick()
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The two ways a live feed can change a row, and what each one reaches.
3
+ *
4
+ * A `$state` array passed as `data` has two update paths and they are not
5
+ * interchangeable, which is the whole design problem of a ticking grid:
6
+ *
7
+ * 1. Mutate a field through the proxy (`rows[i].price = x`). The visible
8
+ * cell re-renders because its template reads the field through the
9
+ * proxy. The row model does NOT run - it caches on the array reference
10
+ * and the reference did not change - so an active sort or filter is
11
+ * stale for that row until something replaces the array.
12
+ * 2. Replace the array (`rows = next`, or `api.applyTransaction`). The row
13
+ * model rebuilds and re-runs the pipeline over every row; sort and filter
14
+ * are current again.
15
+ *
16
+ * Replacing one ELEMENT through the proxy (`rows[i] = {...}`) is the case a
17
+ * React-trained hand reaches for, and it is the one that does neither: the
18
+ * base row keeps its old `original`, so the cell keeps its old value.
19
+ *
20
+ * These are the facts the real-time docs and the blog describe; if one of
21
+ * them changes, this file is where it shows up first.
22
+ *
23
+ * Lives in a `.svelte.test.ts` so `$state` is available.
24
+ */
25
+ import { describe, expect, it } from 'vitest'
26
+ import { mount, unmount, flushSync } from 'svelte'
27
+ import SvGrid from './SvGrid.svelte'
28
+ import {
29
+ createCoreRowModel,
30
+ createFilteredRowModel,
31
+ createSortedRowModel,
32
+ rowSortingFeature,
33
+ sortFns,
34
+ tableFeatures,
35
+ } from './index'
36
+ import type { SvGridApi } from './index'
37
+
38
+ type Quote = { id: string; symbol: string; price: number }
39
+
40
+ const features = tableFeatures({ rowSortingFeature })
41
+ const columns = [
42
+ { field: 'symbol', header: 'Symbol', width: 120 },
43
+ { field: 'price', header: 'Price', width: 120, editorType: 'number' as const },
44
+ ]
45
+
46
+ function seed(): Quote[] {
47
+ return [
48
+ { id: 'a', symbol: 'AAA', price: 10 },
49
+ { id: 'b', symbol: 'BBB', price: 20 },
50
+ { id: 'c', symbol: 'CCC', price: 30 },
51
+ ]
52
+ }
53
+
54
+ /**
55
+ * The props object must be the `$state` proxy itself, not a copy of it: a
56
+ * spread would snapshot `data` at mount and the reassignment tests would be
57
+ * testing nothing.
58
+ */
59
+ function liveProps(data: Quote[]) {
60
+ const props = $state({
61
+ data,
62
+ columns,
63
+ features,
64
+ _rowModels: {
65
+ coreRowModel: createCoreRowModel(),
66
+ filteredRowModel: createFilteredRowModel(),
67
+ sortedRowModel: createSortedRowModel(sortFns),
68
+ },
69
+ getRowId: (r: Quote) => r.id,
70
+ // Price descending: CCC, BBB, AAA. A tick that lifts AAA above CCC is
71
+ // the observable difference between "cell updated" and "re-sorted".
72
+ initialSorting: [{ id: 'price', desc: true }],
73
+ containerHeight: 400,
74
+ virtualization: false,
75
+ onApiReady: undefined as ((next: SvGridApi<typeof features, Quote>) => void) | undefined,
76
+ })
77
+ return props
78
+ }
79
+
80
+ function mountGrid(props: ReturnType<typeof liveProps>) {
81
+ const target = document.createElement('div')
82
+ document.body.appendChild(target)
83
+ let api: SvGridApi<typeof features, Quote> | null = null
84
+ props.onApiReady = (next) => (api = next)
85
+ const app = mount(SvGrid, { target, props: props as never })
86
+ flushSync()
87
+ const dataRows = () =>
88
+ Array.from(target.querySelectorAll('tbody.sv-grid-body tr.sv-grid-row:not(.sv-grid-row-spacer)'))
89
+ const cellsOf = (tr: Element) =>
90
+ Array.from(tr.querySelectorAll('.sv-grid-cell:not(.sv-grid-selection-cell)')).map((td) =>
91
+ td.textContent?.trim(),
92
+ )
93
+ const symbolsInOrder = () => dataRows().map((tr) => cellsOf(tr)[0])
94
+ const priceOf = (symbol: string) => {
95
+ const tr = dataRows().find((row) => cellsOf(row)[0] === symbol)
96
+ return tr ? cellsOf(tr)[1] : undefined
97
+ }
98
+ return {
99
+ app,
100
+ target,
101
+ api: () => api!,
102
+ symbolsInOrder,
103
+ priceOf,
104
+ done() {
105
+ unmount(app)
106
+ target.remove()
107
+ },
108
+ }
109
+ }
110
+
111
+ describe('live update paths', () => {
112
+ it('starts sorted by price descending', () => {
113
+ const props = liveProps(seed())
114
+ const g = mountGrid(props)
115
+ expect(g.symbolsInOrder()).toEqual(['CCC', 'BBB', 'AAA'])
116
+ g.done()
117
+ })
118
+
119
+ it('proxy field write: the cell updates, the sort does not re-run', () => {
120
+ const props = liveProps(seed())
121
+ const g = mountGrid(props)
122
+
123
+ props.data[0]!.price = 99 // AAA now the highest price
124
+ flushSync()
125
+
126
+ expect(g.priceOf('AAA')).toBe('99')
127
+ // Still where it was: the row model never saw the write.
128
+ expect(g.symbolsInOrder()).toEqual(['CCC', 'BBB', 'AAA'])
129
+ g.done()
130
+ })
131
+
132
+ it('proxy element replacement: neither the cell nor the sort updates', () => {
133
+ const props = liveProps(seed())
134
+ const g = mountGrid(props)
135
+
136
+ props.data[0] = { ...props.data[0]!, price: 99 }
137
+ flushSync()
138
+
139
+ // The base row still points at the object it was built from.
140
+ expect(g.priceOf('AAA')).toBe('10')
141
+ expect(g.symbolsInOrder()).toEqual(['CCC', 'BBB', 'AAA'])
142
+ g.done()
143
+ })
144
+
145
+ it('array reassignment: the cell updates and the sort re-runs', () => {
146
+ const props = liveProps(seed())
147
+ const g = mountGrid(props)
148
+
149
+ props.data = props.data.map((r) => (r.id === 'a' ? { ...r, price: 99 } : r))
150
+ flushSync()
151
+
152
+ expect(g.priceOf('AAA')).toBe('99')
153
+ expect(g.symbolsInOrder()).toEqual(['AAA', 'CCC', 'BBB'])
154
+ g.done()
155
+ })
156
+
157
+ it('applyTransaction: the cell updates and the sort re-runs', () => {
158
+ const props = liveProps(seed())
159
+ const g = mountGrid(props)
160
+
161
+ g.api().applyTransaction({ update: [{ id: 'a', symbol: 'AAA', price: 99 }] })
162
+ flushSync()
163
+
164
+ expect(g.priceOf('AAA')).toBe('99')
165
+ expect(g.symbolsInOrder()).toEqual(['AAA', 'CCC', 'BBB'])
166
+ g.done()
167
+ })
168
+
169
+ it('a proxy field write is caught up by the next array replacement', () => {
170
+ const props = liveProps(seed())
171
+ const g = mountGrid(props)
172
+
173
+ props.data[0]!.price = 99
174
+ flushSync()
175
+ expect(g.symbolsInOrder()).toEqual(['CCC', 'BBB', 'AAA'])
176
+
177
+ // Any structural change re-runs the pipeline over the current values,
178
+ // including the one the proxy write left behind.
179
+ props.data = [...props.data]
180
+ flushSync()
181
+ expect(g.symbolsInOrder()).toEqual(['AAA', 'CCC', 'BBB'])
182
+ g.done()
183
+ })
184
+ })
@@ -75,6 +75,14 @@ function mountGrid(extra: Record<string, unknown> = {}) {
75
75
  )
76
76
  }
77
77
 
78
+ /**
79
+ * The close-on-scroll listener arms one animation frame after a popover
80
+ * mounts, so a scroll queued before the opening click cannot dismiss what
81
+ * that click opened. Every open helper waits that frame out before a case
82
+ * dispatches its scroll.
83
+ */
84
+ const nextFrame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
85
+
78
86
  async function openContextMenu(target: HTMLElement) {
79
87
  const cell = target.querySelector('.sv-grid-cell[data-svgrid-row]') as HTMLElement
80
88
  const r = cell.getBoundingClientRect()
@@ -86,6 +94,7 @@ async function openContextMenu(target: HTMLElement) {
86
94
  expect(menu).not.toBeNull()
87
95
  expect(menu!.querySelectorAll('.sv-grid-menu-item').length).toBeGreaterThan(0)
88
96
  })
97
+ await nextFrame()
89
98
  return target.querySelector('.sv-grid-context-menu') as HTMLElement
90
99
  }
91
100
 
@@ -100,6 +109,7 @@ async function openCommentEditor(target: HTMLElement) {
100
109
  await vi.waitFor(() => {
101
110
  expect(target.querySelector('.sv-grid-comment-editor')).not.toBeNull()
102
111
  })
112
+ await nextFrame()
103
113
  return target.querySelector('.sv-grid-comment-editor') as HTMLElement
104
114
  }
105
115
 
@@ -177,6 +177,22 @@ describe('rowPlaceholder', () => {
177
177
  destroy()
178
178
  })
179
179
 
180
+ it('styles footer and grand-total rows a row model flags on the data', async () => {
181
+ // A server-side model cannot reach the row objects the grid builds, so
182
+ // it puts the client model's flags on the data instead; same chrome.
183
+ const data = [
184
+ { id: 1, name: 'Row 1' },
185
+ { id: 2, name: 'Total', __groupFooter: true },
186
+ { id: 3, name: 'Grand total', __groupFooter: true, __grandTotal: true },
187
+ ] as Row[]
188
+ const { target, destroy } = await mountGrid({}, data)
189
+ await tick()
190
+ const rows = [...target.querySelectorAll('tbody .sv-grid-row')]
191
+ expect(rows.map((r) => r.classList.contains('sv-grid-group-footer-row'))).toEqual([false, true, true])
192
+ expect(rows.map((r) => r.classList.contains('sv-grid-grand-total-row'))).toEqual([false, false, true])
193
+ destroy()
194
+ })
195
+
180
196
  it('keeps placeholder rows out of select-all', async () => {
181
197
  const { api, destroy } = await mountGrid({
182
198
  showRowSelection: true,
@@ -40,7 +40,7 @@ const rows: Row[] = [
40
40
  const cols: ColumnDef<typeof features, Row>[] = [
41
41
  { field: 'id', header: 'ID', width: 100, editable: false },
42
42
  { field: 'account', header: 'Account', width: 200, editable: false },
43
- { field: 'arr', header: 'ARR', width: 130, editable: false, align: 'right',
43
+ { field: 'arr', header: 'ARR', width: 130, editable: false, align: 'right', cellClass: 'money',
44
44
  format: { type: 'number', options: { style: 'currency', currency: 'USD', maximumFractionDigits: 0 } } },
45
45
  { field: 'seats', header: 'Seats', width: 90, editable: false, align: 'right' },
46
46
  ]
@@ -167,6 +167,20 @@ describe('SvGrid - pinnedTopRows render', () => {
167
167
  } finally { destroy() }
168
168
  })
169
169
 
170
+ it('aligns and classes the cells the way the column says (a total sat left under a right-aligned column)', async () => {
171
+ const { target, destroy } = await mountGrid({ pinnedTopRows: [TOTALS] })
172
+ try {
173
+ await tick()
174
+ const tds = target.querySelectorAll('tr.sv-grid-pinned-row-top td')
175
+ expect(tds[0]!.getAttribute('data-align')).toBe('left')
176
+ expect(tds[2]!.getAttribute('data-align')).toBe('right')
177
+ expect(tds[3]!.getAttribute('data-align')).toBe('right')
178
+ // A static cellClass reaches the pinned cell; only a function did before.
179
+ expect(tds[2]!.classList.contains('money')).toBe(true)
180
+ expect(tds[0]!.classList.contains('money')).toBe(false)
181
+ } finally { destroy() }
182
+ })
183
+
170
184
  it('stacks multiple pinned-top rows with data-pinned-index', async () => {
171
185
  const { target, destroy } = await mountGrid({
172
186
  pinnedTopRows: [TOTALS, BENCHMARK],
@@ -87,7 +87,7 @@ describe('selectionBar - without the Enterprise renderer', () => {
87
87
  it('says a license is needed, and links to it', async () => {
88
88
  // Naming the package is what unblocks a developer trying it; saying a key
89
89
  // is needed is what they have to know before shipping. Enterprise is
90
- // SOFT-gated, so the wording must not imply the feature is inert without
90
+ // runs without a key, so the wording must not imply the feature is inert without
91
91
  // one - it says "watermark", which is what actually happens.
92
92
  const { api, target, destroy } = await mountGrid(true)
93
93
  await tick()
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Sticky group rows: the group a row belongs to stays under the header
3
+ * while its rows scroll past. Under virtualization the band renders a copy
4
+ * of each ancestor; without it the real rows stick. Driven through the
5
+ * `serverGroup` seam with plain data, so no row model is needed to say
6
+ * which rows are groups and how deep they sit.
7
+ */
8
+ import { describe, expect, it } from 'vitest'
9
+ import { mount, unmount } from 'svelte'
10
+ import SvGrid from './SvGrid.svelte'
11
+ import { createCoreRowModel, tableFeatures } from './index'
12
+ import type { ColumnDef, SvGridApi } from './index'
13
+
14
+ type Row = { id: string; name: string; kind: 'group' | 'leaf'; level: number }
15
+ const features = tableFeatures({})
16
+ const cols: ColumnDef<typeof features, Row>[] = [
17
+ { field: 'name', header: 'Name', width: 200 },
18
+ { field: 'id', header: 'Id', width: 80 },
19
+ ]
20
+
21
+ const tick = () => new Promise<void>((r) => setTimeout(r))
22
+
23
+ /** A(0) > A1(1) > 20 leaves, A2(1) > 20 leaves, B(0) > B1(1) > 20 leaves: 65 rows. */
24
+ function tree(): Row[] {
25
+ const out: Row[] = []
26
+ let n = 0
27
+ const leaves = (under: string) => {
28
+ for (let i = 0; i < 20; i += 1) out.push({ id: `${under}-${i}`, name: `leaf ${n++}`, kind: 'leaf', level: 2 })
29
+ }
30
+ out.push({ id: 'A', name: 'A', kind: 'group', level: 0 })
31
+ out.push({ id: 'A1', name: 'A1', kind: 'group', level: 1 })
32
+ leaves('A1')
33
+ out.push({ id: 'A2', name: 'A2', kind: 'group', level: 1 })
34
+ leaves('A2')
35
+ out.push({ id: 'B', name: 'B', kind: 'group', level: 0 })
36
+ out.push({ id: 'B1', name: 'B1', kind: 'group', level: 1 })
37
+ leaves('B1')
38
+ return out
39
+ }
40
+
41
+ function mountGrid(extra: Record<string, unknown>) {
42
+ return new Promise<{ api: SvGridApi<typeof features, Row>; target: HTMLElement; destroy: () => void }>((res, rej) => {
43
+ const target = document.createElement('div')
44
+ document.body.appendChild(target)
45
+ const app = mount(SvGrid, {
46
+ target,
47
+ props: {
48
+ data: tree(),
49
+ columns: cols,
50
+ features,
51
+ _rowModels: { coreRowModel: createCoreRowModel() },
52
+ getRowId: (r: Row) => r.id,
53
+ rowHeight: 30,
54
+ containerHeight: 200,
55
+ stickyGroupRows: true,
56
+ serverGroup: {
57
+ isGroup: (r: Row) => r.kind === 'group',
58
+ level: (r: Row) => r.level,
59
+ expanded: () => true,
60
+ onToggle: () => {},
61
+ },
62
+ onApiReady(api: SvGridApi<typeof features, Row>) {
63
+ res({ api, target, destroy: () => { unmount(app); target.remove() } })
64
+ },
65
+ ...extra,
66
+ } as any,
67
+ })
68
+ queueMicrotask(() => { if (!target.querySelector('[role="grid"]')) rej(new Error('no grid')) })
69
+ })
70
+ }
71
+
72
+ async function scrollTo(target: HTMLElement, top: number) {
73
+ const container = target.querySelector('.sv-grid-container') as HTMLElement
74
+ container.scrollTop = top
75
+ container.dispatchEvent(new Event('scroll'))
76
+ // The virtualizer syncs to the scroll position on the next animation frame.
77
+ await new Promise<void>((r) => requestAnimationFrame(() => r()))
78
+ await tick()
79
+ await tick()
80
+ }
81
+
82
+ const stickyNames = (target: HTMLElement) =>
83
+ [...target.querySelectorAll<HTMLElement>('tr.sv-grid-row-sticky-group')].map((tr) => tr.textContent!.replace(/\s+/g, ' ').trim().split(' ')[0])
84
+
85
+ describe('stickyGroupRows under virtualization', () => {
86
+ it('renders copies of the ancestors of the first row under the header, deepest last', async () => {
87
+ const { target, destroy } = await mountGrid({ virtualization: true })
88
+ try {
89
+ await tick()
90
+ // Row 0 is A itself: nothing to hold.
91
+ expect(stickyNames(target)).toEqual([])
92
+ // Row 10 is a leaf under A1: A, then A1.
93
+ await scrollTo(target, 300)
94
+ expect(stickyNames(target)).toEqual(['A', 'A1'])
95
+ const band = [...target.querySelectorAll<HTMLElement>('tr.sv-grid-row-sticky-group')]
96
+ expect(band[0]!.getAttribute('aria-hidden')).toBe('true')
97
+ expect(band[1]!.classList.contains('sv-grid-row-sticky-group-last')).toBe(true)
98
+ // The second sits a row under the first.
99
+ expect(parseInt(band[1]!.style.top, 10) - parseInt(band[0]!.style.top, 10)).toBe(30)
100
+ // A copy carries no cell ids, so the real row's id stays unique.
101
+ expect(band[0]!.querySelector('[id]')).toBeNull()
102
+ // Row 30 is a leaf under A2.
103
+ await scrollTo(target, 900)
104
+ expect(stickyNames(target)).toEqual(['A', 'A2'])
105
+ // Row 50 is a leaf under B1.
106
+ await scrollTo(target, 1500)
107
+ expect(stickyNames(target)).toEqual(['B', 'B1'])
108
+ // Back to the top: the band is gone.
109
+ await scrollTo(target, 0)
110
+ expect(stickyNames(target)).toEqual([])
111
+ } finally {
112
+ destroy()
113
+ }
114
+ })
115
+
116
+ it('is off unless asked for', async () => {
117
+ const { target, destroy } = await mountGrid({ virtualization: true, stickyGroupRows: false })
118
+ try {
119
+ await scrollTo(target, 300)
120
+ expect(stickyNames(target)).toEqual([])
121
+ } finally {
122
+ destroy()
123
+ }
124
+ })
125
+ })
126
+
127
+ describe('stickyGroupRows without virtualization', () => {
128
+ it('makes the real ancestor rows sticky instead of copying them', async () => {
129
+ const { target, destroy } = await mountGrid({ virtualization: false })
130
+ try {
131
+ await scrollTo(target, 300)
132
+ const sticky = [...target.querySelectorAll<HTMLElement>('tr.sv-grid-row-sticky-group')]
133
+ expect(sticky.map((tr) => tr.textContent!.trim().split(/\s+/)[0])).toEqual(['A', 'A1'])
134
+ // They are the rows themselves: cells keep their row index.
135
+ expect(sticky[0]!.querySelector('[data-svgrid-row]')?.getAttribute('data-svgrid-row')).toBe('0')
136
+ expect(sticky[1]!.querySelector('[data-svgrid-row]')?.getAttribute('data-svgrid-row')).toBe('1')
137
+ expect(sticky[0]!.getAttribute('aria-hidden')).toBeNull()
138
+ // Every row renders exactly once.
139
+ const rows = [...target.querySelectorAll('tbody.sv-grid-body tr.sv-grid-row')]
140
+ expect(rows).toHaveLength(65)
141
+ } finally {
142
+ destroy()
143
+ }
144
+ })
145
+ })
@@ -95,7 +95,7 @@ describe('Enterprise upsell notes', () => {
95
95
  })
96
96
 
97
97
  const note = target.querySelector(LICENSE)!
98
- // Soft-gated: the feature runs without a key and the grid watermarks.
98
+ // The feature runs without a key and the grid watermarks.
99
99
  // The wording must not imply it is inert, because it is not.
100
100
  expect(note.textContent, name).toContain('license key')
101
101
  expect(note.textContent, name).toContain('watermark')
package/src/test-setup.ts CHANGED
@@ -59,4 +59,16 @@ if (typeof Element !== 'undefined' && !Element.prototype.animate) {
59
59
  }
60
60
  }
61
61
 
62
+ // jsdom has no canvas: getContext() returns null and prints "Not implemented:
63
+ // HTMLCanvasElement's getContext() method" through its virtual console on
64
+ // every call, hundreds of lines per run that bury the real warnings. The
65
+ // callers (the sheet's text measuring, the picture rasteriser) already treat a
66
+ // null context as "no canvas", so answering null quietly changes nothing a
67
+ // test can see; a test that needs a context still spies on the prototype.
68
+ if (typeof HTMLCanvasElement !== 'undefined') {
69
+ ;(HTMLCanvasElement.prototype as any).getContext = function () {
70
+ return null
71
+ }
72
+ }
73
+
62
74
  export {}
@@ -97,4 +97,62 @@ describe('api.applyTransaction', () => {
97
97
  destroy()
98
98
  }
99
99
  })
100
+ it('removes a row that the same transaction also updates, by id or by reference', async () => {
101
+ const { api, destroy } = await mountGrid()
102
+ try {
103
+ const byId = api.applyTransaction({ update: [{ id: 'a', symbol: 'AAPL', price: 1 }], remove: ['a'] })
104
+ await tick()
105
+ expect(byId).toEqual({ added: 0, updated: 1, removed: 1 })
106
+ expect(api.getData().map((o) => o.id)).toEqual(['b', 'c'])
107
+ const ref = api.getData()[0]! // 'b'
108
+ const byRef = api.applyTransaction({ update: [{ id: 'b', symbol: 'MSFT', price: 2 }], remove: [ref] })
109
+ await tick()
110
+ expect(byRef).toEqual({ added: 0, updated: 1, removed: 1 })
111
+ expect(api.getData().map((o) => o.id)).toEqual(['c'])
112
+ } finally {
113
+ destroy()
114
+ }
115
+ })
116
+
117
+ it('an update batch looks its rows up by id instead of walking the array', async () => {
118
+ // The id -> index map is built once per data array and carried across
119
+ // update-only transactions, so the second tick calls getRowId only for
120
+ // the rows in the batch. Before, every transaction called it for every
121
+ // row - 100k calls per tick on a 100k-row feed.
122
+ const target = document.createElement('div')
123
+ document.body.appendChild(target)
124
+ let calls = 0
125
+ let captured: SvGridApi<typeof features, Order> | null = null
126
+ const app = mount(SvGrid, {
127
+ target,
128
+ props: {
129
+ data: seed,
130
+ columns,
131
+ features,
132
+ getRowId: (o: Order) => { calls++; return o.id },
133
+ _rowModels: { coreRowModel: createCoreRowModel(), filteredRowModel: createFilteredRowModel(), sortedRowModel: createSortedRowModel(sortFns) },
134
+ containerHeight: 400,
135
+ virtualization: false,
136
+ onApiReady(api: SvGridApi<typeof features, Order>) { captured = api },
137
+ } as any,
138
+ })
139
+ try {
140
+ await tick()
141
+ const api = captured!
142
+ api.applyTransaction({ update: [{ id: 'b', symbol: 'MSFT', price: 201 }] })
143
+ await tick()
144
+ const afterFirst = calls
145
+ api.applyTransaction({ update: [{ id: 'b', symbol: 'MSFT', price: 202 }] })
146
+ await tick()
147
+ // One call: the id of the update itself. The row model's own getRowId
148
+ // calls for the replaced row are not part of this count because the
149
+ // map answered the lookup.
150
+ expect(calls - afterFirst).toBeLessThanOrEqual(2)
151
+ expect(api.getData()[1]!.price).toBe(202)
152
+ } finally {
153
+ unmount(app)
154
+ target.remove()
155
+ }
156
+ })
100
157
  })
158
+
package/src/validate.ts CHANGED
@@ -77,7 +77,13 @@ export function validateGridConfig<
77
77
  // (a key absent from row 0 but present later) does not trip it. A row
78
78
  // model shows placeholder rows before its first block lands; those carry
79
79
  // no data keys at all and say nothing about the columns.
80
- const sample = (input.data ?? []).filter((row) => !rowPlaceholderState(row)).slice(0, FIELD_SAMPLE_ROWS)
80
+ // Stops at the sample size: this runs on every data change, and a filter
81
+ // over all the rows first was O(n) per tick on a live feed.
82
+ const sample: TData[] = []
83
+ for (const row of input.data ?? []) {
84
+ if (sample.length >= FIELD_SAMPLE_ROWS) break
85
+ if (!rowPlaceholderState(row)) sample.push(row)
86
+ }
81
87
  // Under server-side grouping the first rows are group rows, which carry
82
88
  // a key and aggregates and none of the leaf fields; nothing to judge by.
83
89
  if (sample.length && !input.serverGroup) {