@svgrid/grid 1.0.2 → 1.1.1

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 (216) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +137 -39
  3. package/dist/GridFooter.svelte +164 -0
  4. package/dist/GridFooter.svelte.d.ts +29 -0
  5. package/dist/GridMenus.svelte +648 -0
  6. package/dist/GridMenus.svelte.d.ts +31 -0
  7. package/dist/SvGrid.controller.svelte.d.ts +472 -0
  8. package/dist/SvGrid.controller.svelte.js +1917 -0
  9. package/dist/SvGrid.css +2012 -0
  10. package/dist/SvGrid.helpers.d.ts +35 -0
  11. package/dist/SvGrid.helpers.js +160 -0
  12. package/dist/SvGrid.helpers.test.d.ts +1 -0
  13. package/dist/SvGrid.helpers.test.js +298 -0
  14. package/dist/SvGrid.svelte +2346 -8742
  15. package/dist/SvGrid.svelte.d.ts +4 -357
  16. package/dist/SvGrid.types.d.ts +523 -0
  17. package/dist/SvGrid.types.js +1 -0
  18. package/dist/SvGridChart.svelte +1060 -23
  19. package/dist/SvGridChart.svelte.d.ts +17 -0
  20. package/dist/aligned-grids.d.ts +6 -0
  21. package/dist/aligned-grids.js +84 -0
  22. package/dist/aligned-grids.test.d.ts +1 -0
  23. package/dist/aligned-grids.test.js +75 -0
  24. package/dist/build-api.coverage.test.d.ts +20 -0
  25. package/dist/build-api.coverage.test.js +505 -0
  26. package/dist/build-api.d.ts +5 -0
  27. package/dist/build-api.js +557 -0
  28. package/dist/cell-render.d.ts +15 -0
  29. package/dist/cell-render.js +246 -0
  30. package/dist/cell-render.test.d.ts +1 -0
  31. package/dist/cell-render.test.js +338 -0
  32. package/dist/cell-values.d.ts +28 -0
  33. package/dist/cell-values.js +89 -0
  34. package/dist/chart-export.test.d.ts +1 -0
  35. package/dist/chart-export.test.js +302 -0
  36. package/dist/chart.coverage.test.d.ts +1 -0
  37. package/dist/chart.coverage.test.js +748 -0
  38. package/dist/chart.d.ts +370 -3
  39. package/dist/chart.js +1135 -42
  40. package/dist/clipboard.d.ts +15 -0
  41. package/dist/clipboard.js +420 -0
  42. package/dist/clipboard.test.d.ts +1 -0
  43. package/dist/clipboard.test.js +700 -0
  44. package/dist/collaboration.coverage.test.d.ts +1 -0
  45. package/dist/collaboration.coverage.test.js +200 -0
  46. package/dist/column-groups.d.ts +19 -0
  47. package/dist/column-groups.js +62 -0
  48. package/dist/column-groups.test.d.ts +1 -0
  49. package/dist/column-groups.test.js +56 -0
  50. package/dist/column-types.d.ts +10 -0
  51. package/dist/column-types.js +63 -0
  52. package/dist/column-types.test.d.ts +1 -0
  53. package/dist/column-types.test.js +62 -0
  54. package/dist/columns.d.ts +30 -0
  55. package/dist/columns.js +277 -0
  56. package/dist/columns.test.d.ts +1 -0
  57. package/dist/columns.test.js +625 -0
  58. package/dist/core.d.ts +86 -1
  59. package/dist/css.d.ts +3 -0
  60. package/dist/editing.d.ts +31 -0
  61. package/dist/editing.js +529 -0
  62. package/dist/editing.test.d.ts +1 -0
  63. package/dist/editing.test.js +732 -0
  64. package/dist/editors/cell-editors.coverage.test.d.ts +1 -0
  65. package/dist/editors/cell-editors.coverage.test.js +139 -0
  66. package/dist/editors/cell-editors.d.ts +1 -1
  67. package/dist/facet-buckets.d.ts +13 -0
  68. package/dist/facet-buckets.js +54 -0
  69. package/dist/facet-buckets.test.d.ts +1 -0
  70. package/dist/facet-buckets.test.js +296 -0
  71. package/dist/features.d.ts +5 -0
  72. package/dist/features.js +30 -0
  73. package/dist/filter-operators.d.ts +16 -0
  74. package/dist/filter-operators.js +69 -0
  75. package/dist/filter-operators.test.d.ts +1 -0
  76. package/dist/filter-operators.test.js +135 -0
  77. package/dist/hyperformula-adapter.d.ts +82 -0
  78. package/dist/hyperformula-adapter.js +73 -0
  79. package/dist/hyperformula-adapter.test.d.ts +1 -0
  80. package/dist/hyperformula-adapter.test.js +205 -0
  81. package/dist/index.d.ts +9 -3
  82. package/dist/index.js +8 -2
  83. package/dist/keyboard-handlers.coverage.test.d.ts +1 -0
  84. package/dist/keyboard-handlers.coverage.test.js +495 -0
  85. package/dist/keyboard-handlers.d.ts +7 -0
  86. package/dist/keyboard-handlers.js +202 -0
  87. package/dist/menus.d.ts +40 -0
  88. package/dist/menus.js +390 -0
  89. package/dist/menus.test.d.ts +1 -0
  90. package/dist/menus.test.js +560 -0
  91. package/dist/named-views.coverage.test.d.ts +1 -0
  92. package/dist/named-views.coverage.test.js +180 -0
  93. package/dist/named-views.d.ts +27 -0
  94. package/dist/named-views.js +39 -0
  95. package/dist/row-drag.d.ts +49 -0
  96. package/dist/row-drag.js +221 -0
  97. package/dist/row-drag.test.d.ts +1 -0
  98. package/dist/row-drag.test.js +142 -0
  99. package/dist/row-resize.d.ts +43 -0
  100. package/dist/row-resize.js +158 -0
  101. package/dist/row-resize.test.d.ts +1 -0
  102. package/dist/row-resize.test.js +329 -0
  103. package/dist/scroll-sync.d.ts +9 -0
  104. package/dist/scroll-sync.js +89 -0
  105. package/dist/scroll-sync.test.d.ts +1 -0
  106. package/dist/scroll-sync.test.js +290 -0
  107. package/dist/selection.d.ts +32 -0
  108. package/dist/selection.js +427 -0
  109. package/dist/selection.multi-range.test.d.ts +1 -0
  110. package/dist/selection.multi-range.test.js +55 -0
  111. package/dist/selection.test.d.ts +1 -0
  112. package/dist/selection.test.js +647 -0
  113. package/dist/server-data-source.coverage.test.d.ts +1 -0
  114. package/dist/server-data-source.coverage.test.js +154 -0
  115. package/dist/spreadsheet.d.ts +110 -0
  116. package/dist/spreadsheet.js +242 -0
  117. package/dist/spreadsheet.test.d.ts +1 -0
  118. package/dist/spreadsheet.test.js +446 -0
  119. package/dist/summaries.d.ts +12 -0
  120. package/dist/summaries.js +65 -0
  121. package/dist/sv-grid-scrollbar.js +13 -1
  122. package/dist/svgrid-wrapper.types.d.ts +31 -1
  123. package/dist/svgrid.behavior.test.js +20 -0
  124. package/dist/svgrid.comments-autocomplete.test.d.ts +1 -0
  125. package/dist/svgrid.comments-autocomplete.test.js +96 -0
  126. package/dist/svgrid.context-menu.test.d.ts +1 -0
  127. package/dist/svgrid.context-menu.test.js +102 -0
  128. package/dist/svgrid.interaction.test.js +31 -0
  129. package/dist/svgrid.new-features.wrapper.test.js +64 -6
  130. package/dist/svgrid.wrapper.test.js +27 -1
  131. package/dist/test-setup.js +9 -3
  132. package/dist/virtualization/column-virtualizer.d.ts +2 -0
  133. package/dist/virtualization/scroll-scaling.d.ts +45 -0
  134. package/dist/virtualization/scroll-scaling.js +99 -0
  135. package/dist/virtualization/scroll-scaling.test.d.ts +1 -0
  136. package/dist/virtualization/scroll-scaling.test.js +127 -0
  137. package/dist/virtualization/svelte-virtualizer.svelte.d.ts +2 -0
  138. package/dist/virtualization/svelte-virtualizer.svelte.js +2 -0
  139. package/dist/virtualization/virtualizer.d.ts +7 -0
  140. package/dist/virtualization/virtualizer.js +30 -0
  141. package/package.json +2 -1
  142. package/src/GridFooter.svelte +164 -0
  143. package/src/GridMenus.svelte +648 -0
  144. package/src/SvGrid.controller.svelte.ts +2352 -0
  145. package/src/SvGrid.css +2012 -0
  146. package/src/SvGrid.helpers.test.ts +415 -0
  147. package/src/SvGrid.helpers.ts +185 -0
  148. package/src/SvGrid.svelte +2346 -8742
  149. package/src/SvGrid.types.ts +537 -0
  150. package/src/SvGridChart.svelte +1060 -23
  151. package/src/aligned-grids.test.ts +80 -0
  152. package/src/aligned-grids.ts +87 -0
  153. package/src/build-api.coverage.test.ts +532 -0
  154. package/src/build-api.ts +683 -0
  155. package/src/cell-render.test.ts +451 -0
  156. package/src/cell-render.ts +426 -0
  157. package/src/cell-values.ts +114 -0
  158. package/src/chart-export.test.ts +370 -0
  159. package/src/chart.coverage.test.ts +814 -0
  160. package/src/chart.ts +1352 -47
  161. package/src/clipboard.test.ts +780 -0
  162. package/src/clipboard.ts +552 -0
  163. package/src/collaboration.coverage.test.ts +220 -0
  164. package/src/column-groups.test.ts +59 -0
  165. package/src/column-groups.ts +80 -0
  166. package/src/column-types.test.ts +68 -0
  167. package/src/column-types.ts +82 -0
  168. package/src/columns.test.ts +702 -0
  169. package/src/columns.ts +419 -0
  170. package/src/core.ts +86 -0
  171. package/src/css.d.ts +3 -0
  172. package/src/editing.test.ts +837 -0
  173. package/src/editing.ts +669 -0
  174. package/src/editors/cell-editors.coverage.test.ts +156 -0
  175. package/src/editors/cell-editors.ts +1 -0
  176. package/src/facet-buckets.test.ts +353 -0
  177. package/src/facet-buckets.ts +67 -0
  178. package/src/features.ts +128 -0
  179. package/src/filter-operators.test.ts +174 -0
  180. package/src/filter-operators.ts +87 -0
  181. package/src/hyperformula-adapter.test.ts +256 -0
  182. package/src/hyperformula-adapter.ts +124 -0
  183. package/src/index.ts +49 -0
  184. package/src/keyboard-handlers.coverage.test.ts +560 -0
  185. package/src/keyboard-handlers.ts +353 -0
  186. package/src/keyboard.ts +97 -97
  187. package/src/menus.test.ts +620 -0
  188. package/src/menus.ts +555 -0
  189. package/src/named-views.coverage.test.ts +210 -0
  190. package/src/named-views.ts +48 -0
  191. package/src/row-drag.test.ts +168 -0
  192. package/src/row-drag.ts +255 -0
  193. package/src/row-resize.test.ts +369 -0
  194. package/src/row-resize.ts +171 -0
  195. package/src/scroll-sync.test.ts +330 -0
  196. package/src/scroll-sync.ts +218 -0
  197. package/src/selection.multi-range.test.ts +61 -0
  198. package/src/selection.test.ts +722 -0
  199. package/src/selection.ts +579 -0
  200. package/src/server-data-source.coverage.test.ts +180 -0
  201. package/src/spreadsheet.test.ts +489 -0
  202. package/src/spreadsheet.ts +304 -0
  203. package/src/summaries.ts +204 -0
  204. package/src/sv-grid-scrollbar.ts +13 -1
  205. package/src/svgrid-wrapper.types.ts +31 -1
  206. package/src/svgrid.behavior.test.ts +22 -0
  207. package/src/svgrid.comments-autocomplete.test.ts +112 -0
  208. package/src/svgrid.context-menu.test.ts +126 -0
  209. package/src/svgrid.interaction.test.ts +30 -0
  210. package/src/svgrid.new-features.wrapper.test.ts +67 -6
  211. package/src/svgrid.wrapper.test.ts +27 -1
  212. package/src/test-setup.ts +9 -6
  213. package/src/virtualization/scroll-scaling.test.ts +148 -0
  214. package/src/virtualization/scroll-scaling.ts +121 -0
  215. package/src/virtualization/svelte-virtualizer.svelte.ts +2 -0
  216. package/src/virtualization/virtualizer.ts +26 -0
