@carto/ps-react-ui 4.3.3 → 4.3.5

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 (301) hide show
  1. package/dist/components.js +3 -3
  2. package/dist/components.js.map +1 -1
  3. package/dist/{lasso-tool-BwRzEW7k.js → lasso-tool-wFqOD6wk.js} +13 -13
  4. package/dist/lasso-tool-wFqOD6wk.js.map +1 -0
  5. package/dist/types/components/common-types.d.ts +41 -0
  6. package/dist/types/components/types.d.ts +1 -1
  7. package/dist/types/widgets/echart/echart-ui.d.ts +1 -1
  8. package/dist/types/widgets/echart/types.d.ts +4 -0
  9. package/dist/widgets/actions.js +1 -1
  10. package/dist/widgets/bar.js +1 -1
  11. package/dist/widgets/category.js +1 -1
  12. package/dist/widgets/echart.js +96 -85
  13. package/dist/widgets/echart.js.map +1 -1
  14. package/dist/widgets/formula.js +1 -1
  15. package/dist/widgets/histogram.js +1 -1
  16. package/dist/widgets/markdown.js +1 -1
  17. package/dist/widgets/pie.js +1 -1
  18. package/dist/widgets/scatterplot.js +1 -1
  19. package/dist/widgets/spread.js +1 -1
  20. package/dist/widgets/table.js +1 -1
  21. package/dist/widgets/timeseries.js +1 -1
  22. package/dist/widgets/toolbar-actions.js.map +1 -1
  23. package/dist/widgets/wrapper.js +1 -1
  24. package/package.json +8 -3
  25. package/src/components/basemaps/basemaps.test.tsx +196 -0
  26. package/src/components/basemaps/basemaps.tsx +128 -0
  27. package/src/components/basemaps/const.ts +13 -0
  28. package/src/components/basemaps/group-wrapper.test.tsx +38 -0
  29. package/src/components/basemaps/group-wrapper.tsx +28 -0
  30. package/src/components/basemaps/group.test.tsx +52 -0
  31. package/src/components/basemaps/group.tsx +42 -0
  32. package/src/components/basemaps/header.test.tsx +54 -0
  33. package/src/components/basemaps/header.tsx +36 -0
  34. package/src/components/basemaps/styles.ts +76 -0
  35. package/src/components/basemaps/types.ts +30 -0
  36. package/src/components/common-types.ts +1 -0
  37. package/src/components/geolocation-controls/const.ts +6 -0
  38. package/src/components/geolocation-controls/geolocation-controls.test.tsx +133 -0
  39. package/src/components/geolocation-controls/geolocation-controls.tsx +95 -0
  40. package/src/components/geolocation-controls/types.ts +17 -0
  41. package/src/components/index.ts +64 -0
  42. package/src/components/lasso-tool/chip.tsx +37 -0
  43. package/src/components/lasso-tool/const.tsx +70 -0
  44. package/src/components/lasso-tool/icons.tsx +91 -0
  45. package/src/components/lasso-tool/lasso-tool-inline.test.tsx +168 -0
  46. package/src/components/lasso-tool/lasso-tool-inline.tsx +245 -0
  47. package/src/components/lasso-tool/lasso-tool.test.tsx +212 -0
  48. package/src/components/lasso-tool/lasso-tool.tsx +479 -0
  49. package/src/components/lasso-tool/styles.ts +143 -0
  50. package/src/components/lasso-tool/types.ts +114 -0
  51. package/src/components/list-data/list-data-skeleton.test.tsx +10 -0
  52. package/src/components/list-data/list-data-skeleton.tsx +40 -0
  53. package/src/components/list-data/list-data.test.tsx +94 -0
  54. package/src/components/list-data/list-data.tsx +106 -0
  55. package/src/components/list-data/styles.ts +37 -0
  56. package/src/components/list-data/types.ts +25 -0
  57. package/src/components/measurement-tools/const.tsx +108 -0
  58. package/src/components/measurement-tools/icons.tsx +54 -0
  59. package/src/components/measurement-tools/measurement-tools.test.tsx +165 -0
  60. package/src/components/measurement-tools/measurement-tools.tsx +443 -0
  61. package/src/components/measurement-tools/styles.ts +91 -0
  62. package/src/components/measurement-tools/types.ts +77 -0
  63. package/src/components/no-data-alert/no-data-alert.test.tsx +31 -0
  64. package/src/components/no-data-alert/no-data-alert.tsx +59 -0
  65. package/src/components/responsive-drawer/responsive-drawer.test.tsx +91 -0
  66. package/src/components/responsive-drawer/responsive-drawer.tsx +53 -0
  67. package/src/components/smart-tooltip/smart-tooltip.test.tsx +168 -0
  68. package/src/components/smart-tooltip/smart-tooltip.tsx +40 -0
  69. package/src/components/tooltip/tooltip.test.tsx +86 -0
  70. package/src/components/tooltip/tooltip.tsx +30 -0
  71. package/src/components/types.ts +1 -0
  72. package/src/components/zoom-controls/styles.ts +27 -0
  73. package/src/components/zoom-controls/types.ts +19 -0
  74. package/src/components/zoom-controls/zoom-controls.test.tsx +101 -0
  75. package/src/components/zoom-controls/zoom-controls.tsx +114 -0
  76. package/src/hooks/index.ts +2 -0
  77. package/src/hooks/use-debounce.ts +55 -0
  78. package/src/hooks/use-skeleton.test.tsx +32 -0
  79. package/src/hooks/use-skeleton.ts +19 -0
  80. package/src/hooks/use-widget-ref.ts +33 -0
  81. package/src/widgets/README.md +277 -0
  82. package/src/widgets/_shared/chart-config/config-factory.ts +67 -0
  83. package/src/widgets/_shared/chart-config/csv-modifiers.ts +56 -0
  84. package/src/widgets/_shared/chart-config/index.ts +21 -0
  85. package/src/widgets/_shared/chart-config/option-builders.ts +203 -0
  86. package/src/widgets/_shared/skeleton/index.ts +5 -0
  87. package/src/widgets/_shared/skeleton/styles.ts +20 -0
  88. package/src/widgets/actions/change-column/change-column-icon.tsx +10 -0
  89. package/src/widgets/actions/change-column/change-column.test.tsx +163 -0
  90. package/src/widgets/actions/change-column/change-column.tsx +141 -0
  91. package/src/widgets/actions/change-column/sortable-column-item.tsx +49 -0
  92. package/src/widgets/actions/change-column/types.ts +20 -0
  93. package/src/widgets/actions/download/download.test.tsx +322 -0
  94. package/src/widgets/actions/download/download.tsx +118 -0
  95. package/src/widgets/actions/download/exports.test.tsx +275 -0
  96. package/src/widgets/actions/download/exports.tsx +103 -0
  97. package/src/widgets/actions/download/types.ts +21 -0
  98. package/src/widgets/actions/fullscreen/fullscreen.test.tsx +269 -0
  99. package/src/widgets/actions/fullscreen/fullscreen.tsx +82 -0
  100. package/src/widgets/actions/fullscreen/styles.ts +17 -0
  101. package/src/widgets/actions/fullscreen/types.ts +27 -0
  102. package/src/widgets/actions/index.ts +51 -0
  103. package/src/widgets/actions/lock-selection/lock-selection.test.tsx +186 -0
  104. package/src/widgets/actions/lock-selection/lock-selection.tsx +133 -0
  105. package/src/widgets/actions/lock-selection/types.ts +41 -0
  106. package/src/widgets/actions/relative-data/relative-data.test.tsx +267 -0
  107. package/src/widgets/actions/relative-data/relative-data.tsx +133 -0
  108. package/src/widgets/actions/relative-data/style.ts +9 -0
  109. package/src/widgets/actions/relative-data/types.ts +31 -0
  110. package/src/widgets/actions/relative-data/utils.test.ts +223 -0
  111. package/src/widgets/actions/relative-data/utils.ts +58 -0
  112. package/src/widgets/actions/searcher/searcher-toggle.test.tsx +354 -0
  113. package/src/widgets/actions/searcher/searcher-toggle.tsx +73 -0
  114. package/src/widgets/actions/searcher/searcher.tsx +205 -0
  115. package/src/widgets/actions/searcher/types.ts +72 -0
  116. package/src/widgets/actions/shared/styles.ts +12 -0
  117. package/src/widgets/actions/stack-toggle/grouped-bar-chart-icon.tsx +14 -0
  118. package/src/widgets/actions/stack-toggle/stack-toggle.test.tsx +270 -0
  119. package/src/widgets/actions/stack-toggle/stack-toggle.tsx +146 -0
  120. package/src/widgets/actions/stack-toggle/types.ts +29 -0
  121. package/src/widgets/actions/zoom-toggle/index.ts +2 -0
  122. package/src/widgets/actions/zoom-toggle/style.ts +14 -0
  123. package/src/widgets/actions/zoom-toggle/types.ts +44 -0
  124. package/src/widgets/actions/zoom-toggle/zoom-toggle.tsx +186 -0
  125. package/src/widgets/bar/config.ts +122 -0
  126. package/src/widgets/bar/index.ts +9 -0
  127. package/src/widgets/bar/skeleton.tsx +60 -0
  128. package/src/widgets/bar/style.ts +33 -0
  129. package/src/widgets/bar/types.ts +16 -0
  130. package/src/widgets/category/category-ui.test.tsx +399 -0
  131. package/src/widgets/category/category-ui.tsx +156 -0
  132. package/src/widgets/category/components/category-bar.tsx +28 -0
  133. package/src/widgets/category/components/category-legend.tsx +30 -0
  134. package/src/widgets/category/components/category-row-multi.tsx +50 -0
  135. package/src/widgets/category/components/category-row-other.tsx +23 -0
  136. package/src/widgets/category/components/category-row-single.tsx +47 -0
  137. package/src/widgets/category/components/index.ts +14 -0
  138. package/src/widgets/category/config.ts +85 -0
  139. package/src/widgets/category/index.ts +30 -0
  140. package/src/widgets/category/skeleton.tsx +24 -0
  141. package/src/widgets/category/style.ts +133 -0
  142. package/src/widgets/category/types.ts +40 -0
  143. package/src/widgets/echart/const.ts +1 -0
  144. package/src/widgets/echart/echart-ui.test.tsx +537 -0
  145. package/src/widgets/echart/echart-ui.tsx +92 -0
  146. package/src/widgets/echart/echart.test.tsx +562 -0
  147. package/src/widgets/echart/echart.tsx +68 -0
  148. package/src/widgets/echart/index.ts +16 -0
  149. package/src/widgets/echart/options.ts +53 -0
  150. package/src/widgets/echart/types.ts +45 -0
  151. package/src/widgets/echart/utils.ts +169 -0
  152. package/src/widgets/error/error.test.tsx +331 -0
  153. package/src/widgets/error/error.tsx +40 -0
  154. package/src/widgets/error/index.ts +2 -0
  155. package/src/widgets/error/types.ts +14 -0
  156. package/src/widgets/formula/components/item.test.tsx +249 -0
  157. package/src/widgets/formula/components/item.tsx +18 -0
  158. package/src/widgets/formula/components/prefix.test.tsx +341 -0
  159. package/src/widgets/formula/components/prefix.tsx +18 -0
  160. package/src/widgets/formula/components/row.test.tsx +364 -0
  161. package/src/widgets/formula/components/row.tsx +21 -0
  162. package/src/widgets/formula/components/series.tsx +34 -0
  163. package/src/widgets/formula/components/suffix.test.tsx +383 -0
  164. package/src/widgets/formula/components/suffix.tsx +28 -0
  165. package/src/widgets/formula/components/value.test.tsx +329 -0
  166. package/src/widgets/formula/components/value.tsx +29 -0
  167. package/src/widgets/formula/config.ts +27 -0
  168. package/src/widgets/formula/formula-ui.test.tsx +399 -0
  169. package/src/widgets/formula/formula-ui.tsx +27 -0
  170. package/src/widgets/formula/index.ts +24 -0
  171. package/src/widgets/formula/serializer.test.tsx +144 -0
  172. package/src/widgets/formula/serializer.ts +28 -0
  173. package/src/widgets/formula/skeleton.tsx +10 -0
  174. package/src/widgets/formula/style.ts +23 -0
  175. package/src/widgets/formula/types.ts +50 -0
  176. package/src/widgets/histogram/config.ts +143 -0
  177. package/src/widgets/histogram/index.ts +8 -0
  178. package/src/widgets/histogram/skeleton.tsx +52 -0
  179. package/src/widgets/histogram/style.ts +8 -0
  180. package/src/widgets/histogram/types.ts +17 -0
  181. package/src/widgets/index.ts +25 -0
  182. package/src/widgets/loader/index.ts +4 -0
  183. package/src/widgets/loader/loader.tsx +70 -0
  184. package/src/widgets/loader/types.ts +11 -0
  185. package/src/widgets/loader/utils.test.ts +112 -0
  186. package/src/widgets/loader/utils.ts +35 -0
  187. package/src/widgets/markdown/config.ts +18 -0
  188. package/src/widgets/markdown/index.ts +14 -0
  189. package/src/widgets/markdown/markdown-ui.test.tsx +341 -0
  190. package/src/widgets/markdown/markdown-ui.tsx +52 -0
  191. package/src/widgets/markdown/markdown.tsx +20 -0
  192. package/src/widgets/markdown/skeleton.tsx +12 -0
  193. package/src/widgets/markdown/style.ts +28 -0
  194. package/src/widgets/markdown/types.ts +28 -0
  195. package/src/widgets/no-data/index.ts +2 -0
  196. package/src/widgets/no-data/no-data.test.tsx +447 -0
  197. package/src/widgets/no-data/no-data.tsx +116 -0
  198. package/src/widgets/no-data/style.ts +18 -0
  199. package/src/widgets/no-data/types.ts +72 -0
  200. package/src/widgets/note/index.ts +2 -0
  201. package/src/widgets/note/note.test.tsx +391 -0
  202. package/src/widgets/note/note.tsx +114 -0
  203. package/src/widgets/note/style.ts +29 -0
  204. package/src/widgets/note/types.ts +9 -0
  205. package/src/widgets/pie/config.ts +177 -0
  206. package/src/widgets/pie/index.ts +8 -0
  207. package/src/widgets/pie/skeleton.tsx +70 -0
  208. package/src/widgets/pie/style.ts +8 -0
  209. package/src/widgets/pie/types.ts +16 -0
  210. package/src/widgets/range/components/range-item.tsx +213 -0
  211. package/src/widgets/range/config.ts +10 -0
  212. package/src/widgets/range/index.ts +16 -0
  213. package/src/widgets/range/range-ui.test.tsx +203 -0
  214. package/src/widgets/range/range-ui.tsx +11 -0
  215. package/src/widgets/range/serializer.test.ts +70 -0
  216. package/src/widgets/range/serializer.ts +27 -0
  217. package/src/widgets/range/skeleton.tsx +14 -0
  218. package/src/widgets/range/style.ts +37 -0
  219. package/src/widgets/range/types.ts +39 -0
  220. package/src/widgets/scatterplot/config.ts +138 -0
  221. package/src/widgets/scatterplot/index.ts +8 -0
  222. package/src/widgets/scatterplot/skeleton.tsx +59 -0
  223. package/src/widgets/scatterplot/style.ts +21 -0
  224. package/src/widgets/scatterplot/types.ts +17 -0
  225. package/src/widgets/selection-summary/index.ts +6 -0
  226. package/src/widgets/selection-summary/selection-summary.tsx +46 -0
  227. package/src/widgets/selection-summary/style.ts +10 -0
  228. package/src/widgets/selection-summary/types.ts +14 -0
  229. package/src/widgets/skeleton-loader/index.ts +2 -0
  230. package/src/widgets/skeleton-loader/skeleton-loader.test.tsx +139 -0
  231. package/src/widgets/skeleton-loader/skeleton-loader.tsx +28 -0
  232. package/src/widgets/skeleton-loader/types.ts +8 -0
  233. package/src/widgets/spread/components/max-value.tsx +29 -0
  234. package/src/widgets/spread/components/min-value.tsx +29 -0
  235. package/src/widgets/spread/components/separator.tsx +6 -0
  236. package/src/widgets/spread/config.ts +34 -0
  237. package/src/widgets/spread/index.ts +23 -0
  238. package/src/widgets/spread/skeleton.tsx +10 -0
  239. package/src/widgets/spread/spread-ui.test.tsx +368 -0
  240. package/src/widgets/spread/spread-ui.tsx +29 -0
  241. package/src/widgets/spread/style.ts +22 -0
  242. package/src/widgets/spread/types.ts +47 -0
  243. package/src/widgets/stores/index.ts +9 -0
  244. package/src/widgets/stores/types.ts +192 -0
  245. package/src/widgets/stores/widget-store.test.ts +601 -0
  246. package/src/widgets/stores/widget-store.ts +239 -0
  247. package/src/widgets/subheader/index.ts +3 -0
  248. package/src/widgets/subheader/style.ts +20 -0
  249. package/src/widgets/subheader/subheader.test.tsx +45 -0
  250. package/src/widgets/subheader/subheader.tsx +16 -0
  251. package/src/widgets/subheader/types.ts +11 -0
  252. package/src/widgets/table/components/cell-header.tsx +58 -0
  253. package/src/widgets/table/components/cell.tsx +80 -0
  254. package/src/widgets/table/components/index.ts +4 -0
  255. package/src/widgets/table/components/pagination-actions.tsx +67 -0
  256. package/src/widgets/table/components/pagination.tsx +41 -0
  257. package/src/widgets/table/components/row.tsx +60 -0
  258. package/src/widgets/table/config.ts +71 -0
  259. package/src/widgets/table/helpers.test.ts +244 -0
  260. package/src/widgets/table/helpers.ts +107 -0
  261. package/src/widgets/table/hooks/index.ts +7 -0
  262. package/src/widgets/table/hooks/use-pagination.test.ts +294 -0
  263. package/src/widgets/table/hooks/use-pagination.ts +155 -0
  264. package/src/widgets/table/hooks/use-selection.test.ts +504 -0
  265. package/src/widgets/table/hooks/use-selection.ts +189 -0
  266. package/src/widgets/table/hooks/use-sort.test.ts +296 -0
  267. package/src/widgets/table/hooks/use-sort.ts +138 -0
  268. package/src/widgets/table/index.ts +53 -0
  269. package/src/widgets/table/serializer.ts +54 -0
  270. package/src/widgets/table/skeleton.tsx +48 -0
  271. package/src/widgets/table/style.ts +34 -0
  272. package/src/widgets/table/table-ui.tsx +64 -0
  273. package/src/widgets/table/types.ts +223 -0
  274. package/src/widgets/timeseries/config.ts +135 -0
  275. package/src/widgets/timeseries/index.ts +8 -0
  276. package/src/widgets/timeseries/skeleton.tsx +55 -0
  277. package/src/widgets/timeseries/style.ts +36 -0
  278. package/src/widgets/timeseries/types.ts +17 -0
  279. package/src/widgets/toolbar-actions/index.ts +6 -0
  280. package/src/widgets/toolbar-actions/styles.ts +38 -0
  281. package/src/widgets/toolbar-actions/toolbar-actions.test.tsx +691 -0
  282. package/src/widgets/toolbar-actions/toolbar-actions.tsx +145 -0
  283. package/src/widgets/toolbar-actions/types.ts +60 -0
  284. package/src/widgets/wrapper/components/actions.test.tsx +101 -0
  285. package/src/widgets/wrapper/components/actions.tsx +30 -0
  286. package/src/widgets/wrapper/components/options.test.tsx +323 -0
  287. package/src/widgets/wrapper/components/options.tsx +73 -0
  288. package/src/widgets/wrapper/components/title.test.tsx +126 -0
  289. package/src/widgets/wrapper/components/title.tsx +32 -0
  290. package/src/widgets/wrapper/index.ts +16 -0
  291. package/src/widgets/wrapper/styles.ts +98 -0
  292. package/src/widgets/wrapper/types.ts +55 -0
  293. package/src/widgets/wrapper/wrapper-ui.test.tsx +232 -0
  294. package/src/widgets/wrapper/wrapper-ui.tsx +57 -0
  295. package/src/widgets/wrapper/wrapper.test.tsx +365 -0
  296. package/src/widgets/wrapper/wrapper.tsx +50 -0
  297. package/dist/lasso-tool-BwRzEW7k.js.map +0 -1
  298. package/dist/types/common/common.d.ts +0 -3
  299. package/dist/types/common/index.d.ts +0 -26
  300. package/dist/types/common/lasso-tools.d.ts +0 -36
  301. package/dist/types/common/measurement-tools.d.ts +0 -65
