@svgrid/grid 2.1.22 → 2.2.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 (295) hide show
  1. package/README.md +9 -2
  2. package/dist/DockNodeView.svelte +306 -0
  3. package/dist/DockNodeView.svelte.d.ts +7 -0
  4. package/dist/DockPopoutHost.svelte +17 -0
  5. package/dist/DockPopoutHost.svelte.d.ts +14 -0
  6. package/dist/FlexRender.svelte +96 -96
  7. package/dist/GridFooter.svelte +178 -178
  8. package/dist/GridMenus.svelte +83 -3
  9. package/dist/SvAutoComplete.svelte +5 -3
  10. package/dist/SvCarousel.svelte +141 -163
  11. package/dist/SvColorInput.svelte +187 -186
  12. package/dist/SvColorInput.svelte.d.ts +1 -1
  13. package/dist/SvComboBox.svelte +7 -3
  14. package/dist/SvCommand.svelte +28 -85
  15. package/dist/SvContextMenu.svelte +116 -116
  16. package/dist/SvCountryInput.svelte +1 -1
  17. package/dist/SvDateRangeInput.svelte +1 -1
  18. package/dist/SvDateTimePicker.svelte +9 -3
  19. package/dist/SvDateTimePicker.svelte.d.ts +3 -0
  20. package/dist/SvDockLayout.svelte +244 -0
  21. package/dist/SvDockLayout.svelte.d.ts +77 -0
  22. package/dist/SvDockManager.svelte +922 -0
  23. package/dist/SvDockManager.svelte.d.ts +106 -0
  24. package/dist/SvDrawer.svelte +250 -228
  25. package/dist/SvDrawer.svelte.d.ts +4 -0
  26. package/dist/SvDropDownList.svelte +85 -13
  27. package/dist/SvDropDownList.svelte.d.ts +3 -0
  28. package/dist/SvField.svelte +21 -4
  29. package/dist/SvField.svelte.d.ts +1 -0
  30. package/dist/SvForm.svelte +201 -91
  31. package/dist/SvForm.svelte.d.ts +15 -22
  32. package/dist/SvGrid.controller.svelte.d.ts +32 -1
  33. package/dist/SvGrid.controller.svelte.js +156 -27
  34. package/dist/SvGrid.css +193 -5
  35. package/dist/SvGrid.svelte +3131 -2786
  36. package/dist/SvGrid.types.d.ts +452 -1
  37. package/dist/SvGridBoard.svelte +2339 -2339
  38. package/dist/SvGridChart.svelte +1724 -1724
  39. package/dist/SvGridChartPanel.svelte +485 -485
  40. package/dist/SvGridDropdown.svelte +689 -682
  41. package/dist/SvGridSelect.svelte +1 -0
  42. package/dist/SvGroupCell.svelte +109 -109
  43. package/dist/SvListBox.svelte +36 -14
  44. package/dist/SvMaskedInput.svelte +4 -3
  45. package/dist/SvMaskedInput.svelte.d.ts +1 -1
  46. package/dist/SvMenu.svelte +124 -124
  47. package/dist/SvMenuList.svelte +117 -165
  48. package/dist/SvMenuList.svelte.d.ts +2 -13
  49. package/dist/SvModal.svelte +12 -29
  50. package/dist/SvMultiSelect.svelte +1 -0
  51. package/dist/SvNumberInput.svelte +131 -130
  52. package/dist/SvNumberInput.svelte.d.ts +1 -1
  53. package/dist/SvOtpInput.svelte +158 -158
  54. package/dist/SvPagination.svelte +18 -18
  55. package/dist/SvPasswordInput.svelte +3 -2
  56. package/dist/SvPasswordInput.svelte.d.ts +1 -1
  57. package/dist/SvPhoneInput.svelte +3 -2
  58. package/dist/SvPhoneInput.svelte.d.ts +1 -1
  59. package/dist/SvPopover.svelte +139 -139
  60. package/dist/SvRowGroupPanel.svelte +170 -170
  61. package/dist/SvScrollArea.svelte +61 -61
  62. package/dist/SvStepper.svelte +6 -9
  63. package/dist/SvTagsInput.svelte +3 -2
  64. package/dist/SvTagsInput.svelte.d.ts +1 -1
  65. package/dist/SvTooltip.svelte +127 -138
  66. package/dist/SvTour.svelte +204 -176
  67. package/dist/SvTree.svelte +377 -377
  68. package/dist/SvTreeSelect.svelte +230 -229
  69. package/dist/build-api.js +76 -54
  70. package/dist/cdn/svgrid.js +10913 -6450
  71. package/dist/cdn/svgrid.svelte-external.js +9628 -5481
  72. package/dist/chart-export.js +8 -8
  73. package/dist/columns.js +6 -0
  74. package/dist/conditional-formatting.d.ts +67 -3
  75. package/dist/conditional-formatting.js +124 -17
  76. package/dist/createCarousel.svelte.d.ts +72 -0
  77. package/dist/createCarousel.svelte.js +80 -0
  78. package/dist/createCommand.svelte.d.ts +62 -0
  79. package/dist/createCommand.svelte.js +109 -0
  80. package/dist/createForm.svelte.d.ts +62 -0
  81. package/dist/createForm.svelte.js +374 -0
  82. package/dist/createMenu.svelte.d.ts +88 -0
  83. package/dist/createMenu.svelte.js +153 -0
  84. package/dist/createOverlay.svelte.d.ts +38 -0
  85. package/dist/createOverlay.svelte.js +68 -0
  86. package/dist/createPagination.d.ts +74 -0
  87. package/dist/createPagination.js +71 -0
  88. package/dist/createPopoverSelect.svelte.js +6 -12
  89. package/dist/createStepper.d.ts +44 -0
  90. package/dist/createStepper.js +26 -0
  91. package/dist/createTooltip.svelte.d.ts +32 -0
  92. package/dist/createTooltip.svelte.js +61 -0
  93. package/dist/datetime/timezone.d.ts +36 -0
  94. package/dist/datetime/timezone.js +110 -0
  95. package/dist/dock-context.d.ts +52 -0
  96. package/dist/dock-context.js +1 -0
  97. package/dist/dock-manager-model.d.ts +136 -0
  98. package/dist/dock-manager-model.js +514 -0
  99. package/dist/dock-model.d.ts +105 -0
  100. package/dist/dock-model.js +288 -0
  101. package/dist/dock-popout.d.ts +30 -0
  102. package/dist/dock-popout.js +81 -0
  103. package/dist/editor-contract.d.ts +2 -0
  104. package/dist/filter-operators.d.ts +6 -0
  105. package/dist/filter-operators.js +35 -2
  106. package/dist/filtering/excel-filters.d.ts +18 -1
  107. package/dist/filtering/excel-filters.js +56 -0
  108. package/dist/form-field.d.ts +73 -0
  109. package/dist/form-field.js +15 -0
  110. package/dist/index.d.ts +25 -5
  111. package/dist/index.js +40 -4
  112. package/dist/list-nav.d.ts +18 -0
  113. package/dist/list-nav.js +30 -0
  114. package/dist/list-option.d.ts +2 -0
  115. package/dist/menu-item.d.ts +20 -0
  116. package/dist/menu-item.js +1 -0
  117. package/dist/menus.d.ts +5 -0
  118. package/dist/menus.js +68 -16
  119. package/dist/panel-resize.d.ts +25 -0
  120. package/dist/panel-resize.js +40 -0
  121. package/dist/popover.d.ts +29 -3
  122. package/dist/popover.js +22 -6
  123. package/dist/recurrence.d.ts +33 -7
  124. package/dist/recurrence.js +151 -13
  125. package/dist/row-drag.d.ts +8 -0
  126. package/dist/row-drag.js +11 -0
  127. package/dist/scheduler-ical.d.ts +30 -0
  128. package/dist/scheduler-ical.js +180 -0
  129. package/dist/scheduler-model.d.ts +269 -0
  130. package/dist/scheduler-model.js +602 -0
  131. package/dist/scheduler-view.svelte.d.ts +22 -0
  132. package/dist/scheduler-view.svelte.js +13 -0
  133. package/dist/spreadsheet.js +20 -12
  134. package/dist/ui-app.types.d.ts +3 -0
  135. package/package.json +14 -10
  136. package/src/DockNodeView.svelte +306 -0
  137. package/src/DockPopoutHost.svelte +17 -0
  138. package/src/FlexRender.svelte +96 -96
  139. package/src/GridFooter.svelte +178 -178
  140. package/src/GridMenus.svelte +83 -3
  141. package/src/SvAutoComplete.svelte +5 -3
  142. package/src/SvCarousel.svelte +141 -163
  143. package/src/SvCarousel.test.ts +68 -0
  144. package/src/SvColorInput.svelte +187 -186
  145. package/src/SvComboBox.svelte +7 -3
  146. package/src/SvCommand.svelte +28 -85
  147. package/src/SvCommand.test.ts +78 -0
  148. package/src/SvContextMenu.svelte +116 -116
  149. package/src/SvCountryInput.svelte +1 -1
  150. package/src/SvDateRangeInput.svelte +1 -1
  151. package/src/SvDateTimePicker.svelte +9 -3
  152. package/src/SvDockLayout.svelte +244 -0
  153. package/src/SvDockManager.svelte +922 -0
  154. package/src/SvDrawer.svelte +250 -228
  155. package/src/SvDropDownList.svelte +85 -13
  156. package/src/SvField.svelte +21 -4
  157. package/src/SvForm.svelte +201 -91
  158. package/src/SvForm.test.ts +299 -0
  159. package/src/SvGrid.controller.svelte.ts +3158 -3034
  160. package/src/SvGrid.css +193 -5
  161. package/src/SvGrid.svelte +3131 -2786
  162. package/src/SvGrid.types.ts +1489 -1027
  163. package/src/SvGridBoard.svelte +2339 -2339
  164. package/src/SvGridChart.svelte +1724 -1724
  165. package/src/SvGridChartPanel.svelte +485 -485
  166. package/src/SvGridDropdown.svelte +689 -682
  167. package/src/SvGridSelect.svelte +1 -0
  168. package/src/SvGroupCell.svelte +109 -109
  169. package/src/SvListBox.svelte +36 -14
  170. package/src/SvMaskedInput.svelte +4 -3
  171. package/src/SvMenu.svelte +124 -124
  172. package/src/SvMenu.test.ts +97 -73
  173. package/src/SvMenuList.svelte +117 -165
  174. package/src/SvModal.svelte +12 -29
  175. package/src/SvModal.test.ts +82 -0
  176. package/src/SvMultiSelect.svelte +1 -0
  177. package/src/SvNumberInput.svelte +131 -130
  178. package/src/SvOtpInput.svelte +158 -158
  179. package/src/SvPagination.svelte +18 -18
  180. package/src/SvPasswordInput.svelte +3 -2
  181. package/src/SvPhoneInput.svelte +3 -2
  182. package/src/SvPopover.svelte +139 -139
  183. package/src/SvRowGroupPanel.svelte +170 -170
  184. package/src/SvScrollArea.svelte +61 -61
  185. package/src/SvStepper.svelte +6 -9
  186. package/src/SvTagsInput.svelte +3 -2
  187. package/src/SvTooltip.svelte +127 -138
  188. package/src/SvTooltip.test.ts +73 -0
  189. package/src/SvTour.svelte +204 -176
  190. package/src/SvTree.svelte +377 -377
  191. package/src/SvTreeSelect.svelte +230 -229
  192. package/src/a11y.contract.test.ts +49 -49
  193. package/src/a11y.test.ts +59 -59
  194. package/src/a11y.ts +61 -61
  195. package/src/build-api.ts +870 -847
  196. package/src/cell-formatting.ts +169 -169
  197. package/src/cell-render.ts +469 -469
  198. package/src/chart-export.ts +201 -201
  199. package/src/chart.ts +2296 -2296
  200. package/src/collaboration.test.ts +104 -104
  201. package/src/collaboration.ts +167 -167
  202. package/src/columns.ts +5 -0
  203. package/src/conditional-formatting.test.ts +104 -0
  204. package/src/conditional-formatting.ts +176 -19
  205. package/src/core.performance.test.ts +30 -30
  206. package/src/core.ts +1111 -1111
  207. package/src/createAutocomplete.svelte.ts +132 -132
  208. package/src/createCarousel.svelte.ts +104 -0
  209. package/src/createCombobox.svelte.ts +179 -179
  210. package/src/createCommand.svelte.ts +125 -0
  211. package/src/createCountryInput.svelte.ts +157 -157
  212. package/src/createDropdownList.svelte.ts +162 -162
  213. package/src/createForm.svelte.ts +333 -0
  214. package/src/createGrid.svelte.ts +42 -42
  215. package/src/createGrid.test.ts +10 -10
  216. package/src/createGridState.svelte.ts +17 -17
  217. package/src/createMenu.svelte.ts +202 -0
  218. package/src/createOverlay.svelte.ts +101 -0
  219. package/src/createPagination.test.ts +65 -0
  220. package/src/createPagination.ts +84 -0
  221. package/src/createPopoverSelect.svelte.ts +206 -210
  222. package/src/createStepper.test.ts +62 -0
  223. package/src/createStepper.ts +61 -0
  224. package/src/createTooltip.svelte.ts +86 -0
  225. package/src/createTree.svelte.ts +319 -319
  226. package/src/datetime/timezone.test.ts +69 -0
  227. package/src/datetime/timezone.ts +134 -0
  228. package/src/dock-context.ts +47 -0
  229. package/src/dock-manager-model.test.ts +275 -0
  230. package/src/dock-manager-model.ts +593 -0
  231. package/src/dock-model.test.ts +163 -0
  232. package/src/dock-model.ts +368 -0
  233. package/src/dock-popout.ts +94 -0
  234. package/src/editing.test.ts +859 -859
  235. package/src/editing.ts +675 -675
  236. package/src/editor-contract.ts +2 -0
  237. package/src/export-data-api.test.ts +126 -126
  238. package/src/export-format.test.ts +107 -107
  239. package/src/export-format.ts +598 -598
  240. package/src/filter-operators.test.ts +59 -0
  241. package/src/filter-operators.ts +35 -2
  242. package/src/filtering/excel-filters.test.ts +86 -1
  243. package/src/filtering/excel-filters.ts +65 -0
  244. package/src/flex-render.ts +3 -3
  245. package/src/form-field.ts +87 -0
  246. package/src/index.ts +623 -469
  247. package/src/keyboard.test.ts +59 -59
  248. package/src/keyboard.ts +97 -97
  249. package/src/list-nav.test.ts +49 -0
  250. package/src/list-nav.ts +29 -0
  251. package/src/list-option.ts +2 -0
  252. package/src/menu-item.ts +21 -0
  253. package/src/menus.test.ts +48 -0
  254. package/src/menus.ts +639 -582
  255. package/src/merge-objects.ts +48 -48
  256. package/src/overlays.test.ts +90 -90
  257. package/src/panel-resize.ts +52 -0
  258. package/src/popover.bounds.test.ts +93 -0
  259. package/src/popover.ts +51 -7
  260. package/src/recurrence.test.ts +88 -1
  261. package/src/recurrence.ts +166 -15
  262. package/src/render-component.ts +28 -28
  263. package/src/row-drag.ts +10 -0
  264. package/src/scheduler-ical.test.ts +68 -0
  265. package/src/scheduler-ical.ts +180 -0
  266. package/src/scheduler-model.test.ts +546 -0
  267. package/src/scheduler-model.ts +853 -0
  268. package/src/scheduler-view.svelte.ts +33 -0
  269. package/src/selection.test.ts +754 -754
  270. package/src/selection.ts +600 -600
  271. package/src/server-data-source.test.ts +289 -289
  272. package/src/server-data-source.ts +413 -413
  273. package/src/sparkline.test.ts +68 -68
  274. package/src/sparkline.ts +169 -169
  275. package/src/spreadsheet.test.ts +489 -489
  276. package/src/spreadsheet.ts +312 -304
  277. package/src/static-functions.ts +11 -11
  278. package/src/subscribe.ts +38 -38
  279. package/src/svgrid-wrapper.types.ts +501 -501
  280. package/src/svgrid.behavior.test.ts +706 -706
  281. package/src/svgrid.charting.test.ts +527 -527
  282. package/src/svgrid.effect-driven-filter.svelte.test.ts +116 -0
  283. package/src/svgrid.features.test.ts +157 -157
  284. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  285. package/src/svgrid.wrapper.test.ts +40 -40
  286. package/src/test-setup.ts +62 -62
  287. package/src/themes/index.ts +159 -159
  288. package/src/ui-app.types.ts +3 -0
  289. package/src/virtual.test.ts +6 -4
  290. package/src/virtualization/column-virtualizer.test.ts +27 -27
  291. package/src/virtualization/column-virtualizer.ts +30 -30
  292. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  293. package/src/virtualization/types.ts +30 -30
  294. package/src/virtualization/virtualizer.test.ts +47 -47
  295. package/src/virtualization/virtualizer.ts +296 -296