@@ -0,0 +1,210 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ attachAutoSavedView,
4
+ createNamedViews,
5
+ localStorageViews,
6
+ memoryViews,
7
+ type ViewStorage,
8
+ } from './named-views'
9
+ import type { SvGridViewState } from './svgrid-wrapper.types'
10
+
11
+ function fakeHost(initial: Partial<SvGridViewState> = {}) {
12
+ let state = { sorting: [], columnFilters: [], ...initial } as SvGridViewState
13
+ return {
14
+ getState: () => state,
15
+ setState: vi.fn((s: Partial<SvGridViewState>) => {
16
+ state = { ...state, ...s }
17
+ }),
18
+ set: (s: SvGridViewState) => {
19
+ state = s
20
+ },
21
+ }
22
+ }
23
+
24
+ describe('createNamedViews - list ordering and edge cases', () => {
25
+ it('lists views sorted by createdAt ascending', () => {
26
+ const store = memoryViews([
27
+ { name: 'late', state: {}, createdAt: 200 },
28
+ { name: 'early', state: {}, createdAt: 100 },
29
+ ])
30
+ const views = createNamedViews(fakeHost(), { storage: store })
31
+ expect(views.list().map((v) => v.name)).toEqual(['early', 'late'])
32
+ })
33
+
34
+ it('defaults to memory storage when none is passed', () => {
35
+ const views = createNamedViews(fakeHost({ sorting: [{ id: 'a', desc: true }] }))
36
+ views.save('only')
37
+ expect(views.has('only')).toBe(true)
38
+ expect(views.list()).toHaveLength(1)
39
+ })
40
+
41
+ it('rename returns false when the source name is unknown', () => {
42
+ const views = createNamedViews(fakeHost(), { storage: memoryViews() })
43
+ views.save('a')
44
+ expect(views.rename('missing', 'b')).toBe(false)
45
+ expect(views.list().map((v) => v.name)).toEqual(['a'])
46
+ })
47
+
48
+ it('load returns false and does not call setState for unknown name', () => {
49
+ const host = fakeHost()
50
+ const views = createNamedViews(host, { storage: memoryViews() })
51
+ host.setState.mockClear()
52
+ expect(views.load('nope')).toBe(false)
53
+ expect(host.setState).not.toHaveBeenCalled()
54
+ })
55
+
56
+ it('save captures the current host state snapshot', () => {
57
+ const host = fakeHost({ sorting: [{ id: 'z', desc: false }] })
58
+ const views = createNamedViews(host, { storage: memoryViews() })
59
+ const v = views.save('snap')
60
+ expect(v.state).toMatchObject({ sorting: [{ id: 'z', desc: false }] })
61
+ expect(typeof v.createdAt).toBe('number')
62
+ })
63
+ })
64
+
65
+ describe('localStorageViews', () => {
66
+ beforeEach(() => localStorage.clear())
67
+ afterEach(() => localStorage.clear())
68
+
69
+ it('reads [] when the key is absent', () => {
70
+ const store = localStorageViews('svgrid:absent')
71
+ expect(store.read()).toEqual([])
72
+ })
73
+
74
+ it('round-trips views through localStorage', () => {
75
+ const store = localStorageViews('svgrid:rt')
76
+ const views = [{ name: 'x', state: {}, createdAt: 1 }]
77
+ store.write(views)
78
+ expect(store.read()).toEqual(views)
79
+ // raw JSON is actually present
80
+ expect(JSON.parse(localStorage.getItem('svgrid:rt')!)).toEqual(views)
81
+ })
82
+
83
+ it('returns [] for corrupt JSON', () => {
84
+ localStorage.setItem('svgrid:bad', '{not valid json')
85
+ expect(localStorageViews('svgrid:bad').read()).toEqual([])
86
+ })
87
+
88
+ it('returns [] when the stored value is not an array', () => {
89
+ localStorage.setItem('svgrid:obj', JSON.stringify({ foo: 'bar' }))
90
+ expect(localStorageViews('svgrid:obj').read()).toEqual([])
91
+ })
92
+
93
+ it('swallows write errors (quota / private mode)', () => {
94
+ const store = localStorageViews('svgrid:quota')
95
+ const spy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
96
+ throw new DOMException('quota', 'QuotaExceededError')
97
+ })
98
+ expect(() => store.write([{ name: 'a', state: {}, createdAt: 1 }])).not.toThrow()
99
+ spy.mockRestore()
100
+ })
101
+
102
+ it('works end-to-end as a NamedViews adapter', () => {
103
+ const views = createNamedViews(fakeHost({ sorting: [{ id: 'p', desc: true }] }), {
104
+ storage: localStorageViews('svgrid:adapter'),
105
+ })
106
+ views.save('persisted')
107
+ const reopened = createNamedViews(fakeHost(), {
108
+ storage: localStorageViews('svgrid:adapter'),
109
+ })
110
+ expect(reopened.has('persisted')).toBe(true)
111
+ expect(reopened.load('persisted')).toBe(true)
112
+ })
113
+ })
114
+
115
+ describe('attachAutoSavedView', () => {
116
+ beforeEach(() => vi.useFakeTimers())
117
+ afterEach(() => vi.useRealTimers())
118
+
119
+ it('restores an existing autosave slot on attach', () => {
120
+ const host = fakeHost()
121
+ const store: ViewStorage = memoryViews([
122
+ { name: '__autosave', state: { sorting: [{ id: 'restored', desc: true }] }, createdAt: 1 },
123
+ ])
124
+ const views = createNamedViews(host, { storage: store })
125
+ const off = attachAutoSavedView(host, views)
126
+ expect(host.setState).toHaveBeenCalledWith(
127
+ expect.objectContaining({ sorting: [{ id: 'restored', desc: true }] }),
128
+ )
129
+ off()
130
+ })
131
+
132
+ it('skips restore when skipRestore is set', () => {
133
+ const host = fakeHost()
134
+ const store = memoryViews([{ name: '__autosave', state: { sorting: [] }, createdAt: 1 }])
135
+ const views = createNamedViews(host, { storage: store })
136
+ const off = attachAutoSavedView(host, views, { skipRestore: true })
137
+ expect(host.setState).not.toHaveBeenCalled()
138
+ off()
139
+ })
140
+
141
+ it('saves when the state snapshot changes after an interval', () => {
142
+ const host = fakeHost({ sorting: [] })
143
+ const views = createNamedViews(host, { storage: memoryViews() })
144
+ const off = attachAutoSavedView(host, views, { intervalMs: 500 })
145
+ expect(views.has('__autosave')).toBe(false)
146
+ // mutate the host state, then let the poller tick
147
+ host.set({ sorting: [{ id: 'new', desc: false }], columnFilters: [] } as SvGridViewState)
148
+ vi.advanceTimersByTime(500)
149
+ expect(views.has('__autosave')).toBe(true)
150
+ off()
151
+ })
152
+
153
+ it('does not save when the snapshot is unchanged', () => {
154
+ const host = fakeHost({ sorting: [] })
155
+ const views = createNamedViews(host, { storage: memoryViews() })
156
+ const saveSpy = vi.spyOn(views, 'save')
157
+ const off = attachAutoSavedView(host, views, { intervalMs: 300 })
158
+ vi.advanceTimersByTime(900) // several ticks, no state change
159
+ expect(saveSpy).not.toHaveBeenCalled()
160
+ off()
161
+ })
162
+
163
+ it('uses a custom slot name', () => {
164
+ const host = fakeHost({ sorting: [] })
165
+ const views = createNamedViews(host, { storage: memoryViews() })
166
+ const off = attachAutoSavedView(host, views, { name: 'mySlot', intervalMs: 200 })
167
+ host.set({ sorting: [{ id: 'q', desc: true }], columnFilters: [] } as SvGridViewState)
168
+ vi.advanceTimersByTime(200)
169
+ expect(views.has('mySlot')).toBe(true)
170
+ off()
171
+ })
172
+
173
+ it('clamps the interval to a minimum of 100ms', () => {
174
+ const host = fakeHost({ sorting: [] })
175
+ const views = createNamedViews(host, { storage: memoryViews() })
176
+ const setIntervalSpy = vi.spyOn(globalThis, 'setInterval')
177
+ const off = attachAutoSavedView(host, views, { intervalMs: 5 })
178
+ expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 100)
179
+ off()
180
+ setIntervalSpy.mockRestore()
181
+ })
182
+
183
+ it('recovers when getState throws inside the poll loop', () => {
184
+ let throwing = false
185
+ const host = {
186
+ getState: () => {
187
+ if (throwing) throw new Error('boom')
188
+ return { sorting: [], columnFilters: [] } as SvGridViewState
189
+ },
190
+ setState: vi.fn(),
191
+ }
192
+ const views = createNamedViews(host, { storage: memoryViews() })
193
+ const saveSpy = vi.spyOn(views, 'save')
194
+ const off = attachAutoSavedView(host, views, { intervalMs: 200 })
195
+ throwing = true
196
+ expect(() => vi.advanceTimersByTime(400)).not.toThrow()
197
+ expect(saveSpy).not.toHaveBeenCalled()
198
+ off()
199
+ })
200
+
201
+ it('detach stops the polling interval', () => {
202
+ const host = fakeHost({ sorting: [] })
203
+ const views = createNamedViews(host, { storage: memoryViews() })
204
+ const clearSpy = vi.spyOn(globalThis, 'clearInterval')
205
+ const off = attachAutoSavedView(host, views, { intervalMs: 200 })
206
+ off()
207
+ expect(clearSpy).toHaveBeenCalled()
208
+ clearSpy.mockRestore()
209
+ })
210
+ })
@@ -118,3 +118,51 @@ export function createNamedViews(
118
118
  },
