@svgrid/grid 1.0.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 (213) hide show
  1. package/LICENSE +33 -0
  2. package/README.md +39 -0
  3. package/dist/FlexRender.svelte +96 -0
  4. package/dist/FlexRender.svelte.d.ts +49 -0
  5. package/dist/SvGrid.svelte +8742 -0
  6. package/dist/SvGrid.svelte.d.ts +381 -0
  7. package/dist/SvGridChart.svelte +653 -0
  8. package/dist/SvGridChart.svelte.d.ts +25 -0
  9. package/dist/SvGridChart.test.d.ts +1 -0
  10. package/dist/SvGridChart.test.js +62 -0
  11. package/dist/SvGridDropdown.svelte +675 -0
  12. package/dist/SvGridDropdown.svelte.d.ts +40 -0
  13. package/dist/a11y.contract.test.d.ts +1 -0
  14. package/dist/a11y.contract.test.js +39 -0
  15. package/dist/a11y.d.ts +38 -0
  16. package/dist/a11y.js +37 -0
  17. package/dist/a11y.test.d.ts +1 -0
  18. package/dist/a11y.test.js +44 -0
  19. package/dist/cell-formatting.d.ts +17 -0
  20. package/dist/cell-formatting.js +141 -0
  21. package/dist/cell-formatting.test.d.ts +1 -0
  22. package/dist/cell-formatting.test.js +234 -0
  23. package/dist/chart-export.d.ts +14 -0
  24. package/dist/chart-export.js +112 -0
  25. package/dist/chart.d.ts +226 -0
  26. package/dist/chart.js +732 -0
  27. package/dist/chart.test.d.ts +1 -0
  28. package/dist/chart.test.js +289 -0
  29. package/dist/collaboration.d.ts +74 -0
  30. package/dist/collaboration.js +98 -0
  31. package/dist/collaboration.test.d.ts +1 -0
  32. package/dist/collaboration.test.js +65 -0
  33. package/dist/conditional-formatting.d.ts +91 -0
  34. package/dist/conditional-formatting.js +170 -0
  35. package/dist/conditional-formatting.test.d.ts +1 -0
  36. package/dist/conditional-formatting.test.js +87 -0
  37. package/dist/core.coverage.test.d.ts +1 -0
  38. package/dist/core.coverage.test.js +186 -0
  39. package/dist/core.d.ts +370 -0
  40. package/dist/core.js +568 -0
  41. package/dist/core.performance.test.d.ts +1 -0
  42. package/dist/core.performance.test.js +29 -0
  43. package/dist/createGrid.svelte.d.ts +6 -0
  44. package/dist/createGrid.svelte.js +17 -0
  45. package/dist/createGrid.test.d.ts +1 -0
  46. package/dist/createGrid.test.js +9 -0
  47. package/dist/createGridState.svelte.d.ts +3 -0
  48. package/dist/createGridState.svelte.js +13 -0
  49. package/dist/editors/cell-editors.d.ts +23 -0
  50. package/dist/editors/cell-editors.js +97 -0
  51. package/dist/editors/cell-editors.test.d.ts +1 -0
  52. package/dist/editors/cell-editors.test.js +75 -0
  53. package/dist/fill-patterns.d.ts +30 -0
  54. package/dist/fill-patterns.js +207 -0
  55. package/dist/fill-patterns.test.d.ts +1 -0
  56. package/dist/fill-patterns.test.js +81 -0
  57. package/dist/filtering/excel-filters.d.ts +26 -0
  58. package/dist/filtering/excel-filters.js +64 -0
  59. package/dist/filtering/excel-filters.test.d.ts +1 -0
  60. package/dist/filtering/excel-filters.test.js +116 -0
  61. package/dist/filtering/locale-filter.test.d.ts +13 -0
  62. package/dist/filtering/locale-filter.test.js +189 -0
  63. package/dist/flex-render.d.ts +1 -0
  64. package/dist/flex-render.js +2 -0
  65. package/dist/flex-render.test.d.ts +1 -0
  66. package/dist/flex-render.test.js +142 -0
  67. package/dist/group-aggregate.test.d.ts +1 -0
  68. package/dist/group-aggregate.test.js +32 -0
  69. package/dist/index.d.ts +29 -0
  70. package/dist/index.js +27 -0
  71. package/dist/keyboard.d.ts +8 -0
  72. package/dist/keyboard.js +91 -0
  73. package/dist/keyboard.test.d.ts +1 -0
  74. package/dist/keyboard.test.js +27 -0
  75. package/dist/merge-objects.d.ts +9 -0
  76. package/dist/merge-objects.js +28 -0
  77. package/dist/named-views.d.ts +42 -0
  78. package/dist/named-views.js +80 -0
  79. package/dist/named-views.test.d.ts +1 -0
  80. package/dist/named-views.test.js +57 -0
  81. package/dist/new-features.test.d.ts +1 -0
  82. package/dist/new-features.test.js +183 -0
  83. package/dist/render-component.d.ts +13 -0
  84. package/dist/render-component.js +14 -0
  85. package/dist/render-component.test.d.ts +1 -0
  86. package/dist/render-component.test.js +41 -0
  87. package/dist/server-data-source.d.ts +76 -0
  88. package/dist/server-data-source.js +83 -0
  89. package/dist/server-data-source.test.d.ts +1 -0
  90. package/dist/server-data-source.test.js +81 -0
  91. package/dist/sparkline.d.ts +56 -0
  92. package/dist/sparkline.js +97 -0
  93. package/dist/sparkline.test.d.ts +1 -0
  94. package/dist/sparkline.test.js +52 -0
  95. package/dist/static-functions.d.ts +1 -0
  96. package/dist/static-functions.js +1 -0
  97. package/dist/subscribe.d.ts +9 -0
  98. package/dist/subscribe.js +31 -0
  99. package/dist/subscribe.test.d.ts +1 -0
  100. package/dist/subscribe.test.js +93 -0
  101. package/dist/sv-grid-scrollbar.d.ts +1 -0
  102. package/dist/sv-grid-scrollbar.js +310 -0
  103. package/dist/svgrid-wrapper.types.d.ts +341 -0
  104. package/dist/svgrid-wrapper.types.js +1 -0
  105. package/dist/svgrid.api-extensions.test.d.ts +14 -0
  106. package/dist/svgrid.api-extensions.test.js +334 -0
  107. package/dist/svgrid.api.test.d.ts +15 -0
  108. package/dist/svgrid.api.test.js +169 -0
  109. package/dist/svgrid.behavior.test.d.ts +15 -0
  110. package/dist/svgrid.behavior.test.js +605 -0
  111. package/dist/svgrid.column-reorder.test.d.ts +15 -0
  112. package/dist/svgrid.column-reorder.test.js +224 -0
  113. package/dist/svgrid.features.test.d.ts +1 -0
  114. package/dist/svgrid.features.test.js +129 -0
  115. package/dist/svgrid.interaction.test.d.ts +9 -0
  116. package/dist/svgrid.interaction.test.js +318 -0
  117. package/dist/svgrid.locale-filtering.test.d.ts +15 -0
  118. package/dist/svgrid.locale-filtering.test.js +261 -0
  119. package/dist/svgrid.new-features.wrapper.test.d.ts +1 -0
  120. package/dist/svgrid.new-features.wrapper.test.js +136 -0
  121. package/dist/svgrid.row-pinning.test.d.ts +14 -0
  122. package/dist/svgrid.row-pinning.test.js +250 -0
  123. package/dist/svgrid.set-filter.test.d.ts +17 -0
  124. package/dist/svgrid.set-filter.test.js +268 -0
  125. package/dist/svgrid.wrapper.test.d.ts +1 -0
  126. package/dist/svgrid.wrapper.test.js +33 -0
  127. package/dist/test-setup.d.ts +1 -0
  128. package/dist/test-setup.js +29 -0
  129. package/dist/transaction.test.d.ts +1 -0
  130. package/dist/transaction.test.js +89 -0
  131. package/dist/virtualization/column-virtualizer.d.ts +20 -0
  132. package/dist/virtualization/column-virtualizer.js +19 -0
  133. package/dist/virtualization/column-virtualizer.test.d.ts +1 -0
  134. package/dist/virtualization/column-virtualizer.test.js +25 -0
  135. package/dist/virtualization/svelte-virtualizer.svelte.d.ts +11 -0
  136. package/dist/virtualization/svelte-virtualizer.svelte.js +20 -0
  137. package/dist/virtualization/types.d.ts +28 -0
  138. package/dist/virtualization/types.js +1 -0
  139. package/dist/virtualization/virtualizer.d.ts +13 -0
  140. package/dist/virtualization/virtualizer.js +232 -0
  141. package/dist/virtualization/virtualizer.test.d.ts +1 -0
  142. package/dist/virtualization/virtualizer.test.js +41 -0
  143. package/package.json +69 -0
  144. package/src/FlexRender.svelte +96 -0
  145. package/src/SvGrid.svelte +8742 -0
  146. package/src/SvGridChart.svelte +653 -0
  147. package/src/SvGridChart.test.ts +69 -0
  148. package/src/SvGridDropdown.svelte +675 -0
  149. package/src/a11y.contract.test.ts +49 -0
  150. package/src/a11y.test.ts +59 -0
  151. package/src/a11y.ts +59 -0
  152. package/src/cell-formatting.test.ts +273 -0
  153. package/src/cell-formatting.ts +169 -0
  154. package/src/chart-export.ts +144 -0
  155. package/src/chart.test.ts +311 -0
  156. package/src/chart.ts +934 -0
  157. package/src/collaboration.test.ts +74 -0
  158. package/src/collaboration.ts +161 -0
  159. package/src/conditional-formatting.test.ts +107 -0
  160. package/src/conditional-formatting.ts +239 -0
  161. package/src/core.coverage.test.ts +230 -0
  162. package/src/core.performance.test.ts +30 -0
  163. package/src/core.ts +991 -0
  164. package/src/createGrid.svelte.ts +42 -0
  165. package/src/createGrid.test.ts +10 -0
  166. package/src/createGridState.svelte.ts +17 -0
  167. package/src/editors/cell-editors.test.ts +80 -0
  168. package/src/editors/cell-editors.ts +127 -0
  169. package/src/fill-patterns.test.ts +93 -0
  170. package/src/fill-patterns.ts +229 -0
  171. package/src/filtering/excel-filters.test.ts +131 -0
  172. package/src/filtering/excel-filters.ts +101 -0
  173. package/src/filtering/locale-filter.test.ts +220 -0
  174. package/src/flex-render.test.ts +143 -0
  175. package/src/flex-render.ts +3 -0
  176. package/src/group-aggregate.test.ts +48 -0
  177. package/src/index.ts +159 -0
  178. package/src/keyboard.test.ts +59 -0
  179. package/src/keyboard.ts +97 -0
  180. package/src/merge-objects.ts +48 -0
  181. package/src/named-views.test.ts +66 -0
  182. package/src/named-views.ts +120 -0
  183. package/src/new-features.test.ts +217 -0
  184. package/src/render-component.test.ts +51 -0
  185. package/src/render-component.ts +28 -0
  186. package/src/server-data-source.test.ts +88 -0
  187. package/src/server-data-source.ts +163 -0
  188. package/src/sparkline.test.ts +59 -0
  189. package/src/sparkline.ts +164 -0
  190. package/src/static-functions.ts +11 -0
  191. package/src/subscribe.test.ts +103 -0
  192. package/src/subscribe.ts +38 -0
  193. package/src/sv-grid-scrollbar.ts +347 -0
  194. package/src/svgrid-wrapper.types.ts +382 -0
  195. package/src/svgrid.api-extensions.test.ts +362 -0
  196. package/src/svgrid.api.test.ts +192 -0
  197. package/src/svgrid.behavior.test.ts +657 -0
  198. package/src/svgrid.column-reorder.test.ts +234 -0
  199. package/src/svgrid.features.test.ts +157 -0
  200. package/src/svgrid.interaction.test.ts +355 -0
  201. package/src/svgrid.locale-filtering.test.ts +259 -0
  202. package/src/svgrid.new-features.wrapper.test.ts +164 -0
  203. package/src/svgrid.row-pinning.test.ts +266 -0
  204. package/src/svgrid.set-filter.test.ts +270 -0
  205. package/src/svgrid.wrapper.test.ts +35 -0
  206. package/src/test-setup.ts +37 -0
  207. package/src/transaction.test.ts +100 -0
  208. package/src/virtualization/column-virtualizer.test.ts +27 -0
  209. package/src/virtualization/column-virtualizer.ts +30 -0
  210. package/src/virtualization/svelte-virtualizer.svelte.ts +24 -0
  211. package/src/virtualization/types.ts +30 -0
  212. package/src/virtualization/virtualizer.test.ts +47 -0
  213. package/src/virtualization/virtualizer.ts +270 -0
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Tests for the headless-engine features shipped alongside the wrapper
3
+ * additions (`getRowId`, the `between` filter operator). Wrapper-level
4
+ * additions are covered by the source-level smoke tests in
5
+ * `svgrid.new-features.wrapper.test.ts` - jsdom + Svelte 5 mount is
6
+ * brittle for tests that only need to confirm props are read.
7
+ */
8
+ import { describe, expect, it } from 'vitest'
9
+ import {
10
+ columnFilteringFeature,
11
+ createCoreRowModel,
12
+ createSvGrid,
13
+ rowSortingFeature,
14
+ tableFeatures,
15
+ type ColumnDef,
16
+ } from './index'
17
+ import { applyExcelFilter } from './filtering/excel-filters'
18
+
19
+ type Row = { id: string; name: string; age: number }
20
+
21
+ const data: Row[] = [
22
+ { id: 'ada', name: 'Ada Lovelace', age: 36 },
23
+ { id: 'linus', name: 'Linus Torvalds', age: 54 },
24
+ { id: 'grace', name: 'Grace Hopper', age: 85 },
25
+ ]
26
+
27
+ const columns: ColumnDef<{ rowSortingFeature: { key: string } }, Row>[] = [
28
+ { field: 'id', header: 'ID' },
29
+ { field: 'name', header: 'Name' },
30
+ { field: 'age', header: 'Age' },
31
+ ]
32
+
33
+ const features = tableFeatures({ rowSortingFeature })
34
+
35
+ describe('createSvGrid - getRowId', () => {
36
+ it('falls back to the array index as id when no getRowId is provided', () => {
37
+ const grid = createSvGrid({
38
+ _features: features,
39
+ _rowModels: { coreRowModel: createCoreRowModel<Row>() },
40
+ columns,
41
+ data,
42
+ })
43
+ const rows = grid.getRowModel().rows
44
+ expect(rows.map((r) => r.id)).toEqual(['0', '1', '2'])
45
+ })
46
+
47
+ it('uses the result of getRowId(row, index) as the row id', () => {
48
+ const grid = createSvGrid<typeof features, Row>({
49
+ _features: features,
50
+ _rowModels: { coreRowModel: createCoreRowModel<Row>() },
51
+ columns,
52
+ data,
53
+ getRowId: (row) => row.id,
54
+ })
55
+ const rows = grid.getRowModel().rows
56
+ expect(rows.map((r) => r.id)).toEqual(['ada', 'linus', 'grace'])
57
+ })
58
+
59
+ it('passes the row index to getRowId', () => {
60
+ const seen: Array<{ id: string; index: number }> = []
61
+ createSvGrid<typeof features, Row>({
62
+ _features: features,
63
+ _rowModels: { coreRowModel: createCoreRowModel<Row>() },
64
+ columns,
65
+ data,
66
+ getRowId: (row, index) => {
67
+ seen.push({ id: row.id, index })
68
+ return row.id
69
+ },
70
+ }).getRowModel().rows
71
+ expect(seen).toEqual([
72
+ { id: 'ada', index: 0 },
73
+ { id: 'linus', index: 1 },
74
+ { id: 'grace', index: 2 },
75
+ ])
76
+ })
77
+
78
+ it('preserves the same id across `getRowModel()` invocations (stable identity)', () => {
79
+ const grid = createSvGrid<typeof features, Row>({
80
+ _features: features,
81
+ _rowModels: { coreRowModel: createCoreRowModel<Row>() },
82
+ columns,
83
+ data,
84
+ getRowId: (row) => `r:${row.id}`,
85
+ })
86
+ const first = grid.getRowModel().rows.map((r) => r.id)
87
+ const second = grid.getRowModel().rows.map((r) => r.id)
88
+ expect(first).toEqual(second)
89
+ expect(first).toEqual(['r:ada', 'r:linus', 'r:grace'])
90
+ })
91
+ })
92
+
93
+ describe('applyExcelFilter - between operator', () => {
94
+ it('returns true when the value is inside the inclusive range', () => {
95
+ expect(applyExcelFilter(50, { id: 'age', operator: 'between', value: 18, valueTo: 65 })).toBe(true)
96
+ })
97
+
98
+ it('is inclusive at the lower bound', () => {
99
+ expect(applyExcelFilter(18, { id: 'age', operator: 'between', value: 18, valueTo: 65 })).toBe(true)
100
+ })
101
+
102
+ it('is inclusive at the upper bound', () => {
103
+ expect(applyExcelFilter(65, { id: 'age', operator: 'between', value: 18, valueTo: 65 })).toBe(true)
104
+ })
105
+
106
+ it('returns false when the value is below the range', () => {
107
+ expect(applyExcelFilter(17, { id: 'age', operator: 'between', value: 18, valueTo: 65 })).toBe(false)
108
+ })
109
+
110
+ it('returns false when the value is above the range', () => {
111
+ expect(applyExcelFilter(66, { id: 'age', operator: 'between', value: 18, valueTo: 65 })).toBe(false)
112
+ })
113
+
114
+ it('parses string endpoints as numbers (the wrapper hands strings through)', () => {
115
+ expect(applyExcelFilter('30', { id: 'age', operator: 'between', value: '18', valueTo: '65' })).toBe(true)
116
+ })
117
+
118
+ it('works with ISO date strings via lexicographic compare', () => {
119
+ expect(applyExcelFilter('2026-06-15', {
120
+ id: 'd', operator: 'between', value: '2026-01-01', valueTo: '2026-12-31',
121
+ })).toBe(true)
122
+ expect(applyExcelFilter('2025-12-31', {
123
+ id: 'd', operator: 'between', value: '2026-01-01', valueTo: '2026-12-31',
124
+ })).toBe(false)
125
+ expect(applyExcelFilter('2027-01-01', {
126
+ id: 'd', operator: 'between', value: '2026-01-01', valueTo: '2026-12-31',
127
+ })).toBe(false)
128
+ })
129
+ })
130
+
131
+ describe('ColumnDef.sortable + ColumnDef.filterable', () => {
132
+ const features = tableFeatures({
133
+ rowSortingFeature,
134
+ columnFilteringFeature,
135
+ })
136
+
137
+ function columnsWith(opts: {
138
+ nameSortable?: boolean
139
+ nameFilterable?: boolean
140
+ ageSortable?: boolean
141
+ }): ColumnDef<typeof features, Row>[] {
142
+ return [
143
+ { field: 'id', header: 'ID' },
144
+ { field: 'name', header: 'Name', sortable: opts.nameSortable, filterable: opts.nameFilterable },
145
+ { field: 'age', header: 'Age', sortable: opts.ageSortable },
146
+ ]
147
+ }
148
+
149
+ it('getCanSort() returns true by default when rowSortingFeature is on', () => {
150
+ const grid = createSvGrid({
151
+ _features: features,
152
+ _rowModels: { coreRowModel: createCoreRowModel<Row>() },
153
+ data,
154
+ columns: columnsWith({}),
155
+ })
156
+ const name = grid.getAllColumns().find((c) => c.id === 'name')
157
+ expect(name?.getCanSort()).toBe(true)
158
+ })
159
+
160
+ it('getCanSort() returns false when columnDef.sortable === false', () => {
161
+ const grid = createSvGrid({
162
+ _features: features,
163
+ _rowModels: { coreRowModel: createCoreRowModel<Row>() },
164
+ data,
165
+ columns: columnsWith({ nameSortable: false }),
166
+ })
167
+ const name = grid.getAllColumns().find((c) => c.id === 'name')
168
+ const age = grid.getAllColumns().find((c) => c.id === 'age')
169
+ expect(name?.getCanSort()).toBe(false)
170
+ expect(age?.getCanSort()).toBe(true)
171
+ })
172
+
173
+ it('getCanSort() still returns false even with sortable: true if the feature is OFF', () => {
174
+ const noSortFeatures = tableFeatures({ columnFilteringFeature })
175
+ const grid = createSvGrid({
176
+ _features: noSortFeatures,
177
+ _rowModels: { coreRowModel: createCoreRowModel<Row>() },
178
+ data,
179
+ columns: [
180
+ { field: 'name', header: 'Name', sortable: true },
181
+ ] as ColumnDef<typeof noSortFeatures, Row>[],
182
+ })
183
+ const name = grid.getAllColumns().find((c) => c.id === 'name')
184
+ expect(name?.getCanSort()).toBe(false)
185
+ })
186
+
187
+ it('getCanFilter() returns false when columnDef.filterable === false', () => {
188
+ const grid = createSvGrid({
189
+ _features: features,
190
+ _rowModels: { coreRowModel: createCoreRowModel<Row>() },
191
+ data,
192
+ columns: columnsWith({ nameFilterable: false }),
193
+ })
194
+ const name = grid.getAllColumns().find((c) => c.id === 'name')
195
+ const age = grid.getAllColumns().find((c) => c.id === 'age')
196
+ expect(name?.getCanFilter()).toBe(false)
197
+ expect(age?.getCanFilter()).toBe(true)
198
+ })
199
+
200
+ it('sortable + filterable are independent', () => {
201
+ const grid = createSvGrid({
202
+ _features: features,
203
+ _rowModels: { coreRowModel: createCoreRowModel<Row>() },
204
+ data,
205
+ columns: [
206
+ { field: 'name', header: 'Name', sortable: false, filterable: true },
207
+ { field: 'age', header: 'Age', sortable: true, filterable: false },
208
+ ] as ColumnDef<typeof features, Row>[],
209
+ })
210
+ const name = grid.getAllColumns().find((c) => c.id === 'name')
211
+ const age = grid.getAllColumns().find((c) => c.id === 'age')
212
+ expect(name?.getCanSort()).toBe(false)
213
+ expect(name?.getCanFilter()).toBe(true)
214
+ expect(age?.getCanSort()).toBe(true)
215
+ expect(age?.getCanFilter()).toBe(false)
216
+ })
217
+ })
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Unit tests for the renderComponent / renderSnippet factories.
3
+ */
4
+ import { describe, expect, it } from 'vitest'
5
+ import {
6
+ RenderComponentConfig,
7
+ RenderSnippetConfig,
8
+ renderComponent,
9
+ renderSnippet,
10
+ } from './render-component'
11
+
12
+ describe('renderComponent', () => {
13
+ it('returns a RenderComponentConfig instance', () => {
14
+ const FakeComponent = (() => null) as any
15
+ const config = renderComponent(FakeComponent)
16
+ expect(config).toBeInstanceOf(RenderComponentConfig)
17
+ expect(config.component).toBe(FakeComponent)
18
+ expect(config.props).toBeUndefined()
19
+ })
20
+
21
+ it('captures the props payload as-is', () => {
22
+ const FakeComponent = (() => null) as any
23
+ const config = renderComponent(FakeComponent, { id: 1, label: 'Hello' } as any)
24
+ expect(config.props).toEqual({ id: 1, label: 'Hello' })
25
+ })
26
+
27
+ it('reuses the constructor instance fields', () => {
28
+ const FakeComponent = (() => null) as any
29
+ const a = renderComponent(FakeComponent, { x: 1 } as any)
30
+ const b = renderComponent(FakeComponent, { x: 1 } as any)
31
+ // Different instances - the factory does not memoize.
32
+ expect(a).not.toBe(b)
33
+ expect(a.component).toBe(b.component)
34
+ })
35
+ })
36
+
37
+ describe('renderSnippet', () => {
38
+ it('returns a RenderSnippetConfig instance with the captured snippet + params', () => {
39
+ const fakeSnippet = (() => null) as any
40
+ const config = renderSnippet(fakeSnippet, { value: 42 })
41
+ expect(config).toBeInstanceOf(RenderSnippetConfig)
42
+ expect(config.snippet).toBe(fakeSnippet)
43
+ expect(config.params).toEqual({ value: 42 })
44
+ })
45
+
46
+ it('allows an undefined params payload', () => {
47
+ const fakeSnippet = (() => null) as any
48
+ const config = renderSnippet(fakeSnippet)
49
+ expect(config.params).toBeUndefined()
50
+ })
51
+ })
@@ -0,0 +1,28 @@
1
+ import type { Component, ComponentProps, Snippet } from 'svelte'
2
+
3
+ export class RenderComponentConfig<TComponent extends Component> {
4
+ constructor(
5
+ public component: TComponent,
6
+ public props?: ComponentProps<TComponent> | Record<string, never>,
7
+ ) {}
8
+ }
9
+
10
+ export class RenderSnippetConfig<TProps> {
11
+ constructor(
12
+ public snippet: Snippet<[TProps]>,
13
+ public params?: TProps,
14
+ ) {}
15
+ }
16
+
17
+ export const renderComponent = <
18
+ TComponent extends Component<any>,
19
+ TProps extends ComponentProps<TComponent>,
20
+ >(
21
+ component: TComponent,
22
+ props?: TProps,
23
+ ) => new RenderComponentConfig(component, props)
24
+
25
+ export const renderSnippet = <TProps>(
26
+ snippet: Snippet<[TProps]>,
27
+ params?: TProps,
28
+ ) => new RenderSnippetConfig(snippet, params)
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createServerDataSource, type ServerDataSource } from './server-data-source'
3
+
4
+ type Row = { id: number }
5
+
6
+ function makeSource(total = 250, delay = 0): ServerDataSource<Row> & { calls: number } {
7
+ const all: Row[] = Array.from({ length: total }, (_, i) => ({ id: i }))
8
+ return {
9
+ calls: 0,
10
+ async getRows(req) {
11
+ this.calls += 1
12
+ if (delay) await new Promise((r) => setTimeout(r, delay))
13
+ // server applies sort: reverse when desc on 'id'
14
+ let rows = all
15
+ if (req.sortModel[0]?.id === 'id' && req.sortModel[0]?.desc) rows = [...all].reverse()
16
+ return { rows: rows.slice(req.startRow, req.endRow), rowCount: total }
17
+ },
18
+ }
19
+ }
20
+
21
+ const flush = () => new Promise((r) => setTimeout(r, 0))
22
+
23
+ describe('createServerDataSource', () => {
24
+ it('fetches a page and reports total + pageCount', async () => {
25
+ const src = makeSource(250)
26
+ const states: any[] = []
27
+ const ctl = createServerDataSource(src, { pageSize: 50, onChange: (s) => states.push(s) })
28
+ ctl.refresh()
29
+ await flush()
30
+ const last = ctl.getState()
31
+ expect(last.rows).toHaveLength(50)
32
+ expect(last.total).toBe(250)
33
+ expect(last.pageCount).toBe(5)
34
+ expect(last.loading).toBe(false)
35
+ // emitted a loading=true then loading=false
36
+ expect(states.some((s) => s.loading)).toBe(true)
37
+ })
38
+
39
+ it('setPage fetches the right slice', async () => {
40
+ const src = makeSource(250)
41
+ const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
42
+ ctl.setPage(2)
43
+ await flush()
44
+ expect(ctl.getState().rows[0]).toEqual({ id: 100 })
45
+ })
46
+
47
+ it('setSort resets to page 0 and re-fetches', async () => {
48
+ const src = makeSource(250)
49
+ const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
50
+ ctl.setPage(2)
51
+ await flush()
52
+ ctl.setSort([{ id: 'id', desc: true }])
53
+ await flush()
54
+ const s = ctl.getState()
55
+ expect(s.pageIndex).toBe(0)
56
+ expect(s.rows[0]).toEqual({ id: 249 }) // reversed
57
+ })
58
+
59
+ it('ignores a stale response when a newer request supersedes it', async () => {
60
+ // slow source: first call (page 0) resolves AFTER the second (page 1)
61
+ const all = Array.from({ length: 100 }, (_, i) => ({ id: i }))
62
+ let call = 0
63
+ const src: ServerDataSource<Row> = {
64
+ getRows: vi.fn(async (req) => {
65
+ call += 1
66
+ const delay = call === 1 ? 40 : 5 // first call is slow
67
+ await new Promise((r) => setTimeout(r, delay))
68
+ return { rows: all.slice(req.startRow, req.endRow), rowCount: 100 }
69
+ }),
70
+ }
71
+ const ctl = createServerDataSource(src, { pageSize: 10, onChange: () => {} })
72
+ ctl.refresh() // page 0 (slow)
73
+ ctl.setPage(3) // page 30 (fast) - should win
74
+ await new Promise((r) => setTimeout(r, 80))
75
+ expect(ctl.getState().rows[0]).toEqual({ id: 30 })
76
+ })
77
+
78
+ it('dispose stops further updates', async () => {
79
+ const src = makeSource(50, 20)
80
+ const onChange = vi.fn()
81
+ const ctl = createServerDataSource(src, { pageSize: 10, onChange })
82
+ ctl.refresh()
83
+ ctl.dispose()
84
+ await new Promise((r) => setTimeout(r, 40))
85
+ // only the synchronous loading=true emit happened before dispose
86
+ expect(ctl.getState().rows).toHaveLength(0)
87
+ })
88
+ })
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Server-Side Row Model (SSRM) controller. A single, documented datasource
3
+ * contract for grids whose data lives on the server - the "I have a million
4
+ * rows in a database" case. The consumer implements ONE async `getRows`
5
+ * function; this controller owns the request lifecycle (sort, filter, page),
6
+ * de-dupes/races, and pushes results back through `onChange`.
7
+ *
8
+ * It's headless and framework-agnostic on purpose: wire `setSort` /
9
+ * `setFilter` / `setPage` to the grid's controlled callbacks, and render the
10
+ * grid from the `{ rows, total, loading }` it hands you. See the demo.
11
+ */
12
+ export type ServerSortModel = Array<{ id: string; desc: boolean }>
13
+
14
+ export type ServerFilterModel = {
15
+ /** Free-text global search. */
16
+ global?: string
17
+ /**
18
+ * Per-column filters, keyed by column id. `value` (+ `valueTo`) carry the
19
+ * operator-style filter; `selectedValues` carries a facet/checklist
20
+ * selection (set-filter). Either or both may be present.
21
+ */
22
+ columns?: Record<
23
+ string,
24
+ { operator: string; value: string; valueTo?: string; selectedValues?: string[] }
25
+ >
26
+ }
27
+
28
+ export type ServerRequest = {
29
+ /** Zero-based index of the first row wanted (inclusive). */
30
+ startRow: number
31
+ /** Index just past the last row wanted (exclusive). */
32
+ endRow: number
33
+ pageIndex: number
34
+ pageSize: number
35
+ sortModel: ServerSortModel
36
+ filterModel: ServerFilterModel
37
+ }
38
+
39
+ export type ServerResult<TData> = {
40
+ rows: ReadonlyArray<TData>
41
+ /** Total row count after filtering (for the pager). */
42
+ rowCount: number
43
+ }
44
+
45
+ export type ServerDataSource<TData> = {
46
+ getRows(request: ServerRequest): Promise<ServerResult<TData>>
47
+ }
48
+
49
+ export type ServerState<TData> = {
50
+ rows: ReadonlyArray<TData>
51
+ total: number
52
+ loading: boolean
53
+ error: unknown
54
+ pageIndex: number
55
+ pageSize: number
56
+ pageCount: number
57
+ sortModel: ServerSortModel
58
+ filterModel: ServerFilterModel
59
+ }
60
+
61
+ export type ServerController<TData> = {
62
+ /** Re-fetch the current page (e.g. after a mutation). */
63
+ refresh(): void
64
+ setSort(sortModel: ServerSortModel): void
65
+ setFilter(filterModel: ServerFilterModel): void
66
+ setPage(pageIndex: number): void
67
+ setPageSize(pageSize: number): void
68
+ getState(): ServerState<TData>
69
+ /** Stop accepting in-flight responses (call on unmount). */
70
+ dispose(): void
71
+ }
72
+
73
+ export type ServerControllerOptions<TData> = {
74
+ pageSize?: number
75
+ /** Called whenever any of `rows` / `total` / `loading` / page changes. */
76
+ onChange: (state: ServerState<TData>) => void
77
+ }
78
+
79
+ export function createServerDataSource<TData>(
80
+ source: ServerDataSource<TData>,
81
+ options: ServerControllerOptions<TData>,
82
+ ): ServerController<TData> {
83
+ const state: ServerState<TData> = {
84
+ rows: [],
85
+ total: 0,
86
+ loading: false,
87
+ error: null,
88
+ pageIndex: 0,
89
+ pageSize: options.pageSize ?? 50,
90
+ pageCount: 1,
91
+ sortModel: [],
92
+ filterModel: {},
93
+ }
94
+
95
+ // Monotonic request id: only the latest fetch is allowed to land, so a slow
96
+ // response for an old sort/filter can't clobber a newer one.
97
+ let requestSeq = 0
98
+ let disposed = false
99
+
100
+ const emit = () => {
101
+ state.pageCount = Math.max(1, Math.ceil(state.total / state.pageSize))
102
+ options.onChange({ ...state })
103
+ }
104
+
105
+ async function fetchPage() {
106
+ if (disposed) return
107
+ const id = ++requestSeq
108
+ state.loading = true
109
+ state.error = null
110
+ emit()
111
+ const startRow = state.pageIndex * state.pageSize
112
+ try {
113
+ const result = await source.getRows({
114
+ startRow,
115
+ endRow: startRow + state.pageSize,
116
+ pageIndex: state.pageIndex,
117
+ pageSize: state.pageSize,
118
+ sortModel: state.sortModel,
119
+ filterModel: state.filterModel,
120
+ })
121
+ if (disposed || id !== requestSeq) return // stale
122
+ state.rows = result.rows
123
+ state.total = result.rowCount
124
+ state.loading = false
125
+ emit()
126
+ } catch (err) {
127
+ if (disposed || id !== requestSeq) return
128
+ state.rows = []
129
+ state.error = err
130
+ state.loading = false
131
+ emit()
132
+ }
133
+ }
134
+
135
+ return {
136
+ refresh: fetchPage,
137
+ setSort(sortModel) {
138
+ state.sortModel = sortModel
139
+ state.pageIndex = 0
140
+ void fetchPage()
141
+ },
142
+ setFilter(filterModel) {
143
+ state.filterModel = filterModel
144
+ state.pageIndex = 0
145
+ void fetchPage()
146
+ },
147
+ setPage(pageIndex) {
148
+ const clamped = Math.max(0, pageIndex)
149
+ if (clamped === state.pageIndex) return
150
+ state.pageIndex = clamped
151
+ void fetchPage()
152
+ },
153
+ setPageSize(pageSize) {
154
+ state.pageSize = Math.max(1, pageSize)
155
+ state.pageIndex = 0
156
+ void fetchPage()
157
+ },
158
+ getState: () => ({ ...state }),
159
+ dispose() {
160
+ disposed = true
161
+ },
162
+ }
163
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildSparkline, toSparklineValues } from './sparkline'
3
+
4
+ describe('toSparklineValues', () => {
5
+ it('passes through number arrays and drops non-finite', () => {
6
+ expect(toSparklineValues([1, 2, NaN, 3])).toEqual([1, 2, 3])
7
+ })
8
+ it('parses comma / space separated strings', () => {
9
+ expect(toSparklineValues('1, 2 3,4')).toEqual([1, 2, 3, 4])
10
+ })
11
+ it('returns [] for empty / unusable input', () => {
12
+ expect(toSparklineValues(null)).toEqual([])
13
+ expect(toSparklineValues('')).toEqual([])
14
+ expect(toSparklineValues({})).toEqual([])
15
+ })
16
+ })
17
+
18
+ describe('buildSparkline', () => {
19
+ it('returns null for empty data', () => {
20
+ expect(buildSparkline([])).toBeNull()
21
+ })
22
+
23
+ it('builds a line path through every point', () => {
24
+ const g = buildSparkline([0, 5, 10], { type: 'line', width: 100, height: 20 })!
25
+ expect(g.type).toBe('line')
26
+ expect(g.linePath.startsWith('M')).toBe(true)
27
+ // three points => one M + two L commands
28
+ expect((g.linePath.match(/[ML]/g) ?? []).length).toBe(3)
29
+ expect(g.lastPoint).not.toBeNull()
30
+ })
31
+
32
+ it('closes the area path back to the baseline', () => {
33
+ const g = buildSparkline([1, 2, 3], { type: 'area' })!
34
+ expect(g.areaPath.endsWith('Z')).toBe(true)
35
+ })
36
+
37
+ it('emits one bar per value and flags negatives', () => {
38
+ const g = buildSparkline([-2, 4, -1], { type: 'bar' })!
39
+ expect(g.bars).toHaveLength(3)
40
+ expect(g.bars[0]!.negative).toBe(true)
41
+ expect(g.bars[1]!.negative).toBe(false)
42
+ })
43
+
44
+ it('winloss uses sign only, equal-height up/down bars', () => {
45
+ const g = buildSparkline([1, -1, 1], { type: 'winloss', height: 20 })!
46
+ expect(g.bars).toHaveLength(3)
47
+ expect(g.bars[0]!.negative).toBe(false)
48
+ expect(g.bars[1]!.negative).toBe(true)
49
+ // up and down bars are the same height (sign-only chart)
50
+ expect(g.bars[0]!.h).toBeCloseTo(g.bars[1]!.h)
51
+ })
52
+
53
+ it('honors a fixed min/max scale', () => {
54
+ const a = buildSparkline([5, 5, 5], { type: 'line', min: 0, max: 10 })!
55
+ // flat series on a 0..10 scale sits at the vertical middle, not the top
56
+ const ys = a.linePath.match(/,(-?\d+(\.\d+)?)/g)!.map((s) => Number(s.slice(1)))
57
+ expect(new Set(ys).size).toBe(1)
58
+ })
59
+ })