@@ -1,489 +1,489 @@
1
- import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2
- import { spreadsheetLayout, spansToMerges } from './spreadsheet'
3
- import type { SpreadsheetActionOptions, SpanColumn } from './spreadsheet'
4
-
5
- // jsdom does not implement the global `CSS` object, which `findCell` uses via
6
- // `CSS.escape`. Provide a minimal polyfill so the action's selector queries
7
- // run. (A real browser ships CSS.escape; this is purely an env gap.)
8
- if (typeof globalThis.CSS === 'undefined') {
9
- // @ts-expect-error - minimal shim for the test environment
10
- globalThis.CSS = { escape: (s: string) => s.replace(/["\\]/g, '\\$&') }
11
- } else if (typeof globalThis.CSS.escape !== 'function') {
12
- // @ts-expect-error - augment partial CSS object
13
- globalThis.CSS.escape = (s: string) => s.replace(/["\\]/g, '\\$&')
14
- }
15
-
16
- // ---------------------------------------------------------------------------
17
- // Helpers
18
- // ---------------------------------------------------------------------------
19
-
20
- /** Flush a queued requestAnimationFrame callback. The action batches its
21
- * work into one rAF, so tests must advance a frame to observe the effect. */
22
- function flushFrame(): Promise<void> {
23
- return new Promise((resolve) => {
24
- // Two rAFs: the first lets any already-queued callback run, the second
25
- // guarantees we resolve AFTER that callback has executed.
26
- requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
27
- })
28
- }
29
-
30
- /** Build a `<table>` with a header-less body of `rows` x `cols`. Each TD is
31
- * tagged with `data-svgrid-row` (display index) + `data-col-id` exactly the
32
- * way SvGrid emits them, which is what the layout helper queries on. */
33
- function buildGrid(
34
- rows: number,
35
- columnOrder: readonly string[],
36
- ): { host: HTMLElement; cellAt: (r: number, colId: string) => HTMLTableCellElement } {
37
- const host = document.createElement('div')
38
- const table = document.createElement('table')
39
- const tbody = document.createElement('tbody')
40
- for (let r = 0; r < rows; r += 1) {
41
- const tr = document.createElement('tr')
42
- for (const colId of columnOrder) {
43
- const td = document.createElement('td')
44
- td.setAttribute('data-svgrid-row', String(r))
45
- td.setAttribute('data-col-id', colId)
46
- td.textContent = `${colId}-${r}`
47
- tr.appendChild(td)
48
- }
49
- tbody.appendChild(tr)
50
- }
51
- table.appendChild(tbody)
52
- host.appendChild(table)
53
- document.body.appendChild(host)
54
-
55
- const cellAt = (r: number, colId: string): HTMLTableCellElement => {
56
- const el = host.querySelector<HTMLTableCellElement>(
57
- `td[data-svgrid-row="${r}"][data-col-id="${colId}"]`,
58
- )
59
- if (!el) throw new Error(`no cell at row=${r} col=${colId}`)
60
- return el
61
- }
62
- return { host, cellAt }
63
- }
64
-
65
- let hosts: HTMLElement[] = []
66
- function track(host: HTMLElement): HTMLElement {
67
- hosts.push(host)
68
- return host
69
- }
70
-
71
- beforeEach(() => {
72
- hosts = []
73
- })
74
- afterEach(() => {
75
- for (const h of hosts) h.remove()
76
- document.body.innerHTML = ''
77
- })
78
-
79
- // ---------------------------------------------------------------------------
80
- // Merges
81
- // ---------------------------------------------------------------------------
82
-
83
- describe('spreadsheetLayout - merges', () => {
84
- it('applies colspan + hides covered cells horizontally', async () => {
85
- const cols = ['a', 'b', 'c']
86
- const { host, cellAt } = buildGrid(2, cols)
87
- track(host)
88
- const action = spreadsheetLayout(host, {
89
- columnOrder: cols,
90
- merges: [{ rowIndex: 0, columnId: 'a', colspan: 2 }],
91
- })
92
- await flushFrame()
93
-
94
- const origin = cellAt(0, 'a')
95
- expect(origin.getAttribute('colspan')).toBe('2')
96
- expect(origin.hasAttribute('rowspan')).toBe(false)
97
- expect(origin.getAttribute('data-svgrid-sheet')).toBe('')
98
- // Covered neighbour to the right is hidden + marked.
99
- expect(cellAt(0, 'b').style.display).toBe('none')
100
- expect(cellAt(0, 'b').getAttribute('data-svgrid-sheet')).toBe('')
101
- // Untouched cells stay visible.
102
- expect(cellAt(0, 'c').style.display).toBe('')
103
- expect(cellAt(1, 'a').style.display).toBe('')
104
-
105
- action.destroy()
106
- })
107
-
108
- it('applies rowspan + hides covered cells vertically', async () => {
109
- const cols = ['a', 'b']
110
- const { host, cellAt } = buildGrid(3, cols)
111
- track(host)
112
- const action = spreadsheetLayout(host, {
113
- columnOrder: cols,
114
- merges: [{ rowIndex: 0, columnId: 'a', rowspan: 2 }],
115
- })
116
- await flushFrame()
117
-
118
- const origin = cellAt(0, 'a')
119
- expect(origin.getAttribute('rowspan')).toBe('2')
120
- expect(origin.hasAttribute('colspan')).toBe(false)
121
- expect(cellAt(1, 'a').style.display).toBe('none')
122
- expect(cellAt(2, 'a').style.display).toBe('') // outside the span
123
- action.destroy()
124
- })
125
-
126
- it('handles a rectangular colspan + rowspan block', async () => {
127
- const cols = ['a', 'b', 'c']
128
- const { host, cellAt } = buildGrid(3, cols)
129
- track(host)
130
- const action = spreadsheetLayout(host, {
131
- columnOrder: cols,
132
- merges: [{ rowIndex: 0, columnId: 'a', colspan: 2, rowspan: 2 }],
133
- })
134
- await flushFrame()
135
-
136
- expect(cellAt(0, 'a').getAttribute('colspan')).toBe('2')
137
- expect(cellAt(0, 'a').getAttribute('rowspan')).toBe('2')
138
- // All covered cells except the origin are hidden.
139
- expect(cellAt(0, 'b').style.display).toBe('none')
140
- expect(cellAt(1, 'a').style.display).toBe('none')
141
- expect(cellAt(1, 'b').style.display).toBe('none')
142
- // c column + row 2 untouched.
143
- expect(cellAt(0, 'c').style.display).toBe('')
144
- expect(cellAt(2, 'a').style.display).toBe('')
145
- action.destroy()
146
- })
147
-
148
- it('clamps zero/negative span to a minimum of 1 (no colspan/rowspan attr)', async () => {
149
- const cols = ['a', 'b']
150
- const { host, cellAt } = buildGrid(2, cols)
151
- track(host)
152
- const action = spreadsheetLayout(host, {
153
- columnOrder: cols,
154
- merges: [{ rowIndex: 0, columnId: 'a', colspan: 0, rowspan: -5 }],
155
- })
156
- await flushFrame()
157
- const origin = cellAt(0, 'a')
158
- // Clamped to 1, so no span attributes are written, but it is still marked.
159
- expect(origin.hasAttribute('colspan')).toBe(false)
160
- expect(origin.hasAttribute('rowspan')).toBe(false)
161
- expect(origin.getAttribute('data-svgrid-sheet')).toBe('')
162
- expect(cellAt(0, 'b').style.display).toBe('') // nothing covered
163
- action.destroy()
164
- })
165
-
166
- it('skips a merge whose columnId is not in columnOrder', async () => {
167
- const cols = ['a', 'b']
168
- const { host, cellAt } = buildGrid(1, cols)
169
- track(host)
170
- const action = spreadsheetLayout(host, {
171
- columnOrder: cols,
172
- merges: [{ rowIndex: 0, columnId: 'ghost', colspan: 2 }],
173
- })
174
- await flushFrame()
175
- expect(cellAt(0, 'a').hasAttribute('data-svgrid-sheet')).toBe(false)
176
- expect(cellAt(0, 'b').style.display).toBe('')
177
- action.destroy()
178
- })
179
-
180
- it('skips a merge whose origin cell is not rendered', async () => {
181
- const cols = ['a', 'b']
182
- const { host, cellAt } = buildGrid(1, cols)
183
- track(host)
184
- const action = spreadsheetLayout(host, {
185
- columnOrder: cols,
186
- merges: [{ rowIndex: 99, columnId: 'a', colspan: 2 }],
187
- })
188
- await flushFrame()
189
- // Nothing marked anywhere.
190
- expect(host.querySelectorAll('[data-svgrid-sheet]').length).toBe(0)
191
- expect(cellAt(0, 'a').style.display).toBe('')
192
- action.destroy()
193
- })
194
-
195
- it('ignores covered cells that fall outside the columnOrder or render window', async () => {
196
- const cols = ['a', 'b']
197
- // Only 1 row rendered, but ask for a colspan that runs off the column
198
- // list AND a rowspan that runs off the rendered rows.
199
- const { host, cellAt } = buildGrid(1, cols)
200
- track(host)
201
- const action = spreadsheetLayout(host, {
202
- columnOrder: cols,
203
- merges: [{ rowIndex: 0, columnId: 'b', colspan: 3, rowspan: 3 }],
204
- })
205
- await flushFrame()
206
- const origin = cellAt(0, 'b')
207
- expect(origin.getAttribute('colspan')).toBe('3')
208
- expect(origin.getAttribute('rowspan')).toBe('3')
209
- // No sibling to the right of b, and rows 1/2 don't exist - so the only
210
- // marked cell is the origin itself.
211
- expect(host.querySelectorAll('[data-svgrid-sheet]').length).toBe(1)
212
- action.destroy()
213
- })
214
- })
215
-
216
- // ---------------------------------------------------------------------------
217
- // Selection-edge inheritance
218
- // ---------------------------------------------------------------------------
219
-
220
- describe('spreadsheetLayout - selection edge inheritance', () => {
221
- it('mirrors range-right / range-bottom / selected-range flags onto the origin', async () => {
222
- const cols = ['a', 'b']
223
- const { host, cellAt } = buildGrid(2, cols)
224
- track(host)
225
- // Mark the far covered cell with range edge flags BEFORE applying.
226
- cellAt(1, 'b').setAttribute('data-range-right', 'true')
227
- cellAt(1, 'b').setAttribute('data-range-bottom', 'true')
228
- cellAt(0, 'b').setAttribute('data-selected-range', 'true')
229
-
230
- const action = spreadsheetLayout(host, {
231
- columnOrder: cols,
232
- merges: [{ rowIndex: 0, columnId: 'a', colspan: 2, rowspan: 2 }],
233
- })
234
- await flushFrame()
235
- const origin = cellAt(0, 'a')
236
- expect(origin.classList.contains('sv-merge-edge-right')).toBe(true)
237
- expect(origin.classList.contains('sv-merge-edge-bottom')).toBe(true)
238
- expect(origin.classList.contains('sv-merge-in-range')).toBe(true)
239
- action.destroy()
240
- })
241
-
242
- it('leaves edge classes off when no range flags are present', async () => {
243
- const cols = ['a', 'b']
244
- const { host, cellAt } = buildGrid(2, cols)
245
- track(host)
246
- const action = spreadsheetLayout(host, {
247
- columnOrder: cols,
248
- merges: [{ rowIndex: 0, columnId: 'a', colspan: 2, rowspan: 2 }],
249
- })
250
- await flushFrame()
251
- const origin = cellAt(0, 'a')
252
- expect(origin.classList.contains('sv-merge-edge-right')).toBe(false)
253
- expect(origin.classList.contains('sv-merge-edge-bottom')).toBe(false)
254
- expect(origin.classList.contains('sv-merge-in-range')).toBe(false)
255
- action.destroy()
256
- })
257
-
258
- it('treats the origin own selected-range flag as in-range', async () => {
259
- const cols = ['a']
260
- const { host, cellAt } = buildGrid(1, cols)
261
- track(host)
262
- cellAt(0, 'a').setAttribute('data-selected-range', 'true')
263
- const action = spreadsheetLayout(host, {
264
- columnOrder: cols,
265
- merges: [{ rowIndex: 0, columnId: 'a' }],
266
- })
267
- await flushFrame()
268
- expect(cellAt(0, 'a').classList.contains('sv-merge-in-range')).toBe(true)
269
- action.destroy()
270
- })
271
- })
272
-
273
- // ---------------------------------------------------------------------------
274
- // Borders
275
- // ---------------------------------------------------------------------------
276
-
277
- describe('spreadsheetLayout - borders', () => {
278
- it('injects an overlay with the requested edge styles', async () => {
279
- const cols = ['a']
280
- const { host, cellAt } = buildGrid(1, cols)
281
- track(host)
282
- const action = spreadsheetLayout(host, {
283
- columnOrder: cols,
284
- borders: [
285
- {
286
- rowIndex: 0,
287
- columnId: 'a',
288
- top: { width: 3, style: 'dashed', color: 'red' },
289
- bottom: {}, // defaults: 2px solid currentColor
290
- },
291
- ],
292
- })
293
- await flushFrame()
294
- const td = cellAt(0, 'a')
295
- const overlay = td.querySelector<HTMLElement>('.sv-cell-border-overlay')
296
- expect(overlay).not.toBeNull()
297
- expect(overlay!.style.borderTop).toBe('3px dashed red')
298
- // jsdom's CSSOM drops the `currentColor` keyword from the border
299
- // shorthand, but the width + style survive, confirming the default
300
- // (2px solid) edge was emitted.
301
- expect(overlay!.style.borderBottom).toContain('2px solid')
302
- // No left/right set.
303
- expect(overlay!.style.borderLeft).toBe('')
304
- expect(td.getAttribute('data-svgrid-sheet')).toBe('')
305
- // The TD becomes a positioning context.
306
- expect(td.style.position).toBe('relative')
307
- action.destroy()
308
- })
309
-
310
- it('skips a border spec with no edges set (no overlay injected)', async () => {
311
- const cols = ['a']
312
- const { host, cellAt } = buildGrid(1, cols)
313
- track(host)
314
- const action = spreadsheetLayout(host, {
315
- columnOrder: cols,
316
- borders: [{ rowIndex: 0, columnId: 'a' }],
317
- })
318
- await flushFrame()
319
- expect(cellAt(0, 'a').querySelector('.sv-cell-border-overlay')).toBeNull()
320
- expect(cellAt(0, 'a').hasAttribute('data-svgrid-sheet')).toBe(false)
321
- action.destroy()
322
- })
323
-
324
- it('skips a border spec whose target cell is absent', async () => {
325
- const cols = ['a']
326
- const { host } = buildGrid(1, cols)
327
- track(host)
328
- const action = spreadsheetLayout(host, {
329
- columnOrder: cols,
330
- borders: [{ rowIndex: 5, columnId: 'a', top: { width: 1 } }],
331
- })
332
- await flushFrame()
333
- expect(host.querySelectorAll('.sv-cell-border-overlay').length).toBe(0)
334
- action.destroy()
335
- })
336
-
337
- it('does not override an already-positioned TD', async () => {
338
- const cols = ['a']
339
- const { host, cellAt } = buildGrid(1, cols)
340
- track(host)
341
- cellAt(0, 'a').style.position = 'absolute'
342
- const action = spreadsheetLayout(host, {
343
- columnOrder: cols,
344
- borders: [{ rowIndex: 0, columnId: 'a', left: { color: 'blue' } }],
345
- })
346
- await flushFrame()
347
- expect(cellAt(0, 'a').style.position).toBe('absolute')
348
- action.destroy()
349
- })
350
- })
351
-
352
- // ---------------------------------------------------------------------------
353
- // Cleanup + update + destroy
354
- // ---------------------------------------------------------------------------
355
-
356
- describe('spreadsheetLayout - update + cleanup', () => {
357
- it('clears prior decorations on the next apply (update path)', async () => {
358
- const cols = ['a', 'b']
359
- const { host, cellAt } = buildGrid(2, cols)
360
- track(host)
361
- const action = spreadsheetLayout(host, {
362
- columnOrder: cols,
363
- merges: [{ rowIndex: 0, columnId: 'a', colspan: 2 }],
364
- borders: [{ rowIndex: 1, columnId: 'a', top: { width: 1 } }],
365
- })
366
- await flushFrame()
367
- expect(cellAt(0, 'a').getAttribute('colspan')).toBe('2')
368
- expect(cellAt(1, 'a').querySelector('.sv-cell-border-overlay')).not.toBeNull()
369
-
370
- // Update to an empty spec - everything from the previous run is removed.
371
- action.update({ columnOrder: cols, merges: [], borders: [] })
372
- await flushFrame()
373
- expect(cellAt(0, 'a').hasAttribute('colspan')).toBe(false)
374
- expect(cellAt(0, 'b').style.display).toBe('')
375
- expect(cellAt(0, 'a').hasAttribute('data-svgrid-sheet')).toBe(false)
376
- expect(cellAt(1, 'a').querySelector('.sv-cell-border-overlay')).toBeNull()
377
- action.destroy()
378
- })
379
-
380
- it('re-applies decorations when the grid body mutates (MutationObserver)', async () => {
381
- const cols = ['a', 'b']
382
- const { host, cellAt } = buildGrid(1, cols)
383
- track(host)
384
- const action = spreadsheetLayout(host, {
385
- columnOrder: cols,
386
- merges: [{ rowIndex: 1, columnId: 'a', colspan: 2 }],
387
- })
388
- await flushFrame()
389
- // Row 1 does not exist yet - nothing applied.
390
- expect(host.querySelectorAll('[data-svgrid-sheet]').length).toBe(0)
391
-
392
- // Simulate the grid rendering a new row -> observer schedules apply.
393
- const tbody = host.querySelector('tbody')!
394
- const tr = document.createElement('tr')
395
- for (const colId of cols) {
396
- const td = document.createElement('td')
397
- td.setAttribute('data-svgrid-row', '1')
398
- td.setAttribute('data-col-id', colId)
399
- tr.appendChild(td)
400
- }
401
- tbody.appendChild(tr)
402
- await flushFrame()
403
- await flushFrame()
404
- expect(cellAt(1, 'a').getAttribute('colspan')).toBe('2')
405
- action.destroy()
406
- })
407
-
408
- it('destroy disconnects the observer and cancels a pending frame', async () => {
409
- const cols = ['a', 'b']
410
- const { host, cellAt } = buildGrid(1, cols)
411
- track(host)
412
- // Destroy immediately - the first scheduled frame is pending.
413
- const action = spreadsheetLayout(host, {
414
- columnOrder: cols,
415
- merges: [{ rowIndex: 0, columnId: 'a', colspan: 2 }],
416
- })
417
- action.destroy()
418
- await flushFrame()
419
- await flushFrame()
420
- // Frame was cancelled before apply ran.
421
- expect(cellAt(0, 'a').hasAttribute('colspan')).toBe(false)
422
-
423
- // After destroy, mutations no longer trigger apply.
424
- const tbody = host.querySelector('tbody')!
425
- tbody.appendChild(document.createElement('tr'))
426
- await flushFrame()
427
- await flushFrame()
428
- expect(cellAt(0, 'a').hasAttribute('colspan')).toBe(false)
429
- })
430
-
431
- it('treats null merges / borders as empty (no throw)', async () => {
432
- const cols = ['a']
433
- const { host } = buildGrid(1, cols)
434
- track(host)
435
- const opts: SpreadsheetActionOptions = {
436
- columnOrder: cols,
437
- merges: null,
438
- borders: null,
439
- }
440
- const action = spreadsheetLayout(host, opts)
441
- await flushFrame()
442
- expect(host.querySelectorAll('[data-svgrid-sheet]').length).toBe(0)
443
- action.destroy()
444
- })
445
- })
446
-
447
- describe('spansToMerges - declarative colSpan/rowSpan -> MergeSpec[]', () => {
448
- type Row = { region: string; q: string; amt: number }
449
- const rows: Row[] = [
450
- { region: 'AMER', q: 'Q1', amt: 1 },
451
- { region: 'AMER', q: 'Q2', amt: 2 },
452
- { region: 'AMER', q: 'Q3', amt: 3 },
453
- { region: 'EMEA', q: 'Q1', amt: 4 },
454
- { region: 'EMEA', q: 'Q2', amt: 5 },
455
- ]
456
- // Merge each run of equal `region` values downward.
457
- function regionRowSpan(): SpanColumn<Row>['rowSpan'] {
458
- return ({ data, rowIndex }) => {
459
- if (rowIndex > 0 && rows[rowIndex - 1]!.region === data.region) return 1 // covered
460
- let n = 1
461
- while (rows[rowIndex + n]?.region === data.region) n += 1
462
- return n
463
- }
464
- }
465
-
466
- it('emits a rowspan merge at each run origin and skips covered rows', () => {
467
- const columns: SpanColumn<Row>[] = [{ id: 'region', field: 'region', rowSpan: regionRowSpan() }]
468
- const merges = spansToMerges(rows, columns)
469
- // AMER spans rows 0..2 (rowspan 3), EMEA spans rows 3..4 (rowspan 2).
470
- expect(merges).toEqual([
471
- { rowIndex: 0, columnId: 'region', colspan: undefined, rowspan: 3 },
472
- { rowIndex: 3, columnId: 'region', colspan: undefined, rowspan: 2 },
473
- ])
474
- })
475
-
476
- it('supports colSpan and marks covered columns', () => {
477
- const columns: SpanColumn<Row>[] = [
478
- { id: 'a', field: 'region', colSpan: ({ rowIndex }) => (rowIndex === 0 ? 2 : 1) },
479
- { id: 'b', field: 'q' },
480
- { id: 'c', field: 'amt' },
481
- ]
482
- const merges = spansToMerges(rows.slice(0, 1), columns)
483
- expect(merges).toEqual([{ rowIndex: 0, columnId: 'a', colspan: 2, rowspan: undefined }])
484
- })
485
-
486
- it('returns no merges when no column defines spans', () => {
487
- expect(spansToMerges(rows, [{ id: 'region', field: 'region' }])).toEqual([])
488
- })
489
- })
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2
+ import { spreadsheetLayout, spansToMerges } from './spreadsheet'
3
+ import type { SpreadsheetActionOptions, SpanColumn } from './spreadsheet'
4
+
5
+ // jsdom does not implement the global `CSS` object, which `findCell` uses via
6
+ // `CSS.escape`. Provide a minimal polyfill so the action's selector queries
7
+ // run. (A real browser ships CSS.escape; this is purely an env gap.)
8
+ if (typeof globalThis.CSS === 'undefined') {
9
+ // @ts-expect-error - minimal shim for the test environment
10
+ globalThis.CSS = { escape: (s: string) => s.replace(/["\\]/g, '\\$&') }
11
+ } else if (typeof globalThis.CSS.escape !== 'function') {
12
+ // @ts-expect-error - augment partial CSS object
13
+ globalThis.CSS.escape = (s: string) => s.replace(/["\\]/g, '\\$&')
14
+ }
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** Flush a queued requestAnimationFrame callback. The action batches its
21
+ * work into one rAF, so tests must advance a frame to observe the effect. */
22
+ function flushFrame(): Promise<void> {
23
+ return new Promise((resolve) => {
24
+ // Two rAFs: the first lets any already-queued callback run, the second
25
+ // guarantees we resolve AFTER that callback has executed.
26
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
27
+ })
28
+ }
29
+
30
+ /** Build a `<table>` with a header-less body of `rows` x `cols`. Each TD is
31
+ * tagged with `data-svgrid-row` (display index) + `data-col-id` exactly the
32
+ * way SvGrid emits them, which is what the layout helper queries on. */
33
+ function buildGrid(
34
+ rows: number,
35
+ columnOrder: readonly string[],
36
+ ): { host: HTMLElement; cellAt: (r: number, colId: string) => HTMLTableCellElement } {
37
+ const host = document.createElement('div')
38
+ const table = document.createElement('table')
39
+ const tbody = document.createElement('tbody')
40
+ for (let r = 0; r < rows; r += 1) {
41
+ const tr = document.createElement('tr')
42
+ for (const colId of columnOrder) {
43
+ const td = document.createElement('td')
44
+ td.setAttribute('data-svgrid-row', String(r))
45
+ td.setAttribute('data-col-id', colId)
46
+ td.textContent = `${colId}-${r}`
47
+ tr.appendChild(td)
48
+ }
49
+ tbody.appendChild(tr)
50
+ }
51
+ table.appendChild(tbody)
52
+ host.appendChild(table)
53
+ document.body.appendChild(host)
54
+
55
+ const cellAt = (r: number, colId: string): HTMLTableCellElement => {
56
+ const el = host.querySelector<HTMLTableCellElement>(
57
+ `td[data-svgrid-row="${r}"][data-col-id="${colId}"]`,
58
+ )
59
+ if (!el) throw new Error(`no cell at row=${r} col=${colId}`)
60
+ return el
61
+ }
62
+ return { host, cellAt }
63
+ }
64
+
65
+ let hosts: HTMLElement[] = []
66
+ function track(host: HTMLElement): HTMLElement {
67
+ hosts.push(host)
68
+ return host
69
+ }
70
+
71
+ beforeEach(() => {
72
+ hosts = []
73
+ })
74
+ afterEach(() => {
75
+ for (const h of hosts) h.remove()
76
+ document.body.innerHTML = ''
77
+ })
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Merges
81
+ // ---------------------------------------------------------------------------
82
+
83
+ describe('spreadsheetLayout - merges', () => {
84
+ it('applies colspan + hides covered cells horizontally', async () => {
85
+ const cols = ['a', 'b', 'c']
86
+ const { host, cellAt } = buildGrid(2, cols)
87
+ track(host)
88
+ const action = spreadsheetLayout(host, {
89
+ columnOrder: cols,
90
+ merges: [{ rowIndex: 0, columnId: 'a', colspan: 2 }],
91
+ })
92
+ await flushFrame()
93
+
94
+ const origin = cellAt(0, 'a')
95
+ expect(origin.getAttribute('colspan')).toBe('2')
96
+ expect(origin.hasAttribute('rowspan')).toBe(false)
97
+ expect(origin.getAttribute('data-svgrid-sheet')).toBe('')
98
+ // Covered neighbour to the right is hidden + marked.
99
+ expect(cellAt(0, 'b').style.display).toBe('none')
100
+ expect(cellAt(0, 'b').getAttribute('data-svgrid-sheet')).toBe('')
101
+ // Untouched cells stay visible.
102
+ expect(cellAt(0, 'c').style.display).toBe('')
103
+ expect(cellAt(1, 'a').style.display).toBe('')
104
+
105
+ action.destroy()
106
+ })
107
+
108
+ it('applies rowspan + hides covered cells vertically', async () => {
109
+ const cols = ['a', 'b']
110
+ const { host, cellAt } = buildGrid(3, cols)
111
+ track(host)
112
+ const action = spreadsheetLayout(host, {
113
+ columnOrder: cols,
114
+ merges: [{ rowIndex: 0, columnId: 'a', rowspan: 2 }],
115
+ })
116
+ await flushFrame()
117
+
118
+ const origin = cellAt(0, 'a')
119
+ expect(origin.getAttribute('rowspan')).toBe('2')
120
+ expect(origin.hasAttribute('colspan')).toBe(false)
121
+ expect(cellAt(1, 'a').style.display).toBe('none')
122
+ expect(cellAt(2, 'a').style.display).toBe('') // outside the span
123
+ action.destroy()
124
+ })
125
+
126
+ it('handles a rectangular colspan + rowspan block', async () => {
127
+ const cols = ['a', 'b', 'c']
128
+ const { host, cellAt } = buildGrid(3, cols)
129
+ track(host)
130
+ const action = spreadsheetLayout(host, {
131
+ columnOrder: cols,
132
+ merges: [{ rowIndex: 0, columnId: 'a', colspan: 2, rowspan: 2 }],
133
+ })
134
+ await flushFrame()
135
+
136
+ expect(cellAt(0, 'a').getAttribute('colspan')).toBe('2')
137
+ expect(cellAt(0, 'a').getAttribute('rowspan')).toBe('2')
138
+ // All covered cells except the origin are hidden.
139
+ expect(cellAt(0, 'b').style.display).toBe('none')
140
+ expect(cellAt(1, 'a').style.display).toBe('none')
141
+ expect(cellAt(1, 'b').style.display).toBe('none')
142
+ // c column + row 2 untouched.
143
+ expect(cellAt(0, 'c').style.display).toBe('')
144
+ expect(cellAt(2, 'a').style.display).toBe('')
145
+ action.destroy()
146
+ })
147
+
148
+ it('clamps zero/negative span to a minimum of 1 (no colspan/rowspan attr)', async () => {
149
+ const cols = ['a', 'b']
150
+ const { host, cellAt } = buildGrid(2, cols)
151
+ track(host)
152
+ const action = spreadsheetLayout(host, {
153
+ columnOrder: cols,
154
+ merges: [{ rowIndex: 0, columnId: 'a', colspan: 0, rowspan: -5 }],
155
+ })
156
+ await flushFrame()
157
+ const origin = cellAt(0, 'a')
158
+ // Clamped to 1, so no span attributes are written, but it is still marked.
159
+ expect(origin.hasAttribute('colspan')).toBe(false)
160
+ expect(origin.hasAttribute('rowspan')).toBe(false)
161
+ expect(origin.getAttribute('data-svgrid-sheet')).toBe('')
162
+ expect(cellAt(0, 'b').style.display).toBe('') // nothing covered
163
+ action.destroy()
164
+ })
165
+
166
+ it('skips a merge whose columnId is not in columnOrder', async () => {
167
+ const cols = ['a', 'b']
168
+ const { host, cellAt } = buildGrid(1, cols)
169
+ track(host)
170
+ const action = spreadsheetLayout(host, {
171
+ columnOrder: cols,
172
+ merges: [{ rowIndex: 0, columnId: 'ghost', colspan: 2 }],
173
+ })
174
+ await flushFrame()
175
+ expect(cellAt(0, 'a').hasAttribute('data-svgrid-sheet')).toBe(false)
176
+ expect(cellAt(0, 'b').style.display).toBe('')
177
+ action.destroy()
178
+ })
179
+
180
+ it('skips a merge whose origin cell is not rendered', async () => {
181
+ const cols = ['a', 'b']
182
+ const { host, cellAt } = buildGrid(1, cols)
183
+ track(host)
184
+ const action = spreadsheetLayout(host, {
185
+ columnOrder: cols,
186
+ merges: [{ rowIndex: 99, columnId: 'a', colspan: 2 }],
187
+ })
188
+ await flushFrame()
189
+ // Nothing marked anywhere.
190
+ expect(host.querySelectorAll('[data-svgrid-sheet]').length).toBe(0)
191
+ expect(cellAt(0, 'a').style.display).toBe('')
192
+ action.destroy()
193
+ })
194
+
195
+ it('ignores covered cells that fall outside the columnOrder or render window', async () => {
196
+ const cols = ['a', 'b']
197
+ // Only 1 row rendered, but ask for a colspan that runs off the column
198
+ // list AND a rowspan that runs off the rendered rows.
199
+ const { host, cellAt } = buildGrid(1, cols)
200
+ track(host)
201
+ const action = spreadsheetLayout(host, {
202
+ columnOrder: cols,
203
+ merges: [{ rowIndex: 0, columnId: 'b', colspan: 3, rowspan: 3 }],
204
+ })
205
+ await flushFrame()
206
+ const origin = cellAt(0, 'b')
207
+ expect(origin.getAttribute('colspan')).toBe('3')
208
+ expect(origin.getAttribute('rowspan')).toBe('3')
209
+ // No sibling to the right of b, and rows 1/2 don't exist - so the only
210
+ // marked cell is the origin itself.
211
+ expect(host.querySelectorAll('[data-svgrid-sheet]').length).toBe(1)
212
+ action.destroy()
213
+ })
214
+ })
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Selection-edge inheritance
218
+ // ---------------------------------------------------------------------------
219
+
220
+ describe('spreadsheetLayout - selection edge inheritance', () => {
221
+ it('mirrors range-right / range-bottom / selected-range flags onto the origin', async () => {
222
+ const cols = ['a', 'b']
223
+ const { host, cellAt } = buildGrid(2, cols)
224
+ track(host)
225
+ // Mark the far covered cell with range edge flags BEFORE applying.
226
+ cellAt(1, 'b').setAttribute('data-range-right', 'true')
227
+ cellAt(1, 'b').setAttribute('data-range-bottom', 'true')
228
+ cellAt(0, 'b').setAttribute('data-selected-range', 'true')
229
+
230
+ const action = spreadsheetLayout(host, {
231
+ columnOrder: cols,
232
+ merges: [{ rowIndex: 0, columnId: 'a', colspan: 2, rowspan: 2 }],
233
+ })
234
+ await flushFrame()
235
+ const origin = cellAt(0, 'a')
236
+ expect(origin.classList.contains('sv-merge-edge-right')).toBe(true)
237
+ expect(origin.classList.contains('sv-merge-edge-bottom')).toBe(true)
238
+ expect(origin.classList.contains('sv-merge-in-range')).toBe(true)
239
+ action.destroy()
240
+ })
241
+
242
+ it('leaves edge classes off when no range flags are present', async () => {
243
+ const cols = ['a', 'b']
244
+ const { host, cellAt } = buildGrid(2, cols)
245
+ track(host)
246
+ const action = spreadsheetLayout(host, {
247
+ columnOrder: cols,
248
+ merges: [{ rowIndex: 0, columnId: 'a', colspan: 2, rowspan: 2 }],
249
+ })
250
+ await flushFrame()
251
+ const origin = cellAt(0, 'a')
252
+ expect(origin.classList.contains('sv-merge-edge-right')).toBe(false)
253
+ expect(origin.classList.contains('sv-merge-edge-bottom')).toBe(false)
254
+ expect(origin.classList.contains('sv-merge-in-range')).toBe(false)
255
+ action.destroy()
256
+ })
257
+
258
+ it('treats the origin own selected-range flag as in-range', async () => {
259
+ const cols = ['a']
260
+ const { host, cellAt } = buildGrid(1, cols)
261
+ track(host)
262
+ cellAt(0, 'a').setAttribute('data-selected-range', 'true')
263
+ const action = spreadsheetLayout(host, {
264
+ columnOrder: cols,
265
+ merges: [{ rowIndex: 0, columnId: 'a' }],
266
+ })
267
+ await flushFrame()
268
+ expect(cellAt(0, 'a').classList.contains('sv-merge-in-range')).toBe(true)
269
+ action.destroy()
270
+ })
271
+ })
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // Borders
275
+ // ---------------------------------------------------------------------------
276
+
277
+ describe('spreadsheetLayout - borders', () => {
278
+ it('injects an overlay with the requested edge styles', async () => {
279
+ const cols = ['a']
280
+ const { host, cellAt } = buildGrid(1, cols)
281
+ track(host)
282
+ const action = spreadsheetLayout(host, {
283
+ columnOrder: cols,
284
+ borders: [
285
+ {
286
+ rowIndex: 0,
287
+ columnId: 'a',
288
+ top: { width: 3, style: 'dashed', color: 'red' },
289
+ bottom: {}, // defaults: 2px solid currentColor
290
+ },
291
+ ],
292
+ })
293
+ await flushFrame()
294
+ const td = cellAt(0, 'a')
295
+ const overlay = td.querySelector<HTMLElement>('.sv-cell-border-overlay')
296
+ expect(overlay).not.toBeNull()
297
+ expect(overlay!.style.borderTop).toBe('3px dashed red')
298
+ // jsdom's CSSOM drops the `currentColor` keyword from the border
299
+ // shorthand, but the width + style survive, confirming the default
300
+ // (2px solid) edge was emitted.
301
+ expect(overlay!.style.borderBottom).toContain('2px solid')
302
+ // No left/right set.
303
+ expect(overlay!.style.borderLeft).toBe('')
304
+ expect(td.getAttribute('data-svgrid-sheet')).toBe('')
305
+ // The TD becomes a positioning context.
306
+ expect(td.style.position).toBe('relative')
307
+ action.destroy()
308
+ })
309
+
310
+ it('skips a border spec with no edges set (no overlay injected)', async () => {
311
+ const cols = ['a']
312
+ const { host, cellAt } = buildGrid(1, cols)
313
+ track(host)
314
+ const action = spreadsheetLayout(host, {
315
+ columnOrder: cols,
316
+ borders: [{ rowIndex: 0, columnId: 'a' }],
317
+ })
318
+ await flushFrame()
319
+ expect(cellAt(0, 'a').querySelector('.sv-cell-border-overlay')).toBeNull()
320
+ expect(cellAt(0, 'a').hasAttribute('data-svgrid-sheet')).toBe(false)
321
+ action.destroy()
322
+ })
323
+
324
+ it('skips a border spec whose target cell is absent', async () => {
325
+ const cols = ['a']
326
+ const { host } = buildGrid(1, cols)
327
+ track(host)
328
+ const action = spreadsheetLayout(host, {
329
+ columnOrder: cols,
330
+ borders: [{ rowIndex: 5, columnId: 'a', top: { width: 1 } }],
331
+ })
332
+ await flushFrame()
333
+ expect(host.querySelectorAll('.sv-cell-border-overlay').length).toBe(0)
334
+ action.destroy()
335
+ })
336
+
337
+ it('does not override an already-positioned TD', async () => {
338
+ const cols = ['a']
339
+ const { host, cellAt } = buildGrid(1, cols)
340
+ track(host)
341
+ cellAt(0, 'a').style.position = 'absolute'
342
+ const action = spreadsheetLayout(host, {
343
+ columnOrder: cols,
344
+ borders: [{ rowIndex: 0, columnId: 'a', left: { color: 'blue' } }],
345
+ })
346
+ await flushFrame()
347
+ expect(cellAt(0, 'a').style.position).toBe('absolute')
348
+ action.destroy()
349
+ })
350
+ })
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // Cleanup + update + destroy
354
+ // ---------------------------------------------------------------------------
355
+
356
+ describe('spreadsheetLayout - update + cleanup', () => {
357
+ it('clears prior decorations on the next apply (update path)', async () => {
358
+ const cols = ['a', 'b']
359
+ const { host, cellAt } = buildGrid(2, cols)
360
+ track(host)
361
+ const action = spreadsheetLayout(host, {
362
+ columnOrder: cols,
363
+ merges: [{ rowIndex: 0, columnId: 'a', colspan: 2 }],
364
+ borders: [{ rowIndex: 1, columnId: 'a', top: { width: 1 } }],
365
+ })
366
+ await flushFrame()
367
+ expect(cellAt(0, 'a').getAttribute('colspan')).toBe('2')
368
+ expect(cellAt(1, 'a').querySelector('.sv-cell-border-overlay')).not.toBeNull()
369
+
370
+ // Update to an empty spec - everything from the previous run is removed.
371
+ action.update({ columnOrder: cols, merges: [], borders: [] })
372
+ await flushFrame()
373
+ expect(cellAt(0, 'a').hasAttribute('colspan')).toBe(false)
374
+ expect(cellAt(0, 'b').style.display).toBe('')
375
+ expect(cellAt(0, 'a').hasAttribute('data-svgrid-sheet')).toBe(false)
376
+ expect(cellAt(1, 'a').querySelector('.sv-cell-border-overlay')).toBeNull()
377
+ action.destroy()
378
+ })
379
+
380
+ it('re-applies decorations when the grid body mutates (MutationObserver)', async () => {
381
+ const cols = ['a', 'b']
382
+ const { host, cellAt } = buildGrid(1, cols)
383
+ track(host)
384
+ const action = spreadsheetLayout(host, {
385
+ columnOrder: cols,
386
+ merges: [{ rowIndex: 1, columnId: 'a', colspan: 2 }],
387
+ })
388
+ await flushFrame()
389
+ // Row 1 does not exist yet - nothing applied.
390
+ expect(host.querySelectorAll('[data-svgrid-sheet]').length).toBe(0)
391
+
392
+ // Simulate the grid rendering a new row -> observer schedules apply.
393
+ const tbody = host.querySelector('tbody')!
394
+ const tr = document.createElement('tr')
395
+ for (const colId of cols) {
396
+ const td = document.createElement('td')
397
+ td.setAttribute('data-svgrid-row', '1')
398
+ td.setAttribute('data-col-id', colId)
399
+ tr.appendChild(td)
400
+ }
401
+ tbody.appendChild(tr)
402
+ await flushFrame()
403
+ await flushFrame()
404
+ expect(cellAt(1, 'a').getAttribute('colspan')).toBe('2')
405
+ action.destroy()
406
+ })
407
+
408
+ it('destroy disconnects the observer and cancels a pending frame', async () => {
409
+ const cols = ['a', 'b']
410
+ const { host, cellAt } = buildGrid(1, cols)
411
+ track(host)
412
+ // Destroy immediately - the first scheduled frame is pending.
413
+ const action = spreadsheetLayout(host, {
414
+ columnOrder: cols,
415
+ merges: [{ rowIndex: 0, columnId: 'a', colspan: 2 }],
416
+ })
417
+ action.destroy()
418
+ await flushFrame()
419
+ await flushFrame()
420
+ // Frame was cancelled before apply ran.
421
+ expect(cellAt(0, 'a').hasAttribute('colspan')).toBe(false)
422
+
423
+ // After destroy, mutations no longer trigger apply.
424
+ const tbody = host.querySelector('tbody')!
425
+ tbody.appendChild(document.createElement('tr'))
426
+ await flushFrame()
427
+ await flushFrame()
428
+ expect(cellAt(0, 'a').hasAttribute('colspan')).toBe(false)
429
+ })
430
+
431
+ it('treats null merges / borders as empty (no throw)', async () => {
432
+ const cols = ['a']
433
+ const { host } = buildGrid(1, cols)
434
+ track(host)
435
+ const opts: SpreadsheetActionOptions = {
436
+ columnOrder: cols,
437
+ merges: null,
438
+ borders: null,
439
+ }
440
+ const action = spreadsheetLayout(host, opts)
441
+ await flushFrame()
442
+ expect(host.querySelectorAll('[data-svgrid-sheet]').length).toBe(0)
443
+ action.destroy()
444
+ })
445
+ })
446
+
447
+ describe('spansToMerges - declarative colSpan/rowSpan -> MergeSpec[]', () => {
448
+ type Row = { region: string; q: string; amt: number }
449
+ const rows: Row[] = [
450
+ { region: 'AMER', q: 'Q1', amt: 1 },
451
+ { region: 'AMER', q: 'Q2', amt: 2 },
452
+ { region: 'AMER', q: 'Q3', amt: 3 },
453
+ { region: 'EMEA', q: 'Q1', amt: 4 },
454
+ { region: 'EMEA', q: 'Q2', amt: 5 },
455
+ ]
456
+ // Merge each run of equal `region` values downward.
457
+ function regionRowSpan(): SpanColumn<Row>['rowSpan'] {
458
+ return ({ data, rowIndex }) => {
459
+ if (rowIndex > 0 && rows[rowIndex - 1]!.region === data.region) return 1 // covered
460
+ let n = 1
461
+ while (rows[rowIndex + n]?.region === data.region) n += 1
462
+ return n
463
+ }
464
+ }
465
+
466
+ it('emits a rowspan merge at each run origin and skips covered rows', () => {
467
+ const columns: SpanColumn<Row>[] = [{ id: 'region', field: 'region', rowSpan: regionRowSpan() }]
468
+ const merges = spansToMerges(rows, columns)
469
+ // AMER spans rows 0..2 (rowspan 3), EMEA spans rows 3..4 (rowspan 2).
470
+ expect(merges).toEqual([
471
+ { rowIndex: 0, columnId: 'region', colspan: undefined, rowspan: 3 },
472
+ { rowIndex: 3, columnId: 'region', colspan: undefined, rowspan: 2 },
473
+ ])
474
+ })
475
+
476
+ it('supports colSpan and marks covered columns', () => {
477
+ const columns: SpanColumn<Row>[] = [
478
+ { id: 'a', field: 'region', colSpan: ({ rowIndex }) => (rowIndex === 0 ? 2 : 1) },
479
+ { id: 'b', field: 'q' },
480
+ { id: 'c', field: 'amt' },
481
+ ]
482
+ const merges = spansToMerges(rows.slice(0, 1), columns)
483
+ expect(merges).toEqual([{ rowIndex: 0, columnId: 'a', colspan: 2, rowspan: undefined }])
484
+ })
485
+
486
+ it('returns no merges when no column defines spans', () => {
487
+ expect(spansToMerges(rows, [{ id: 'region', field: 'region' }])).toEqual([])
488
+ })
489
+ })