119
119
  }
120
120
  }
121
+
122
+ export type AutoSavedViewOptions = {
123
+ /** Slot name inside the NamedViews store. Default `'__autosave'`. */
124
+ name?: string
125
+ /** Sample interval in ms. Default 800. */
126
+ intervalMs?: number
127
+ /** Skip restore-on-mount (e.g. you already loaded a URL view first). */
128
+ skipRestore?: boolean
129
+ }
130
+
131
+ /**
132
+ * Attach an "always-save-current-layout" slot to a `NamedViews` manager.
133
+ * Restores once on attach (if a saved view exists under `name`) and
134
+ * polls `host.getState()` thereafter, saving when the JSON snapshot
135
+ * changes.
136
+ *
137
+ * Returns a `detach()` that stops polling - call it from `onDestroy`.
138
+ *
139
+ * ```ts
140
+ * const views = createNamedViews(api, { storage: localStorageViews('myapp:views') })
141
+ * const off = attachAutoSavedView(api, views)
142
+ * onDestroy(off)
143
+ * ```
144
+ *
145
+ * Distinct from a user-saved named view: the slot is a single, fixed
146
+ * name reserved for "what the user left the page looking at." The user
147
+ * can still call `views.save('Q3 review')` etc. through the same store.
148
+ */
149
+ export function attachAutoSavedView(
150
+ host: ViewStateHost,
151
+ views: NamedViews,
152
+ opts: AutoSavedViewOptions = {},
153
+ ): () => void {
154
+ const name = opts.name ?? '__autosave'
155
+ if (!opts.skipRestore && views.has(name)) views.load(name)
156
+
157
+ const interval = Math.max(100, opts.intervalMs ?? 800)
158
+ let last = JSON.stringify(host.getState())
159
+ const handle = setInterval(() => {
160
+ let next: string
161
+ try { next = JSON.stringify(host.getState()) } catch { return }
162
+ if (next === last) return
163
+ last = next
164
+ views.save(name)
165
+ }, interval)
166
+
167
+ return () => clearInterval(handle)
168
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Unit tests for the managed row-drag handlers produced by createRowDrag(ctx).
3
+ *
4
+ * createRowDrag is a factory closing over a mutable `ctx` handle and a
5
+ * module-level singleton "bus" that carries the dragged row between grids.
6
+ * Because the bus is shared, we can create TWO fake ctxs (two grids) and drive
7
+ * a genuine grid-to-grid move without mounting anything.
8
+ */
9
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
10
+ import { createRowDrag, rowDropZone } from './row-drag'
11
+
12
+ type AnyCtx = Record<string, any>
13
+ type Task = { id: number; title: string }
14
+
15
+ function makeCtx(rows: Task[], props: AnyCtx = {}): AnyCtx {
16
+ return {
17
+ props,
18
+ internalData: rows,
19
+ get allRows() {
20
+ // allRows tracks internalData order; each Row wraps `.original`.
21
+ return (this.internalData as Task[]).map((r) => ({ original: r }))
22
+ },
23
+ rowDragActive: false,
24
+ rowDropIndex: null,
25
+ rowDropSide: null,
26
+ }
27
+ }
28
+
29
+ function fakeEvent(clientY = 0, rectTop = 0, rectHeight = 40): any {
30
+ return {
31
+ clientY,
32
+ preventDefault: vi.fn(),
33
+ stopPropagation: vi.fn(),
34
+ dataTransfer: { setData: vi.fn(), effectAllowed: '', dropEffect: '' },
35
+ currentTarget: {
36
+ getBoundingClientRect: () => ({ top: rectTop, height: rectHeight, bottom: rectTop + rectHeight, left: 0 }),
37
+ },
38
+ }
39
+ }
40
+
41
+ const P = { rowDragManaged: true }
42
+
43
+ describe('createRowDrag - same-grid reorder', () => {
44
+ it('moves a dragged row after the drop target', () => {
45
+ const rows: Task[] = [
46
+ { id: 1, title: 'a' },
47
+ { id: 2, title: 'b' },
48
+ { id: 3, title: 'c' },
49
+ { id: 4, title: 'd' },
50
+ ]
51
+ const ctx = makeCtx(rows, { ...P })
52
+ const h = createRowDrag(ctx)
53
+
54
+ h.onRowDragStart(fakeEvent(), 0) // drag "a"
55
+ ctx.rowDropSide = 'after'
56
+ h.onRowDrop(fakeEvent(), 2) // drop after "c"
57
+
58
+ expect((ctx.internalData as Task[]).map((r) => r.title)).toEqual(['b', 'c', 'a', 'd'])
59
+ })
60
+
61
+ it('drops before the target when the side is "before"', () => {
62
+ const rows: Task[] = [
63
+ { id: 1, title: 'a' },
64
+ { id: 2, title: 'b' },
65
+ { id: 3, title: 'c' },
66
+ ]
67
+ const ctx = makeCtx(rows, { ...P })
68
+ const h = createRowDrag(ctx)
69
+ h.onRowDragStart(fakeEvent(), 2) // drag "c"
70
+ ctx.rowDropSide = 'before'
71
+ h.onRowDrop(fakeEvent(), 0) // before "a"
72
+ expect((ctx.internalData as Task[]).map((r) => r.title)).toEqual(['c', 'a', 'b'])
73
+ })
74
+ })
75
+
76
+ describe('createRowDrag - grid-to-grid', () => {
77
+ it('moves a row from source into target when groups match', () => {
78
+ const a = makeCtx([{ id: 1, title: 'a1' }, { id: 2, title: 'a2' }], { ...P, rowDragGroup: 'g' })
79
+ const b = makeCtx([{ id: 9, title: 'b1' }], { ...P, rowDragGroup: 'g', onRowDragEnd: vi.fn() })
80
+ const ha = createRowDrag(a)
81
+ const hb = createRowDrag(b)
82
+
83
+ ha.onRowDragStart(fakeEvent(), 1) // drag "a2" out of grid A
84
+ b.rowDropSide = 'before'
85
+ hb.onRowDrop(fakeEvent(), 0) // drop onto grid B before "b1"
86
+
87
+ expect((a.internalData as Task[]).map((r) => r.title)).toEqual(['a1'])
88
+ expect((b.internalData as Task[]).map((r) => r.title)).toEqual(['a2', 'b1'])
89
+ expect(b.props.onRowDragEnd).toHaveBeenCalledWith(
90
+ expect.objectContaining({ sameGrid: false, toIndex: 0, row: expect.objectContaining({ title: 'a2' }) }),
91
+ )
92
+ })
93
+
94
+ it('does NOT accept a drop when groups differ', () => {
95
+ const a = makeCtx([{ id: 1, title: 'a1' }, { id: 2, title: 'a2' }], { ...P, rowDragGroup: 'g' })
96
+ const b = makeCtx([{ id: 9, title: 'b1' }], { ...P, rowDragGroup: 'other' })
97
+ const ha = createRowDrag(a)
98
+ const hb = createRowDrag(b)
99
+
100
+ ha.onRowDragStart(fakeEvent(), 0)
101
+ const ev = fakeEvent()
102
+ hb.onRowDragOver(ev, 0)
103
+ expect(ev.preventDefault).not.toHaveBeenCalled() // over rejected
104
+ hb.onRowDrop(fakeEvent(), 0)
105
+ expect((b.internalData as Task[]).map((r) => r.title)).toEqual(['b1']) // unchanged
106
+ expect((a.internalData as Task[]).length).toBe(2) // source untouched
107
+ })
108
+
109
+ it('appends to the target on an empty-space (container) drop', () => {
110
+ const a = makeCtx([{ id: 1, title: 'a1' }, { id: 2, title: 'a2' }], { ...P, rowDragGroup: 'g' })
111
+ const b = makeCtx([{ id: 9, title: 'b1' }], { ...P, rowDragGroup: 'g' })
112
+ const ha = createRowDrag(a)
113
+ const hb = createRowDrag(b)
114
+ ha.onRowDragStart(fakeEvent(), 0) // drag "a1"
115
+ hb.onRowsContainerDrop(fakeEvent())
116
+ expect((b.internalData as Task[]).map((r) => r.title)).toEqual(['b1', 'a1'])
117
+ expect((a.internalData as Task[]).map((r) => r.title)).toEqual(['a2'])
118
+ })
119
+ })
120
+
121
+ describe('createRowDrag - disabled', () => {
122
+ it('is inert when rowDragManaged is off', () => {
123
+ const ctx = makeCtx([{ id: 1, title: 'a' }, { id: 2, title: 'b' }], {})
124
+ const h = createRowDrag(ctx)
125
+ h.onRowDragStart(fakeEvent(), 0)
126
+ ctx.rowDropSide = 'after'
127
+ h.onRowDrop(fakeEvent(), 1)
128
+ expect((ctx.internalData as Task[]).map((r) => r.title)).toEqual(['a', 'b'])
129
+ })
130
+ })
131
+
132
+ describe('rowDropZone (external drop target)', () => {
133
+ function dropOn(el: HTMLElement) {
134
+ const ev = new Event('drop', { bubbles: true, cancelable: true })
135
+ el.dispatchEvent(ev)
136
+ }
137
+
138
+ it('removes the dragged row from its source grid and calls onDrop', () => {
139
+ const a = makeCtx([{ id: 1, title: 'a1' }, { id: 2, title: 'a2' }], { ...P, rowDragGroup: 'g' })
140
+ const ha = createRowDrag(a)
141
+ ha.onRowDragStart(fakeEvent(), 0) // start dragging "a1"
142
+
143
+ const el = document.createElement('div')
144
+ const dropped: any[] = []
145
+ const action = rowDropZone(el, { group: 'g', onDrop: (e) => dropped.push(e) })
146
+ dropOn(el)
147
+
148
+ expect(dropped.length).toBe(1)
149
+ expect(dropped[0].row.title).toBe('a1')
150
+ expect((a.internalData as Task[]).map((r) => r.title)).toEqual(['a2']) // removed from source
151
+ action.destroy()
152
+ })
153
+
154
+ it('ignores drops when the group does not match', () => {
155
+ const a = makeCtx([{ id: 1, title: 'a1' }], { ...P, rowDragGroup: 'g' })
156
+ const ha = createRowDrag(a)
157
+ ha.onRowDragStart(fakeEvent(), 0)
158
+
159
+ const el = document.createElement('div')
160
+ const dropped: any[] = []
161
+ const action = rowDropZone(el, { group: 'other', onDrop: (e) => dropped.push(e) })
162
+ dropOn(el)
163
+
164
+ expect(dropped.length).toBe(0)
165
+ expect((a.internalData as Task[]).length).toBe(1) // untouched
166
+ action.destroy()
167
+ })
168
+ })
@@ -0,0 +1,255 @@
1
+ // Managed row dragging - reorder rows within a grid and move rows between
2
+ // grids that share a `rowDragGroup`. "Managed" means the grid mutates its own
3
+ // `internalData` on drop (splice out of the source, splice into the target) so
4
+ // consumers get working drag-and-drop with zero wiring; an optional
5
+ // `onRowDragEnd` callback lets them mirror the change into their own state.
6
+ //
7
+ // Cross-grid coordination rides on a module-level singleton (`bus`). Because
8
+ // every SvGrid instance in a bundle shares this module, the source grid can
9
+ // hand the target grid both the dragged row and a closure that removes it from
10
+ // the source - the missing half of an HTML5 drag payload, which only carries
11
+ // strings. Rows are matched by object identity, so this works without
12
+ // `getRowId`.
13
+
14
+ type RowDragBus = {
15
+ group: string | null;
16
+ gridId: number;
17
+ row: unknown;
18
+ removeFromSource: () => void;
19
+ sourceProps: { onRowDragEnd?: (e: RowDragEndEvent) => void };
20
+ };
21
+
22
+ export type RowDragEndEvent<TData = unknown> = {
23
+ row: TData;
24
+ toIndex: number;
25
+ sameGrid: boolean;
26
+ fromGridId: number;
27
+ toGridId: number;
28
+ };
29
+
30
+ // Shared across all grids in the bundle - this is what makes grid-to-grid work.
31
+ let bus: RowDragBus | null = null;
32
+ let gridSeq = 0;
33
+
34
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
+ export function createRowDrag<TFeatures, TData>(ctx: any) {
36
+ const gridId = ++gridSeq;
37
+
38
+ const managed = () => ctx.props.rowDragManaged === true;
39
+ const group = (): string | null => ctx.props.rowDragGroup ?? null;
40
+
41
+ function originalAt(rowIndex: number): unknown {
42
+ const r = ctx.allRows[rowIndex];
43
+ return r ? r.original : null;
44
+ }
45
+
46
+ // Can THIS grid accept the row currently on the bus? Same grid always may
47
+ // (a reorder); a different grid only when both opt into the same group.
48
+ function canAccept(): boolean {
49
+ if (!managed() || !bus) return false;
50
+ if (bus.gridId === gridId) return true;
51
+ return bus.group != null && bus.group === group();
52
+ }
53
+
54
+ function clearIndicators() {
55
+ ctx.rowDragActive = false;
56
+ ctx.rowDropIndex = null;
57
+ ctx.rowDropSide = null;
58
+ }
59
+
60
+ function emitEnd(row: unknown, toIndex: number, sameGrid: boolean, fromGridId: number) {
61
+ ctx.props.onRowDragEnd?.({
62
+ row,
63
+ toIndex,
64
+ sameGrid,
65
+ fromGridId,
66
+ toGridId: gridId,
67
+ } as RowDragEndEvent<TData>);
68
+ }
69
+
70
+ function onRowDragStart(e: DragEvent, rowIndex: number) {
71
+ if (!managed()) return;
72
+ const row = originalAt(rowIndex);
73
+ if (row == null) return;
74
+ bus = {
75
+ group: group(),
76
+ gridId,
77
+ row,
78
+ sourceProps: ctx.props,
79
+ removeFromSource: () => {
80
+ ctx.internalData = (ctx.internalData as unknown[]).filter((r) => r !== row);
81
+ },
82
+ };
83
+ ctx.rowDragActive = true;
84
+ try {
85
+ e.dataTransfer?.setData("text/plain", String(rowIndex));
86
+ if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
87
+ } catch {
88
+ // Some environments (tests) provide a partial dataTransfer.
89
+ }
90
+ }
91
+
92
+ function onRowDragOver(e: DragEvent, rowIndex: number) {
93
+ if (!canAccept()) return;
94
+ e.preventDefault();
95
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
96
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
97
+ ctx.rowDropIndex = rowIndex;
98
+ ctx.rowDropSide = e.clientY < rect.top + rect.height / 2 ? "before" : "after";
99
+ }
100
+
101
+ function onRowDragLeave(rowIndex: number) {
102
+ if (ctx.rowDropIndex === rowIndex) {
103
+ ctx.rowDropIndex = null;
104
+ ctx.rowDropSide = null;
105
+ }
106
+ }
107
+
108
+ function onRowDrop(e: DragEvent, rowIndex: number) {
109
+ if (!canAccept() || !bus) return;
110
+ e.preventDefault();
111
+ e.stopPropagation(); // don't let the tbody handler also fire (end-append)
112
+ const side = ctx.rowDropSide ?? "before";
113
+ const dragged = bus.row;
114
+ const target = originalAt(rowIndex);
115
+ const sourceGridId = bus.gridId;
116
+ const sameGrid = sourceGridId === gridId;
117
+ clearIndicators();
118
+
119
+ const data = (ctx.internalData as unknown[]).slice();
120
+ if (sameGrid) {
121
+ const from = data.indexOf(dragged);
122
+ if (from < 0) {
123
+ bus = null;
124
+ return;
125
+ }
126
+ data.splice(from, 1);
127
+ }
128
+ let to = target != null ? data.indexOf(target) : data.length;
129
+ if (to < 0) to = data.length;
130
+ if (side === "after") to += 1;
131
+ data.splice(to, 0, dragged);
132
+ ctx.internalData = data;
133
+ if (!sameGrid) bus.removeFromSource();
134
+ emitEnd(dragged, to, sameGrid, sourceGridId);
135
+ bus = null;
136
+ }
137
+
138
+ // Dropping in the empty area below the last row, or into an empty target
139
+ // grid, appends. Row-level drops call stopPropagation so this only fires for
140
+ // genuine empty-space drops.
141
+ function onRowsContainerDragOver(e: DragEvent) {
142
+ if (!canAccept()) return;
143
+ e.preventDefault();
144
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
145
+ }
146
+
147
+ function onRowsContainerDrop(e: DragEvent) {
148
+ if (!canAccept() || !bus) return;
149
+ e.preventDefault();
150
+ const dragged = bus.row;
151
+ const sourceGridId = bus.gridId;
152
+ const sameGrid = sourceGridId === gridId;
153
+ clearIndicators();
154
+
155
+ const data = (ctx.internalData as unknown[]).slice();
156
+ if (sameGrid) {
157
+ const from = data.indexOf(dragged);
158
+ if (from >= 0) data.splice(from, 1);
159
+ }
160
+ data.push(dragged);
161
+ ctx.internalData = data;
162
+ if (!sameGrid) bus.removeFromSource();
163
+ emitEnd(dragged, data.length - 1, sameGrid, sourceGridId);
164
+ bus = null;
165
+ }
166
+
167
+ function onRowDragEnd() {
168
+ clearIndicators();
169
+ bus = null;
170
+ }
171
+
172
+ return {
173
+ onRowDragStart,
174
+ onRowDragOver,
175
+ onRowDragLeave,
176
+ onRowDrop,
177
+ onRowsContainerDragOver,
178
+ onRowsContainerDrop,
179
+ onRowDragEnd,
180
+ rowDragGridId: gridId,
181
+ };
182
+ }
183
+
184
+ /** Options for the {@link rowDropZone} action. */
185
+ export type RowDropZoneOptions<TData = unknown> = {
186
+ /**
187
+ * Only accept rows dragged from a grid with this `rowDragGroup`. Omit to
188
+ * accept a managed row drag from any grid.
189
+ */
190
+ group?: string;
191
+ /**
192
+ * Called when a managed row is dropped on the element. The row has already
193
+ * been removed from its source grid; add it to your own list/state here.
194
+ */
195
+ onDrop: (payload: { row: TData; fromGridId: number }) => void;
196
+ /** Class toggled on the element while a matching row is dragged over it. */
197
+ overClass?: string;
198
+ };
199
+
200
+ /**
201
+ * Svelte action turning ANY element into an external drop target for managed
202
+ * row dragging - a trash can, an "assign to" bucket, a second pane. Attach with
203
+ * `use:rowDropZone={{ group, onDrop }}`. On drop the dragged row is removed from
204
+ * its source grid and handed to `onDrop`.
205
+ *
206
+ * ```svelte
207
+ * <div use:rowDropZone={{ group: 'board', onDrop: (e) => archive(e.row) }}>Archive</div>
208
+ * ```
209
+ */
210
+ export function rowDropZone<TData = unknown>(
211
+ node: HTMLElement,
212
+ options: RowDropZoneOptions<TData>,
213
+ ) {
214
+ let opts = options;
215
+ const cls = () => opts.overClass ?? "sv-grid-row-dropzone-over";
216
+
217
+ function matches(): boolean {
218
+ if (!bus) return false;
219
+ return opts.group == null || bus.group === opts.group;
220
+ }
221
+ function onOver(e: DragEvent) {
222
+ if (!matches()) return;
223
+ e.preventDefault();
224
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
225
+ node.classList.add(cls());
226
+ }
227
+ function onLeave() {
228
+ node.classList.remove(cls());
229
+ }
230
+ function onDrop(e: DragEvent) {
231
+ if (!matches() || !bus) return;
232
+ e.preventDefault();
233
+ e.stopPropagation();
234
+ node.classList.remove(cls());
235
+ const row = bus.row as TData;
236
+ const fromGridId = bus.gridId;
237
+ bus.removeFromSource();
238
+ bus = null;
239
+ opts.onDrop({ row, fromGridId });
240
+ }
241
+
242
+ node.addEventListener("dragover", onOver);
243
+ node.addEventListener("dragleave", onLeave);
244
+ node.addEventListener("drop", onDrop);
245
+ return {
246
+ update(next: RowDropZoneOptions<TData>) {
247
+ opts = next;
248
+ },
249
+ destroy() {
250
+ node.removeEventListener("dragover", onOver);
251
+ node.removeEventListener("dragleave", onLeave);
252
+ node.removeEventListener("drop", onDrop);
253
+ },
254
+ };
255
+ }