@cdc/map 4.26.2 → 4.26.4

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 (118) hide show
  1. package/CONFIG.md +235 -0
  2. package/README.md +70 -24
  3. package/dist/cdcmap-CY9IcPSi.es.js +6 -0
  4. package/dist/cdcmap-DlpiY3fQ.es.js +4 -0
  5. package/dist/cdcmap.js +31260 -27946
  6. package/examples/{testing-layer-2.json → __data__/testing-layer-2.json} +1 -1
  7. package/examples/{testing-layer.json → __data__/testing-layer.json} +1 -1
  8. package/examples/county-hsa-toggle.json +51993 -0
  9. package/examples/custom-map-layers.json +2 -2
  10. package/examples/default-county.json +3 -3
  11. package/examples/minimal-example.json +69 -0
  12. package/examples/private/annotation-bug.json +642 -0
  13. package/examples/private/css-issue.json +314 -0
  14. package/examples/private/region-breaking.json +1639 -0
  15. package/examples/private/test1.json +27247 -0
  16. package/package.json +4 -4
  17. package/src/CdcMap.tsx +3 -14
  18. package/src/CdcMapComponent.tsx +302 -164
  19. package/src/_stories/CdcMap.Defaults.smoke.stories.tsx +76 -0
  20. package/src/_stories/CdcMap.Editor.ColumnsSectionTests.stories.tsx +601 -0
  21. package/src/_stories/CdcMap.Editor.DataTableSectionTests.stories.tsx +404 -0
  22. package/src/_stories/CdcMap.Editor.FiltersSectionTests.stories.tsx +229 -0
  23. package/src/_stories/CdcMap.Editor.GeneralSectionTests.stories.tsx +262 -0
  24. package/src/_stories/CdcMap.Editor.LegendSectionTests.stories.tsx +541 -0
  25. package/src/_stories/CdcMap.Editor.MultiCountryWorldMapTests.stories.tsx +359 -0
  26. package/src/_stories/CdcMap.Editor.PatternSettingsSectionTests.stories.tsx +516 -0
  27. package/src/_stories/CdcMap.Editor.SmallMultiplesSectionTests.stories.tsx +165 -0
  28. package/src/_stories/CdcMap.Editor.TextAnnotationsSectionTests.stories.tsx +145 -0
  29. package/src/_stories/CdcMap.Editor.TypeSectionTests.stories.tsx +312 -0
  30. package/src/_stories/CdcMap.Editor.VisualSectionTests.stories.tsx +359 -0
  31. package/src/_stories/CdcMap.Editor.ZoomControlsTests.stories.tsx +88 -0
  32. package/src/_stories/{CdcMap.stories.tsx → CdcMap.smoke.stories.tsx} +23 -1
  33. package/src/_stories/Map.HTMLInDataTable.stories.tsx +385 -0
  34. package/src/_stories/_mock/legends/legend-tests.json +3 -3
  35. package/src/_stories/_mock/multi-state-show-unselected.json +82 -0
  36. package/src/cdcMapComponent.styles.css +2 -2
  37. package/src/components/Annotation/Annotation.Draggable.styles.css +4 -4
  38. package/src/components/Annotation/AnnotationDropdown.styles.css +1 -1
  39. package/src/components/Annotation/AnnotationList.styles.css +13 -13
  40. package/src/components/Annotation/AnnotationList.tsx +1 -1
  41. package/src/components/EditorPanel/components/EditorPanel.tsx +905 -416
  42. package/src/components/EditorPanel/components/HexShapeSettings.tsx +1 -1
  43. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +112 -117
  44. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings-style.css +1 -1
  45. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +31 -15
  46. package/src/components/EditorPanel/components/editorPanel.styles.css +55 -25
  47. package/src/components/Legend/components/Legend.tsx +12 -7
  48. package/src/components/Legend/components/LegendGroup/legend.group.css +5 -5
  49. package/src/components/Legend/components/LegendItem.Hex.tsx +4 -2
  50. package/src/components/Legend/components/index.scss +2 -3
  51. package/src/components/NavigationMenu.tsx +2 -1
  52. package/src/components/SmallMultiples/SmallMultiples.css +5 -5
  53. package/src/components/SmallMultiples/SynchronizedTooltip.tsx +1 -1
  54. package/src/components/UsaMap/components/SingleState/SingleState.StateOutput.tsx +32 -17
  55. package/src/components/UsaMap/components/TerritoriesSection.tsx +3 -2
  56. package/src/components/UsaMap/components/Territory/Territory.Rectangle.tsx +13 -8
  57. package/src/components/UsaMap/components/UsaMap.County.tsx +629 -231
  58. package/src/components/UsaMap/components/UsaMap.Region.styles.css +1 -1
  59. package/src/components/UsaMap/components/UsaMap.SingleState.styles.css +2 -2
  60. package/src/components/UsaMap/components/UsaMap.State.tsx +14 -9
  61. package/src/components/UsaMap/data/cb_2019_us_county_20m.json +75817 -1
  62. package/src/components/UsaMap/data/hsa_fips_mapping.json +3144 -0
  63. package/src/components/WorldMap/WorldMap.tsx +10 -13
  64. package/src/components/WorldMap/data/world-topo-updated.json +1 -0
  65. package/src/components/WorldMap/data/world-topo.json +1 -1
  66. package/src/components/WorldMap/worldMap.styles.css +1 -1
  67. package/src/components/ZoomControls.tsx +49 -18
  68. package/src/components/zoomControls.styles.css +27 -11
  69. package/src/data/initial-state.js +15 -5
  70. package/src/data/legacy-defaults.ts +8 -0
  71. package/src/data/supported-counties.json +1 -1
  72. package/src/data/supported-geos.js +19 -0
  73. package/src/helpers/colors.ts +2 -1
  74. package/src/helpers/countyTerritories.ts +38 -0
  75. package/src/helpers/dataTableHelpers.ts +85 -0
  76. package/src/helpers/displayGeoName.ts +19 -11
  77. package/src/helpers/getMapContainerClasses.ts +8 -2
  78. package/src/helpers/getMatchingPatternForRow.ts +67 -0
  79. package/src/helpers/getPatternForRow.ts +11 -18
  80. package/src/helpers/tests/countyTerritories.test.ts +87 -0
  81. package/src/helpers/tests/dataTableHelpers.test.ts +78 -0
  82. package/src/helpers/tests/displayGeoName.test.ts +17 -0
  83. package/src/helpers/tests/getMatchingPatternForRow.test.ts +150 -0
  84. package/src/helpers/tests/getPatternForRow.test.ts +140 -2
  85. package/src/helpers/urlDataHelpers.ts +7 -1
  86. package/src/hooks/useApplyTooltipsToGeo.tsx +7 -4
  87. package/src/hooks/useMapLayers.tsx +1 -1
  88. package/src/hooks/useResizeObserver.ts +36 -22
  89. package/src/hooks/useTooltip.test.tsx +64 -0
  90. package/src/hooks/useTooltip.ts +46 -15
  91. package/src/scss/editor-panel.scss +1 -1
  92. package/src/scss/main.scss +140 -6
  93. package/src/scss/map.scss +9 -4
  94. package/src/store/map.actions.ts +5 -0
  95. package/src/store/map.reducer.ts +13 -0
  96. package/src/test/CdcMap.test.jsx +26 -2
  97. package/src/types/MapConfig.ts +28 -4
  98. package/src/types/MapContext.ts +5 -1
  99. package/topojson-updater/README.txt +1 -1
  100. package/dist/cdcmap-Cf9_fbQf.es.js +0 -6
  101. package/examples/__data__/city-state-data.json +0 -668
  102. package/examples/city-state.json +0 -434
  103. package/examples/default-world-data.json +0 -1450
  104. package/examples/new-cities.json +0 -656
  105. package/src/_stories/CdcMap.Editor.stories.tsx +0 -3475
  106. package/src/helpers/componentHelpers.ts +0 -8
  107. package/topojson-updater/package-lock.json +0 -223
  108. /package/src/_stories/{CdcMap.ColumnWrap.stories.tsx → CdcMap.ColumnWrap.smoke.stories.tsx} +0 -0
  109. /package/src/_stories/{CdcMap.DistrictOfColumbia.stories.tsx → CdcMap.DistrictOfColumbia.smoke.stories.tsx} +0 -0
  110. /package/src/_stories/{CdcMap.Filters.stories.tsx → CdcMap.Filters.smoke.stories.tsx} +0 -0
  111. /package/src/_stories/{CdcMap.Legend.Gradient.stories.tsx → CdcMap.Legend.Gradient.smoke.stories.tsx} +0 -0
  112. /package/src/_stories/{CdcMap.Legend.stories.tsx → CdcMap.Legend.smoke.stories.tsx} +0 -0
  113. /package/src/_stories/{CdcMap.Patterns.stories.tsx → CdcMap.Patterns.smoke.stories.tsx} +0 -0
  114. /package/src/_stories/{CdcMap.SmallMultiples.stories.tsx → CdcMap.SmallMultiples.smoke.stories.tsx} +0 -0
  115. /package/src/_stories/{CdcMap.Table.stories.tsx → CdcMap.Table.smoke.stories.tsx} +0 -0
  116. /package/src/_stories/{CdcMap.ZeroColor.stories.tsx → CdcMap.ZeroColor.smoke.stories.tsx} +0 -0
  117. /package/src/_stories/{GoogleMap.stories.tsx → GoogleMap.smoke.stories.tsx} +0 -0
  118. /package/src/_stories/{UsaMap.NoData.stories.tsx → UsaMap.NoData.smoke.stories.tsx} +0 -0
