@svgrid/grid 2.2.16 → 2.2.18

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 (290) hide show
  1. package/README.md +1 -1
  2. package/dist/GridFooter.svelte +12 -11
  3. package/dist/SvAnchor.svelte +65 -0
  4. package/dist/SvAnchor.svelte.d.ts +24 -0
  5. package/dist/SvAspectRatio.svelte +38 -0
  6. package/dist/SvAspectRatio.svelte.d.ts +16 -0
  7. package/dist/SvAutoComplete.svelte +52 -7
  8. package/dist/SvAutoComplete.svelte.d.ts +4 -1
  9. package/dist/SvBlockquote.svelte +43 -0
  10. package/dist/SvBlockquote.svelte.d.ts +16 -0
  11. package/dist/SvCalendar.svelte +33 -2
  12. package/dist/SvCalendar.svelte.d.ts +8 -1
  13. package/dist/SvCheckBox.svelte +6 -2
  14. package/dist/SvCode.svelte +52 -0
  15. package/dist/SvCode.svelte.d.ts +19 -0
  16. package/dist/SvCollapsible.svelte +75 -0
  17. package/dist/SvCollapsible.svelte.d.ts +27 -0
  18. package/dist/SvColorInput.svelte +11 -17
  19. package/dist/SvColorInput.svelte.d.ts +2 -0
  20. package/dist/SvComboBox.svelte +85 -8
  21. package/dist/SvComboBox.svelte.d.ts +14 -0
  22. package/dist/SvCountryInput.svelte +2 -2
  23. package/dist/SvCountryInput.svelte.d.ts +1 -1
  24. package/dist/SvDropDownList.svelte +154 -51
  25. package/dist/SvDropDownList.svelte.d.ts +17 -3
  26. package/dist/SvDurationInput.svelte +59 -41
  27. package/dist/SvDurationInput.svelte.d.ts +18 -0
  28. package/dist/SvField.svelte +203 -12
  29. package/dist/SvField.svelte.d.ts +45 -8
  30. package/dist/SvForm.svelte +195 -21
  31. package/dist/SvForm.svelte.d.ts +23 -0
  32. package/dist/SvGrid.controller.svelte.d.ts +12 -1
  33. package/dist/SvGrid.controller.svelte.js +87 -7
  34. package/dist/SvGrid.svelte +383 -181
  35. package/dist/SvGrid.types.d.ts +123 -2
  36. package/dist/SvGridChartView.svelte +70 -0
  37. package/dist/SvGridChartView.svelte.d.ts +11 -0
  38. package/dist/SvGridDropdown.svelte +9 -2
  39. package/dist/SvGridSelect.svelte +5 -2
  40. package/dist/SvGridSelect.svelte.d.ts +1 -1
  41. package/dist/SvGroup.svelte +41 -0
  42. package/dist/SvGroup.svelte.d.ts +27 -0
  43. package/dist/SvHoverCard.svelte +70 -0
  44. package/dist/SvHoverCard.svelte.d.ts +41 -0
  45. package/dist/SvKbd.svelte +51 -0
  46. package/dist/SvKbd.svelte.d.ts +18 -0
  47. package/dist/SvList.svelte +55 -0
  48. package/dist/SvList.svelte.d.ts +23 -0
  49. package/dist/SvListBox.svelte +166 -77
  50. package/dist/SvListBox.svelte.d.ts +17 -4
  51. package/dist/SvLoadingOverlay.svelte +53 -0
  52. package/dist/SvLoadingOverlay.svelte.d.ts +28 -0
  53. package/dist/SvMark.svelte +36 -0
  54. package/dist/SvMark.svelte.d.ts +14 -0
  55. package/dist/SvMaskedInput.svelte +50 -35
  56. package/dist/SvMaskedInput.svelte.d.ts +17 -2
  57. package/dist/SvMenuList.svelte +32 -3
  58. package/dist/SvMenubar.svelte +200 -0
  59. package/dist/SvMenubar.svelte.d.ts +18 -0
  60. package/dist/SvMultiSelect.svelte +7 -4
  61. package/dist/SvMultiSelect.svelte.d.ts +1 -1
  62. package/dist/SvNumberInput.svelte +79 -42
  63. package/dist/SvNumberInput.svelte.d.ts +25 -0
  64. package/dist/SvOtpInput.svelte +2 -2
  65. package/dist/SvPasswordInput.svelte +51 -31
  66. package/dist/SvPasswordInput.svelte.d.ts +17 -0
  67. package/dist/SvPhoneInput.svelte +39 -28
  68. package/dist/SvPhoneInput.svelte.d.ts +4 -0
  69. package/dist/SvPopconfirm.svelte +79 -0
  70. package/dist/SvPopconfirm.svelte.d.ts +42 -0
  71. package/dist/SvPopover.svelte +87 -29
  72. package/dist/SvPopover.svelte.d.ts +18 -4
  73. package/dist/SvRadioGroup.svelte +5 -1
  74. package/dist/SvResult.svelte +73 -0
  75. package/dist/SvResult.svelte.d.ts +28 -0
  76. package/dist/SvSegmented.svelte +114 -0
  77. package/dist/SvSegmented.svelte.d.ts +29 -0
  78. package/dist/SvSimpleGrid.svelte +41 -0
  79. package/dist/SvSimpleGrid.svelte.d.ts +25 -0
  80. package/dist/SvSlider.svelte +23 -10
  81. package/dist/SvSpinner.svelte +43 -0
  82. package/dist/SvSpinner.svelte.d.ts +20 -0
  83. package/dist/SvStack.svelte +37 -0
  84. package/dist/SvStack.svelte.d.ts +24 -0
  85. package/dist/SvSwitchButton.svelte +6 -1
  86. package/dist/SvTagsInput.svelte +9 -5
  87. package/dist/SvText.svelte +76 -0
  88. package/dist/SvText.svelte.d.ts +31 -0
  89. package/dist/SvTextInput.svelte +75 -43
  90. package/dist/SvTextInput.svelte.d.ts +28 -1
  91. package/dist/SvTimePicker.svelte +32 -2
  92. package/dist/SvTimePicker.svelte.d.ts +8 -9
  93. package/dist/SvTitle.svelte +57 -0
  94. package/dist/SvTitle.svelte.d.ts +24 -0
  95. package/dist/SvToaster.svelte +41 -6
  96. package/dist/SvToggleButton.svelte +4 -1
  97. package/dist/SvTooltip.svelte +68 -34
  98. package/dist/SvTooltip.svelte.d.ts +14 -2
  99. package/dist/SvTree.svelte +81 -21
  100. package/dist/SvTree.svelte.d.ts +3 -2
  101. package/dist/SvTreeSelect.svelte +5 -2
  102. package/dist/SvTreeSelect.svelte.d.ts +1 -1
  103. package/dist/SvVisuallyHidden.svelte +49 -0
  104. package/dist/SvVisuallyHidden.svelte.d.ts +19 -0
  105. package/dist/ai.d.ts +289 -0
  106. package/dist/ai.js +1013 -0
  107. package/dist/board-view.svelte.d.ts +22 -0
  108. package/dist/board-view.svelte.js +13 -0
  109. package/dist/build-api.js +36 -6
  110. package/dist/cdn/GridMenus-0AK9O7qG.js +420 -0
  111. package/dist/cdn/GridMenus-BjUoCXoD.js +416 -0
  112. package/dist/cdn/SvDateTimePicker-CYC7gRER.js +2176 -0
  113. package/dist/cdn/SvDateTimePicker-DtQKd4Ns.js +2198 -0
  114. package/dist/cdn/SvGridChart-BcLCYvw9.js +1195 -0
  115. package/dist/cdn/SvGridChart-BhnFdLd0.js +1203 -0
  116. package/dist/cdn/SvGridChartPanel-Cmx9DHAS.js +561 -0
  117. package/dist/cdn/SvGridChartPanel-DvjwxLvK.js +577 -0
  118. package/dist/cdn/SvGridChartView-B40DTJSX.js +54 -0
  119. package/dist/cdn/SvGridChartView-PwqI4s02.js +53 -0
  120. package/dist/cdn/SvGridDropdown-DjALc_4f.js +255 -0
  121. package/dist/cdn/SvGridDropdown-hpnHvOGt.js +254 -0
  122. package/dist/cdn/chart-T1usDN0o.js +1212 -0
  123. package/dist/cdn/disclose-version-DoD9AFD_.js +2958 -0
  124. package/dist/cdn/export-format-JT5pWVT8.js +561 -0
  125. package/dist/cdn/popover-P-9P2YYn.js +69 -0
  126. package/dist/cdn/rolldown-runtime-Dy4uBu1J.js +11 -0
  127. package/dist/cdn/src-BLhdLmoN.js +25707 -0
  128. package/dist/cdn/src-BwnUVj3T.js +25623 -0
  129. package/dist/cdn/svgrid.js +9 -32897
  130. package/dist/cdn/svgrid.svelte-external.js +9 -30052
  131. package/dist/cell-formatting.d.ts +2 -2
  132. package/dist/cell-render.js +4 -4
  133. package/dist/chart-view.svelte.d.ts +25 -0
  134. package/dist/chart-view.svelte.js +14 -0
  135. package/dist/createCombobox.svelte.d.ts +5 -0
  136. package/dist/createCombobox.svelte.js +21 -5
  137. package/dist/createDropdownList.svelte.d.ts +3 -0
  138. package/dist/createDropdownList.svelte.js +8 -2
  139. package/dist/createForm.svelte.d.ts +18 -0
  140. package/dist/createForm.svelte.js +51 -6
  141. package/dist/createMenu.svelte.js +27 -0
  142. package/dist/createPopoverSelect.svelte.d.ts +3 -0
  143. package/dist/createPopoverSelect.svelte.js +9 -1
  144. package/dist/createSlider.svelte.d.ts +8 -21
  145. package/dist/createSlider.svelte.js +34 -0
  146. package/dist/createTooltip.svelte.d.ts +12 -0
  147. package/dist/createTooltip.svelte.js +72 -9
  148. package/dist/editor-contract.d.ts +15 -0
  149. package/dist/export-provider.d.ts +27 -0
  150. package/dist/export-provider.js +9 -0
  151. package/dist/filter-operators.d.ts +6 -0
  152. package/dist/filter-operators.js +33 -0
  153. package/dist/form-field.d.ts +45 -2
  154. package/dist/grid-messages.d.ts +61 -0
  155. package/dist/grid-messages.js +72 -0
  156. package/dist/group-display.d.ts +62 -0
  157. package/dist/group-display.js +78 -0
  158. package/dist/index.d.ts +36 -4
  159. package/dist/index.js +43 -2
  160. package/dist/js-scroller.svelte.d.ts +35 -0
  161. package/dist/js-scroller.svelte.js +154 -0
  162. package/dist/list-option.d.ts +46 -0
  163. package/dist/list-option.js +53 -0
  164. package/dist/menus.js +25 -11
  165. package/dist/pivot-view.svelte.d.ts +72 -0
  166. package/dist/pivot-view.svelte.js +13 -0
  167. package/dist/positioning.d.ts +111 -0
  168. package/dist/positioning.js +191 -0
  169. package/dist/scheduler-model.d.ts +3 -0
  170. package/dist/scheduler-model.js +22 -5
  171. package/dist/svgrid-wrapper.types.d.ts +18 -0
  172. package/dist/toast-store.svelte.d.ts +54 -0
  173. package/dist/toast-store.svelte.js +65 -15
  174. package/package.json +9 -1
  175. package/src/GridFooter.svelte +12 -11
  176. package/src/SvAnchor.svelte +65 -0
  177. package/src/SvAspectRatio.svelte +38 -0
  178. package/src/SvAutoComplete.svelte +52 -7
  179. package/src/SvBlockquote.svelte +43 -0
  180. package/src/SvCalendar.svelte +33 -2
  181. package/src/SvCalendar.test.ts +15 -0
  182. package/src/SvCheckBox.svelte +6 -2
  183. package/src/SvCode.svelte +52 -0
  184. package/src/SvCollapsible.svelte +75 -0
  185. package/src/SvColorInput.svelte +11 -17
  186. package/src/SvComboBox.svelte +85 -8
  187. package/src/SvCountryInput.svelte +2 -2
  188. package/src/SvDropDownList.svelte +154 -51
  189. package/src/SvDurationInput.svelte +59 -41
  190. package/src/SvField.svelte +203 -12
  191. package/src/SvForm.svelte +195 -21
  192. package/src/SvForm.test.ts +112 -0
  193. package/src/SvGrid.controller.svelte.ts +88 -7
  194. package/src/SvGrid.svelte +383 -181
  195. package/src/SvGrid.types.ts +125 -1
  196. package/src/SvGridChartView.svelte +70 -0
  197. package/src/SvGridDropdown.svelte +9 -2
  198. package/src/SvGridSelect.svelte +5 -2
  199. package/src/SvGroup.svelte +41 -0
  200. package/src/SvHoverCard.svelte +70 -0
  201. package/src/SvKbd.svelte +51 -0
  202. package/src/SvList.svelte +55 -0
  203. package/src/SvListBox.svelte +166 -77
  204. package/src/SvLoadingOverlay.svelte +53 -0
  205. package/src/SvMark.svelte +36 -0
  206. package/src/SvMaskedInput.svelte +50 -35
  207. package/src/SvMenuList.svelte +32 -3
  208. package/src/SvMenubar.svelte +200 -0
  209. package/src/SvMultiSelect.svelte +7 -4
  210. package/src/SvNumberInput.svelte +79 -42
  211. package/src/SvOtpInput.svelte +2 -2
  212. package/src/SvPasswordInput.svelte +51 -31
  213. package/src/SvPhoneInput.svelte +39 -28
  214. package/src/SvPopconfirm.svelte +79 -0
  215. package/src/SvPopover.svelte +87 -29
  216. package/src/SvRadioGroup.svelte +5 -1
  217. package/src/SvResult.svelte +73 -0
  218. package/src/SvSegmented.svelte +114 -0
  219. package/src/SvSimpleGrid.svelte +41 -0
  220. package/src/SvSlider.svelte +23 -10
  221. package/src/SvSpinner.svelte +43 -0
  222. package/src/SvStack.svelte +37 -0
  223. package/src/SvSwitchButton.svelte +6 -1
  224. package/src/SvTagsInput.svelte +9 -5
  225. package/src/SvText.svelte +76 -0
  226. package/src/SvTextInput.svelte +75 -43
  227. package/src/SvTimePicker.svelte +32 -2
  228. package/src/SvTitle.svelte +57 -0
  229. package/src/SvToaster.svelte +41 -6
  230. package/src/SvToaster.test.ts +32 -0
  231. package/src/SvToggleButton.svelte +4 -1
  232. package/src/SvTooltip.svelte +68 -34
  233. package/src/SvTree.svelte +81 -21
  234. package/src/SvTreeSelect.svelte +5 -2
  235. package/src/SvVisuallyHidden.svelte +49 -0
  236. package/src/ai.test.ts +502 -0
  237. package/src/ai.ts +1391 -0
  238. package/src/board-view.svelte.ts +33 -0
  239. package/src/build-api.coverage.test.ts +101 -0
  240. package/src/build-api.ts +43 -18
  241. package/src/builtin-editors.grid.test.ts +1 -1
  242. package/src/cell-formatting.ts +5 -5
  243. package/src/cell-render.ts +4 -4
  244. package/src/chart-view.svelte.ts +36 -0
  245. package/src/createCombobox.svelte.ts +17 -5
  246. package/src/createDropdownList.svelte.ts +8 -2
  247. package/src/createForm.svelte.ts +59 -6
  248. package/src/createMenu.svelte.ts +23 -0
  249. package/src/createPopoverSelect.svelte.ts +8 -1
  250. package/src/createSlider.svelte.ts +20 -0
  251. package/src/createTooltip.svelte.ts +67 -9
  252. package/src/editor-contract.ts +16 -0
  253. package/src/export-provider.ts +40 -0
  254. package/src/filter-operators.ts +37 -0
  255. package/src/form-field.ts +42 -2
  256. package/src/grid-messages.test.ts +24 -0
  257. package/src/grid-messages.ts +137 -0
  258. package/src/group-display.test.ts +77 -0
  259. package/src/group-display.ts +117 -0
  260. package/src/i18n.grid.test.ts +73 -0
  261. package/src/index.ts +91 -2
  262. package/src/js-scroller.svelte.ts +173 -0
  263. package/src/list-option.test.ts +56 -0
  264. package/src/list-option.ts +81 -0
  265. package/src/menus.test.ts +32 -0
  266. package/src/menus.ts +25 -12
  267. package/src/pivot-view.svelte.ts +90 -0
  268. package/src/pivot-view.test.ts +38 -0
  269. package/src/pivot.grid.test.ts +102 -0
  270. package/src/positioning.test.ts +141 -0
  271. package/src/positioning.ts +266 -0
  272. package/src/scheduler-model.test.ts +16 -0
  273. package/src/scheduler-model.ts +24 -5
  274. package/src/svgrid-wrapper.types.ts +23 -0
  275. package/src/svgrid.charting.test.ts +8 -1
  276. package/src/svgrid.comments-autocomplete.test.ts +7 -2
  277. package/src/svgrid.context-menu.test.ts +8 -3
  278. package/src/toast-store.svelte.ts +98 -0
  279. package/src/toast-store.test.ts +62 -1
  280. package/src/ui-breadth.test.ts +138 -0
  281. package/src/ui-buttons.test.ts +39 -0
  282. package/src/ui-inputs.test.ts +2 -2
  283. package/src/ui-range.test.ts +9 -0
  284. package/src/ui-selection.test.ts +36 -0
  285. package/src/ui-tier1.test.ts +1 -1
  286. package/src/virtual.test.ts +20 -6
  287. package/dist/SvGridBoard.svelte +0 -2339
  288. package/dist/SvGridBoard.svelte.d.ts +0 -31
  289. package/src/SvGridBoard.svelte +0 -2339
  290. package/src/board.test.ts +0 -927