@@ -0,0 +1,322 @@
1
+ import { describe, test, expect, beforeEach, vi } from 'vitest'
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
3
+ import { Download } from './download'
4
+ import { useWidgetStore } from '../../../widgets'
5
+ import type { DownloadItem } from './types'
6
+
7
+ describe('Download', () => {
8
+ const widgetId = 'test-download-widget'
9
+
10
+ beforeEach(() => {
11
+ // Clear store before each test
12
+ useWidgetStore.getState().clearWidgets()
13
+ })
14
+
15
+ test('disables button when download items array is empty', () => {
16
+ render(<Download id={widgetId} items={[]} />)
17
+ const button = screen.getByRole('button', { name: 'download options' })
18
+ expect(button.hasAttribute('disabled')).toBeTruthy()
19
+ })
20
+
21
+ test('renders download button when download options exist', () => {
22
+ const mockDownload: DownloadItem[] = [
23
+ {
24
+ id: 'csv',
25
+ label: 'CSV',
26
+ modifier: vi.fn().mockResolvedValue('data:text/csv;base64,'),
27
+ },
28
+ ]
29
+
30
+ useWidgetStore.getState().setWidget(widgetId, {
31
+ data: [['a', 'b']],
32
+ })
33
+
34
+ render(<Download id={widgetId} items={mockDownload} />)
35
+
36
+ const button = screen.getByRole('button', { name: 'download options' })
37
+ expect(button).toBeTruthy()
38
+ })
39
+
40
+ test('uses custom aria label when provided', () => {
41
+ const mockDownload: DownloadItem[] = [
42
+ {
43
+ id: 'csv',
44
+ label: 'CSV',
45
+ modifier: vi.fn().mockResolvedValue('data:text/csv;base64,'),
46
+ },
47
+ ]
48
+
49
+ render(
50
+ <Download
51
+ id={widgetId}
52
+ items={mockDownload}
53
+ labels={{ ariaLabel: 'custom download' }}
54
+ />,
55
+ )
56
+
57
+ const button = screen.getByRole('button', { name: 'custom download' })
58
+ expect(button).toBeTruthy()
59
+ })
60
+
61
+ test('opens menu when button is clicked', async () => {
62
+ const mockDownload: DownloadItem[] = [
63
+ {
64
+ id: 'csv',
65
+ label: 'CSV',
66
+ modifier: vi.fn().mockResolvedValue('data:text/csv;base64,'),
67
+ },
68
+ {
69
+ id: 'png',
70
+ label: 'PNG',
71
+ modifier: vi.fn().mockResolvedValue('data:image/png;base64,'),
72
+ },
73
+ ]
74
+
75
+ render(<Download id={widgetId} items={mockDownload} />)
76
+
77
+ const button = screen.getByRole('button', { name: 'download options' })
78
+ fireEvent.click(button)
79
+
80
+ await waitFor(() => {
81
+ expect(screen.getByText('CSV')).toBeTruthy()
82
+ expect(screen.getByText('PNG')).toBeTruthy()
83
+ })
84
+ })
85
+
86
+ test('renders menu items with icons when provided', async () => {
87
+ const mockIcon = <span data-testid='test-icon'>Icon</span>
88
+ const mockDownload: DownloadItem[] = [
89
+ {
90
+ id: 'csv',
91
+ label: 'CSV',
92
+ icon: mockIcon,
93
+ modifier: vi.fn().mockResolvedValue('data:text/csv;base64,'),
94
+ },
95
+ ]
96
+
97
+ render(<Download id={widgetId} items={mockDownload} />)
98
+
99
+ const button = screen.getByRole('button', { name: 'download options' })
100
+ fireEvent.click(button)
101
+
102
+ await waitFor(() => {
103
+ expect(screen.getByTestId('test-icon')).toBeTruthy()
104
+ })
105
+ })
106
+
107
+ test('calls modifier and triggers download when menu item is clicked', async () => {
108
+ const mockModifier = vi.fn().mockResolvedValue('data:text/csv;base64,test')
109
+ const mockCallback = vi.fn()
110
+ const mockDownload: DownloadItem[] = [
111
+ {
112
+ id: 'csv',
113
+ label: 'CSV',
114
+ filename: 'test.csv',
115
+ modifier: mockModifier,
116
+ callback: mockCallback,
117
+ },
118
+ ]
119
+
120
+ useWidgetStore.getState().setWidget(widgetId, {
121
+ data: [['test', 'data']],
122
+ })
123
+
124
+ // Mock document methods
125
+ const createElementSpy = vi.spyOn(document, 'createElement')
126
+ const appendChildSpy = vi.spyOn(document.body, 'appendChild')
127
+ const removeChildSpy = vi.spyOn(document.body, 'removeChild')
128
+
129
+ render(<Download id={widgetId} items={mockDownload} />)
130
+
131
+ const button = screen.getByRole('button', { name: 'download options' })
132
+ fireEvent.click(button)
133
+
134
+ const csvMenuItem = await screen.findByText('CSV')
135
+ fireEvent.click(csvMenuItem)
136
+
137
+ await waitFor(() => {
138
+ expect(mockModifier).toHaveBeenCalledWith([['test', 'data']])
139
+ expect(createElementSpy).toHaveBeenCalledWith('a')
140
+ expect(mockCallback).toHaveBeenCalled()
141
+ })
142
+
143
+ createElementSpy.mockRestore()
144
+ appendChildSpy.mockRestore()
145
+ removeChildSpy.mockRestore()
146
+ })
147
+
148
+ test('shows loading spinner during download', async () => {
149
+ const mockModifier = vi.fn().mockImplementation(
150
+ () =>
151
+ new Promise((resolve) => {
152
+ setTimeout(() => resolve('data:text/csv;base64,test'), 100)
153
+ }),
154
+ )
155
+
156
+ const mockDownload: DownloadItem[] = [
157
+ {
158
+ id: 'csv',
159
+ label: 'CSV',
160
+ modifier: mockModifier,
161
+ },
162
+ ]
163
+
164
+ render(<Download id={widgetId} items={mockDownload} />)
165
+
166
+ const button = screen.getByRole('button', { name: 'download options' })
167
+ fireEvent.click(button)
168
+
169
+ const csvMenuItem = await screen.findByText('CSV')
170
+ fireEvent.click(csvMenuItem)
171
+
172
+ // Check that loading spinner is shown during download
173
+ await waitFor(() => {
174
+ expect(screen.getByRole('progressbar')).toBeTruthy()
175
+ })
176
+
177
+ // Wait for download to complete and spinner to disappear
178
+ await waitFor(
179
+ () => {
180
+ expect(screen.queryByRole('progressbar')).toBeNull()
181
+ },
182
+ { timeout: 200 },
183
+ )
184
+ })
185
+
186
+ test('handles disabled menu items', async () => {
187
+ const mockDownload: DownloadItem[] = [
188
+ {
189
+ id: 'csv',
190
+ label: 'CSV',
191
+ disabled: true,
192
+ modifier: vi.fn().mockResolvedValue('data:text/csv;base64,'),
193
+ },
194
+ ]
195
+
196
+ render(<Download id={widgetId} items={mockDownload} />)
197
+
198
+ const button = screen.getByRole('button', { name: 'download options' })
199
+ fireEvent.click(button)
200
+
201
+ const csvMenuItem = await screen.findByText('CSV')
202
+ expect(csvMenuItem.closest('li')?.getAttribute('aria-disabled')).toBe(
203
+ 'true',
204
+ )
205
+ })
206
+
207
+ test('closes menu after clicking a menu item', async () => {
208
+ const mockDownload: DownloadItem[] = [
209
+ {
210
+ id: 'csv',
211
+ label: 'CSV',
212
+ modifier: vi.fn().mockResolvedValue('data:text/csv;base64,'),
213
+ },
214
+ ]
215
+
216
+ render(<Download id={widgetId} items={mockDownload} />)
217
+
218
+ const button = screen.getByRole('button', { name: 'download options' })
219
+ fireEvent.click(button)
220
+
221
+ const csvMenuItem = await screen.findByText('CSV')
222
+ fireEvent.click(csvMenuItem)
223
+
224
+ await waitFor(() => {
225
+ expect(screen.queryByRole('menu')).toBeNull()
226
+ })
227
+ })
228
+
229
+ test('does not download if modifier returns undefined', async () => {
230
+ const mockModifier = vi.fn().mockResolvedValue(undefined)
231
+ const mockCallback = vi.fn()
232
+ const mockDownload: DownloadItem[] = [
233
+ {
234
+ id: 'csv',
235
+ label: 'CSV',
236
+ modifier: mockModifier,
237
+ callback: mockCallback,
238
+ },
239
+ ]
240
+
241
+ const createElementSpy = vi.spyOn(document, 'createElement')
242
+
243
+ render(<Download id={widgetId} items={mockDownload} />)
244
+
245
+ const button = screen.getByRole('button', { name: 'download options' })
246
+ fireEvent.click(button)
247
+
248
+ const csvMenuItem = await screen.findByText('CSV')
249
+ fireEvent.click(csvMenuItem)
250
+
251
+ await waitFor(() => {
252
+ expect(mockModifier).toHaveBeenCalled()
253
+ })
254
+
255
+ // Should not create download link (check for 'a' element specifically)
256
+ expect(createElementSpy).not.toHaveBeenCalledWith('a')
257
+ expect(mockCallback).not.toHaveBeenCalled()
258
+
259
+ createElementSpy.mockRestore()
260
+ })
261
+
262
+ test('stops event propagation on button click', () => {
263
+ const mockDownload: DownloadItem[] = [
264
+ {
265
+ id: 'csv',
266
+ label: 'CSV',
267
+ modifier: vi.fn().mockResolvedValue('data:text/csv;base64,'),
268
+ },
269
+ ]
270
+
271
+ const parentClickHandler = vi.fn()
272
+
273
+ render(
274
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
275
+ <div onClick={parentClickHandler}>
276
+ <Download id={widgetId} items={mockDownload} />
277
+ </div>,
278
+ )
279
+
280
+ const button = screen.getByRole('button', { name: 'download options' })
281
+ fireEvent.click(button)
282
+
283
+ expect(parentClickHandler).not.toHaveBeenCalled()
284
+ })
285
+
286
+ test('uses widget id as default filename when filename is not provided', async () => {
287
+ const mockModifier = vi.fn().mockResolvedValue('data:text/csv;base64,test')
288
+ const mockDownload: DownloadItem[] = [
289
+ {
290
+ id: 'csv',
291
+ label: 'CSV',
292
+ modifier: mockModifier,
293
+ },
294
+ ]
295
+
296
+ let capturedLink: HTMLAnchorElement | null = null
297
+ const originalCreateElement = document.createElement.bind(document)
298
+ const createElementSpy = vi
299
+ .spyOn(document, 'createElement')
300
+ .mockImplementation((tagName: string) => {
301
+ const element = originalCreateElement(tagName)
302
+ if (tagName === 'a') {
303
+ capturedLink = element as HTMLAnchorElement
304
+ }
305
+ return element
306
+ })
307
+
308
+ render(<Download id={widgetId} items={mockDownload} />)
309
+
310
+ const button = screen.getByRole('button', { name: 'download options' })
311
+ fireEvent.click(button)
312
+
313
+ const csvMenuItem = await screen.findByText('CSV')
314
+ fireEvent.click(csvMenuItem)
315
+
316
+ await waitFor(() => {
317
+ expect(capturedLink?.download).toBe(widgetId)
318
+ })
319
+
320
+ createElementSpy.mockRestore()
321
+ })
322
+ })
@@ -0,0 +1,118 @@
1
+ import {
2
+ CircularProgress,
3
+ IconButton,
4
+ ListItemIcon,
5
+ ListItemText,
6
+ Menu,
7
+ MenuItem,
8
+ } from '@mui/material'
9
+ import type { DownloadItem, DownloadProps } from './types'
10
+ import { FileDownloadOutlined } from '@mui/icons-material'
11
+ import { useState, type MouseEvent } from 'react'
12
+ import { useWidgetStore } from '../../../widgets'
13
+ import { useShallow } from 'zustand/shallow'
14
+
15
+ export function Download({
16
+ id,
17
+ items,
18
+ labels = {},
19
+ Icon,
20
+ IconButtonProps,
21
+ }: DownloadProps) {
22
+ const data = useWidgetStore(useShallow((state) => state.getWidget(id)?.data))
23
+
24
+ const [isDownloading, setIsDownloading] = useState(false)
25
+ const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
26
+
27
+ const handleToggle = (event: MouseEvent<HTMLElement>) => {
28
+ event.stopPropagation()
29
+ setAnchorEl(event.currentTarget)
30
+ }
31
+
32
+ const handleDownload = (data: string, option: DownloadItem) => {
33
+ const link = document.createElement('a')
34
+ link.href = data
35
+ link.download = option.filename ?? id
36
+ link.style.display = 'none'
37
+ document.body.appendChild(link)
38
+ link.click()
39
+ document.body.removeChild(link)
40
+ option.callback?.(link.href)
41
+ }
42
+
43
+ const handleClick = async (
44
+ event: MouseEvent<HTMLElement>,
45
+ option: DownloadItem,
46
+ ) => {
47
+ event.stopPropagation()
48
+
49
+ setIsDownloading(true)
50
+ setAnchorEl(null)
51
+
52
+ const result = await option.modifier(data)
53
+
54
+ if (!result) {
55
+ setIsDownloading(false)
56
+ return
57
+ }
58
+
59
+ handleDownload(result, option)
60
+ setIsDownloading(false)
61
+ }
62
+
63
+ return (
64
+ <>
65
+ <IconButton
66
+ size='small'
67
+ aria-label={labels.ariaLabel ?? 'download options'}
68
+ aria-controls='options-menu'
69
+ aria-haspopup='true'
70
+ onClick={handleToggle}
71
+ {...IconButtonProps}
72
+ disabled={isDownloading || items.length === 0}
73
+ className={isDownloading || anchorEl ? 'active' : ''}
74
+ >
75
+ {isDownloading ? (
76
+ <CircularProgress size={18} color='inherit' />
77
+ ) : (
78
+ (Icon ?? <FileDownloadOutlined />)
79
+ )}
80
+ </IconButton>
81
+ <Menu
82
+ variant='menu'
83
+ elevation={8}
84
+ anchorOrigin={{
85
+ vertical: 'bottom',
86
+ horizontal: 'right',
87
+ }}
88
+ transformOrigin={{
89
+ vertical: 'top',
90
+ horizontal: 'right',
91
+ }}
92
+ anchorEl={anchorEl}
93
+ open={Boolean(anchorEl)}
94
+ onClose={() => setAnchorEl(null)}
95
+ MenuListProps={{
96
+ sx: {
97
+ paddingBottom: 0,
98
+ },
99
+ }}
100
+ >
101
+ {items.map((option) => (
102
+ <MenuItem
103
+ key={option.id}
104
+ disabled={option.disabled}
105
+ onClick={(e) => void handleClick(e, option)}
106
+ >
107
+ {option.icon && (
108
+ <ListItemIcon sx={{ color: 'text.primary' }}>
109
+ {option.icon}
110
+ </ListItemIcon>
111
+ )}
112
+ <ListItemText>{option.label}</ListItemText>
113
+ </MenuItem>
114
+ ))}
115
+ </Menu>
116
+ </>
117
+ )
118
+ }
@@ -0,0 +1,275 @@
1
+ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { downloadToCSV, downloadToPNG } from './exports'
3
+ import { createRef } from 'react'
4
+ import type { RefObject } from 'react'
5
+
6
+ // Mock html2canvas
7
+ vi.mock('html2canvas', () => ({
8
+ default: vi.fn(),
9
+ }))
10
+
11
+ describe('downloadToCSV', () => {
12
+ test('has correct label', () => {
13
+ expect(downloadToCSV.label).toBe('CSV')
14
+ })
15
+
16
+ test('has icon', () => {
17
+ expect(downloadToCSV.icon).toBeTruthy()
18
+ })
19
+
20
+ test('converts data to CSV format', async () => {
21
+ const data: string[][] = [
22
+ ['Name', 'Age', 'City'],
23
+ ['John', '30', 'New York'],
24
+ ['Jane', '25', 'Los Angeles'],
25
+ ]
26
+
27
+ const result = await downloadToCSV.modifier(data)
28
+
29
+ expect(result).toBeTruthy()
30
+ expect(result).toContain('blob:')
31
+ })
32
+
33
+ test('creates blob URL from CSV data', async () => {
34
+ const data: string[][] = [
35
+ ['a', 'b'],
36
+ ['c', 'd'],
37
+ ]
38
+
39
+ const result = await downloadToCSV.modifier(data)
40
+
41
+ expect(result).toBeTruthy()
42
+ expect(typeof result).toBe('string')
43
+ expect(result!.startsWith('blob:')).toBe(true)
44
+ })
45
+
46
+ test('callback revokes object URL', () => {
47
+ const revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL')
48
+ const testUrl = 'blob:test-url'
49
+
50
+ downloadToCSV.callback?.(testUrl)
51
+
52
+ expect(revokeObjectURLSpy).toHaveBeenCalledWith(testUrl)
53
+
54
+ revokeObjectURLSpy.mockRestore()
55
+ })
56
+
57
+ test('handles empty data array', async () => {
58
+ const data: unknown[][] = []
59
+
60
+ const result = await downloadToCSV.modifier(data)
61
+
62
+ expect(result).toBeTruthy()
63
+ })
64
+
65
+ test('handles single row data', async () => {
66
+ const data: string[][] = [['single', 'row']]
67
+
68
+ const result = await downloadToCSV.modifier(data)
69
+
70
+ expect(result).toBeTruthy()
71
+ expect(result).toContain('blob:')
72
+ })
73
+
74
+ test('handles data with special characters', async () => {
75
+ const data: string[][] = [
76
+ ['Name', 'Description'],
77
+ ['Test', 'Hello, World!'],
78
+ ]
79
+
80
+ const result = await downloadToCSV.modifier(data)
81
+
82
+ expect(result).toBeTruthy()
83
+ })
84
+ })
85
+
86
+ describe('downloadToPNG', () => {
87
+ let mockRef: RefObject<HTMLDivElement>
88
+ let mockElement: HTMLDivElement
89
+
90
+ beforeEach(() => {
91
+ // Create a real DOM element
92
+ mockElement = document.createElement('div')
93
+ mockElement.style.width = '800px'
94
+ mockElement.style.height = '600px'
95
+ document.body.appendChild(mockElement)
96
+
97
+ mockRef = createRef() as RefObject<HTMLDivElement>
98
+ // Manually set the current property
99
+ Object.defineProperty(mockRef, 'current', {
100
+ writable: true,
101
+ value: mockElement,
102
+ })
103
+ })
104
+
105
+ afterEach(() => {
106
+ document.body.removeChild(mockElement)
107
+ vi.clearAllMocks()
108
+ })
109
+
110
+ test('returns download item with correct label', () => {
111
+ expect(downloadToPNG.label).toBe('PNG')
112
+ })
113
+
114
+ test('has icon', () => {
115
+ expect(downloadToPNG.icon).toBeTruthy()
116
+ })
117
+
118
+ test('modifier returns undefined when ref is null', async () => {
119
+ const nullRef = createRef() as RefObject<HTMLDivElement>
120
+
121
+ const result = await downloadToPNG.modifier(nullRef)
122
+
123
+ expect(result).toBeUndefined()
124
+ })
125
+
126
+ test('modifier returns undefined when ref is a function', async () => {
127
+ const functionRef = vi.fn() as unknown as RefObject<HTMLDivElement>
128
+
129
+ const result = await downloadToPNG.modifier(functionRef)
130
+
131
+ expect(result).toBeUndefined()
132
+ })
133
+
134
+ test('removes toolbar and actions containers from clone', async () => {
135
+ const html2canvas = (await import('html2canvas')).default as ReturnType<
136
+ typeof vi.fn
137
+ >
138
+
139
+ const toolbar = document.createElement('div')
140
+ toolbar.className = 'widget-toolbar-container'
141
+ mockElement.appendChild(toolbar)
142
+
143
+ const actions = document.createElement('div')
144
+ actions.className = 'widget-wrapper-actions'
145
+ mockElement.appendChild(actions)
146
+
147
+ const mockCanvas = document.createElement('canvas')
148
+ mockCanvas.toDataURL = vi.fn().mockReturnValue('data:image/png;base64,test')
149
+
150
+ let cloneElement: HTMLElement | undefined
151
+
152
+ html2canvas.mockImplementation((element: HTMLElement) => {
153
+ cloneElement = element
154
+ return mockCanvas
155
+ })
156
+
157
+ await downloadToPNG.modifier(mockRef)
158
+
159
+ // The clone should not have toolbar or actions
160
+ expect(cloneElement?.querySelector('.widget-toolbar-container')).toBeNull()
161
+ expect(cloneElement?.querySelector('.widget-wrapper-actions')).toBeNull()
162
+ })
163
+
164
+ test('does not modify original element', async () => {
165
+ const html2canvas = (await import('html2canvas')).default as ReturnType<
166
+ typeof vi.fn
167
+ >
168
+
169
+ const toolbar = document.createElement('div')
170
+ toolbar.className = 'widget-toolbar-container'
171
+ mockElement.appendChild(toolbar)
172
+
173
+ const actions = document.createElement('div')
174
+ actions.className = 'widget-wrapper-actions'
175
+ mockElement.appendChild(actions)
176
+
177
+ const mockCanvas = document.createElement('canvas')
178
+ mockCanvas.toDataURL = vi.fn().mockReturnValue('data:image/png;base64,test')
179
+
180
+ html2canvas.mockResolvedValue(mockCanvas)
181
+
182
+ await downloadToPNG.modifier(mockRef)
183
+
184
+ // Original element should still have toolbar and actions
185
+ expect(mockElement.querySelector('.widget-toolbar-container')).toBe(toolbar)
186
+ expect(mockElement.querySelector('.widget-wrapper-actions')).toBe(actions)
187
+ })
188
+
189
+ test('calls html2canvas with correct options', async () => {
190
+ const html2canvas = (await import('html2canvas')).default as ReturnType<
191
+ typeof vi.fn
192
+ >
193
+
194
+ const mockCanvas = document.createElement('canvas')
195
+ mockCanvas.toDataURL = vi.fn().mockReturnValue('data:image/png;base64,test')
196
+
197
+ html2canvas.mockResolvedValue(mockCanvas)
198
+
199
+ await downloadToPNG.modifier(mockRef)
200
+
201
+ expect(html2canvas).toHaveBeenCalledWith(
202
+ mockElement,
203
+ expect.objectContaining({
204
+ useCORS: true,
205
+ scale: 2,
206
+ backgroundColor: '#fff',
207
+ }),
208
+ )
209
+ })
210
+
211
+ test('returns PNG data URL', async () => {
212
+ const html2canvas = (await import('html2canvas')).default as ReturnType<
213
+ typeof vi.fn
214
+ >
215
+
216
+ const mockCanvas = document.createElement('canvas')
217
+ const testDataUrl = 'data:image/png;base64,testdata'
218
+ mockCanvas.toDataURL = vi.fn().mockReturnValue(testDataUrl)
219
+
220
+ html2canvas.mockResolvedValue(mockCanvas)
221
+
222
+ const result = await downloadToPNG.modifier(mockRef)
223
+
224
+ expect(result).toBe(testDataUrl)
225
+ })
226
+
227
+ test('calculates dimensions from clone element', async () => {
228
+ const html2canvas = (await import('html2canvas')).default as ReturnType<
229
+ typeof vi.fn
230
+ >
231
+
232
+ const toolbar = document.createElement('div')
233
+ toolbar.className = 'widget-toolbar-container'
234
+ toolbar.style.height = '50px'
235
+ mockElement.appendChild(toolbar)
236
+
237
+ const mockCanvas = document.createElement('canvas')
238
+ mockCanvas.toDataURL = vi.fn().mockReturnValue('data:image/png;base64,test')
239
+
240
+ let calledElement: HTMLElement | null = null
241
+
242
+ html2canvas.mockImplementation((element) => {
243
+ calledElement = element as HTMLElement
244
+ return mockCanvas
245
+ })
246
+
247
+ await downloadToPNG.modifier(mockRef)
248
+
249
+ // Should be called with a clone (different element)
250
+ expect(calledElement).not.toBe(mockElement)
251
+ expect(html2canvas).toHaveBeenCalledWith(
252
+ calledElement,
253
+ expect.objectContaining({
254
+ height: expect.any(Number) as number,
255
+ width: expect.any(Number) as number,
256
+ }),
257
+ )
258
+ })
259
+
260
+ test('handles element without toolbar or actions', async () => {
261
+ const html2canvas = (await import('html2canvas')).default as ReturnType<
262
+ typeof vi.fn
263
+ >
264
+
265
+ const mockCanvas = document.createElement('canvas')
266
+ mockCanvas.toDataURL = vi.fn().mockReturnValue('data:image/png;base64,test')
267
+
268
+ html2canvas.mockResolvedValue(mockCanvas)
269
+
270
+ const result = await downloadToPNG.modifier(mockRef)
271
+
272
+ expect(result).toBeTruthy()
273
+ expect(result).toContain('data:image/png')
274
+ })
275
+ })