@@ -0,0 +1,541 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { within, userEvent, expect } from 'storybook/test'
3
+ import CdcMap from '../CdcMap'
4
+ import usaStateGradientConfig from './_mock/usa-state-gradient.json'
5
+ import multiCountryConfig from './_mock/multi-country.json'
6
+ import wastewaterMapSmallMultiplesConfig from './_mock/small_multiples/wastewater-map-small-multiples.json'
7
+ import { performAndAssert, waitForEditor, waitForPresence, openAccordion } from '@cdc/core/helpers/testing'
8
+
9
+ type Story = StoryObj<typeof CdcMap>
10
+
11
+ const mapMeta: Meta<typeof CdcMap> = {
12
+ title: 'Components/Templates/Map/Editor Tests',
13
+ component: CdcMap,
14
+ parameters: {
15
+ layout: 'fullscreen'
16
+ }
17
+ }
18
+
19
+ export default mapMeta
20
+
21
+ const DEFAULT_ARGS = {
22
+ isEditor: true,
23
+ config: usaStateGradientConfig
24
+ }
25
+
26
+ export const LegendSectionTests: Story = {
27
+ args: {
28
+ ...DEFAULT_ARGS
29
+ },
30
+ play: async ({ canvasElement }) => {
31
+ const canvas = within(canvasElement)
32
+
33
+ await waitForEditor(canvas)
34
+ await waitForPresence('.map-container', canvasElement)
35
+
36
+ await openAccordion(canvas, 'Legend')
37
+
38
+ // ==========================================================================
39
+ // TEST: Legend Type
40
+ // ==========================================================================
41
+ const legendTypeSelect = Array.from(canvasElement.querySelectorAll('select') || []).find(select => {
42
+ const label = select.closest('label')
43
+ const labelSpan = label?.querySelector('.edit-label')
44
+ return labelSpan?.textContent?.includes('Legend Type')
45
+ }) as HTMLSelectElement
46
+
47
+ const getLegendType = () => {
48
+ const legendContainer = canvasElement.querySelector('.legend-container')
49
+ // For gradient legend, get labels from SVG text elements
50
+ const textElements = Array.from(legendContainer?.querySelectorAll('text tspan') || [])
51
+ const labels = textElements.map(el => el.textContent?.trim() || '')
52
+ return {
53
+ legendHTML: legendContainer?.innerHTML || '',
54
+ labels
55
+ }
56
+ }
57
+
58
+ await performAndAssert(
59
+ 'Legend Type → Equal Interval',
60
+ getLegendType,
61
+ async () => {
62
+ await userEvent.selectOptions(legendTypeSelect, 'equalinterval')
63
+ },
64
+ (before, after) => {
65
+ // Equal interval creates evenly spaced ranges (e.g., "0 - < 43.33", "43.33 - < 86.67")
66
+ return after.labels.length > 0 && after.labels.join(',') !== before.labels.join(',')
67
+ }
68
+ )
69
+
70
+ await performAndAssert(
71
+ 'Legend Type → Equal Number',
72
+ getLegendType,
73
+ async () => {
74
+ await userEvent.selectOptions(legendTypeSelect, 'equalnumber')
75
+ },
76
+ (before, after) => {
77
+ // Equal number (quantiles) creates ranges with equal counts (e.g., "0 - 40", "40 - 57")
78
+ return after.labels.length > 0 && after.labels.join(',') !== before.labels.join(',')
79
+ }
80
+ )
81
+
82
+ // ==========================================================================
83
+ // TEST: Show Legend checkbox
84
+ // ==========================================================================
85
+ const showLegendCheckbox = canvas.getByLabelText('Show Legend')
86
+
87
+ const getLegendVisibility = () => {
88
+ const legendContainer = canvasElement.querySelector('.legend-container')
89
+ return {
90
+ legendExists: Boolean(legendContainer),
91
+ isVisible: legendContainer ? !legendContainer.classList.contains('hidden') : false
92
+ }
93
+ }
94
+
95
+ await performAndAssert(
96
+ 'Show Legend → Uncheck',
97
+ getLegendVisibility,
98
+ async () => {
99
+ await userEvent.click(showLegendCheckbox)
100
+ },
101
+ (before, after) => {
102
+ return before.isVisible && !after.isVisible
103
+ }
104
+ )
105
+
106
+ await performAndAssert(
107
+ 'Show Legend → Check',
108
+ getLegendVisibility,
109
+ async () => {
110
+ await userEvent.click(showLegendCheckbox)
111
+ },
112
+ (before, after) => {
113
+ return !before.isVisible && after.isVisible
114
+ }
115
+ )
116
+
117
+ // ==========================================================================
118
+ // TEST: Legend Position
119
+ // ==========================================================================
120
+ const legendPositionSelect = Array.from(canvasElement.querySelectorAll('select') || []).find(select => {
121
+ const label = select.closest('label')
122
+ const labelSpan = label?.querySelector('.edit-label')
123
+ return labelSpan?.textContent?.includes('Legend Position')
124
+ }) as HTMLSelectElement
125
+
126
+ const getLegendPosition = () => {
127
+ const legendAside = canvasElement.querySelector('aside[aria-label="Legend"]') as HTMLElement
128
+ return {
129
+ classes: legendAside ? Array.from(legendAside.classList) : [],
130
+ isBottom: legendAside?.classList.contains('bottom') ?? false,
131
+ isSide: legendAside?.classList.contains('side') ?? false,
132
+ isTop: legendAside?.classList.contains('top') ?? false
133
+ }
134
+ }
135
+
136
+ await performAndAssert(
137
+ 'Legend Position → Side',
138
+ getLegendPosition,
139
+ async () => {
140
+ await userEvent.selectOptions(legendPositionSelect, 'side')
141
+ },
142
+ (before, after) => {
143
+ return !before.isSide && after.isSide
144
+ }
145
+ )
146
+
147
+ await performAndAssert(
148
+ 'Legend Position → Top',
149
+ getLegendPosition,
150
+ async () => {
151
+ await userEvent.selectOptions(legendPositionSelect, 'top')
152
+ },
153
+ (before, after) => {
154
+ return !before.isTop && after.isTop
155
+ }
156
+ )
157
+
158
+ await performAndAssert(
159
+ 'Legend Position → Bottom',
160
+ getLegendPosition,
161
+ async () => {
162
+ await userEvent.selectOptions(legendPositionSelect, 'bottom')
163
+ },
164
+ (before, after) => {
165
+ return !before.isBottom && after.isBottom
166
+ }
167
+ )
168
+
169
+ // ==========================================================================
170
+ // TEST: Legend Style
171
+ // ==========================================================================
172
+ const legendStyleSelect = Array.from(canvasElement.querySelectorAll('select') || []).find(select => {
173
+ const label = select.closest('label')
174
+ const labelSpan = label?.querySelector('.edit-label')
175
+ return labelSpan?.textContent?.includes('Legend Style')
176
+ }) as HTMLSelectElement
177
+
178
+ const getLegendStyle = () => {
179
+ const legendContainer = canvasElement.querySelector('.legend-container')
180
+ const legendItems = legendContainer?.querySelectorAll('.legend-container__li')
181
+ const linearGradient = legendContainer?.querySelector('linearGradient')
182
+ const legendHTML = legendContainer?.innerHTML || ''
183
+
184
+ return {
185
+ hasLegendItems: (legendItems?.length ?? 0) > 0,
186
+ hasGradient: Boolean(linearGradient),
187
+ legendHTML
188
+ }
189
+ }
190
+
191
+ await performAndAssert(
192
+ 'Legend Style → Circles',
193
+ getLegendStyle,
194
+ async () => {
195
+ await userEvent.selectOptions(legendStyleSelect, 'circles')
196
+ },
197
+ (before, after) => {
198
+ return after.hasLegendItems && !after.hasGradient && after.legendHTML !== before.legendHTML
199
+ }
200
+ )
201
+
202
+ await performAndAssert(
203
+ 'Legend Style → Boxes',
204
+ getLegendStyle,
205
+ async () => {
206
+ await userEvent.selectOptions(legendStyleSelect, 'boxes')
207
+ },
208
+ (before, after) => {
209
+ return after.hasLegendItems && !after.hasGradient && after.legendHTML !== before.legendHTML
210
+ }
211
+ )
212
+
213
+ await performAndAssert(
214
+ 'Legend Style → Gradient',
215
+ getLegendStyle,
216
+ async () => {
217
+ await userEvent.selectOptions(legendStyleSelect, 'gradient')
218
+ },
219
+ (before, after) => {
220
+ return !before.hasGradient && after.hasGradient
221
+ }
222
+ )
223
+
224
+ // ==========================================================================
225
+ // TEST: Gradient Style (only visible when Legend Style is gradient)
226
+ // ==========================================================================
227
+ const gradientStyleSelect = Array.from(canvasElement.querySelectorAll('select') || []).find(select => {
228
+ const label = select.closest('label')
229
+ return label?.textContent?.includes('Gradient Style')
230
+ }) as HTMLSelectElement
231
+
232
+ const getGradientStyle = () => {
233
+ const legendContainer = canvasElement.querySelector('.legend-container')
234
+ const svgHTML = legendContainer?.querySelector('svg')?.outerHTML || ''
235
+
236
+ return {
237
+ svgHTML,
238
+ hasLinearGradient: svgHTML.includes('linearGradient')
239
+ }
240
+ }
241
+
242
+ await performAndAssert(
243
+ 'Gradient Style → Linear blocks',
244
+ getGradientStyle,
245
+ async () => {
246
+ await userEvent.selectOptions(gradientStyleSelect, 'linear blocks')
247
+ },
248
+ (before, after) => {
249
+ return after.hasLinearGradient && after.svgHTML !== before.svgHTML
250
+ }
251
+ )
252
+
253
+ await performAndAssert(
254
+ 'Gradient Style → Smooth',
255
+ getGradientStyle,
256
+ async () => {
257
+ await userEvent.selectOptions(gradientStyleSelect, 'smooth')
258
+ },
259
+ (before, after) => {
260
+ return after.hasLinearGradient && after.svgHTML !== before.svgHTML
261
+ }
262
+ )
263
+
264
+ // ==========================================================================
265
+ // TEST: Tick Rotation (only visible when Legend Style is gradient)
266
+ // ==========================================================================
267
+ const tickRotationInput = Array.from(canvasElement.querySelectorAll('input[type="number"]') || []).find(input => {
268
+ const label = input.closest('label')
269
+ const labelSpan = label?.querySelector('.edit-label')
270
+ return labelSpan?.textContent?.includes('Tick Rotation')
271
+ }) as HTMLInputElement
272
+
273
+ const getTickRotation = () => {
274
+ const legendContainer = canvasElement.querySelector('.legend-container')
275
+ const svgHTML = legendContainer?.querySelector('svg')?.outerHTML || ''
276
+ const textElements = Array.from(legendContainer?.querySelectorAll('text') || [])
277
+ const transforms = textElements.map(el => el.getAttribute('transform') || '')
278
+ return {
279
+ svgHTML,
280
+ transforms,
281
+ hasRotation: transforms.some(t => t.includes('rotate'))
282
+ }
283
+ }
284
+
285
+ await performAndAssert(
286
+ 'Tick Rotation → Set to 45 degrees',
287
+ getTickRotation,
288
+ async () => {
289
+ await userEvent.clear(tickRotationInput)
290
+ await userEvent.type(tickRotationInput, '45')
291
+ },
292
+ (before, after) => {
293
+ // Check that rotation transform is applied to text elements
294
+ return after.hasRotation && after.svgHTML !== before.svgHTML
295
+ }
296
+ )
297
+
298
+ // ==========================================================================
299
+ // TEST: Hide Legend Box
300
+ // ==========================================================================
301
+ const hideLegendBoxCheckbox = Array.from(canvasElement.querySelectorAll('input[type="checkbox"]') || []).find(
302
+ input => {
303
+ const label = input.closest('label')
304
+ const labelSpan = label?.querySelector('.edit-label')
305
+ return labelSpan?.textContent?.includes('Hide Legend Box')
306
+ }
307
+ ) as HTMLInputElement
308
+
309
+ const getLegendBorder = () => {
310
+ const legendAside = canvasElement.querySelector('aside[aria-label="Legend"]') as HTMLElement
311
+ return {
312
+ hasNoBorder: legendAside?.classList.contains('no-border') ?? false
313
+ }
314
+ }
315
+
316
+ await performAndAssert(
317
+ 'Hide Legend Box → Uncheck',
318
+ getLegendBorder,
319
+ async () => {
320
+ await userEvent.click(hideLegendBoxCheckbox)
321
+ },
322
+ (before, after) => {
323
+ return before.hasNoBorder && !after.hasNoBorder
324
+ }
325
+ )
326
+
327
+ await performAndAssert(
328
+ 'Hide Legend Box → Check',
329
+ getLegendBorder,
330
+ async () => {
331
+ await userEvent.click(hideLegendBoxCheckbox)
332
+ },
333
+ (before, after) => {
334
+ return !before.hasNoBorder && after.hasNoBorder
335
+ }
336
+ )
337
+
338
+ // ==========================================================================
339
+ // TEST: Vertical sorted legend
340
+ // ==========================================================================
341
+ // First switch to boxes style and side position to enable vertical sorting
342
+ await userEvent.selectOptions(legendStyleSelect, 'boxes')
343
+ await userEvent.selectOptions(legendPositionSelect, 'side')
344
+
345
+ const verticalSortedCheckbox = Array.from(canvasElement.querySelectorAll('input[type="checkbox"]') || []).find(
346
+ input => {
347
+ const label = input.closest('label')
348
+ const labelSpan = label?.querySelector('.edit-label')
349
+ return labelSpan?.textContent?.includes('Vertical sorted legend')
350
+ }
351
+ ) as HTMLInputElement
352
+
353
+ const getVerticalSorted = () => {
354
+ const legendUl = canvasElement.querySelector('.legend-container__ul')
355
+ return {
356
+ hasVerticalSorted: legendUl?.classList.contains('vertical-sorted') ?? false
357
+ }
358
+ }
359
+
360
+ await performAndAssert(
361
+ 'Vertical sorted legend → Check',
362
+ getVerticalSorted,
363
+ async () => {
364
+ await userEvent.click(verticalSortedCheckbox)
365
+ },
366
+ (before, after) => {
367
+ return !before.hasVerticalSorted && after.hasVerticalSorted
368
+ }
369
+ )
370
+
371
+ await performAndAssert(
372
+ 'Vertical sorted legend → Uncheck',
373
+ getVerticalSorted,
374
+ async () => {
375
+ await userEvent.click(verticalSortedCheckbox)
376
+ },
377
+ (before, after) => {
378
+ return before.hasVerticalSorted && !after.hasVerticalSorted
379
+ }
380
+ )
381
+
382
+ // NOTE: Show Special Classes Last is skipped because it doesn't produce
383
+ // visible changes with the current test data (no special classes present)
384
+
385
+ // ==========================================================================
386
+ // TEST: Separate Zero
387
+ // ==========================================================================
388
+ const separateZeroCheckbox = Array.from(canvasElement.querySelectorAll('input[type="checkbox"]') || []).find(
389
+ input => {
390
+ const label = input.closest('label')
391
+ const labelSpan = label?.querySelector('.edit-label')
392
+ return labelSpan?.textContent?.includes('Separate Zero')
393
+ }
394
+ ) as HTMLInputElement
395
+
396
+ const getSeparateZero = () => {
397
+ const legendContainer = canvasElement.querySelector('.legend-container')
398
+ const legendItems = Array.from(legendContainer?.querySelectorAll('.legend-container__li') || [])
399
+ const itemLabels = legendItems.map(item => item.textContent?.trim() || '')
400
+ const hasZeroItem = itemLabels.some(label => label.includes('0') && !label.match(/[1-9]/))
401
+ return {
402
+ itemCount: legendItems.length,
403
+ itemLabels,
404
+ hasZeroItem
405
+ }
406
+ }
407
+
408
+ await performAndAssert(
409
+ 'Separate Zero → Check',
410
+ getSeparateZero,
411
+ async () => {
412
+ await userEvent.click(separateZeroCheckbox)
413
+ },
414
+ (before, after) => {
415
+ // Should separate zero into its own legend item (e.g., "0" instead of "0 - 40")
416
+ return after.hasZeroItem && after.itemLabels.join(',') !== before.itemLabels.join(',')
417
+ }
418
+ )
419
+
420
+ await performAndAssert(
421
+ 'Separate Zero → Uncheck',
422
+ getSeparateZero,
423
+ async () => {
424
+ await userEvent.click(separateZeroCheckbox)
425
+ },
426
+ (before, after) => {
427
+ // Should merge zero back into a range
428
+ return !after.hasZeroItem && after.itemLabels.join(',') !== before.itemLabels.join(',')
429
+ }
430
+ )
431
+
432
+ // ==========================================================================
433
+ // TEST: Number of Items
434
+ // ==========================================================================
435
+ const numberOfItemsSelect = Array.from(canvasElement.querySelectorAll('select') || []).find(select => {
436
+ const label = select.closest('label')
437
+ const labelSpan = label?.querySelector('.edit-label')
438
+ return labelSpan?.textContent?.includes('Number of Items')
439
+ }) as HTMLSelectElement
440
+
441
+ const getNumberOfItems = () => {
442
+ const legendContainer = canvasElement.querySelector('.legend-container')
443
+ const legendItems = legendContainer?.querySelectorAll('.legend-container__li')
444
+ return {
445
+ itemCount: legendItems?.length ?? 0,
446
+ legendHTML: legendContainer?.innerHTML || ''
447
+ }
448
+ }
449
+
450
+ await performAndAssert(
451
+ 'Number of Items → Set to 5',
452
+ getNumberOfItems,
453
+ async () => {
454
+ await userEvent.selectOptions(numberOfItemsSelect, '5')
455
+ },
456
+ (before, after) => {
457
+ // Should have 5 legend items
458
+ return after.itemCount === 5 && after.itemCount !== before.itemCount
459
+ }
460
+ )
461
+
462
+ await performAndAssert(
463
+ 'Number of Items → Set to 3',
464
+ getNumberOfItems,
465
+ async () => {
466
+ await userEvent.selectOptions(numberOfItemsSelect, '3')
467
+ },
468
+ (before, after) => {
469
+ // Should have 3 legend items
470
+ return after.itemCount === 3 && after.itemCount !== before.itemCount
471
+ }
472
+ )
473
+
474
+ // Switch back to gradient for the legend title test
475
+ await userEvent.selectOptions(legendPositionSelect, 'bottom')
476
+ await userEvent.selectOptions(legendStyleSelect, 'gradient')
477
+
478
+ // ==========================================================================
479
+ // TEST: Legend Title
480
+ // ==========================================================================
481
+ const legendTitleInput = Array.from(canvasElement.querySelectorAll('input[type="text"]') || []).find(input => {
482
+ const label = input.closest('label')
483
+ return label?.textContent?.includes('Legend Title')
484
+ }) as HTMLInputElement
485
+
486
+ const getLegendTitle = () => {
487
+ const legendContainer = canvasElement.querySelector('.legend-container')
488
+ const titleElement = legendContainer?.querySelector('.legend-container__title, .legend-title')
489
+ return {
490
+ titleText: titleElement?.textContent?.trim() || '',
491
+ titleExists: Boolean(titleElement),
492
+ legendHTML: legendContainer?.innerHTML || ''
493
+ }
494
+ }
495
+
496
+ await performAndAssert(
497
+ 'Legend Title → Set custom title',
498
+ getLegendTitle,
499
+ async () => {
500
+ await userEvent.clear(legendTitleInput)
501
+ await userEvent.type(legendTitleInput, 'Custom Legend Title')
502
+ },
503
+ (before, after) => {
504
+ return after.titleText.includes('Custom Legend Title') && after.legendHTML !== before.legendHTML
505
+ }
506
+ )
507
+
508
+ // ==========================================================================
509
+ // TEST: Legend Description
510
+ // ==========================================================================
511
+ const legendDescriptionTextarea = Array.from(canvasElement.querySelectorAll('textarea') || []).find(textarea => {
512
+ const label = textarea.closest('label')
513
+ return label?.textContent?.includes('Legend Description')
514
+ }) as HTMLTextAreaElement
515
+
516
+ const getLegendDescription = () => {
517
+ const legendContainer = canvasElement.querySelector('.legend-container')
518
+ const descriptionElement = legendContainer?.querySelector('.legend-container__description, .legend-description')
519
+ return {
520
+ descriptionText: descriptionElement?.textContent?.trim() || '',
521
+ descriptionExists: Boolean(descriptionElement),
522
+ legendHTML: legendContainer?.innerHTML || ''
523
+ }
524
+ }
525
+
526
+ await performAndAssert(
527
+ 'Legend Description → Set custom description',
528
+ getLegendDescription,
529
+ async () => {
530
+ await userEvent.clear(legendDescriptionTextarea)
531
+ await userEvent.type(legendDescriptionTextarea, 'This is a custom legend description for testing.')
532
+ },
533
+ (before, after) => {
534
+ return (
535
+ after.descriptionText.includes('This is a custom legend description') &&
536
+ after.legendHTML !== before.legendHTML
537
+ )
538
+ }
539
+ )
540
+ }
541
+ }