package/src/board.test.ts DELETED
@@ -1,927 +0,0 @@
1
- /**
2
- * Component tests for Kanban board mode (SvGridBoard): lane derivation,
3
- * card rendering, WIP counts, empty lanes, and drag-to-move (onCardMove).
4
- */
5
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
- import { mount, unmount, flushSync, createRawSnippet } from 'svelte'
7
- import SvGridBoard from './SvGridBoard.svelte'
8
- import SvGrid from './SvGrid.svelte'
9
- import SvDropDownList from './SvDropDownList.svelte'
10
- import { createDismissableLayer } from './a11y/dismissable'
11
-
12
- function mountOn(props: Record<string, unknown>) {
13
- const target = document.createElement('div')
14
- document.body.appendChild(target)
15
- const app = mount(SvGridBoard as any, { target, props: props as any })
16
- flushSync()
17
- return { target, destroy: () => { unmount(app); target.remove() } }
18
- }
19
-
20
- afterEach(() => { document.body.innerHTML = '' })
21
-
22
- const data = [
23
- { id: 1, title: 'Design spec', status: 'todo', owner: 'Sam' },
24
- { id: 2, title: 'Write tests', status: 'todo', owner: 'Lee' },
25
- { id: 3, title: 'Ship it', status: 'done', owner: 'Sam' },
26
- ]
27
- const columns = [
28
- { field: 'title', header: 'Title' },
29
- { field: 'owner', header: 'Owner' },
30
- { field: 'status', header: 'Status' },
31
- ]
32
-
33
- function fire(el: Element, type: string) {
34
- el.dispatchEvent(new Event(type, { bubbles: true, cancelable: true }))
35
- flushSync()
36
- }
37
-
38
- describe('SvGridBoard lanes', () => {
39
- it('derives lanes from the distinct groupBy values (first-seen order)', () => {
40
- const { target, destroy } = mountOn({ data, columns, board: { groupBy: 'status' } })
41
- try {
42
- const lanes = target.querySelectorAll('.sv-board-lane')
43
- expect(lanes).toHaveLength(2)
44
- const titles = [...target.querySelectorAll('.sv-board-lane-title')].map((n) => n.textContent)
45
- expect(titles).toEqual(['todo', 'done'])
46
- } finally { destroy() }
47
- })
48
-
49
- it('buckets cards into their lane and shows a count', () => {
50
- const { target, destroy } = mountOn({ data, columns, board: { groupBy: 'status' } })
51
- try {
52
- const counts = [...target.querySelectorAll('.sv-board-lane-count')].map((n) => n.textContent?.trim())
53
- expect(counts).toEqual(['2', '1'])
54
- const firstLaneCards = target.querySelector('.sv-board-lane')!.querySelectorAll('.sv-board-card')
55
- expect(firstLaneCards).toHaveLength(2)
56
- } finally { destroy() }
57
- })
58
-
59
- it('honours explicit ordered lanes with titles, incl. an empty one', () => {
60
- const board = {
61
- groupBy: 'status',
62
- lanes: [
63
- { id: 'todo', title: 'To do' },
64
- { id: 'doing', title: 'In progress' },
65
- { id: 'done', title: 'Done' },
66
- ],
67
- }
68
- const { target, destroy } = mountOn({ data, columns, board })
69
- try {
70
- const titles = [...target.querySelectorAll('.sv-board-lane-title')].map((n) => n.textContent)
71
- expect(titles).toEqual(['To do', 'In progress', 'Done'])
72
- // The empty 'doing' lane shows a placeholder.
73
- expect(target.querySelectorAll('.sv-board-lane-empty')).toHaveLength(1)
74
- } finally { destroy() }
75
- })
76
-
77
- it('flags a lane that exceeds its WIP limit', () => {
78
- const board = { groupBy: 'status', lanes: [{ id: 'todo', title: 'To do', wipLimit: 1 }] }
79
- const { target, destroy } = mountOn({ data, columns, board })
80
- try {
81
- const count = target.querySelector('.sv-board-lane-count')!
82
- expect(count.textContent?.trim()).toBe('2/1')
83
- expect(count.classList.contains('is-over')).toBe(true)
84
- } finally { destroy() }
85
- })
86
- })
87
-
88
- describe('SvGridBoard default card', () => {
89
- it('renders the title field and meta rows', () => {
90
- const { target, destroy } = mountOn({ data, columns, board: { groupBy: 'status' } })
91
- try {
92
- const first = target.querySelector('.sv-board-card')!
93
- expect(first.querySelector('.sv-board-card-title')?.textContent).toBe('Design spec')
94
- // Owner is a meta row; status (the groupBy) and title are excluded.
95
- const labels = [...first.querySelectorAll('.sv-board-card-metarow dt')].map((n) => n.textContent)
96
- expect(labels).toContain('Owner')
97
- expect(labels).not.toContain('Status')
98
- expect(labels).not.toContain('Title')
99
- } finally { destroy() }
100
- })
101
-
102
- it('shows an add-card button only when onCardAdd is set', () => {
103
- const onCardAdd = vi.fn()
104
- const { target, destroy } = mountOn({ data, columns, board: { groupBy: 'status', onCardAdd } })
105
- try {
106
- const add = target.querySelector<HTMLButtonElement>('.sv-board-lane-add')!
107
- expect(add).not.toBeNull()
108
- add.click()
109
- expect(onCardAdd).toHaveBeenCalledWith('todo')
110
- } finally { destroy() }
111
- })
112
- })
113
-
114
- describe('SvGridBoard drag to move (built-in)', () => {
115
- it('fires onCardMove with row + from/to lane when a card is dropped on another lane', () => {
116
- const onCardMove = vi.fn()
117
- const { target, destroy } = mountOn({ data, columns, board: { groupBy: 'status', onCardMove } })
118
- try {
119
- const lanes = target.querySelectorAll('.sv-board-lane')
120
- const card = lanes[0]!.querySelector('.sv-board-card')! // "Design spec" in todo
121
- const targetBody = lanes[1]!.querySelector('.sv-board-lane-body')! // done lane
122
-
123
- fire(card, 'dragstart')
124
- fire(targetBody, 'dragover')
125
- fire(targetBody, 'drop')
126
-
127
- expect(onCardMove).toHaveBeenCalledTimes(1)
128
- const e = onCardMove.mock.calls[0]![0]
129
- expect(e.row.id).toBe(1)
130
- expect(e.fromLane).toBe('todo')
131
- expect(e.toLane).toBe('done')
132
- expect(typeof e.toIndex).toBe('number')
133
- } finally { destroy() }
134
- })
135
-
136
- it('actually moves the card into the target lane itself (no consumer code)', () => {
137
- // No onCardMove: the board applies the move via its own overlay.
138
- const { target, destroy } = mountOn({ data, columns, board: { groupBy: 'status' } })
139
- try {
140
- const lanes = target.querySelectorAll('.sv-board-lane')
141
- const card = lanes[0]!.querySelector('.sv-board-card')!
142
- const targetBody = lanes[1]!.querySelector('.sv-board-lane-body')!
143
-
144
- fire(card, 'dragstart')
145
- fire(targetBody, 'dragover')
146
- fire(targetBody, 'drop')
147
-
148
- const counts = [...target.querySelectorAll('.sv-board-lane-count')].map((n) => n.textContent?.trim())
149
- expect(counts).toEqual(['1', '2']) // todo 2->1, done 1->2
150
- // The moved card now lives in the done lane.
151
- const doneTitles = [...lanes[1]!.querySelectorAll('.sv-board-card-title')].map((n) => n.textContent)
152
- expect(doneTitles).toContain('Design spec')
153
- } finally { destroy() }
154
- })
155
-
156
- it('does not fire onCardMove for a no-op drop onto its own single-card lane', () => {
157
- const onCardMove = vi.fn()
158
- const { target, destroy } = mountOn({ data, columns, board: { groupBy: 'status', onCardMove } })
159
- try {
160
- const lanes = target.querySelectorAll('.sv-board-lane')
161
- const doneBody = lanes[1]!.querySelector('.sv-board-lane-body')! // done: single card
162
- const card = doneBody.querySelector('.sv-board-card')!
163
- fire(card, 'dragstart')
164
- fire(doneBody, 'drop')
165
- expect(onCardMove).not.toHaveBeenCalled()
166
- } finally { destroy() }
167
- })
168
- })
169
-
170
- describe('SvGridBoard card editing (built-in)', () => {
171
- function dblclick(el: Element) {
172
- el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
173
- flushSync()
174
- }
175
-
176
- it('double-click opens the inline editor with a field per editable column', () => {
177
- const { target, destroy } = mountOn({ data, columns, board: { groupBy: 'status', editable: true } })
178
- try {
179
- dblclick(target.querySelector('.sv-board-card')!)
180
- const form = target.querySelector('.sv-board-card-edit')
181
- expect(form).not.toBeNull()
182
- // title + owner (status is the groupBy field, excluded)
183
- expect(form!.querySelectorAll('input')).toHaveLength(2)
184
- } finally { destroy() }
185
- })
186
-
187
- it('saving commits the changed fields and updates the card', () => {
188
- const onCardCommit = vi.fn()
189
- const { target, destroy } = mountOn({ data, columns, board: { groupBy: 'status', editable: true, onCardCommit } })
190
- try {
191
- dblclick(target.querySelector('.sv-board-card')!)
192
- const input = target.querySelector<HTMLInputElement>('.sv-board-card-edit input')!
193
- input.value = 'Renamed'
194
- input.dispatchEvent(new Event('input', { bubbles: true }))
195
- flushSync()
196
- const save = [...target.querySelectorAll('.sv-board-btn')].find((b) => b.textContent === 'Save')! as HTMLButtonElement
197
- save.click()
198
- flushSync()
199
- expect(onCardCommit).toHaveBeenCalledTimes(1)
200
- expect(onCardCommit.mock.calls[0]![0].changes.title).toBe('Renamed')
201
- expect(target.querySelector('.sv-board-card-edit')).toBeNull()
202
- expect(target.querySelector('.sv-board-card-title')?.textContent).toBe('Renamed')
203
- } finally { destroy() }
204
- })
205
-
206
- it('cancel closes the editor without committing', () => {
207
- const onCardCommit = vi.fn()
208
- const { target, destroy } = mountOn({ data, columns, board: { groupBy: 'status', editable: true, onCardCommit } })
209
- try {
210
- dblclick(target.querySelector('.sv-board-card')!)
211
- const cancel = [...target.querySelectorAll('.sv-board-btn')].find((b) => b.textContent === 'Cancel')! as HTMLButtonElement
212
- cancel.click()
213
- flushSync()
214
- expect(onCardCommit).not.toHaveBeenCalled()
215
- expect(target.querySelector('.sv-board-card-edit')).toBeNull()
216
- } finally { destroy() }
217
- })
218
-
219
- it('onCardEdit is used instead of the built-in editor when provided', () => {
220
- const onCardEdit = vi.fn()
221
- const { target, destroy } = mountOn({ data, columns, board: { groupBy: 'status', editable: true, onCardEdit } })
222
- try {
223
- dblclick(target.querySelector('.sv-board-card')!)
224
- expect(onCardEdit).toHaveBeenCalledTimes(1)
225
- expect(onCardEdit.mock.calls[0]![0].id).toBe(1)
226
- expect(target.querySelector('.sv-board-card-edit')).toBeNull()
227
- } finally { destroy() }
228
- })
229
- })
230
-
231
- describe('SvGridBoard keyboard navigation', () => {
232
- function keydown(el: Element, key: string) {
233
- el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
234
- flushSync()
235
- }
236
- const activeTitle = () =>
237
- (document.activeElement as HTMLElement | null)?.querySelector('.sv-board-card-title')?.textContent
238
-
239
- it('Arrow Down/Up moves focus between cards in a lane (not grabbed)', () => {
240
- const { target, destroy } = mountOn({ data, columns, board: { groupBy: 'status' } })
241
- try {
242
- const first = target.querySelector<HTMLElement>('.sv-board-card')! // Design spec (todo)
243
- first.focus()
244
- keydown(first, 'ArrowDown')
245
- expect(activeTitle()).toBe('Write tests')
246
- keydown(document.activeElement!, 'ArrowUp')
247
- expect(activeTitle()).toBe('Design spec')
248
- } finally { destroy() }
249
- })
250
-
251
- it('Arrow Right moves focus to the adjacent lane (not grabbed)', () => {
252
- const { target, destroy } = mountOn({ data, columns, board: { groupBy: 'status' } })
253
- try {
254
- const first = target.querySelector<HTMLElement>('.sv-board-card')! // todo
255
- first.focus()
256
- keydown(first, 'ArrowRight')
257
- expect(activeTitle()).toBe('Ship it') // the done lane's card
258
- } finally { destroy() }
259
- })
260
- })
261
-
262
- describe('SvGridBoard keyboard drag (built-in)', () => {
263
- function keydown(el: Element, key: string) {
264
- el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
265
- flushSync()
266
- }
267
-
268
- it('Space grabs a card and ArrowRight moves it to the next lane', () => {
269
- const onCardMove = vi.fn()
270
- const { target, destroy } = mountOn({ data, columns, board: { groupBy: 'status', onCardMove } })
271
- try {
272
- const lanes = target.querySelectorAll('.sv-board-lane')
273
- const card = lanes[0]!.querySelector('.sv-board-card')! // todo
274
- keydown(card, ' ')
275
- expect(card.getAttribute('aria-grabbed')).toBe('true')
276
- keydown(card, 'ArrowRight')
277
- expect(onCardMove).toHaveBeenCalledTimes(1)
278
- expect(onCardMove.mock.calls[0]![0].toLane).toBe('done')
279
- const doneTitles = [...lanes[1]!.querySelectorAll('.sv-board-card-title')].map((n) => n.textContent)
280
- expect(doneTitles).toContain('Design spec')
281
- } finally { destroy() }
282
- })
283
-
284
- it('Escape cancels a grab and restores the card', () => {
285
- const onCardMove = vi.fn()
286
- const { target, destroy } = mountOn({ data, columns, board: { groupBy: 'status', onCardMove } })
287
- try {
288
- const lanes = target.querySelectorAll('.sv-board-lane')
289
- const card = lanes[0]!.querySelector('.sv-board-card')!
290
- keydown(card, ' ')
291
- keydown(card, 'ArrowRight') // move to done -> the node is recreated in the new lane
292
- const moved = [...target.querySelectorAll('.sv-board-card')].find(
293
- (c) => c.querySelector('.sv-board-card-title')?.textContent === 'Design spec',
294
- )!
295
- keydown(moved, 'Escape') // cancel -> back to todo
296
- const todoTitles = [...target.querySelectorAll('.sv-board-lane')[0]!.querySelectorAll('.sv-board-card-title')].map((n) => n.textContent)
297
- expect(todoTitles).toContain('Design spec')
298
- } finally { destroy() }
299
- })
300
- })
301
-
302
- describe('SvGridBoard extensions', () => {
303
- it('swimlaneBy renders a band per value, each with the full lane set', () => {
304
- const { target, destroy } = mountOn({ data, columns, board: { groupBy: 'status', swimlaneBy: 'owner' } })
305
- try {
306
- const bands = target.querySelectorAll('.sv-board-swim')
307
- expect(bands).toHaveLength(2) // Sam, Lee
308
- expect(bands[0]!.querySelectorAll('.sv-board-lane')).toHaveLength(2) // todo, done
309
- // Sam's todo lane holds "Design spec"; Lee's done lane is empty.
310
- const samTodo = bands[0]!.querySelectorAll('.sv-board-lane')[0]!
311
- expect(samTodo.querySelector('.sv-board-card-title')?.textContent).toBe('Design spec')
312
- } finally { destroy() }
313
- })
314
-
315
- it('dragging across bands reassigns both lane and swimlane', () => {
316
- const onCardMove = vi.fn()
317
- const { target, destroy } = mountOn({ data, columns, board: { groupBy: 'status', swimlaneBy: 'owner', onCardMove } })
318
- try {
319
- const bands = target.querySelectorAll('.sv-board-swim')
320
- const samTodoCard = bands[0]!.querySelectorAll('.sv-board-lane')[0]!.querySelector('.sv-board-card')!
321
- const leeDoneBody = bands[1]!.querySelectorAll('.sv-board-lane')[1]!.querySelector('.sv-board-lane-body')!
322
- fire(samTodoCard, 'dragstart')
323
- fire(leeDoneBody, 'dragover')
324
- fire(leeDoneBody, 'drop')
325
- expect(onCardMove).toHaveBeenCalledTimes(1)
326
- const e = onCardMove.mock.calls[0]![0]
327
- expect(e.toLane).toBe('done')
328
- expect(e.fromSwimlane).toBe('Sam')
329
- expect(e.toSwimlane).toBe('Lee')
330
- } finally { destroy() }
331
- })
332
-
333
- it('enforceWip rejects a drop that would exceed a lane WIP limit', () => {
334
- const onCardMove = vi.fn()
335
- const board = {
336
- groupBy: 'status',
337
- enforceWip: true,
338
- lanes: [{ id: 'todo', title: 'To do' }, { id: 'done', title: 'Done', wipLimit: 1 }],
339
- onCardMove,
340
- }
341
- const { target, destroy } = mountOn({ data, columns, board })
342
- try {
343
- const lanes = target.querySelectorAll('.sv-board-lane')
344
- const card = lanes[0]!.querySelector('.sv-board-card')! // a todo card
345
- const doneBody = lanes[1]!.querySelector('.sv-board-lane-body')! // done: 1/1 already
346
- fire(card, 'dragstart')
347
- fire(doneBody, 'dragover')
348
- fire(doneBody, 'drop')
349
- expect(onCardMove).not.toHaveBeenCalled()
350
- const counts = [...target.querySelectorAll('.sv-board-lane-count')].map((n) => n.textContent?.trim())
351
- expect(counts).toEqual(['2', '1/1']) // nothing moved
352
- } finally { destroy() }
353
- })
354
-
355
- it('collapsibleLanes: clicking the toggle collapses the lane body', () => {
356
- const { target, destroy } = mountOn({ data, columns, board: { groupBy: 'status', collapsibleLanes: true } })
357
- try {
358
- const lane = target.querySelector('.sv-board-lane')!
359
- expect(lane.querySelector('.sv-board-lane-body')).not.toBeNull()
360
- const toggle = lane.querySelector<HTMLButtonElement>('.sv-board-lane-toggle')!
361
- toggle.click()
362
- flushSync()
363
- expect(lane.classList.contains('is-collapsed')).toBe(true)
364
- expect(lane.querySelector('.sv-board-lane-body')).toBeNull()
365
- } finally { destroy() }
366
- })
367
- })
368
-
369
- describe('SvGrid board filter passthrough', () => {
370
- it('renders the grid filtered rows; the search box filters cards', () => {
371
- const target = document.createElement('div')
372
- document.body.appendChild(target)
373
- const app = mount(SvGrid as any, { target, props: { data, columns, board: { groupBy: 'status' } } as any })
374
- flushSync()
375
- try {
376
- expect(target.querySelectorAll('.sv-board-card')).toHaveLength(3)
377
- const input = target.querySelector<HTMLInputElement>('.sv-grid-board-search input')!
378
- input.value = 'Ship'
379
- input.dispatchEvent(new Event('input', { bubbles: true }))
380
- flushSync()
381
- expect(target.querySelectorAll('.sv-board-card')).toHaveLength(1)
382
- expect(target.querySelector('.sv-board-card-title')?.textContent).toBe('Ship it')
383
- } finally { unmount(app); target.remove() }
384
- })
385
- })
386
-
387
- describe('SvGridBoard virtualization', () => {
388
- it('renders only a window of cards per lane when virtualized', () => {
389
- const many = Array.from({ length: 100 }, (_, i) => ({
390
- id: i + 1, title: `Card ${i + 1}`, status: 'todo', owner: 'Sam',
391
- }))
392
- const { target, destroy } = mountOn({
393
- data: many, columns, board: { groupBy: 'status', virtualized: true, cardHeight: 60 },
394
- })
395
- try {
396
- const rendered = target.querySelectorAll('.sv-board-card')
397
- expect(rendered.length).toBeGreaterThan(0)
398
- expect(rendered.length).toBeLessThan(30) // a small window, not all 100
399
- const body = target.querySelector('.sv-board-lane-body.is-virtual')!
400
- const spacer = body.querySelector('div[aria-hidden="true"][style*="height"]')
401
- expect(spacer).not.toBeNull()
402
- } finally { destroy() }
403
- })
404
-
405
- it('is off by default (all cards rendered)', () => {
406
- const many = Array.from({ length: 40 }, (_, i) => ({
407
- id: i + 1, title: `Card ${i + 1}`, status: 'todo', owner: 'Sam',
408
- }))
409
- const { target, destroy } = mountOn({ data: many, columns, board: { groupBy: 'status' } })
410
- try {
411
- expect(target.querySelectorAll('.sv-board-card')).toHaveLength(40)
412
- } finally { destroy() }
413
- })
414
- })
415
-
416
- describe('SvGridBoard laneSummary', () => {
417
- it('renders a per-lane summary in the header', () => {
418
- const { target, destroy } = mountOn({
419
- data, columns, board: { groupBy: 'status', laneSummary: (rows: any[]) => `${rows.length} items` },
420
- })
421
- try {
422
- const summaries = [...target.querySelectorAll('.sv-board-lane-summary')].map((n) => n.textContent)
423
- expect(summaries).toEqual(['2 items', '1 items'])
424
- } finally { destroy() }
425
- })
426
- })
427
-
428
- describe('SvGridBoard layout persistence', () => {
429
- const KEY = 'test-board-layout'
430
- const rowId = (r: any) => String(r.id)
431
- beforeEach(() => { try { localStorage.removeItem(KEY) } catch { /* ignore */ } })
432
-
433
- it('persists a move to localStorage and restores it on a fresh mount', () => {
434
- const a = mountOn({ data, columns, getRowId: rowId, board: { groupBy: 'status', persistKey: KEY } })
435
- const lanes = a.target.querySelectorAll('.sv-board-lane')
436
- fire(lanes[0]!.querySelector('.sv-board-card')!, 'dragstart')
437
- fire(lanes[1]!.querySelector('.sv-board-lane-body')!, 'dragover')
438
- fire(lanes[1]!.querySelector('.sv-board-lane-body')!, 'drop')
439
- const saved = JSON.parse(localStorage.getItem(KEY)!)
440
- expect(saved.laneOf['1']).toBe('done')
441
- a.destroy()
442
-
443
- // Remount from the same key: the moved card should reappear in done.
444
- const b = mountOn({ data, columns, getRowId: rowId, board: { groupBy: 'status', persistKey: KEY } })
445
- const bDone = b.target.querySelectorAll('.sv-board-lane')[1]!
446
- const titles = [...bDone.querySelectorAll('.sv-board-card-title')].map((n) => n.textContent)
447
- expect(titles).toContain('Design spec')
448
- b.destroy()
449
- })
450
-
451
- it('fires onLayoutChange with the serialized layout on change', () => {
452
- const onLayoutChange = vi.fn()
453
- const { target, destroy } = mountOn({ data, columns, getRowId: rowId, board: { groupBy: 'status', onLayoutChange } })
454
- const lanes = target.querySelectorAll('.sv-board-lane')
455
- fire(lanes[0]!.querySelector('.sv-board-card')!, 'dragstart')
456
- fire(lanes[1]!.querySelector('.sv-board-lane-body')!, 'dragover')
457
- fire(lanes[1]!.querySelector('.sv-board-lane-body')!, 'drop')
458
- expect(onLayoutChange).toHaveBeenCalled()
459
- const last = onLayoutChange.mock.calls.at(-1)![0]
460
- expect(last.laneOf['1']).toBe('done')
461
- destroy()
462
- })
463
- })
464
-
465
- describe('SvGridBoard sub-tasks', () => {
466
- const rowId = (r: any) => String(r.id)
467
- const subData = [
468
- { id: 1, title: 'Parent A', status: 'todo', items: [{ id: 'x', title: 'one', done: true }, { id: 'y', title: 'two', done: false }] },
469
- { id: 2, title: 'Parent B', status: 'done', items: [] },
470
- ]
471
- const subCols = [{ field: 'title', header: 'Title' }, { field: 'status', header: 'Status' }]
472
-
473
- it('shows a progress count from the subtasks field', () => {
474
- const { target, destroy } = mountOn({ data: subData, columns: subCols, getRowId: rowId, board: { groupBy: 'status', subtasks: 'items' } })
475
- try {
476
- expect(target.querySelector('.sv-board-subs-count')?.textContent).toBe('1/2')
477
- } finally { destroy() }
478
- })
479
-
480
- it('expands to a checklist and toggling fires onSubtaskToggle + updates progress', () => {
481
- const onSubtaskToggle = vi.fn()
482
- const { target, destroy } = mountOn({ data: subData, columns: subCols, getRowId: rowId, board: { groupBy: 'status', subtasks: 'items', onSubtaskToggle } })
483
- try {
484
- target.querySelector<HTMLButtonElement>('.sv-board-subs-head')!.click()
485
- flushSync()
486
- const boxes = target.querySelectorAll<HTMLInputElement>('.sv-board-subs-list input')
487
- expect(boxes).toHaveLength(2)
488
- boxes[1]!.click() // check "two"
489
- flushSync()
490
- expect(onSubtaskToggle).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }), 'y', true)
491
- expect(target.querySelector('.sv-board-subs-count')?.textContent).toBe('2/2')
492
- } finally { destroy() }
493
- })
494
-
495
- it('adds a subtask via the inline input (onSubtaskAdd + progress grows)', () => {
496
- const onSubtaskAdd = vi.fn()
497
- const { target, destroy } = mountOn({ data: subData, columns: subCols, getRowId: rowId, board: { groupBy: 'status', subtasks: 'items', onSubtaskAdd } })
498
- try {
499
- target.querySelector<HTMLButtonElement>('.sv-board-subs-head')!.click()
500
- flushSync()
501
- const input = target.querySelector<HTMLInputElement>('.sv-board-subs-add')!
502
- input.value = 'three'
503
- input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
504
- flushSync()
505
- expect(onSubtaskAdd).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }), 'three')
506
- expect(target.querySelector('.sv-board-subs-count')?.textContent).toBe('1/3')
507
- } finally { destroy() }
508
- })
509
- })
510
-
511
- describe('SvGridBoard card badges', () => {
512
- const badgeData = [{ id: 1, title: 'A', status: 'todo', labels: ['bug', 'urgent'], due: '2020-01-01', people: ['Sam Rivera', 'Lee Park'] }]
513
- const badgeCols = [{ field: 'title', header: 'Title' }, { field: 'status', header: 'Status' }]
514
-
515
- it('renders label, due (overdue), and assignee badges', () => {
516
- const { target, destroy } = mountOn({ data: badgeData, columns: badgeCols, board: { groupBy: 'status', labelsField: 'labels', dueField: 'due', assigneesField: 'people' } })
517
- try {
518
- expect(target.querySelectorAll('.sv-board-badge.is-label')).toHaveLength(2)
519
- const due = target.querySelector('.sv-board-badge.is-due')!
520
- expect(due.classList.contains('is-overdue')).toBe(true)
521
- expect(target.querySelectorAll('.sv-board-avatar')).toHaveLength(2)
522
- } finally { destroy() }
523
- })
524
- })
525
-
526
- describe('SvGridBoard quick-add composer', () => {
527
- it('opens an input and Enter calls onCardAdd(lane, title)', () => {
528
- const onCardAdd = vi.fn()
529
- const { target, destroy } = mountOn({ data, columns, board: { groupBy: 'status', composer: true, onCardAdd } })
530
- try {
531
- const btn = target.querySelector<HTMLButtonElement>('.sv-board-composer-btn')!
532
- expect(btn).not.toBeNull()
533
- btn.click()
534
- flushSync()
535
- const input = target.querySelector<HTMLInputElement>('.sv-board-composer-input')!
536
- input.value = 'New card'
537
- input.dispatchEvent(new Event('input', { bubbles: true }))
538
- flushSync()
539
- input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
540
- flushSync()
541
- expect(onCardAdd).toHaveBeenCalledWith('todo', 'New card')
542
- } finally { destroy() }
543
- })
544
- })
545
-
546
- describe('SvGridBoard lane reorder', () => {
547
- it('drag a lane header to reorder lanes + fire onLaneReorder', () => {
548
- const onLaneReorder = vi.fn()
549
- const board = {
550
- groupBy: 'status',
551
- lanes: [{ id: 'todo', title: 'To do' }, { id: 'done', title: 'Done' }],
552
- reorderableLanes: true,
553
- onLaneReorder,
554
- }
555
- const { target, destroy } = mountOn({ data, columns, board })
556
- try {
557
- const heads = target.querySelectorAll('.sv-board-lane-head')
558
- fire(heads[1]!, 'dragstart') // grab "Done"
559
- fire(heads[0]!, 'dragover') // over "To do"
560
- fire(heads[0]!, 'drop')
561
- const titles = [...target.querySelectorAll('.sv-board-lane-title')].map((n) => n.textContent)
562
- expect(titles).toEqual(['Done', 'To do'])
563
- expect(onLaneReorder).toHaveBeenCalledWith(['done', 'todo'])
564
- } finally { destroy() }
565
- })
566
- })
567
-
568
- describe('SvGridBoard card context menu', () => {
569
- it('right-click opens a menu; an item runs its action (board-native moveTo)', () => {
570
- const onCardMove = vi.fn()
571
- const board = {
572
- groupBy: 'status',
573
- lanes: [{ id: 'todo', title: 'To do' }, { id: 'done', title: 'Done' }],
574
- onCardMove,
575
- cardMenu: (_row: any, ctx: any) => [
576
- { label: 'Move to Done', onSelect: () => ctx.moveTo('done') },
577
- { separator: true },
578
- { label: 'Edit', onSelect: () => ctx.edit() },
579
- ],
580
- }
581
- const { target, destroy } = mountOn({ data, columns, getRowId: (r: any) => String(r.id), board })
582
- try {
583
- const card = target.querySelector('.sv-board-card')! // "Design spec" (todo)
584
- card.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 20 }))
585
- flushSync()
586
- const menu = document.body.querySelector('.sv-board-ctx')
587
- expect(menu).not.toBeNull()
588
- const items = [...document.body.querySelectorAll('.sv-board-ctx [role="menuitem"]')]
589
- const move = items.find((i) => i.textContent?.includes('Move to Done'))! as HTMLElement
590
- move.click()
591
- flushSync()
592
- expect(onCardMove).toHaveBeenCalled()
593
- expect(onCardMove.mock.calls.at(-1)![0].toLane).toBe('done')
594
- expect(document.body.querySelector('.sv-board-ctx')).toBeNull() // closed
595
- } finally { destroy() }
596
- })
597
- })
598
-
599
- describe('SvGridBoard collapsible swimlanes + meta badges', () => {
600
- it('collapsibleSwimlanes: a toggle hides the band lanes', () => {
601
- const { target, destroy } = mountOn({ data, columns, board: { groupBy: 'status', swimlaneBy: 'owner', collapsibleSwimlanes: true } })
602
- try {
603
- const band = target.querySelector('.sv-board-swim')!
604
- expect(band.querySelector('.sv-board-lanes')).not.toBeNull()
605
- band.querySelector<HTMLButtonElement>('.sv-board-swim-toggle')!.click()
606
- flushSync()
607
- expect(band.querySelector('.sv-board-lanes')).toBeNull()
608
- } finally { destroy() }
609
- })
610
-
611
- it('renders comment + attachment count badges and a cover strip', () => {
612
- const richData = [{ id: 1, title: 'A', status: 'todo', comments: ['a', 'b', 'c'], files: 2, cover: '#ff0000' }]
613
- const richCols = [{ field: 'title', header: 'Title' }, { field: 'status', header: 'Status' }]
614
- const { target, destroy } = mountOn({ data: richData, columns: richCols, board: { groupBy: 'status', commentsField: 'comments', attachmentsField: 'files', coverField: 'cover' } })
615
- try {
616
- const metas = [...target.querySelectorAll('.sv-board-badge.is-meta')].map((n) => n.textContent?.trim())
617
- expect(metas).toEqual(['3', '2'])
618
- const cover = target.querySelector<HTMLElement>('.sv-board-card-cover')!
619
- expect(cover).not.toBeNull()
620
- expect(cover.style.background).toContain('rgb(255, 0, 0)')
621
- } finally { destroy() }
622
- })
623
- })
624
-
625
- describe('SvGridBoard power features', () => {
626
- const rowId = (r: any) => String(r.id)
627
- const pData = [
628
- { id: 1, title: 'A', status: 'todo', tag: 'bug', assignee: 'Sam', blocked: true, created: '2020-01-01' },
629
- { id: 2, title: 'B', status: 'todo', tag: 'feat', assignee: 'Lee', blocked: false, created: '2026-07-20' },
630
- { id: 3, title: 'C', status: 'done', tag: 'bug', assignee: 'Sam', blocked: false, created: '2026-07-01' },
631
- ]
632
- const pCols = [{ field: 'title', header: 'Title' }, { field: 'status', header: 'Status' }]
633
-
634
- it('flagField renders a blocked ribbon', () => {
635
- const { target, destroy } = mountOn({ data: pData, columns: pCols, board: { groupBy: 'status', flagField: 'blocked' } })
636
- try {
637
- const first = target.querySelector('.sv-board-card')!
638
- expect(first.classList.contains('is-flagged')).toBe(true)
639
- expect(first.querySelector('.sv-board-card-flag')).not.toBeNull()
640
- } finally { destroy() }
641
- })
642
-
643
- it('ageField renders an age badge', () => {
644
- const { target, destroy } = mountOn({ data: pData, columns: pCols, board: { groupBy: 'status', ageField: 'created' } })
645
- try {
646
- const age = target.querySelector('.sv-board-badge.is-meta[title="Age"]')
647
- expect(age?.textContent?.trim()).toBeTruthy()
648
- } finally { destroy() }
649
- })
650
-
651
- it('facets: a chip filters the visible cards', () => {
652
- const { target, destroy } = mountOn({ data: pData, columns: pCols, board: { groupBy: 'status', facets: ['tag'] } })
653
- try {
654
- const chips = [...target.querySelectorAll<HTMLButtonElement>('.sv-board-facet')]
655
- expect(chips).toHaveLength(2) // bug, feat
656
- const bug = chips.find((c) => c.textContent?.startsWith('bug'))!
657
- bug.click()
658
- flushSync()
659
- expect(target.querySelectorAll('.sv-board-card')).toHaveLength(2) // the two "bug" cards
660
- } finally { destroy() }
661
- })
662
-
663
- it('multi-select: ctrl-click selects, and dragging the selection moves all', () => {
664
- const onCardMove = vi.fn()
665
- const { target, destroy } = mountOn({ data: pData, columns: pCols, getRowId: rowId, board: { groupBy: 'status', selectable: true, onCardMove } })
666
- try {
667
- const todo = target.querySelectorAll('.sv-board-lane')[0]!
668
- const cards = todo.querySelectorAll('.sv-board-card')
669
- cards[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
670
- cards[1]!.dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey: true }))
671
- flushSync()
672
- expect(cards[0]!.classList.contains('is-selected')).toBe(true)
673
- expect(cards[1]!.classList.contains('is-selected')).toBe(true)
674
- // drag one selected card to the done lane -> both move
675
- const doneBody = target.querySelectorAll('.sv-board-lane')[1]!.querySelector('.sv-board-lane-body')!
676
- fire(cards[0]!, 'dragstart')
677
- fire(doneBody, 'dragover')
678
- fire(doneBody, 'drop')
679
- expect(onCardMove).toHaveBeenCalledTimes(2)
680
- expect(onCardMove.mock.calls.every((c) => c[0].toLane === 'done')).toBe(true)
681
- } finally { destroy() }
682
- })
683
-
684
- it('laneMenu: right-click a lane header opens a menu', () => {
685
- const { target, destroy } = mountOn({ data: pData, columns: pCols, board: { groupBy: 'status', laneMenu: () => [{ label: 'Clear' }] } })
686
- try {
687
- const head = target.querySelector('.sv-board-lane-head')!
688
- head.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 5, clientY: 5 }))
689
- flushSync()
690
- expect(document.body.querySelector('.sv-board-ctx')).not.toBeNull()
691
- } finally { destroy() }
692
- })
693
-
694
- it('childrenField: shows a count and expands to child cards', () => {
695
- const epicData = [{ id: 1, title: 'Epic', status: 'todo', children: [{ id: 11, title: 'Story A', status: 'todo' }, { id: 12, title: 'Story B', status: 'done' }] }]
696
- const { target, destroy } = mountOn({ data: epicData, columns: pCols, board: { groupBy: 'status', childrenField: 'children' } })
697
- try {
698
- const head = target.querySelector('.sv-board-kids-head')!
699
- expect(head.textContent).toContain('2')
700
- head.dispatchEvent(new MouseEvent('click', { bubbles: true }))
701
- flushSync()
702
- expect(target.querySelectorAll('.sv-board-kid')).toHaveLength(2)
703
- } finally { destroy() }
704
- })
705
- })
706
-
707
- describe('SvGridBoard lane rename + swimlane summary', () => {
708
- it('double-click a lane title to rename -> onLaneRename', () => {
709
- const onLaneRename = vi.fn()
710
- const board = { groupBy: 'status', lanes: [{ id: 'todo', title: 'To do' }, { id: 'done', title: 'Done' }], onLaneRename }
711
- const { target, destroy } = mountOn({ data, columns, board })
712
- try {
713
- target.querySelector('.sv-board-lane-title')!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
714
- flushSync()
715
- const input = target.querySelector<HTMLInputElement>('.sv-board-lane-rename')!
716
- input.value = 'Backlog'
717
- input.dispatchEvent(new Event('input', { bubbles: true }))
718
- flushSync()
719
- input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
720
- flushSync()
721
- expect(onLaneRename).toHaveBeenCalledWith('todo', 'Backlog')
722
- } finally { destroy() }
723
- })
724
-
725
- it('swimlaneSummary renders in each band header', () => {
726
- const { target, destroy } = mountOn({ data, columns, board: { groupBy: 'status', swimlaneBy: 'owner', swimlaneSummary: (rows: any[]) => `${rows.length} items` } })
727
- try {
728
- const summaries = [...target.querySelectorAll('.sv-board-swim-summary')].map((n) => n.textContent)
729
- expect(summaries).toEqual(['2 items', '1 items']) // Sam: 2, Lee: 1
730
- } finally { destroy() }
731
- })
732
- })
733
-
734
- describe('SvGridBoard badges with a custom card snippet', () => {
735
- it('renders built-in comment/attachment badges below a custom card', () => {
736
- const cardSnippet = createRawSnippet((row: any) => ({
737
- render: () => `<div class="mycard">${row().title}</div>`,
738
- }))
739
- const richData = [{ id: 1, title: 'A', status: 'todo', comments: ['x', 'y'], files: 3 }]
740
- const richCols = [{ field: 'title', header: 'T' }, { field: 'status', header: 'S' }]
741
- const { target, destroy } = mountOn({ data: richData, columns: richCols, board: { groupBy: 'status', card: cardSnippet as any, commentsField: 'comments', attachmentsField: 'files' } })
742
- try {
743
- expect(target.querySelector('.mycard')).not.toBeNull() // custom card rendered
744
- const metas = [...target.querySelectorAll('.sv-board-badge.is-meta')].map((n) => n.textContent?.trim())
745
- expect(metas).toEqual(['2', '3']) // comments + attachments badges appended
746
- } finally { destroy() }
747
- })
748
- })
749
-
750
- describe('SvGridBoard built-in detail drawer', () => {
751
- const rowId = (r: any) => String(r.id)
752
-
753
- it('board.drawer opens an SvForm drawer with all fields; Save commits + closes', () => {
754
- const onCardCommit = vi.fn()
755
- const { target, destroy } = mountOn({ data, columns, getRowId: rowId, board: { groupBy: 'status', drawer: true, onCardCommit } })
756
- try {
757
- target.querySelector('.sv-board-card')!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
758
- flushSync()
759
- const drawer = document.body.querySelector('.sv-drawer')
760
- expect(drawer).not.toBeNull()
761
- const form = document.body.querySelector('.sv-form')!
762
- expect(form.querySelectorAll('input').length).toBeGreaterThanOrEqual(3) // title, owner, status
763
- const save = [...form.querySelectorAll('button')].find((b) => b.textContent?.trim() === 'Save')!
764
- save.click()
765
- flushSync()
766
- expect(onCardCommit).toHaveBeenCalled()
767
- expect(onCardCommit.mock.calls.at(-1)![0].values.title).toBe('Design spec')
768
- expect(document.body.querySelector('.sv-drawer')).toBeNull() // closed
769
- } finally { destroy() }
770
- })
771
-
772
- it('board.drawer.fields limits the drawer to a subset', () => {
773
- const { target, destroy } = mountOn({ data, columns, board: { groupBy: 'status', drawer: { fields: ['title'] } } })
774
- try {
775
- target.querySelector('.sv-board-card')!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
776
- flushSync()
777
- expect(document.body.querySelectorAll('.sv-form input')).toHaveLength(1) // only title
778
- } finally { destroy() }
779
- })
780
- })
781
-
782
- describe('SvGridBoard blocked badge + comments thread', () => {
783
- const rowId = (r: any) => String(r.id)
784
-
785
- it('flagField shows a "Blocked" badge, using a string value as the reason', () => {
786
- const d = [{ id: 1, title: 'A', status: 'todo', blocked: 'Waiting on API' }]
787
- const c = [{ field: 'title', header: 'T' }, { field: 'status', header: 'S' }]
788
- const { target, destroy } = mountOn({ data: d, columns: c, board: { groupBy: 'status', flagField: 'blocked' } })
789
- try {
790
- const badge = target.querySelector('.sv-board-badge.is-blocked')!
791
- expect(badge.textContent?.trim()).toBe('Blocked')
792
- expect(badge.getAttribute('title')).toBe('Waiting on API')
793
- } finally { destroy() }
794
- })
795
-
796
- it('comment badge expands a thread; add + delete fire the callbacks', () => {
797
- const onCommentAdd = vi.fn()
798
- const onCommentDelete = vi.fn()
799
- const d = [{ id: 1, title: 'A', status: 'todo', comments: [{ id: 'c1', text: 'Hi', author: 'Sam' }] }]
800
- const c = [{ field: 'title', header: 'T' }, { field: 'status', header: 'S' }]
801
- const { target, destroy } = mountOn({ data: d, columns: c, getRowId: rowId, board: { groupBy: 'status', commentsField: 'comments', onCommentAdd, onCommentDelete } })
802
- try {
803
- target.querySelector<HTMLButtonElement>('.sv-board-badge.is-btn')!.click()
804
- flushSync()
805
- expect(target.querySelector('.sv-board-comments')).not.toBeNull()
806
- expect(target.querySelectorAll('.sv-board-comment')).toHaveLength(1)
807
- // delete
808
- target.querySelector<HTMLButtonElement>('.sv-board-comment-del')!.click()
809
- flushSync()
810
- expect(onCommentDelete).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }), 'c1')
811
- // write
812
- const input = target.querySelector<HTMLInputElement>('.sv-board-comment-add')!
813
- input.value = 'New comment'
814
- input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
815
- flushSync()
816
- expect(onCommentAdd).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }), 'New comment')
817
- } finally { destroy() }
818
- })
819
- })
820
-
821
- describe('SvGridBoard blocked cards are locked from moving', () => {
822
- const rowId = (r: any) => String(r.id)
823
- const d = [
824
- { id: 1, title: 'A', status: 'todo', blocked: true },
825
- { id: 2, title: 'B', status: 'todo', blocked: false },
826
- ]
827
- const c = [{ field: 'title', header: 'T' }, { field: 'status', header: 'S' }]
828
- const lanes = [{ id: 'todo', title: 'To do' }, { id: 'done', title: 'Done' }]
829
-
830
- it('a blocked card is not draggable and a drag is rejected', () => {
831
- const onCardMove = vi.fn()
832
- const { target, destroy } = mountOn({ data: d, columns: c, getRowId: rowId, board: { groupBy: 'status', flagField: 'blocked', lanes, onCardMove } })
833
- try {
834
- const blocked = target.querySelector('.sv-board-card')! // "A" (blocked)
835
- expect(blocked.getAttribute('draggable')).toBe('false')
836
- expect(blocked.classList.contains('is-locked')).toBe(true)
837
- const doneBody = target.querySelectorAll('.sv-board-lane')[1]!.querySelector('.sv-board-lane-body')!
838
- fire(blocked, 'dragstart')
839
- fire(doneBody, 'dragover')
840
- fire(doneBody, 'drop')
841
- expect(onCardMove).not.toHaveBeenCalled()
842
- } finally { destroy() }
843
- })
844
-
845
- it('Space does not grab a blocked card', () => {
846
- const { target, destroy } = mountOn({ data: d, columns: c, getRowId: rowId, board: { groupBy: 'status', flagField: 'blocked', lanes } })
847
- try {
848
- const blocked = target.querySelector('.sv-board-card')!
849
- blocked.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }))
850
- flushSync()
851
- expect(blocked.getAttribute('aria-grabbed')).toBe('false')
852
- } finally { destroy() }
853
- })
854
-
855
- it('flagBlocksMoves:false keeps a flagged card draggable', () => {
856
- const { target, destroy } = mountOn({ data: d, columns: c, getRowId: rowId, board: { groupBy: 'status', flagField: 'blocked', flagBlocksMoves: false, lanes } })
857
- try {
858
- const blocked = target.querySelector('.sv-board-card')!
859
- expect(blocked.getAttribute('draggable')).toBe('true')
860
- expect(blocked.classList.contains('is-locked')).toBe(false)
861
- } finally { destroy() }
862
- })
863
- })
864
-
865
- describe('SvGridBoard drawer: comments + no outside-close', () => {
866
- const rowId = (r: any) => String(r.id)
867
- const c = [{ field: 'title', header: 'T' }, { field: 'status', header: 'S' }]
868
-
869
- it('the detail drawer includes the comments thread; adding works there', () => {
870
- const onCommentAdd = vi.fn()
871
- const d = [{ id: 1, title: 'A', status: 'todo', comments: [{ id: 'c1', text: 'Hi', author: 'Sam' }] }]
872
- const { target, destroy } = mountOn({ data: d, columns: c, getRowId: rowId, board: { groupBy: 'status', drawer: true, commentsField: 'comments', onCommentAdd } })
873
- try {
874
- target.querySelector('.sv-board-card')!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
875
- flushSync()
876
- const box = document.body.querySelector('.sv-board-drawer-comments')!
877
- expect(box).not.toBeNull()
878
- expect(box.querySelectorAll('.sv-board-comment')).toHaveLength(1)
879
- const input = box.querySelector<HTMLInputElement>('.sv-board-comment-add')!
880
- input.value = 'From drawer'
881
- input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
882
- flushSync()
883
- expect(onCommentAdd).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }), 'From drawer')
884
- } finally { destroy() }
885
- })
886
-
887
- it('the drawer stays open on an outside pointerdown (so opening a select does not close it)', () => {
888
- const d = [{ id: 1, title: 'A', status: 'todo' }]
889
- const { target, destroy } = mountOn({ data: d, columns: c, getRowId: rowId, board: { groupBy: 'status', drawer: true } })
890
- try {
891
- target.querySelector('.sv-board-card')!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
892
- flushSync()
893
- expect(document.body.querySelector('.sv-drawer')).not.toBeNull()
894
- document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
895
- flushSync()
896
- expect(document.body.querySelector('.sv-drawer')).not.toBeNull() // still open
897
- } finally { destroy() }
898
- })
899
- })
900
-
901
- describe('popover editors nest in the dismissable stack', () => {
902
- it('a dropdown opened inside a dialog does not dismiss the dialog when you click an option', () => {
903
- const dialogEl = document.createElement('div')
904
- document.body.appendChild(dialogEl)
905
- const onDialogDismiss = vi.fn()
906
- const dialog = createDismissableLayer({ element: () => dialogEl, onDismiss: onDialogDismiss })
907
- dialog.activate()
908
- const app = mount(SvDropDownList as any, {
909
- target: dialogEl,
910
- props: { options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }], value: 'a' } as any,
911
- })
912
- flushSync()
913
- try {
914
- dialogEl.querySelector('button')!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
915
- flushSync()
916
- const panel = document.body.querySelector('.sv-ddl__panel')
917
- expect(panel).not.toBeNull() // dropdown open (portalled)
918
- panel!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
919
- flushSync()
920
- expect(onDialogDismiss).not.toHaveBeenCalled() // dialog survives the option click
921
- } finally {
922
- dialog.release()
923
- unmount(app)
924
- dialogEl.remove()
925
- }
926
- })
927
- })