@cdc/map 4.25.10 → 4.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/.claude/agents/typescript-organizer.md +118 -0
  2. package/dist/{cdcmap-fce76882.es.js → cdcmap-BnB1QM5d.es.js} +6 -13
  3. package/dist/{cdcmap-c55ac1ea.es.js → cdcmap-D6CG2-Hb.es.js} +5 -12
  4. package/dist/{cdcmap-31a33da1.es.js → cdcmap-MXgURbdZ.es.js} +6 -13
  5. package/dist/{cdcmap-1a1724a1.es.js → cdcmap-dgT_1dIT.es.js} +136 -151
  6. package/dist/cdcmap.js +58397 -55987
  7. package/examples/example-city-state.json +9 -1
  8. package/examples/multi-country-centering.json +45 -0
  9. package/examples/private/city_styles_variable.json +877 -0
  10. package/examples/private/colors-2.json +221 -0
  11. package/examples/private/colors.json +221 -0
  12. package/examples/private/map-filter-issue.json +2260 -0
  13. package/examples/private/map-legend.json +5303 -0
  14. package/index.html +27 -36
  15. package/package.json +6 -5
  16. package/src/CdcMapComponent.tsx +86 -26
  17. package/src/_stories/CdcMap.ColumnWrap.stories.tsx +31 -0
  18. package/src/_stories/CdcMap.DistrictOfColumbia.stories.tsx +320 -0
  19. package/src/_stories/CdcMap.Editor.stories.tsx +3426 -0
  20. package/src/_stories/CdcMap.SmallMultiples.stories.tsx +35 -0
  21. package/src/_stories/CdcMap.stories.tsx +116 -4
  22. package/src/_stories/_mock/column-wrap-test.json +265 -0
  23. package/src/_stories/_mock/multi-country-hide.json +78 -0
  24. package/src/_stories/_mock/multi-country.json +95 -0
  25. package/src/_stories/_mock/multi-state.json +887 -20403
  26. package/src/_stories/_mock/small_multiples/multi-state-small-multiples.json +8399 -0
  27. package/src/_stories/_mock/small_multiples/region-small-multiples.json +657 -0
  28. package/src/_stories/_mock/small_multiples/wastewater-map-small-multiples.json +221 -0
  29. package/src/_stories/_mock/usa-state-gradient.json +3 -4
  30. package/src/components/BubbleList.tsx +1 -1
  31. package/src/components/CityList.tsx +24 -18
  32. package/src/components/EditorPanel/components/EditorPanel.tsx +2380 -2206
  33. package/src/components/EditorPanel/components/HexShapeSettings.tsx +55 -93
  34. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +0 -19
  35. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +27 -37
  36. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +351 -0
  37. package/src/components/EditorPanel/components/Panels/index.tsx +3 -1
  38. package/src/components/Geo.tsx +20 -3
  39. package/src/components/Legend/components/Legend.tsx +58 -75
  40. package/src/components/Legend/components/LegendGroup/Legend.Group.tsx +1 -1
  41. package/src/components/Legend/components/index.scss +23 -6
  42. package/src/components/NavigationMenu.tsx +16 -13
  43. package/src/components/SmallMultiples/SmallMultipleTile.tsx +163 -0
  44. package/src/components/SmallMultiples/SmallMultiples.css +32 -0
  45. package/src/components/SmallMultiples/SmallMultiples.tsx +150 -0
  46. package/src/components/SmallMultiples/SynchronizedTooltip.tsx +105 -0
  47. package/src/components/SmallMultiples/index.tsx +3 -0
  48. package/src/components/UsaMap/components/SingleState/SingleState.CountyOutput.tsx +18 -3
  49. package/src/components/UsaMap/components/TerritoriesSection.tsx +26 -12
  50. package/src/components/UsaMap/components/Territory/Territory.Hexagon.tsx +30 -4
  51. package/src/components/UsaMap/components/Territory/Territory.Rectangle.tsx +29 -9
  52. package/src/components/UsaMap/components/Territory/TerritoryShape.ts +7 -0
  53. package/src/components/UsaMap/components/UsaMap.County.tsx +16 -4
  54. package/src/components/UsaMap/components/UsaMap.Region.tsx +14 -1
  55. package/src/components/UsaMap/components/UsaMap.SingleState.tsx +29 -12
  56. package/src/components/UsaMap/components/UsaMap.State.tsx +30 -5
  57. package/src/components/UsaMap/helpers/map.ts +2 -2
  58. package/src/components/UsaMap/helpers/shapes.ts +9 -6
  59. package/src/components/WorldMap/WorldMap.tsx +81 -11
  60. package/src/data/initial-state.js +11 -0
  61. package/src/data/supported-geos.js +8 -76
  62. package/src/helpers/addUIDs.ts +13 -2
  63. package/src/helpers/applyColorToLegend.ts +25 -1
  64. package/src/helpers/applyLegendToRow.ts +5 -3
  65. package/src/helpers/constants.ts +3 -15
  66. package/src/helpers/displayGeoName.ts +22 -4
  67. package/src/helpers/generateRuntimeFilters.ts +1 -1
  68. package/src/helpers/generateRuntimeLegend.ts +1 -3
  69. package/src/helpers/generateRuntimeLegendHash.ts +1 -1
  70. package/src/helpers/getCountriesPicked.ts +103 -0
  71. package/src/helpers/getMapContainerClasses.ts +7 -0
  72. package/src/helpers/getPatternForRow.ts +2 -5
  73. package/src/helpers/index.ts +2 -4
  74. package/src/helpers/isLegendItemDisabled.ts +2 -2
  75. package/src/helpers/resetLegendToggles.ts +1 -0
  76. package/src/helpers/smallMultiplesHelpers.ts +359 -0
  77. package/src/helpers/tests/hashObj.test.ts +1 -1
  78. package/src/helpers/tests/titleCase.test.ts +76 -0
  79. package/src/helpers/titleCase.ts +13 -13
  80. package/src/helpers/toggleLegendActive.ts +76 -8
  81. package/src/helpers/urlDataHelpers.ts +1 -1
  82. package/src/hooks/useCountryZoom.tsx +241 -0
  83. package/src/hooks/useGeoClickHandler.ts +1 -1
  84. package/src/hooks/useProgrammaticMapTooltip.ts +110 -0
  85. package/src/hooks/useResizeObserver.ts +8 -2
  86. package/src/hooks/useStateZoom.tsx +7 -4
  87. package/src/hooks/useSynchronizedGeographies.ts +56 -0
  88. package/src/index.jsx +1 -0
  89. package/src/scss/editor-panel.scss +4 -440
  90. package/src/scss/main.scss +1 -1
  91. package/src/scss/map.scss +12 -15
  92. package/src/store/map.actions.ts +7 -7
  93. package/src/test/CdcMap.test.jsx +1 -1
  94. package/src/types/MapConfig.ts +32 -11
  95. package/src/types/MapContext.ts +6 -0
  96. package/src/types/runtimeLegend.ts +2 -1
  97. package/LICENSE +0 -201
  98. package/src/components/DataTable.tsx +0 -413
  99. package/src/components/EditorPanel/components/Inputs.tsx +0 -59
  100. package/src/components/MapControls.tsx +0 -44
  101. package/src/helpers/getUniqueValues.ts +0 -19
  102. package/src/helpers/hashObj.ts +0 -25
  103. package/src/hooks/useActiveElement.ts +0 -19
  104. package/src/hooks/useLegendSeparators.ts +0 -26
  105. package/src/scss/mixins.scss +0 -47
  106. package/src/types/Annotations.ts +0 -24
  107. /package/dist/{cdcmap-548642e6.es.js → cdcmap-Ct2SB0vL.es.js} +0 -0
@@ -0,0 +1,3426 @@
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 TypeSectionTests: 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, 'Type')
37
+
38
+ const getMapContainerState = () => {
39
+ const container = canvasElement.querySelector('.map-container') as HTMLElement | null
40
+ const svg = canvasElement.querySelector('svg') as SVGElement | null
41
+
42
+ return {
43
+ classes: container ? Array.from(container.classList) : [],
44
+ hasSvg: Boolean(svg),
45
+ isBubble: container?.classList.contains('bubble') ?? false,
46
+ geoType: container?.classList.contains('us-county')
47
+ ? 'us-county'
48
+ : container?.classList.contains('us')
49
+ ? 'us'
50
+ : container?.classList.contains('single-state')
51
+ ? 'single-state'
52
+ : container?.classList.contains('world')
53
+ ? 'world'
54
+ : container?.classList.contains('us-region')
55
+ ? 'us-region'
56
+ : 'unknown'
57
+ }
58
+ }
59
+
60
+ // ==========================================================================
61
+ // TEST: Geography buttons change the geoType class on the map container
62
+ // ==========================================================================
63
+ const geoButtons = Array.from(canvasElement.querySelectorAll('.geo-buttons button')) as HTMLButtonElement[]
64
+ expect(geoButtons.length).toBeGreaterThanOrEqual(3)
65
+
66
+ const geographyButtonMap = geoButtons.reduce<Record<string, HTMLButtonElement>>((acc, button) => {
67
+ const label = button.textContent?.trim() ?? ''
68
+ acc[label.toLowerCase()] = button
69
+ return acc
70
+ }, {})
71
+
72
+ const targetButtons = [
73
+ { label: 'world', targetClasses: ['world'] },
74
+ { label: 'united states', targetClasses: ['us'] }
75
+ ]
76
+
77
+ for (const { label, targetClasses } of targetButtons) {
78
+ const button = geographyButtonMap[label]
79
+ expect(button).toBeTruthy()
80
+
81
+ await performAndAssert(
82
+ `GeoType → ${label}`,
83
+ getMapContainerState,
84
+ async () => {
85
+ await userEvent.click(button)
86
+ },
87
+ (before, after) =>
88
+ targetClasses.some(
89
+ targetClass => !before.classes.includes(targetClass) && after.classes.includes(targetClass)
90
+ )
91
+ )
92
+ }
93
+
94
+ // ==========================================================================
95
+ // TEST: Geography subtype select toggles county/state classes
96
+ // ==========================================================================
97
+ const subtypeSelect = canvas.getByLabelText(/Geography Subtype/i) as HTMLSelectElement
98
+ const subtypeValues = Array.from(subtypeSelect.options).map(option => option.value)
99
+ expect(subtypeValues).toContain('us-county')
100
+
101
+ await performAndAssert(
102
+ 'GeoType Subtype → US County',
103
+ getMapContainerState,
104
+ async () => {
105
+ await userEvent.selectOptions(subtypeSelect, 'us-county')
106
+ },
107
+ (before, after) => !before.classes.includes('us-county') && after.classes.includes('us-county')
108
+ )
109
+
110
+ await performAndAssert(
111
+ 'GeoType Subtype → Reset',
112
+ getMapContainerState,
113
+ async () => {
114
+ await userEvent.selectOptions(subtypeSelect, 'us')
115
+ },
116
+ (before, after) => before.classes.includes('us-county') && after.classes.includes('us')
117
+ )
118
+
119
+ // ==========================================================================
120
+ // TEST: Map Type select toggles classes/data representation
121
+ // ==========================================================================
122
+ // Use getAllByLabelText to avoid multiple elements error
123
+ const typeSelects = canvas.getAllByLabelText(/Map Type/i, { selector: 'select' }) as HTMLSelectElement[]
124
+ // Assume the first select is the correct one for the Type section
125
+ const typeSelect = typeSelects[0]
126
+ const initialType = typeSelect.value
127
+ const mapTypeOptions = Array.from(typeSelect.options).map(option => option.value)
128
+ expect(mapTypeOptions).toContain('navigation')
129
+
130
+ await performAndAssert(
131
+ 'Map Type → Navigation',
132
+ getMapContainerState,
133
+ async () => {
134
+ await userEvent.selectOptions(typeSelect, 'navigation')
135
+ },
136
+ (before, after) => !before.classes.includes('navigation') && after.classes.includes('navigation')
137
+ )
138
+
139
+ await performAndAssert(
140
+ 'Map Type → Reset',
141
+ getMapContainerState,
142
+ async () => {
143
+ await userEvent.selectOptions(typeSelect, initialType)
144
+ },
145
+ (before, after) =>
146
+ before.classes.includes('navigation') &&
147
+ !after.classes.includes('navigation') &&
148
+ after.classes.includes(initialType)
149
+ )
150
+
151
+ // ==========================================================================
152
+ // TEST: Data Classification Type radio buttons
153
+ // Verifies: Legend structure changes between numeric/quantitative and categorical
154
+ // ==========================================================================
155
+ const numericRadio = canvasElement.querySelector('input[type="radio"][value="equalnumber"]') as HTMLInputElement
156
+ const categoryRadio = canvasElement.querySelector('input[type="radio"][value="category"]') as HTMLInputElement
157
+ expect(numericRadio).toBeTruthy()
158
+ expect(categoryRadio).toBeTruthy()
159
+
160
+ const getLegendStructure = () => {
161
+ const legend = canvasElement.querySelector('.map-legend, .legend-container') as HTMLElement | null
162
+ const legendItems = canvasElement.querySelectorAll('.legend-item, .legend-container > div, .legend li')
163
+ const legendRects = legend?.querySelectorAll('rect')
164
+ const legendText = legend?.querySelectorAll('text')
165
+ return {
166
+ legendExists: Boolean(legend),
167
+ legendItemCount: legendItems.length,
168
+ legendRectCount: legendRects?.length || 0,
169
+ legendTextCount: legendText?.length || 0,
170
+ legendFullHTML: legend?.innerHTML || ''
171
+ }
172
+ }
173
+
174
+ await performAndAssert(
175
+ 'Classification Type → Category',
176
+ getLegendStructure,
177
+ async () => {
178
+ await userEvent.click(categoryRadio)
179
+ },
180
+ (before, after) =>
181
+ before.legendRectCount !== after.legendRectCount &&
182
+ before.legendTextCount !== after.legendTextCount &&
183
+ before.legendFullHTML !== after.legendFullHTML
184
+ )
185
+
186
+ await performAndAssert(
187
+ 'Classification Type → Numeric',
188
+ getLegendStructure,
189
+ async () => {
190
+ await userEvent.click(numericRadio)
191
+ },
192
+ (before, after) =>
193
+ before.legendRectCount !== after.legendRectCount &&
194
+ before.legendTextCount !== after.legendTextCount &&
195
+ before.legendFullHTML !== after.legendFullHTML
196
+ )
197
+
198
+ // ==========================================================================
199
+ // TEST: Display As Hex Map checkbox
200
+ // Verifies: Hexagon SVG polygons appear/disappear in map visualization
201
+ // ==========================================================================
202
+ const hexLabel = Array.from(canvasElement.querySelectorAll('label')).find(label =>
203
+ label.textContent?.includes('Display As Hex Map')
204
+ )
205
+ const actualHexCheckbox = hexLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement
206
+ expect(actualHexCheckbox).toBeTruthy()
207
+
208
+ const getHexVisualization = () => {
209
+ const hexElements = canvasElement.querySelectorAll('.territory-wrapper--hex, polygon[points*="22 0 44 12.702"]')
210
+ return {
211
+ hexElementCount: hexElements.length
212
+ }
213
+ }
214
+
215
+ await performAndAssert(
216
+ 'Display As Hex Map → Enable',
217
+ getHexVisualization,
218
+ async () => {
219
+ await userEvent.click(actualHexCheckbox)
220
+ },
221
+ (before, after) => before.hexElementCount === 0 && after.hexElementCount > 0
222
+ )
223
+
224
+ await performAndAssert(
225
+ 'Display As Hex Map → Disable',
226
+ getHexVisualization,
227
+ async () => {
228
+ await userEvent.click(actualHexCheckbox)
229
+ },
230
+ (before, after) => before.hexElementCount > 0 && after.hexElementCount === 0
231
+ )
232
+
233
+ // ==========================================================================
234
+ // TEST: Show state labels checkbox
235
+ // Verifies: State abbreviation text elements appear/disappear on map SVG
236
+ // ==========================================================================
237
+ const stateLabelsCheckbox = canvas.getByLabelText(/Show state labels/i) as HTMLInputElement
238
+ expect(stateLabelsCheckbox).toBeTruthy()
239
+
240
+ const getStateLabelsVisual = () => {
241
+ const mapSvg = canvasElement.querySelector('svg[role="img"]')
242
+ const textElements = mapSvg?.querySelectorAll('text')
243
+ // State labels are text elements with short state abbreviations (2 chars)
244
+ const stateLabelTexts = Array.from(textElements || []).filter(text => {
245
+ const content = text.textContent?.trim()
246
+ return content && content.length === 2 && /^[A-Z]{2}$/.test(content)
247
+ })
248
+ return {
249
+ stateLabelCount: stateLabelTexts.length
250
+ }
251
+ }
252
+
253
+ await performAndAssert(
254
+ 'Show State Labels → Enable',
255
+ getStateLabelsVisual,
256
+ async () => {
257
+ await userEvent.click(stateLabelsCheckbox)
258
+ },
259
+ (before, after) => before.stateLabelCount === 0 && after.stateLabelCount > 0
260
+ )
261
+
262
+ await performAndAssert(
263
+ 'Show State Labels → Disable',
264
+ getStateLabelsVisual,
265
+ async () => {
266
+ await userEvent.click(stateLabelsCheckbox)
267
+ },
268
+ (before, after) => before.stateLabelCount > 0 && after.stateLabelCount === 0
269
+ )
270
+
271
+ // ==========================================================================
272
+ // TEST: Show All Territories checkbox
273
+ // Verifies: Territory SVG elements appear/disappear in visualization
274
+ // ==========================================================================
275
+ const territoriesLabel = Array.from(canvasElement.querySelectorAll('label')).find(label =>
276
+ label.textContent?.includes('Show All Territories')
277
+ )
278
+ const territoriesCheckbox = territoriesLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement
279
+ expect(territoriesCheckbox).toBeTruthy()
280
+
281
+ const getTerritoriesVisual = () => {
282
+ const territorySection = canvasElement.querySelector('.territories')
283
+ const territorySvgs = territorySection?.querySelectorAll('svg')
284
+ return {
285
+ territorySvgCount: territorySvgs?.length || 0,
286
+ hasTerritorySection: Boolean(territorySection)
287
+ }
288
+ }
289
+
290
+ await performAndAssert(
291
+ 'Show All Territories → Enable',
292
+ getTerritoriesVisual,
293
+ async () => {
294
+ await userEvent.click(territoriesCheckbox)
295
+ },
296
+ (before, after) => before.territorySvgCount !== after.territorySvgCount
297
+ )
298
+
299
+ await performAndAssert(
300
+ 'Show All Territories → Disable',
301
+ getTerritoriesVisual,
302
+ async () => {
303
+ await userEvent.click(territoriesCheckbox)
304
+ },
305
+ (before, after) => before.territorySvgCount !== after.territorySvgCount
306
+ )
307
+ }
308
+ }
309
+
310
+ export const GeneralSectionTests: Story = {
311
+ args: {
312
+ ...DEFAULT_ARGS
313
+ },
314
+ play: async ({ canvasElement }) => {
315
+ const canvas = within(canvasElement)
316
+
317
+ await waitForEditor(canvas)
318
+ await waitForPresence('.map-container', canvasElement)
319
+
320
+ await openAccordion(canvas, 'General')
321
+
322
+ // ==========================================================================
323
+ // TEST: Title field
324
+ // Verifies: Title text appears in the Title component on the visualization
325
+ // ==========================================================================
326
+ const titleInput = canvasElement.querySelector('[data-testid="title-input"]') as HTMLInputElement
327
+ expect(titleInput).toBeTruthy()
328
+
329
+ const getTitleVisual = () => {
330
+ const titleElement = canvasElement.querySelector('.cove-title, .map-title')
331
+ return {
332
+ titleText: titleElement?.textContent || '',
333
+ hasTitleElement: Boolean(titleElement)
334
+ }
335
+ }
336
+
337
+ await performAndAssert(
338
+ 'Title → Update text',
339
+ getTitleVisual,
340
+ async () => {
341
+ await userEvent.clear(titleInput)
342
+ await userEvent.type(titleInput, 'Test Map Title')
343
+ },
344
+ (before, after) => before.titleText !== after.titleText && after.titleText.includes('Test Map Title')
345
+ )
346
+
347
+ // ==========================================================================
348
+ // TEST: Show Title checkbox
349
+ // Verifies: Title element visibility is controlled by showTitle
350
+ // ==========================================================================
351
+ const generalAccordion = canvasElement.querySelector('[aria-expanded="true"]')?.closest('.accordion__item')
352
+ const showTitleLabel = Array.from(generalAccordion?.querySelectorAll('label') || []).find(label =>
353
+ label.textContent?.includes('Show Title')
354
+ )
355
+ const showTitleCheckbox = showTitleLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement
356
+ expect(showTitleCheckbox).toBeTruthy()
357
+
358
+ const getTitleVisibility = () => {
359
+ const titleElement = canvasElement.querySelector('.cove-title, header.cove-component__header')
360
+ return {
361
+ isPresent: Boolean(titleElement)
362
+ }
363
+ }
364
+
365
+ // Test config has showTitle: true, so title starts visible (present in DOM)
366
+ await performAndAssert(
367
+ 'Show Title → Hide',
368
+ getTitleVisibility,
369
+ async () => {
370
+ await userEvent.click(showTitleCheckbox)
371
+ },
372
+ (before, after) => before.isPresent && !after.isPresent
373
+ )
374
+
375
+ await performAndAssert(
376
+ 'Show Title → Show',
377
+ getTitleVisibility,
378
+ async () => {
379
+ await userEvent.click(showTitleCheckbox)
380
+ },
381
+ (before, after) => !before.isPresent && after.isPresent
382
+ )
383
+
384
+ // ==========================================================================
385
+ // TEST: Super Title field
386
+ // Verifies: Super title text appears in the Title component
387
+ // ==========================================================================
388
+ const superTitleInput = canvas.getByLabelText(/Super Title/i) as HTMLInputElement
389
+ expect(superTitleInput).toBeTruthy()
390
+
391
+ const getSuperTitleVisual = () => {
392
+ const titleElement = canvasElement.querySelector('.cove-title, .map-title')
393
+ return {
394
+ titleText: titleElement?.textContent || ''
395
+ }
396
+ }
397
+
398
+ await performAndAssert(
399
+ 'Super Title → Add text',
400
+ getSuperTitleVisual,
401
+ async () => {
402
+ await userEvent.clear(superTitleInput)
403
+ await userEvent.type(superTitleInput, 'Super Title Text')
404
+ },
405
+ (before, after) => !before.titleText.includes('Super Title Text') && after.titleText.includes('Super Title Text')
406
+ )
407
+
408
+ // ==========================================================================
409
+ // TEST: Title Style dropdown
410
+ // Verifies: Changing title style changes the heading element (h2/h3) used
411
+ // ==========================================================================
412
+ const titleStyleSelect = canvas.getByLabelText(/Title Style/i) as HTMLSelectElement
413
+ expect(titleStyleSelect).toBeTruthy()
414
+
415
+ const getTitleStyleVisual = () => {
416
+ const coveTitleElement = canvasElement.querySelector('.cove-title')
417
+ const legacyTitleElement = canvasElement.querySelector('header.cove-component__header')
418
+
419
+ // For modern titles, check for h2 (large) or h3 (small) elements
420
+ const hasH2 = Boolean(coveTitleElement?.querySelector('h2'))
421
+ const hasH3 = Boolean(coveTitleElement?.querySelector('h3'))
422
+
423
+ return {
424
+ hasCoveTitle: Boolean(coveTitleElement),
425
+ hasLegacyTitle: Boolean(legacyTitleElement),
426
+ isSmall: hasH3,
427
+ isLarge: hasH2
428
+ }
429
+ }
430
+
431
+ // Current config has titleStyle: 'small'
432
+ // Test: Change to 'large'
433
+ await performAndAssert(
434
+ 'Title Style → Change to Large',
435
+ getTitleStyleVisual,
436
+ async () => {
437
+ await userEvent.selectOptions(titleStyleSelect, 'large')
438
+ },
439
+ (before, after) => before.isSmall && after.isLarge && after.hasCoveTitle && !after.hasLegacyTitle
440
+ )
441
+
442
+ // Test: Change to 'legacy'
443
+ await performAndAssert(
444
+ 'Title Style → Change to Legacy',
445
+ getTitleStyleVisual,
446
+ async () => {
447
+ await userEvent.selectOptions(titleStyleSelect, 'legacy')
448
+ },
449
+ (before, after) => before.hasCoveTitle && !after.hasCoveTitle && after.hasLegacyTitle
450
+ )
451
+
452
+ // Test: Change back to 'small'
453
+ await performAndAssert(
454
+ 'Title Style → Change back to Small',
455
+ getTitleStyleVisual,
456
+ async () => {
457
+ await userEvent.selectOptions(titleStyleSelect, 'small')
458
+ },
459
+ (before, after) => before.hasLegacyTitle && !after.hasLegacyTitle && after.isSmall && after.hasCoveTitle
460
+ )
461
+
462
+ // ==========================================================================
463
+ // TEST: Message/Intro Text field
464
+ // Verifies: Intro text appears in section with class 'introText'
465
+ // ==========================================================================
466
+ const messageInput = canvas.getByLabelText(/Message/i) as HTMLTextAreaElement
467
+ expect(messageInput).toBeTruthy()
468
+
469
+ const getIntroTextVisual = () => {
470
+ const introSection = canvasElement.querySelector('.introText')
471
+ return {
472
+ introText: introSection?.textContent || '',
473
+ hasIntroSection: Boolean(introSection)
474
+ }
475
+ }
476
+
477
+ await performAndAssert(
478
+ 'Message → Add intro text',
479
+ getIntroTextVisual,
480
+ async () => {
481
+ await userEvent.clear(messageInput)
482
+ await userEvent.type(messageInput, 'This is test intro text')
483
+ },
484
+ (before, after) =>
485
+ !before.introText.includes('This is test intro text') &&
486
+ after.introText.includes('This is test intro text') &&
487
+ after.hasIntroSection
488
+ )
489
+
490
+ // ==========================================================================
491
+ // TEST: Subtext field
492
+ // Verifies: Subtext appears in paragraph with class 'subtext'
493
+ // ==========================================================================
494
+ const subtextInput = canvas.getByLabelText(/Subtext/i) as HTMLTextAreaElement
495
+ expect(subtextInput).toBeTruthy()
496
+
497
+ const getSubtextVisual = () => {
498
+ const subtextElement = canvasElement.querySelector('.subtext')
499
+ return {
500
+ subtextContent: subtextElement?.textContent || '',
501
+ hasSubtext: Boolean(subtextElement)
502
+ }
503
+ }
504
+
505
+ await performAndAssert(
506
+ 'Subtext → Add text',
507
+ getSubtextVisual,
508
+ async () => {
509
+ await userEvent.clear(subtextInput)
510
+ await userEvent.type(subtextInput, 'This is test subtext')
511
+ },
512
+ (before, after) =>
513
+ !before.subtextContent.includes('This is test subtext') &&
514
+ after.subtextContent.includes('This is test subtext') &&
515
+ after.hasSubtext
516
+ )
517
+
518
+ // ==========================================================================
519
+ // TEST: Footnotes field
520
+ // Verifies: Footnotes appear in section with class 'footnotes'
521
+ // ==========================================================================
522
+ const footnotesInput = canvas.getByLabelText(/Footnotes/i) as HTMLTextAreaElement
523
+ expect(footnotesInput).toBeTruthy()
524
+
525
+ const getFootnotesVisual = () => {
526
+ const footnotesSection = canvasElement.querySelector('.footnotes')
527
+ return {
528
+ footnotesContent: footnotesSection?.textContent || '',
529
+ hasFootnotes: Boolean(footnotesSection)
530
+ }
531
+ }
532
+
533
+ await performAndAssert(
534
+ 'Footnotes → Add text',
535
+ getFootnotesVisual,
536
+ async () => {
537
+ await userEvent.clear(footnotesInput)
538
+ await userEvent.type(footnotesInput, 'Test footnote text')
539
+ },
540
+ (before, after) =>
541
+ !before.footnotesContent.includes('Test footnote text') &&
542
+ after.footnotesContent.includes('Test footnote text') &&
543
+ after.hasFootnotes
544
+ )
545
+ }
546
+ }
547
+
548
+ export const ColumnsSectionTests: Story = {
549
+ args: {
550
+ ...DEFAULT_ARGS
551
+ },
552
+ play: async ({ canvasElement }) => {
553
+ const canvas = within(canvasElement)
554
+
555
+ await waitForEditor(canvas)
556
+ await waitForPresence('.map-container', canvasElement)
557
+
558
+ await openAccordion(canvas, 'Columns')
559
+
560
+ // ==========================================================================
561
+ // TEST: Geography column select
562
+ // Verifies: Changing geo column to invalid data makes map gray and legend shows "No data"
563
+ // ==========================================================================
564
+ const columnsAccordion = canvasElement.querySelector('[aria-expanded="true"]')?.closest('.accordion__item')
565
+ const geoColumnLabel = Array.from(columnsAccordion?.querySelectorAll('label') || []).find(label => {
566
+ const columnHeading = label.querySelector('.edit-label.column-heading')
567
+ return columnHeading?.textContent?.includes('Geography')
568
+ })
569
+ const geoSelect = geoColumnLabel?.querySelector('select') as HTMLSelectElement
570
+ expect(geoSelect).toBeTruthy()
571
+
572
+ const getMapAndLegendState = () => {
573
+ const legendContainer = canvasElement.querySelector('.legend-container')
574
+ const legendSvg = legendContainer?.querySelector('svg')
575
+ const gradientStops = legendSvg?.querySelectorAll('linearGradient stop')
576
+
577
+ // Gradient legend has color stops when data is valid
578
+ const hasGradientStops = (gradientStops?.length || 0) > 0
579
+
580
+ return {
581
+ legendHTML: legendContainer?.innerHTML || '',
582
+ hasGradientStops: hasGradientStops,
583
+ gradientStopCount: gradientStops?.length || 0
584
+ }
585
+ }
586
+
587
+ // Change to a column that's NOT valid geography data (Rate instead of STATE)
588
+ await performAndAssert(
589
+ 'Geography Column → Change to invalid column',
590
+ getMapAndLegendState,
591
+ async () => {
592
+ await userEvent.selectOptions(geoSelect, 'Rate')
593
+ },
594
+ (before, after) => {
595
+ // Legend gradient should lose color stops when geo column is invalid
596
+ return before.hasGradientStops && !after.hasGradientStops
597
+ }
598
+ )
599
+
600
+ // Change back to valid geography column
601
+ await performAndAssert(
602
+ 'Geography Column → Change back to valid column',
603
+ getMapAndLegendState,
604
+ async () => {
605
+ await userEvent.selectOptions(geoSelect, 'STATE')
606
+ },
607
+ (before, after) => {
608
+ // Legend gradient should regain color stops when geo column is valid
609
+ return !before.hasGradientStops && after.hasGradientStops
610
+ }
611
+ )
612
+
613
+ // ==========================================================================
614
+ // TEST: Hide Geography Column Name in Tooltip checkbox
615
+ // Verifies: Tooltip content changes when geography label is hidden/shown
616
+ // ==========================================================================
617
+ const hideGeoLabel = Array.from(columnsAccordion?.querySelectorAll('label') || []).find(label =>
618
+ label.textContent?.includes('Hide Geography Column Name in Tooltip')
619
+ )
620
+ const hideGeoCheckbox = hideGeoLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement
621
+ expect(hideGeoCheckbox).toBeTruthy()
622
+
623
+ const getTooltipContent = () => {
624
+ // Get a state geo-group and check its tooltip HTML
625
+ const geoGroup = canvasElement.querySelector('g.geo-group') as SVGGElement
626
+ const tooltipHtml = geoGroup?.getAttribute('data-tooltip-html') || ''
627
+ return {
628
+ tooltipContent: tooltipHtml,
629
+ hasStatePrefix: tooltipHtml.includes('State:')
630
+ }
631
+ }
632
+
633
+ await performAndAssert(
634
+ 'Hide Geography Column Name → Enable',
635
+ getTooltipContent,
636
+ async () => {
637
+ await userEvent.click(hideGeoCheckbox)
638
+ },
639
+ (before, after) => {
640
+ // "State:" prefix should disappear when hidden
641
+ return before.hasStatePrefix && !after.hasStatePrefix
642
+ }
643
+ )
644
+
645
+ await performAndAssert(
646
+ 'Hide Geography Column Name → Disable',
647
+ getTooltipContent,
648
+ async () => {
649
+ await userEvent.click(hideGeoCheckbox)
650
+ },
651
+ (before, after) => {
652
+ // "State:" prefix should reappear when not hidden
653
+ return !before.hasStatePrefix && after.hasStatePrefix
654
+ }
655
+ )
656
+
657
+ // ==========================================================================
658
+ // TEST: Geography Label field
659
+ // Verifies: Custom geography label appears in tooltip instead of default "State:"
660
+ // ==========================================================================
661
+ const geoLabelInput = canvas.getByLabelText(/Geography Label/i) as HTMLInputElement
662
+ expect(geoLabelInput).toBeTruthy()
663
+
664
+ await performAndAssert(
665
+ 'Geography Label → Add custom label',
666
+ getTooltipContent,
667
+ async () => {
668
+ await userEvent.clear(geoLabelInput)
669
+ await userEvent.type(geoLabelInput, 'Region')
670
+ },
671
+ (before, after) => {
672
+ // Custom label "Region:" should appear in tooltip
673
+ return !before.tooltipContent.includes('Region:') && after.tooltipContent.includes('Region:')
674
+ }
675
+ )
676
+
677
+ // ==========================================================================
678
+ // TEST: Data Column select
679
+ // Verifies: Changing data column changes tooltip data values
680
+ // ==========================================================================
681
+ const dataColumnLabel = Array.from(columnsAccordion?.querySelectorAll('label') || []).find(label =>
682
+ label.textContent?.includes('Data Column')
683
+ )
684
+ const dataColumnSelect = dataColumnLabel?.querySelector('select') as HTMLSelectElement
685
+ expect(dataColumnSelect).toBeTruthy()
686
+
687
+ const getDataTooltipContent = () => {
688
+ const geoGroup = canvasElement.querySelector('g.geo-group') as SVGGElement
689
+ const tooltipHtml = geoGroup?.getAttribute('data-tooltip-html') || ''
690
+ return {
691
+ tooltipContent: tooltipHtml
692
+ }
693
+ }
694
+
695
+ await performAndAssert(
696
+ 'Data Column → Change to STATE column',
697
+ getDataTooltipContent,
698
+ async () => {
699
+ await userEvent.selectOptions(dataColumnSelect, 'STATE')
700
+ },
701
+ (before, after) => {
702
+ // Tooltip should show STATE values (e.g., state names) instead of Rate values (numbers)
703
+ const beforeHasNumbers = /\d+/.test(before.tooltipContent)
704
+ const afterHasStateNames = /Alabama|Alaska|Arizona|Arkansas|California/.test(after.tooltipContent)
705
+ return beforeHasNumbers && afterHasStateNames
706
+ }
707
+ )
708
+
709
+ // Change back to Rate
710
+ await performAndAssert(
711
+ 'Data Column → Change back to Rate',
712
+ getDataTooltipContent,
713
+ async () => {
714
+ await userEvent.selectOptions(dataColumnSelect, 'Rate')
715
+ },
716
+ (before, after) => {
717
+ // Tooltip should show numeric Rate values again
718
+ const beforeHasStateNames = /Alabama|Alaska|Arizona|Arkansas|California/.test(before.tooltipContent)
719
+ const afterHasNumbers = /\d+/.test(after.tooltipContent)
720
+ return beforeHasStateNames && afterHasNumbers
721
+ }
722
+ )
723
+
724
+ // ==========================================================================
725
+ // TEST: Hide Data Column Name in Tooltip
726
+ // Verifies: Data column label disappears from tooltip when hidden
727
+ // ==========================================================================
728
+ const hideDataLabel = Array.from(columnsAccordion?.querySelectorAll('label') || []).find(label =>
729
+ label.textContent?.includes('Hide Data Column Name in Tooltip')
730
+ )
731
+ const hideDataCheckbox = hideDataLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement
732
+ expect(hideDataCheckbox).toBeTruthy()
733
+
734
+ await performAndAssert(
735
+ 'Hide Data Column Name → Enable',
736
+ getDataTooltipContent,
737
+ async () => {
738
+ await userEvent.click(hideDataCheckbox)
739
+ },
740
+ (before, after) => {
741
+ // The label prefix (e.g., "Rate:" or similar) should be removed
742
+ // Before should have a colon pattern indicating a label, after should have less structure
743
+ const beforeHasLabelStructure = before.tooltipContent.includes(':</li>') || before.tooltipContent.includes(': ')
744
+ const afterHasLessStructure = after.tooltipContent.length < before.tooltipContent.length
745
+ return beforeHasLabelStructure && afterHasLessStructure
746
+ }
747
+ )
748
+
749
+ await performAndAssert(
750
+ 'Hide Data Column Name → Disable',
751
+ getDataTooltipContent,
752
+ async () => {
753
+ await userEvent.click(hideDataCheckbox)
754
+ },
755
+ (before, after) => {
756
+ // The label prefix should reappear
757
+ const beforeHasLessStructure = before.tooltipContent.length < after.tooltipContent.length
758
+ const afterHasLabelStructure = after.tooltipContent.includes(':</li>') || after.tooltipContent.includes(': ')
759
+ return beforeHasLessStructure && afterHasLabelStructure
760
+ }
761
+ )
762
+
763
+ // ==========================================================================
764
+ // TEST: Data Label field
765
+ // Verifies: Custom data label appears in tooltip
766
+ // ==========================================================================
767
+ const dataLabelInputs = Array.from(columnsAccordion?.querySelectorAll('input') || [])
768
+ const dataLabelInput = dataLabelInputs.find(input => {
769
+ const label = input.closest('label')
770
+ return label?.textContent?.includes('Data Label')
771
+ }) as HTMLInputElement
772
+ expect(dataLabelInput).toBeTruthy()
773
+
774
+ await performAndAssert(
775
+ 'Data Label → Add custom label',
776
+ getDataTooltipContent,
777
+ async () => {
778
+ await userEvent.clear(dataLabelInput)
779
+ await userEvent.type(dataLabelInput, 'Value')
780
+ },
781
+ (before, after) => {
782
+ // Custom label "Value:" should appear in tooltip
783
+ return !before.tooltipContent.includes('Value:') && after.tooltipContent.includes('Value:')
784
+ }
785
+ )
786
+
787
+ // ==========================================================================
788
+ // TEST: Prefix field
789
+ // Verifies: Prefix appears before data values in both tooltip and legend
790
+ // ==========================================================================
791
+ const prefixInputs = Array.from(columnsAccordion?.querySelectorAll('input') || [])
792
+ const prefixInput = prefixInputs.find(input => {
793
+ const label = input.closest('label')
794
+ return label?.textContent?.includes('Prefix')
795
+ }) as HTMLInputElement
796
+ expect(prefixInput).toBeTruthy()
797
+
798
+ const getPrefixVisualization = () => {
799
+ const geoGroup = canvasElement.querySelector('g.geo-group') as SVGGElement
800
+ const tooltipHtml = geoGroup?.getAttribute('data-tooltip-html') || ''
801
+ const legendContainer = canvasElement.querySelector('.legend-container')
802
+ const legendSvg = legendContainer?.querySelector('svg')
803
+ const legendText = legendSvg?.textContent || ''
804
+
805
+ return {
806
+ tooltipHasPrefix: tooltipHtml.includes('$'),
807
+ legendHasPrefix: legendText.includes('$'),
808
+ tooltipContent: tooltipHtml,
809
+ legendContent: legendText
810
+ }
811
+ }
812
+
813
+ await performAndAssert(
814
+ 'Prefix → Add dollar sign',
815
+ getPrefixVisualization,
816
+ async () => {
817
+ await userEvent.clear(prefixInput)
818
+ await userEvent.type(prefixInput, '$')
819
+ },
820
+ (before, after) => {
821
+ // Dollar sign should appear in both tooltip and legend
822
+ return !before.tooltipHasPrefix && after.tooltipHasPrefix && !before.legendHasPrefix && after.legendHasPrefix
823
+ }
824
+ )
825
+
826
+ // ==========================================================================
827
+ // TEST: Suffix field
828
+ // Verifies: Suffix appears after data values in both tooltip and legend
829
+ // ==========================================================================
830
+ const suffixInputs = Array.from(columnsAccordion?.querySelectorAll('input') || [])
831
+ const suffixInput = suffixInputs.find(input => {
832
+ const label = input.closest('label')
833
+ return label?.textContent?.includes('Suffix')
834
+ }) as HTMLInputElement
835
+ expect(suffixInput).toBeTruthy()
836
+
837
+ const getSuffixVisualization = () => {
838
+ const geoGroup = canvasElement.querySelector('g.geo-group') as SVGGElement
839
+ const tooltipHtml = geoGroup?.getAttribute('data-tooltip-html') || ''
840
+ const legendContainer = canvasElement.querySelector('.legend-container')
841
+ const legendSvg = legendContainer?.querySelector('svg')
842
+ const legendText = legendSvg?.textContent || ''
843
+
844
+ return {
845
+ tooltipHasSuffix: tooltipHtml.includes('%'),
846
+ legendHasSuffix: legendText.includes('%'),
847
+ tooltipContent: tooltipHtml,
848
+ legendContent: legendText
849
+ }
850
+ }
851
+
852
+ await performAndAssert(
853
+ 'Suffix → Add percent sign',
854
+ getSuffixVisualization,
855
+ async () => {
856
+ await userEvent.clear(suffixInput)
857
+ await userEvent.type(suffixInput, '%')
858
+ },
859
+ (before, after) => {
860
+ // Percent sign should appear in both tooltip and legend
861
+ return !before.tooltipHasSuffix && after.tooltipHasSuffix && !before.legendHasSuffix && after.legendHasSuffix
862
+ }
863
+ )
864
+
865
+ // ==========================================================================
866
+ // TEST: Round field
867
+ // Verifies: Changing rounding adds decimal places in both tooltip and legend
868
+ // ==========================================================================
869
+ const roundInputs = Array.from(columnsAccordion?.querySelectorAll('input') || [])
870
+ const roundInput = roundInputs.find(input => {
871
+ const label = input.closest('label')
872
+ return label?.textContent?.includes('Round')
873
+ }) as HTMLInputElement
874
+ expect(roundInput).toBeTruthy()
875
+
876
+ const getRoundingVisualization = () => {
877
+ const geoGroup = canvasElement.querySelector('g.geo-group') as SVGGElement
878
+ const tooltipHtml = geoGroup?.getAttribute('data-tooltip-html') || ''
879
+ const legendContainer = canvasElement.querySelector('.legend-container')
880
+ const legendSvg = legendContainer?.querySelector('svg')
881
+ const legendText = legendSvg?.textContent || ''
882
+
883
+ // Check for 2 decimal places pattern (e.g., "12.34", "0.50", etc.)
884
+ const twoDecimalPattern = /\d+\.\d{2}/
885
+
886
+ return {
887
+ tooltipHasTwoDecimals: twoDecimalPattern.test(tooltipHtml),
888
+ legendHasTwoDecimals: twoDecimalPattern.test(legendText),
889
+ tooltipContent: tooltipHtml,
890
+ legendContent: legendText
891
+ }
892
+ }
893
+
894
+ await performAndAssert(
895
+ 'Round → Set to 2 decimal places',
896
+ getRoundingVisualization,
897
+ async () => {
898
+ await userEvent.clear(roundInput)
899
+ await userEvent.type(roundInput, '2')
900
+ },
901
+ (before, after) => {
902
+ // Numbers should now show 2 decimal places in both tooltip and legend
903
+ return (
904
+ !before.tooltipHasTwoDecimals &&
905
+ after.tooltipHasTwoDecimals &&
906
+ !before.legendHasTwoDecimals &&
907
+ after.legendHasTwoDecimals
908
+ )
909
+ }
910
+ )
911
+
912
+ // ==========================================================================
913
+ // TEST: Show in Data Table checkbox
914
+ // Verifies: Primary column appears/disappears in the data table
915
+ // ==========================================================================
916
+ const showInTableLabel = Array.from(columnsAccordion?.querySelectorAll('label') || []).find(label =>
917
+ label.textContent?.includes('Show in Data Table')
918
+ )
919
+ const showInTableCheckbox = showInTableLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement
920
+ expect(showInTableCheckbox).toBeTruthy()
921
+
922
+ const getDataTableState = () => {
923
+ const dataTable = canvasElement.querySelector('.data-table-container, table')
924
+ const tableHeaders = Array.from(dataTable?.querySelectorAll('th') || [])
925
+ const tableHeaderText = tableHeaders.map(th => th.textContent?.trim() || '')
926
+
927
+ // Check if "Rate" column header appears in data table
928
+ const hasRateColumn = tableHeaderText.some(text => text.includes('Rate') || text.includes('Value'))
929
+
930
+ return {
931
+ hasDataTable: Boolean(dataTable),
932
+ tableHeaders: tableHeaderText,
933
+ hasRateColumn: hasRateColumn
934
+ }
935
+ }
936
+
937
+ // Test config has dataTable: true for primary column
938
+ await performAndAssert(
939
+ 'Show in Data Table → Disable',
940
+ getDataTableState,
941
+ async () => {
942
+ await userEvent.click(showInTableCheckbox)
943
+ },
944
+ (before, after) => {
945
+ // Rate column should disappear from data table headers
946
+ return before.hasRateColumn && !after.hasRateColumn
947
+ }
948
+ )
949
+
950
+ await performAndAssert(
951
+ 'Show in Data Table → Enable',
952
+ getDataTableState,
953
+ async () => {
954
+ await userEvent.click(showInTableCheckbox)
955
+ },
956
+ (before, after) => {
957
+ // Rate column should reappear in data table headers
958
+ return !before.hasRateColumn && after.hasRateColumn
959
+ }
960
+ )
961
+
962
+ // ==========================================================================
963
+ // TEST: Show in Tooltips checkbox
964
+ // Verifies: Primary column data appears/disappears in tooltips
965
+ // ==========================================================================
966
+ const showInTooltipsLabel = Array.from(columnsAccordion?.querySelectorAll('label') || []).find(label =>
967
+ label.textContent?.includes('Show in Tooltips')
968
+ )
969
+ const showInTooltipsCheckbox = showInTooltipsLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement
970
+ expect(showInTooltipsCheckbox).toBeTruthy()
971
+
972
+ const getTooltipDataState = () => {
973
+ const geoGroup = canvasElement.querySelector('g.geo-group') as SVGGElement
974
+ const tooltipHtml = geoGroup?.getAttribute('data-tooltip-html') || ''
975
+
976
+ // Check if tooltip contains data values (numbers with potential $ and % from earlier tests)
977
+ // Look for patterns like list items with numeric values
978
+ const hasDataValues = tooltipHtml.includes('<li') && /\$?\d+\.\d{2}%?/.test(tooltipHtml)
979
+
980
+ return {
981
+ tooltipContent: tooltipHtml,
982
+ hasDataValues: hasDataValues,
983
+ tooltipLength: tooltipHtml.length
984
+ }
985
+ }
986
+
987
+ // Test config has tooltip: true for primary column
988
+ await performAndAssert(
989
+ 'Show in Tooltips → Disable',
990
+ getTooltipDataState,
991
+ async () => {
992
+ await userEvent.click(showInTooltipsCheckbox)
993
+ },
994
+ (before, after) => {
995
+ // Tooltip should no longer contain data values, should be shorter
996
+ return before.hasDataValues && !after.hasDataValues && after.tooltipLength < before.tooltipLength
997
+ }
998
+ )
999
+
1000
+ await performAndAssert(
1001
+ 'Show in Tooltips → Enable',
1002
+ getTooltipDataState,
1003
+ async () => {
1004
+ await userEvent.click(showInTooltipsCheckbox)
1005
+ },
1006
+ (before, after) => {
1007
+ // Tooltip should contain data values again, should be longer
1008
+ return !before.hasDataValues && after.hasDataValues && after.tooltipLength > before.tooltipLength
1009
+ }
1010
+ )
1011
+
1012
+ // ==========================================================================
1013
+ // TEST: Add Special Class button
1014
+ // Verifies: New special class item appears in legend
1015
+ // ==========================================================================
1016
+ const addSpecialClassButton = Array.from(columnsAccordion?.querySelectorAll('button') || []).find(button =>
1017
+ button.textContent?.includes('Add Special Class')
1018
+ ) as HTMLButtonElement
1019
+ expect(addSpecialClassButton).toBeTruthy()
1020
+
1021
+ const getSpecialClassVisualization = () => {
1022
+ const legendContainer = canvasElement.querySelector('.legend-container')
1023
+ const legendSvg = legendContainer?.querySelector('svg')
1024
+ const gradientStops = legendSvg?.querySelectorAll('linearGradient stop')
1025
+ const gradientRects = legendSvg?.querySelectorAll('g.visx-group rect')
1026
+
1027
+ // Get all stop colors to detect changes
1028
+ const stopColors = Array.from(gradientStops || []).map(stop => (stop as SVGStopElement).getAttribute('style'))
1029
+
1030
+ // Get all rect fills to detect new colors
1031
+ const rectFills = Array.from(gradientRects || []).map(rect => (rect as SVGRectElement).getAttribute('fill'))
1032
+
1033
+ return {
1034
+ gradientStopCount: gradientStops?.length || 0,
1035
+ gradientRectCount: gradientRects?.length || 0,
1036
+ stopColors: stopColors,
1037
+ rectFills: rectFills,
1038
+ legendHTML: legendContainer?.innerHTML || ''
1039
+ }
1040
+ }
1041
+
1042
+ await performAndAssert(
1043
+ 'Add Special Class → Configure with STATE=Alabama',
1044
+ getSpecialClassVisualization,
1045
+ async () => {
1046
+ // Click the Add Special Class button
1047
+ await userEvent.click(addSpecialClassButton)
1048
+
1049
+ // Find the newly added special class fields
1050
+ const specialClassSection = Array.from(columnsAccordion?.querySelectorAll('.edit-block') || []).find(block =>
1051
+ block.textContent?.includes('Special Class 1')
1052
+ )
1053
+
1054
+ // Get all selects in the special class section
1055
+ // First select is Data Key, second is Value
1056
+ const selects = Array.from(specialClassSection?.querySelectorAll('select') || [])
1057
+ const dataKeySelect = selects[0] as HTMLSelectElement
1058
+ const valueSelect = selects[1] as HTMLSelectElement
1059
+
1060
+ // Set Data Key to STATE
1061
+ await userEvent.selectOptions(dataKeySelect, 'STATE')
1062
+
1063
+ // Wait for the Value select to populate with options based on STATE column
1064
+ await new Promise(resolve => setTimeout(resolve, 200))
1065
+
1066
+ // Select "Alabama" from the Value select
1067
+ await userEvent.selectOptions(valueSelect, 'Alabama')
1068
+
1069
+ // Find and fill in the Label field
1070
+ const labelInput = Array.from(specialClassSection?.querySelectorAll('input') || []).find(input => {
1071
+ const label = input.closest('label')
1072
+ return label?.textContent?.includes('Label')
1073
+ }) as HTMLInputElement
1074
+
1075
+ await userEvent.clear(labelInput)
1076
+ await userEvent.type(labelInput, 'Alabama')
1077
+ },
1078
+ (before, after) => {
1079
+ // Check that "Alabama" text appears in the legend
1080
+ const alabamaInLegend = after.legendHTML.includes('Alabama')
1081
+
1082
+ // Check that the special class gray color appears
1083
+ const hasGrayColor = after.rectFills.includes('#b4b4b4')
1084
+
1085
+ // Legend should change
1086
+ const legendChanged = after.legendHTML !== before.legendHTML
1087
+
1088
+ return alabamaInLegend && hasGrayColor && legendChanged
1089
+ }
1090
+ )
1091
+ }
1092
+ }
1093
+
1094
+ export const LegendSectionTests: Story = {
1095
+ args: {
1096
+ ...DEFAULT_ARGS
1097
+ },
1098
+ play: async ({ canvasElement }) => {
1099
+ const canvas = within(canvasElement)
1100
+
1101
+ await waitForEditor(canvas)
1102
+ await waitForPresence('.map-container', canvasElement)
1103
+
1104
+ await openAccordion(canvas, 'Legend')
1105
+
1106
+ // ==========================================================================
1107
+ // TEST: Legend Type
1108
+ // ==========================================================================
1109
+ const legendTypeSelect = Array.from(canvasElement.querySelectorAll('select') || []).find(select => {
1110
+ const label = select.closest('label')
1111
+ const labelSpan = label?.querySelector('.edit-label')
1112
+ return labelSpan?.textContent?.includes('Legend Type')
1113
+ }) as HTMLSelectElement
1114
+
1115
+ const getLegendType = () => {
1116
+ const legendContainer = canvasElement.querySelector('.legend-container')
1117
+ // For gradient legend, get labels from SVG text elements
1118
+ const textElements = Array.from(legendContainer?.querySelectorAll('text tspan') || [])
1119
+ const labels = textElements.map(el => el.textContent?.trim() || '')
1120
+ return {
1121
+ legendHTML: legendContainer?.innerHTML || '',
1122
+ labels
1123
+ }
1124
+ }
1125
+
1126
+ await performAndAssert(
1127
+ 'Legend Type → Equal Interval',
1128
+ getLegendType,
1129
+ async () => {
1130
+ await userEvent.selectOptions(legendTypeSelect, 'equalinterval')
1131
+ },
1132
+ (before, after) => {
1133
+ // Equal interval creates evenly spaced ranges (e.g., "0 - < 43.33", "43.33 - < 86.67")
1134
+ return after.labels.length > 0 && after.labels.join(',') !== before.labels.join(',')
1135
+ }
1136
+ )
1137
+
1138
+ await performAndAssert(
1139
+ 'Legend Type → Equal Number',
1140
+ getLegendType,
1141
+ async () => {
1142
+ await userEvent.selectOptions(legendTypeSelect, 'equalnumber')
1143
+ },
1144
+ (before, after) => {
1145
+ // Equal number (quantiles) creates ranges with equal counts (e.g., "0 - 40", "40 - 57")
1146
+ return after.labels.length > 0 && after.labels.join(',') !== before.labels.join(',')
1147
+ }
1148
+ )
1149
+
1150
+ // ==========================================================================
1151
+ // TEST: Show Legend checkbox
1152
+ // ==========================================================================
1153
+ const showLegendCheckbox = canvas.getByLabelText('Show Legend')
1154
+
1155
+ const getLegendVisibility = () => {
1156
+ const legendContainer = canvasElement.querySelector('.legend-container')
1157
+ return {
1158
+ legendExists: Boolean(legendContainer),
1159
+ isVisible: legendContainer ? !legendContainer.classList.contains('hidden') : false
1160
+ }
1161
+ }
1162
+
1163
+ await performAndAssert(
1164
+ 'Show Legend → Uncheck',
1165
+ getLegendVisibility,
1166
+ async () => {
1167
+ await userEvent.click(showLegendCheckbox)
1168
+ },
1169
+ (before, after) => {
1170
+ return before.isVisible && !after.isVisible
1171
+ }
1172
+ )
1173
+
1174
+ await performAndAssert(
1175
+ 'Show Legend → Check',
1176
+ getLegendVisibility,
1177
+ async () => {
1178
+ await userEvent.click(showLegendCheckbox)
1179
+ },
1180
+ (before, after) => {
1181
+ return !before.isVisible && after.isVisible
1182
+ }
1183
+ )
1184
+
1185
+ // ==========================================================================
1186
+ // TEST: Legend Position
1187
+ // ==========================================================================
1188
+ const legendPositionSelect = Array.from(canvasElement.querySelectorAll('select') || []).find(select => {
1189
+ const label = select.closest('label')
1190
+ const labelSpan = label?.querySelector('.edit-label')
1191
+ return labelSpan?.textContent?.includes('Legend Position')
1192
+ }) as HTMLSelectElement
1193
+
1194
+ const getLegendPosition = () => {
1195
+ const legendAside = canvasElement.querySelector('aside[aria-label="Legend"]') as HTMLElement
1196
+ return {
1197
+ classes: legendAside ? Array.from(legendAside.classList) : [],
1198
+ isBottom: legendAside?.classList.contains('bottom') ?? false,
1199
+ isSide: legendAside?.classList.contains('side') ?? false,
1200
+ isTop: legendAside?.classList.contains('top') ?? false
1201
+ }
1202
+ }
1203
+
1204
+ await performAndAssert(
1205
+ 'Legend Position → Side',
1206
+ getLegendPosition,
1207
+ async () => {
1208
+ await userEvent.selectOptions(legendPositionSelect, 'side')
1209
+ },
1210
+ (before, after) => {
1211
+ return !before.isSide && after.isSide
1212
+ }
1213
+ )
1214
+
1215
+ await performAndAssert(
1216
+ 'Legend Position → Top',
1217
+ getLegendPosition,
1218
+ async () => {
1219
+ await userEvent.selectOptions(legendPositionSelect, 'top')
1220
+ },
1221
+ (before, after) => {
1222
+ return !before.isTop && after.isTop
1223
+ }
1224
+ )
1225
+
1226
+ await performAndAssert(
1227
+ 'Legend Position → Bottom',
1228
+ getLegendPosition,
1229
+ async () => {
1230
+ await userEvent.selectOptions(legendPositionSelect, 'bottom')
1231
+ },
1232
+ (before, after) => {
1233
+ return !before.isBottom && after.isBottom
1234
+ }
1235
+ )
1236
+
1237
+ // ==========================================================================
1238
+ // TEST: Legend Style
1239
+ // ==========================================================================
1240
+ const legendStyleSelect = Array.from(canvasElement.querySelectorAll('select') || []).find(select => {
1241
+ const label = select.closest('label')
1242
+ const labelSpan = label?.querySelector('.edit-label')
1243
+ return labelSpan?.textContent?.includes('Legend Style')
1244
+ }) as HTMLSelectElement
1245
+
1246
+ const getLegendStyle = () => {
1247
+ const legendContainer = canvasElement.querySelector('.legend-container')
1248
+ const legendItems = legendContainer?.querySelectorAll('.legend-container__li')
1249
+ const linearGradient = legendContainer?.querySelector('linearGradient')
1250
+ const legendHTML = legendContainer?.innerHTML || ''
1251
+
1252
+ return {
1253
+ hasLegendItems: (legendItems?.length ?? 0) > 0,
1254
+ hasGradient: Boolean(linearGradient),
1255
+ legendHTML
1256
+ }
1257
+ }
1258
+
1259
+ await performAndAssert(
1260
+ 'Legend Style → Circles',
1261
+ getLegendStyle,
1262
+ async () => {
1263
+ await userEvent.selectOptions(legendStyleSelect, 'circles')
1264
+ },
1265
+ (before, after) => {
1266
+ return after.hasLegendItems && !after.hasGradient && after.legendHTML !== before.legendHTML
1267
+ }
1268
+ )
1269
+
1270
+ await performAndAssert(
1271
+ 'Legend Style → Boxes',
1272
+ getLegendStyle,
1273
+ async () => {
1274
+ await userEvent.selectOptions(legendStyleSelect, 'boxes')
1275
+ },
1276
+ (before, after) => {
1277
+ return after.hasLegendItems && !after.hasGradient && after.legendHTML !== before.legendHTML
1278
+ }
1279
+ )
1280
+
1281
+ await performAndAssert(
1282
+ 'Legend Style → Gradient',
1283
+ getLegendStyle,
1284
+ async () => {
1285
+ await userEvent.selectOptions(legendStyleSelect, 'gradient')
1286
+ },
1287
+ (before, after) => {
1288
+ return !before.hasGradient && after.hasGradient
1289
+ }
1290
+ )
1291
+
1292
+ // ==========================================================================
1293
+ // TEST: Gradient Style (only visible when Legend Style is gradient)
1294
+ // ==========================================================================
1295
+ const gradientStyleSelect = Array.from(canvasElement.querySelectorAll('select') || []).find(select => {
1296
+ const label = select.closest('label')
1297
+ return label?.textContent?.includes('Gradient Style')
1298
+ }) as HTMLSelectElement
1299
+
1300
+ const getGradientStyle = () => {
1301
+ const legendContainer = canvasElement.querySelector('.legend-container')
1302
+ const svgHTML = legendContainer?.querySelector('svg')?.outerHTML || ''
1303
+
1304
+ return {
1305
+ svgHTML,
1306
+ hasLinearGradient: svgHTML.includes('linearGradient')
1307
+ }
1308
+ }
1309
+
1310
+ await performAndAssert(
1311
+ 'Gradient Style → Linear blocks',
1312
+ getGradientStyle,
1313
+ async () => {
1314
+ await userEvent.selectOptions(gradientStyleSelect, 'linear blocks')
1315
+ },
1316
+ (before, after) => {
1317
+ return after.hasLinearGradient && after.svgHTML !== before.svgHTML
1318
+ }
1319
+ )
1320
+
1321
+ await performAndAssert(
1322
+ 'Gradient Style → Smooth',
1323
+ getGradientStyle,
1324
+ async () => {
1325
+ await userEvent.selectOptions(gradientStyleSelect, 'smooth')
1326
+ },
1327
+ (before, after) => {
1328
+ return after.hasLinearGradient && after.svgHTML !== before.svgHTML
1329
+ }
1330
+ )
1331
+
1332
+ // ==========================================================================
1333
+ // TEST: Tick Rotation (only visible when Legend Style is gradient)
1334
+ // ==========================================================================
1335
+ const tickRotationInput = Array.from(canvasElement.querySelectorAll('input[type="number"]') || []).find(input => {
1336
+ const label = input.closest('label')
1337
+ const labelSpan = label?.querySelector('.edit-label')
1338
+ return labelSpan?.textContent?.includes('Tick Rotation')
1339
+ }) as HTMLInputElement
1340
+
1341
+ const getTickRotation = () => {
1342
+ const legendContainer = canvasElement.querySelector('.legend-container')
1343
+ const svgHTML = legendContainer?.querySelector('svg')?.outerHTML || ''
1344
+ const textElements = Array.from(legendContainer?.querySelectorAll('text') || [])
1345
+ const transforms = textElements.map(el => el.getAttribute('transform') || '')
1346
+ return {
1347
+ svgHTML,
1348
+ transforms,
1349
+ hasRotation: transforms.some(t => t.includes('rotate'))
1350
+ }
1351
+ }
1352
+
1353
+ await performAndAssert(
1354
+ 'Tick Rotation → Set to 45 degrees',
1355
+ getTickRotation,
1356
+ async () => {
1357
+ await userEvent.clear(tickRotationInput)
1358
+ await userEvent.type(tickRotationInput, '45')
1359
+ },
1360
+ (before, after) => {
1361
+ // Check that rotation transform is applied to text elements
1362
+ return after.hasRotation && after.svgHTML !== before.svgHTML
1363
+ }
1364
+ )
1365
+
1366
+ // ==========================================================================
1367
+ // TEST: Hide Legend Box
1368
+ // ==========================================================================
1369
+ const hideLegendBoxCheckbox = Array.from(canvasElement.querySelectorAll('input[type="checkbox"]') || []).find(
1370
+ input => {
1371
+ const label = input.closest('label')
1372
+ const labelSpan = label?.querySelector('.edit-label')
1373
+ return labelSpan?.textContent?.includes('Hide Legend Box')
1374
+ }
1375
+ ) as HTMLInputElement
1376
+
1377
+ const getLegendBorder = () => {
1378
+ const legendAside = canvasElement.querySelector('aside[aria-label="Legend"]') as HTMLElement
1379
+ return {
1380
+ hasNoBorder: legendAside?.classList.contains('no-border') ?? false
1381
+ }
1382
+ }
1383
+
1384
+ await performAndAssert(
1385
+ 'Hide Legend Box → Uncheck',
1386
+ getLegendBorder,
1387
+ async () => {
1388
+ await userEvent.click(hideLegendBoxCheckbox)
1389
+ },
1390
+ (before, after) => {
1391
+ return before.hasNoBorder && !after.hasNoBorder
1392
+ }
1393
+ )
1394
+
1395
+ await performAndAssert(
1396
+ 'Hide Legend Box → Check',
1397
+ getLegendBorder,
1398
+ async () => {
1399
+ await userEvent.click(hideLegendBoxCheckbox)
1400
+ },
1401
+ (before, after) => {
1402
+ return !before.hasNoBorder && after.hasNoBorder
1403
+ }
1404
+ )
1405
+
1406
+ // ==========================================================================
1407
+ // TEST: Vertical sorted legend
1408
+ // ==========================================================================
1409
+ // First switch to boxes style and side position to enable vertical sorting
1410
+ await userEvent.selectOptions(legendStyleSelect, 'boxes')
1411
+ await userEvent.selectOptions(legendPositionSelect, 'side')
1412
+
1413
+ const verticalSortedCheckbox = Array.from(canvasElement.querySelectorAll('input[type="checkbox"]') || []).find(
1414
+ input => {
1415
+ const label = input.closest('label')
1416
+ const labelSpan = label?.querySelector('.edit-label')
1417
+ return labelSpan?.textContent?.includes('Vertical sorted legend')
1418
+ }
1419
+ ) as HTMLInputElement
1420
+
1421
+ const getVerticalSorted = () => {
1422
+ const legendUl = canvasElement.querySelector('.legend-container__ul')
1423
+ return {
1424
+ hasVerticalSorted: legendUl?.classList.contains('vertical-sorted') ?? false
1425
+ }
1426
+ }
1427
+
1428
+ await performAndAssert(
1429
+ 'Vertical sorted legend → Check',
1430
+ getVerticalSorted,
1431
+ async () => {
1432
+ await userEvent.click(verticalSortedCheckbox)
1433
+ },
1434
+ (before, after) => {
1435
+ return !before.hasVerticalSorted && after.hasVerticalSorted
1436
+ }
1437
+ )
1438
+
1439
+ await performAndAssert(
1440
+ 'Vertical sorted legend → Uncheck',
1441
+ getVerticalSorted,
1442
+ async () => {
1443
+ await userEvent.click(verticalSortedCheckbox)
1444
+ },
1445
+ (before, after) => {
1446
+ return before.hasVerticalSorted && !after.hasVerticalSorted
1447
+ }
1448
+ )
1449
+
1450
+ // NOTE: Show Special Classes Last is skipped because it doesn't produce
1451
+ // visible changes with the current test data (no special classes present)
1452
+
1453
+ // ==========================================================================
1454
+ // TEST: Separate Zero
1455
+ // ==========================================================================
1456
+ const separateZeroCheckbox = Array.from(canvasElement.querySelectorAll('input[type="checkbox"]') || []).find(
1457
+ input => {
1458
+ const label = input.closest('label')
1459
+ const labelSpan = label?.querySelector('.edit-label')
1460
+ return labelSpan?.textContent?.includes('Separate Zero')
1461
+ }
1462
+ ) as HTMLInputElement
1463
+
1464
+ const getSeparateZero = () => {
1465
+ const legendContainer = canvasElement.querySelector('.legend-container')
1466
+ const legendItems = Array.from(legendContainer?.querySelectorAll('.legend-container__li') || [])
1467
+ const itemLabels = legendItems.map(item => item.textContent?.trim() || '')
1468
+ const hasZeroItem = itemLabels.some(label => label.includes('0') && !label.match(/[1-9]/))
1469
+ return {
1470
+ itemCount: legendItems.length,
1471
+ itemLabels,
1472
+ hasZeroItem
1473
+ }
1474
+ }
1475
+
1476
+ await performAndAssert(
1477
+ 'Separate Zero → Check',
1478
+ getSeparateZero,
1479
+ async () => {
1480
+ await userEvent.click(separateZeroCheckbox)
1481
+ },
1482
+ (before, after) => {
1483
+ // Should separate zero into its own legend item (e.g., "0" instead of "0 - 40")
1484
+ return after.hasZeroItem && after.itemLabels.join(',') !== before.itemLabels.join(',')
1485
+ }
1486
+ )
1487
+
1488
+ await performAndAssert(
1489
+ 'Separate Zero → Uncheck',
1490
+ getSeparateZero,
1491
+ async () => {
1492
+ await userEvent.click(separateZeroCheckbox)
1493
+ },
1494
+ (before, after) => {
1495
+ // Should merge zero back into a range
1496
+ return !after.hasZeroItem && after.itemLabels.join(',') !== before.itemLabels.join(',')
1497
+ }
1498
+ )
1499
+
1500
+ // ==========================================================================
1501
+ // TEST: Number of Items
1502
+ // ==========================================================================
1503
+ const numberOfItemsSelect = Array.from(canvasElement.querySelectorAll('select') || []).find(select => {
1504
+ const label = select.closest('label')
1505
+ const labelSpan = label?.querySelector('.edit-label')
1506
+ return labelSpan?.textContent?.includes('Number of Items')
1507
+ }) as HTMLSelectElement
1508
+
1509
+ const getNumberOfItems = () => {
1510
+ const legendContainer = canvasElement.querySelector('.legend-container')
1511
+ const legendItems = legendContainer?.querySelectorAll('.legend-container__li')
1512
+ return {
1513
+ itemCount: legendItems?.length ?? 0,
1514
+ legendHTML: legendContainer?.innerHTML || ''
1515
+ }
1516
+ }
1517
+
1518
+ await performAndAssert(
1519
+ 'Number of Items → Set to 5',
1520
+ getNumberOfItems,
1521
+ async () => {
1522
+ await userEvent.selectOptions(numberOfItemsSelect, '5')
1523
+ },
1524
+ (before, after) => {
1525
+ // Should have 5 legend items
1526
+ return after.itemCount === 5 && after.itemCount !== before.itemCount
1527
+ }
1528
+ )
1529
+
1530
+ await performAndAssert(
1531
+ 'Number of Items → Set to 3',
1532
+ getNumberOfItems,
1533
+ async () => {
1534
+ await userEvent.selectOptions(numberOfItemsSelect, '3')
1535
+ },
1536
+ (before, after) => {
1537
+ // Should have 3 legend items
1538
+ return after.itemCount === 3 && after.itemCount !== before.itemCount
1539
+ }
1540
+ )
1541
+
1542
+ // Switch back to gradient for the legend title test
1543
+ await userEvent.selectOptions(legendPositionSelect, 'bottom')
1544
+ await userEvent.selectOptions(legendStyleSelect, 'gradient')
1545
+
1546
+ // ==========================================================================
1547
+ // TEST: Legend Title
1548
+ // ==========================================================================
1549
+ const legendTitleInput = Array.from(canvasElement.querySelectorAll('input[type="text"]') || []).find(input => {
1550
+ const label = input.closest('label')
1551
+ return label?.textContent?.includes('Legend Title')
1552
+ }) as HTMLInputElement
1553
+
1554
+ const getLegendTitle = () => {
1555
+ const legendContainer = canvasElement.querySelector('.legend-container')
1556
+ const titleElement = legendContainer?.querySelector('.legend-container__title, .legend-title')
1557
+ return {
1558
+ titleText: titleElement?.textContent?.trim() || '',
1559
+ titleExists: Boolean(titleElement),
1560
+ legendHTML: legendContainer?.innerHTML || ''
1561
+ }
1562
+ }
1563
+
1564
+ await performAndAssert(
1565
+ 'Legend Title → Set custom title',
1566
+ getLegendTitle,
1567
+ async () => {
1568
+ await userEvent.clear(legendTitleInput)
1569
+ await userEvent.type(legendTitleInput, 'Custom Legend Title')
1570
+ },
1571
+ (before, after) => {
1572
+ return after.titleText.includes('Custom Legend Title') && after.legendHTML !== before.legendHTML
1573
+ }
1574
+ )
1575
+
1576
+ // ==========================================================================
1577
+ // TEST: Legend Description
1578
+ // ==========================================================================
1579
+ const legendDescriptionTextarea = Array.from(canvasElement.querySelectorAll('textarea') || []).find(textarea => {
1580
+ const label = textarea.closest('label')
1581
+ return label?.textContent?.includes('Legend Description')
1582
+ }) as HTMLTextAreaElement
1583
+
1584
+ const getLegendDescription = () => {
1585
+ const legendContainer = canvasElement.querySelector('.legend-container')
1586
+ const descriptionElement = legendContainer?.querySelector('.legend-container__description, .legend-description')
1587
+ return {
1588
+ descriptionText: descriptionElement?.textContent?.trim() || '',
1589
+ descriptionExists: Boolean(descriptionElement),
1590
+ legendHTML: legendContainer?.innerHTML || ''
1591
+ }
1592
+ }
1593
+
1594
+ await performAndAssert(
1595
+ 'Legend Description → Set custom description',
1596
+ getLegendDescription,
1597
+ async () => {
1598
+ await userEvent.clear(legendDescriptionTextarea)
1599
+ await userEvent.type(legendDescriptionTextarea, 'This is a custom legend description for testing.')
1600
+ },
1601
+ (before, after) => {
1602
+ return (
1603
+ after.descriptionText.includes('This is a custom legend description') &&
1604
+ after.legendHTML !== before.legendHTML
1605
+ )
1606
+ }
1607
+ )
1608
+ }
1609
+ }
1610
+
1611
+ export const FiltersSectionTests: Story = {
1612
+ args: {
1613
+ ...DEFAULT_ARGS
1614
+ },
1615
+ play: async ({ canvasElement }) => {
1616
+ const canvas = within(canvasElement)
1617
+
1618
+ await waitForEditor(canvas)
1619
+ await waitForPresence('.map-container', canvasElement)
1620
+
1621
+ await openAccordion(canvas, 'Filters')
1622
+
1623
+ // ==========================================================================
1624
+ // TEST: Add Filter and configure it to filter by STATE
1625
+ // ==========================================================================
1626
+ const addFilterButton = canvas.getByRole('button', { name: /Add Filter/i })
1627
+
1628
+ await performAndAssert(
1629
+ 'Add Filter → Click button',
1630
+ () => {
1631
+ const filtersList = canvasElement.querySelector('.draggable-field-list')
1632
+ const collapsedFilters = filtersList?.querySelectorAll('.editor-field-item') || []
1633
+ return {
1634
+ hasFiltersList: Boolean(filtersList),
1635
+ hasCollapsedFilter: collapsedFilters.length > 0
1636
+ }
1637
+ },
1638
+ async () => {
1639
+ await userEvent.click(addFilterButton)
1640
+ },
1641
+ (before, after) => {
1642
+ // Should create filters list and add a collapsed filter
1643
+ return after.hasFiltersList && after.hasCollapsedFilter
1644
+ }
1645
+ )
1646
+
1647
+ // Find and expand the collapsed filter (click the header expand button)
1648
+ const filtersList = canvasElement.querySelector('.draggable-field-list')
1649
+ const expandButton = filtersList?.querySelector('.editor-field-item__header button') as HTMLButtonElement
1650
+ await userEvent.click(expandButton)
1651
+
1652
+ // Wait for the expanded filter content
1653
+ await waitForPresence('.draggable-field-list .editor-field-item__content', canvasElement)
1654
+
1655
+ // Find the newly added filter section content
1656
+ const filterBlock = filtersList?.querySelector('.editor-field-item__content') as HTMLElement
1657
+
1658
+ // ==========================================================================
1659
+ // TEST: Select STATE as the filter column
1660
+ // ==========================================================================
1661
+ const filterColumnSelect = Array.from(filterBlock?.querySelectorAll('select') || []).find(select => {
1662
+ const label = select.closest('label')
1663
+ const labelSpan = label?.querySelector('.edit-label')
1664
+ return labelSpan?.textContent?.includes('Filter') && !labelSpan?.textContent?.includes('Style')
1665
+ }) as HTMLSelectElement
1666
+
1667
+ const getDefaultValueState = () => {
1668
+ const updatedFilterBlock = filtersList?.querySelector('.editor-field-item__content') as HTMLElement
1669
+ const defaultValueSelect = Array.from(updatedFilterBlock?.querySelectorAll('select') || []).find(select => {
1670
+ const label = select.closest('label')
1671
+ const labelSpan = label?.querySelector('.edit-label')
1672
+ return labelSpan?.textContent?.includes('Filter Default Value')
1673
+ }) as HTMLSelectElement
1674
+ return {
1675
+ hasDefaultValueSelect: Boolean(defaultValueSelect),
1676
+ optionCount: defaultValueSelect?.options.length || 0
1677
+ }
1678
+ }
1679
+
1680
+ await performAndAssert(
1681
+ 'Filter Column → Select STATE',
1682
+ getDefaultValueState,
1683
+ async () => {
1684
+ await userEvent.selectOptions(filterColumnSelect, 'STATE')
1685
+ },
1686
+ (before, after) => {
1687
+ // Should populate the default value select with state options
1688
+ return after.hasDefaultValueSelect && after.optionCount > 0
1689
+ }
1690
+ )
1691
+
1692
+ // ==========================================================================
1693
+ // TEST: Filter Intro Text
1694
+ // ==========================================================================
1695
+ const filterIntroTextarea = Array.from(canvasElement.querySelectorAll('textarea') || []).find(textarea => {
1696
+ const label = textarea.closest('label')
1697
+ return label?.textContent?.includes('Filter intro text')
1698
+ }) as HTMLTextAreaElement
1699
+
1700
+ const getFilterIntroText = () => {
1701
+ const filtersSection = canvasElement.querySelector('.filters-section')
1702
+ const filterIntro = filtersSection?.querySelector('.filters-section__intro-text')
1703
+ return {
1704
+ introText: filterIntro?.textContent?.trim() || '',
1705
+ hasIntro: Boolean(filterIntro)
1706
+ }
1707
+ }
1708
+
1709
+ await performAndAssert(
1710
+ 'Filter Intro Text → Set custom text',
1711
+ getFilterIntroText,
1712
+ async () => {
1713
+ await userEvent.clear(filterIntroTextarea)
1714
+ await userEvent.type(filterIntroTextarea, 'Select a state to filter the map data.')
1715
+ },
1716
+ (before, after) => {
1717
+ return after.hasIntro && after.introText.includes('Select a state to filter the map data')
1718
+ }
1719
+ )
1720
+
1721
+ // ==========================================================================
1722
+ // TEST: Select Alabama as the default filter value
1723
+ // ==========================================================================
1724
+ const updatedFilterBlock = filtersList?.querySelector('.editor-field-item__content') as HTMLElement
1725
+ const defaultValueSelect = Array.from(updatedFilterBlock?.querySelectorAll('select') || []).find(select => {
1726
+ const label = select.closest('label')
1727
+ const labelSpan = label?.querySelector('.edit-label')
1728
+ return labelSpan?.textContent?.includes('Filter Default Value')
1729
+ }) as HTMLSelectElement
1730
+
1731
+ await performAndAssert(
1732
+ 'Filter Default Value → Select Alabama',
1733
+ () => {
1734
+ // Check the legend text labels - when filtered to one state, shows single value
1735
+ const legendContainer = canvasElement.querySelector('.legend-container')
1736
+ const textElements = Array.from(legendContainer?.querySelectorAll('text tspan') || [])
1737
+ const labels = textElements.map(el => el.textContent?.trim()).filter(t => t && t !== '')
1738
+ const labelText = labels.join(',')
1739
+
1740
+ // Verify filter dropdown is rendered
1741
+ const vizContainer = canvasElement.querySelector('.cdc-open-viz-module')
1742
+ const filterSelect = Array.from(vizContainer?.querySelectorAll('select') || []).find(select => {
1743
+ const options = Array.from(select.options).map(opt => opt.value)
1744
+ return options.includes('Alabama')
1745
+ })
1746
+
1747
+ return {
1748
+ labelCount: labels.length,
1749
+ labelText,
1750
+ hasFilterSelect: Boolean(filterSelect),
1751
+ selectedValue: filterSelect?.value || ''
1752
+ }
1753
+ },
1754
+ async () => {
1755
+ await userEvent.selectOptions(defaultValueSelect, 'Alabama')
1756
+ },
1757
+ (before, after) => {
1758
+ // After filtering to Alabama: should have filter dropdown, Alabama selected, and exactly 1 legend label
1759
+ // The legend shows a single value when only one state's data is displayed
1760
+ return after.hasFilterSelect && after.selectedValue === 'Alabama' && after.labelCount === 1
1761
+ }
1762
+ )
1763
+
1764
+ // ==========================================================================
1765
+ // TEST: Filter Label
1766
+ // ==========================================================================
1767
+ const labelInput = Array.from(updatedFilterBlock?.querySelectorAll('input[type="text"]') || []).find(input => {
1768
+ const label = input.closest('label')
1769
+ const labelSpan = label?.querySelector('.edit-label')
1770
+ return labelSpan?.textContent?.includes('Label') && !labelSpan?.textContent?.includes('Query')
1771
+ }) as HTMLInputElement
1772
+
1773
+ await performAndAssert(
1774
+ 'Filter Label → Set custom label',
1775
+ () => {
1776
+ const filtersSection = canvasElement.querySelector('.filters-section')
1777
+ const filterLabel = filtersSection?.querySelector('.form-group label')
1778
+ return {
1779
+ labelText: filterLabel?.textContent?.trim() || ''
1780
+ }
1781
+ },
1782
+ async () => {
1783
+ await userEvent.clear(labelInput)
1784
+ await userEvent.type(labelInput, 'Select State')
1785
+ },
1786
+ (before, after) => {
1787
+ return after.labelText.includes('Select State')
1788
+ }
1789
+ )
1790
+ }
1791
+ }
1792
+
1793
+ export const DataTableSectionTests: Story = {
1794
+ args: {
1795
+ ...DEFAULT_ARGS
1796
+ },
1797
+ play: async ({ canvasElement }) => {
1798
+ const canvas = within(canvasElement)
1799
+
1800
+ await waitForEditor(canvas)
1801
+ await waitForPresence('.map-container', canvasElement)
1802
+
1803
+ await openAccordion(canvas, 'Data Table')
1804
+
1805
+ // ==========================================================================
1806
+ // TEST: Data Table Title
1807
+ // ==========================================================================
1808
+ const dataTableTitleInput = canvasElement.querySelector('#dataTableTitle') as HTMLInputElement
1809
+
1810
+ await performAndAssert(
1811
+ 'Data Table Title → Set custom title',
1812
+ () => {
1813
+ const dataTableHeading = canvasElement.querySelector('.data-table-heading')
1814
+ return {
1815
+ titleText: dataTableHeading?.textContent?.trim() || ''
1816
+ }
1817
+ },
1818
+ async () => {
1819
+ await userEvent.clear(dataTableTitleInput)
1820
+ await userEvent.type(dataTableTitleInput, 'State Population Data')
1821
+ },
1822
+ (before, after) => {
1823
+ return after.titleText.includes('State Population Data')
1824
+ }
1825
+ )
1826
+
1827
+ // ==========================================================================
1828
+ // TEST: Wrap Data Table Columns
1829
+ // ==========================================================================
1830
+ // First expand the data table if it's collapsed
1831
+ const dataTableHeadingButton = canvasElement.querySelector('.data-table-heading') as HTMLElement
1832
+ const dataTableContainer = canvasElement.querySelector('.data-table-container')
1833
+ const isCollapsed = dataTableHeadingButton?.classList.contains('collapsed')
1834
+ if (isCollapsed) {
1835
+ await userEvent.click(dataTableHeadingButton)
1836
+ await new Promise(resolve => setTimeout(resolve, 200))
1837
+ }
1838
+
1839
+ const wrapColumnsCheckbox = Array.from(canvasElement.querySelectorAll('input[type="checkbox"]')).find(checkbox => {
1840
+ const label = checkbox.closest('label')
1841
+ return label?.textContent?.includes('WRAP DATA TABLE COLUMNS')
1842
+ }) as HTMLInputElement
1843
+
1844
+ await performAndAssert(
1845
+ 'Wrap Columns → Toggle wrapping',
1846
+ () => {
1847
+ const dataTable = canvasElement.querySelector('.data-table')
1848
+ const firstCell = dataTable?.querySelector('tbody td')
1849
+ const whiteSpace = firstCell ? window.getComputedStyle(firstCell).whiteSpace : ''
1850
+ return {
1851
+ whiteSpace,
1852
+ hasCells: Boolean(firstCell)
1853
+ }
1854
+ },
1855
+ async () => {
1856
+ await userEvent.click(wrapColumnsCheckbox)
1857
+ },
1858
+ (before, after) => {
1859
+ // When wrap is enabled, white-space should be 'normal' or 'unset', not 'nowrap'
1860
+ return after.hasCells && before.whiteSpace === 'nowrap' && after.whiteSpace !== 'nowrap'
1861
+ }
1862
+ )
1863
+
1864
+ // ==========================================================================
1865
+ // TEST: Show Data Table
1866
+ // ==========================================================================
1867
+ const showDataTableCheckbox = Array.from(canvasElement.querySelectorAll('input[type="checkbox"]')).find(
1868
+ checkbox => {
1869
+ const label = checkbox.closest('label')
1870
+ return label?.textContent?.includes('Show Data Table') && !label?.textContent?.includes('Non Geographic')
1871
+ }
1872
+ ) as HTMLInputElement
1873
+
1874
+ await performAndAssert(
1875
+ 'Show Data Table → Hide data table',
1876
+ () => {
1877
+ const dataTableContainer = canvasElement.querySelector('.data-table-container')
1878
+ const isVisible = dataTableContainer && window.getComputedStyle(dataTableContainer).display !== 'none'
1879
+ return {
1880
+ isVisible: Boolean(isVisible)
1881
+ }
1882
+ },
1883
+ async () => {
1884
+ await userEvent.click(showDataTableCheckbox)
1885
+ },
1886
+ (before, after) => {
1887
+ // Should hide the data table
1888
+ return before.isVisible && !after.isVisible
1889
+ }
1890
+ )
1891
+
1892
+ // Toggle it back on for the next test
1893
+ await userEvent.click(showDataTableCheckbox)
1894
+
1895
+ // ==========================================================================
1896
+ // TEST: Show Non Geographic Data
1897
+ // ==========================================================================
1898
+ const showNonGeoDataCheckbox = Array.from(canvasElement.querySelectorAll('input[type="checkbox"]')).find(
1899
+ checkbox => {
1900
+ const label = checkbox.closest('label')
1901
+ return label?.textContent?.includes('Show Non Geographic Data')
1902
+ }
1903
+ ) as HTMLInputElement
1904
+
1905
+ await performAndAssert(
1906
+ 'Show Non Geographic Data → Toggle visibility',
1907
+ () => {
1908
+ const dataTable = canvasElement.querySelector('.data-table')
1909
+ const rows = Array.from(dataTable?.querySelectorAll('tbody tr') || [])
1910
+ const rowCount = rows.length
1911
+ return {
1912
+ rowCount
1913
+ }
1914
+ },
1915
+ async () => {
1916
+ await userEvent.click(showNonGeoDataCheckbox)
1917
+ },
1918
+ (before, after) => {
1919
+ // Toggling non-geographic data should add rows to the table (overall data object)
1920
+ return after.rowCount > before.rowCount
1921
+ }
1922
+ )
1923
+
1924
+ // ==========================================================================
1925
+ // TEST: Index Column Header
1926
+ // ==========================================================================
1927
+ const indexColumnHeaderInput = Array.from(canvasElement.querySelectorAll('input[type="text"]')).find(input => {
1928
+ const label = input.closest('label')
1929
+ return label?.textContent?.includes('Index Column Header')
1930
+ }) as HTMLInputElement
1931
+
1932
+ await performAndAssert(
1933
+ 'Index Column Header → Set custom header',
1934
+ () => {
1935
+ const dataTable = canvasElement.querySelector('.data-table')
1936
+ const firstHeader = dataTable?.querySelector('thead th')
1937
+ return {
1938
+ headerText: firstHeader?.textContent?.trim() || ''
1939
+ }
1940
+ },
1941
+ async () => {
1942
+ await userEvent.clear(indexColumnHeaderInput)
1943
+ await userEvent.type(indexColumnHeaderInput, 'State/Territory')
1944
+ },
1945
+ (before, after) => {
1946
+ return after.headerText.includes('State/Territory')
1947
+ }
1948
+ )
1949
+
1950
+ // ==========================================================================
1951
+ // TEST: Screen Reader Description
1952
+ // ==========================================================================
1953
+ const screenReaderDescTextarea = Array.from(canvasElement.querySelectorAll('textarea')).find(textarea => {
1954
+ const label = textarea.closest('label')
1955
+ return label?.textContent?.includes('Screen Reader Description')
1956
+ }) as HTMLTextAreaElement
1957
+
1958
+ await performAndAssert(
1959
+ 'Screen Reader Description → Set custom description',
1960
+ () => {
1961
+ const dataTable = canvasElement.querySelector('.data-table')
1962
+ const caption = dataTable?.querySelector('caption')
1963
+ return {
1964
+ captionText: caption?.textContent?.trim() || ''
1965
+ }
1966
+ },
1967
+ async () => {
1968
+ await userEvent.clear(screenReaderDescTextarea)
1969
+ await userEvent.type(screenReaderDescTextarea, 'Table showing state population data by region')
1970
+ },
1971
+ (before, after) => {
1972
+ return after.captionText.includes('Table showing state population data')
1973
+ }
1974
+ )
1975
+
1976
+ // ==========================================================================
1977
+ // TEST: Limit Table Height
1978
+ // ==========================================================================
1979
+ const limitHeightCheckbox = Array.from(canvasElement.querySelectorAll('input[type="checkbox"]')).find(checkbox => {
1980
+ const label = checkbox.closest('label')
1981
+ return label?.textContent?.includes('Limit Table Height')
1982
+ }) as HTMLInputElement
1983
+
1984
+ await performAndAssert(
1985
+ 'Limit Table Height → Enable height limit',
1986
+ () => {
1987
+ // Check if the "Data Table Height" input field appears (conditional rendering)
1988
+ const heightInput = Array.from(canvasElement.querySelectorAll('input[type="number"]')).find(input => {
1989
+ const label = input.closest('label')
1990
+ return label?.textContent?.includes('Data Table Height')
1991
+ })
1992
+ return {
1993
+ hasHeightInput: Boolean(heightInput)
1994
+ }
1995
+ },
1996
+ async () => {
1997
+ await userEvent.click(limitHeightCheckbox)
1998
+ },
1999
+ (before, after) => {
2000
+ // After enabling, the "Data Table Height" input field should appear
2001
+ return !before.hasHeightInput && after.hasHeightInput
2002
+ }
2003
+ )
2004
+
2005
+ // ==========================================================================
2006
+ // TEST: Table Cell Min Width
2007
+ // ==========================================================================
2008
+ const cellMinWidthInput = Array.from(canvasElement.querySelectorAll('input[type="number"]')).find(input => {
2009
+ const label = input.closest('label')
2010
+ return label?.textContent?.includes('Table Cell Min Width')
2011
+ }) as HTMLInputElement
2012
+
2013
+ await performAndAssert(
2014
+ 'Table Cell Min Width → Set minimum width',
2015
+ () => {
2016
+ const dataTable = canvasElement.querySelector('.data-table')
2017
+ const firstCell = dataTable?.querySelector('thead th')
2018
+ const minWidth = firstCell ? window.getComputedStyle(firstCell).minWidth : ''
2019
+ return {
2020
+ minWidth
2021
+ }
2022
+ },
2023
+ async () => {
2024
+ await userEvent.clear(cellMinWidthInput)
2025
+ await userEvent.type(cellMinWidthInput, '150')
2026
+ },
2027
+ (before, after) => {
2028
+ // After setting min width to 150px, cells should have minWidth of 150px
2029
+ return before.minWidth !== after.minWidth && after.minWidth === '150px'
2030
+ }
2031
+ )
2032
+
2033
+ // ==========================================================================
2034
+ // TEST: Show Download CSV Link
2035
+ // ==========================================================================
2036
+ const showDownloadCheckbox = Array.from(canvasElement.querySelectorAll('input[type="checkbox"]')).find(checkbox => {
2037
+ const label = checkbox.closest('label')
2038
+ return label?.textContent?.includes('Show Download CSV Link')
2039
+ }) as HTMLInputElement
2040
+
2041
+ // First toggle it off, then back on
2042
+ await performAndAssert(
2043
+ 'Show Download CSV Link → Toggle off',
2044
+ () => {
2045
+ const downloadLink = Array.from(canvasElement.querySelectorAll('a')).find(link =>
2046
+ link.textContent?.includes('Download Data')
2047
+ )
2048
+ return {
2049
+ hasDownloadLink: Boolean(downloadLink)
2050
+ }
2051
+ },
2052
+ async () => {
2053
+ await userEvent.click(showDownloadCheckbox)
2054
+ },
2055
+ (before, after) => {
2056
+ // After disabling, the download link should disappear
2057
+ return before.hasDownloadLink && !after.hasDownloadLink
2058
+ }
2059
+ )
2060
+
2061
+ await performAndAssert(
2062
+ 'Show Download CSV Link → Toggle back on',
2063
+ () => {
2064
+ const downloadLink = Array.from(canvasElement.querySelectorAll('a')).find(link =>
2065
+ link.textContent?.includes('Download Data')
2066
+ )
2067
+ return {
2068
+ hasDownloadLink: Boolean(downloadLink)
2069
+ }
2070
+ },
2071
+ async () => {
2072
+ await userEvent.click(showDownloadCheckbox)
2073
+ },
2074
+ (before, after) => {
2075
+ // After re-enabling, the download link should reappear
2076
+ return !before.hasDownloadLink && after.hasDownloadLink
2077
+ }
2078
+ )
2079
+
2080
+ // ==========================================================================
2081
+ // TEST: Show Link Below Table
2082
+ // ==========================================================================
2083
+ const showLinkBelowCheckbox = Array.from(canvasElement.querySelectorAll('input[type="checkbox"]')).find(
2084
+ checkbox => {
2085
+ const label = checkbox.closest('label')
2086
+ return label?.textContent?.includes('Show Link Below Table')
2087
+ }
2088
+ ) as HTMLInputElement
2089
+
2090
+ // First toggle it off (move link above), then back on (move link below)
2091
+ await performAndAssert(
2092
+ 'Show Link Below Table → Toggle off (move above)',
2093
+ () => {
2094
+ const dataTableContainer = canvasElement.querySelector('.data-table-container')
2095
+ const downloadSection = canvasElement.querySelector('.download-links')
2096
+ // Check if download link is positioned after the data table
2097
+ const containerRect = dataTableContainer?.getBoundingClientRect()
2098
+ const downloadRect = downloadSection?.getBoundingClientRect()
2099
+ const isBelow = downloadRect && containerRect && downloadRect.top > containerRect.bottom
2100
+ return {
2101
+ isBelow: Boolean(isBelow)
2102
+ }
2103
+ },
2104
+ async () => {
2105
+ await userEvent.click(showLinkBelowCheckbox)
2106
+ },
2107
+ (before, after) => {
2108
+ // After disabling, download link should move above the table
2109
+ return before.isBelow && !after.isBelow
2110
+ }
2111
+ )
2112
+
2113
+ await performAndAssert(
2114
+ 'Show Link Below Table → Toggle back on (move below)',
2115
+ () => {
2116
+ const dataTableContainer = canvasElement.querySelector('.data-table-container')
2117
+ const downloadSection = canvasElement.querySelector('.download-links')
2118
+ const containerRect = dataTableContainer?.getBoundingClientRect()
2119
+ const downloadRect = downloadSection?.getBoundingClientRect()
2120
+ const isBelow = downloadRect && containerRect && downloadRect.top > containerRect.bottom
2121
+ return {
2122
+ isBelow: Boolean(isBelow)
2123
+ }
2124
+ },
2125
+ async () => {
2126
+ await userEvent.click(showLinkBelowCheckbox)
2127
+ },
2128
+ (before, after) => {
2129
+ // After re-enabling, download link should move back below the table
2130
+ return !before.isBelow && after.isBelow
2131
+ }
2132
+ )
2133
+
2134
+ // ==========================================================================
2135
+ // TEST: Enable Image Download
2136
+ // ==========================================================================
2137
+ const enableImageDownloadCheckbox = Array.from(canvasElement.querySelectorAll('input[type="checkbox"]')).find(
2138
+ checkbox => {
2139
+ const label = checkbox.closest('label')
2140
+ return label?.textContent?.includes('Enable Image Download')
2141
+ }
2142
+ ) as HTMLInputElement
2143
+
2144
+ await performAndAssert(
2145
+ 'Enable Image Download → Enable button',
2146
+ () => {
2147
+ const downloadImgButton =
2148
+ Array.from(canvasElement.querySelectorAll('button')).find(
2149
+ btn => btn.textContent?.includes('Download Image') || btn.classList.contains('download-image')
2150
+ ) ||
2151
+ Array.from(canvasElement.querySelectorAll('a[role="button"]')).find(
2152
+ link => link.textContent?.includes('Download Map') && link.textContent?.includes('PNG')
2153
+ )
2154
+ return {
2155
+ hasDownloadImgButton: Boolean(downloadImgButton)
2156
+ }
2157
+ },
2158
+ async () => {
2159
+ await userEvent.click(enableImageDownloadCheckbox)
2160
+ },
2161
+ (before, after) => {
2162
+ // After enabling, the download image button should appear
2163
+ return !before.hasDownloadImgButton && after.hasDownloadImgButton
2164
+ }
2165
+ )
2166
+ }
2167
+ }
2168
+
2169
+ // =================================================================================================
2170
+ // Visual Section Tests
2171
+ // =================================================================================================
2172
+ export const VisualSectionTests: StoryObj<typeof CdcMap> = {
2173
+ name: 'Visual Section Tests',
2174
+ args: {
2175
+ ...DEFAULT_ARGS
2176
+ },
2177
+ play: async ({ canvasElement }) => {
2178
+ const canvas = within(canvasElement)
2179
+
2180
+ // Wait for the editor to load
2181
+ await waitForEditor(canvas)
2182
+
2183
+ // Open the Visual accordion
2184
+ await openAccordion(canvas, 'Visual')
2185
+
2186
+ // ==========================================================================
2187
+ // TEST: Header Theme
2188
+ // ==========================================================================
2189
+ await performAndAssert(
2190
+ 'Header Theme → Select purple theme',
2191
+ () => {
2192
+ const innerContainer = canvasElement.querySelector('.cdc-map-inner-container')
2193
+ const currentTheme = Array.from(innerContainer?.classList || []).find(cls => cls.startsWith('theme-'))
2194
+ return {
2195
+ currentTheme: currentTheme || ''
2196
+ }
2197
+ },
2198
+ async () => {
2199
+ // Find the color palette and click on the purple theme button
2200
+ const colorPalette = canvasElement.querySelector('.color-palette')
2201
+ const purpleTheme = Array.from(colorPalette?.querySelectorAll('button') || []).find(button =>
2202
+ button.classList.contains('theme-purple')
2203
+ ) as HTMLElement
2204
+ await userEvent.click(purpleTheme)
2205
+ },
2206
+ (before, after) => {
2207
+ // After clicking, the header should have the purple theme
2208
+ return after.currentTheme === 'theme-purple'
2209
+ }
2210
+ )
2211
+
2212
+ // ==========================================================================
2213
+ // TEST: Show Title
2214
+ // ==========================================================================
2215
+ const showTitleCheckbox = Array.from(canvasElement.querySelectorAll('input[type="checkbox"]')).find(checkbox => {
2216
+ const label = checkbox.closest('label')
2217
+ return label?.textContent?.includes('Show Title')
2218
+ }) as HTMLInputElement
2219
+
2220
+ await performAndAssert(
2221
+ 'Show Title → Toggle off',
2222
+ () => {
2223
+ const titleElement = canvasElement.querySelector('.cove-title, header.cove-component__header')
2224
+ return {
2225
+ isPresent: Boolean(titleElement)
2226
+ }
2227
+ },
2228
+ async () => {
2229
+ await userEvent.click(showTitleCheckbox)
2230
+ },
2231
+ (before, after) => {
2232
+ // After toggling off, title should be removed from DOM
2233
+ return before.isPresent && !after.isPresent
2234
+ }
2235
+ )
2236
+
2237
+ await performAndAssert(
2238
+ 'Show Title → Toggle back on',
2239
+ () => {
2240
+ const titleElement = canvasElement.querySelector('.cove-title, header.cove-component__header')
2241
+ return {
2242
+ isPresent: Boolean(titleElement)
2243
+ }
2244
+ },
2245
+ async () => {
2246
+ await userEvent.click(showTitleCheckbox)
2247
+ },
2248
+ (before, after) => {
2249
+ // After toggling back on, title should be present in DOM
2250
+ return !before.isPresent && after.isPresent
2251
+ }
2252
+ )
2253
+
2254
+ // ==========================================================================
2255
+ // TEST: Geo Border Color
2256
+ // ==========================================================================
2257
+ const geoBorderColorSelect = Array.from(canvasElement.querySelectorAll('select')).find(select => {
2258
+ const label = select.closest('label')
2259
+ return label?.textContent?.includes('Geo Border Color')
2260
+ }) as HTMLSelectElement
2261
+
2262
+ await performAndAssert(
2263
+ 'Geo Border Color → Change to White',
2264
+ () => {
2265
+ // Check the stroke attribute on SVG paths with class 'single-geo'
2266
+ const geoPaths = canvasElement.querySelectorAll('path.single-geo')
2267
+ const firstPath = geoPaths[0] as SVGPathElement
2268
+ // Get the actual stroke value - could be in attribute, style, or computed
2269
+ const strokeAttr = firstPath?.getAttribute('stroke') || ''
2270
+ const strokeStyle = firstPath?.style?.stroke || ''
2271
+ const computedStroke = firstPath ? window.getComputedStyle(firstPath).stroke : ''
2272
+ return {
2273
+ strokeAttr,
2274
+ strokeStyle,
2275
+ computedStroke,
2276
+ pathCount: geoPaths.length
2277
+ }
2278
+ },
2279
+ async () => {
2280
+ await userEvent.selectOptions(geoBorderColorSelect, 'sameAsBackground')
2281
+ },
2282
+ (before, after) => {
2283
+ // After changing to white, one of the stroke values should change
2284
+ const beforeStroke = before.strokeAttr || before.strokeStyle || before.computedStroke
2285
+ const afterStroke = after.strokeAttr || after.strokeStyle || after.computedStroke
2286
+ return beforeStroke !== '' && afterStroke !== '' && beforeStroke !== afterStroke
2287
+ }
2288
+ )
2289
+
2290
+ // ==========================================================================
2291
+ // TEST: Map Color Palette - Sequential Palette Selection
2292
+ // ==========================================================================
2293
+ await performAndAssert(
2294
+ 'Map Color Palette → Select different sequential palette',
2295
+ () => {
2296
+ // Check the fill colors of actual map path elements (same as we use for stroke)
2297
+ const geoPaths = canvasElement.querySelectorAll('path.single-geo')
2298
+ const firstPath = geoPaths[0] as SVGPathElement
2299
+ // Get the fill value from any available source
2300
+ const fillAttr = firstPath?.getAttribute('fill') || ''
2301
+ const fillStyle = firstPath?.style?.fill || ''
2302
+ const computedFill = firstPath ? window.getComputedStyle(firstPath).fill : ''
2303
+ const firstColor = fillAttr || fillStyle || computedFill
2304
+ return {
2305
+ firstColor,
2306
+ pathCount: geoPaths.length
2307
+ }
2308
+ },
2309
+ async () => {
2310
+ // Find the Sequential palette section
2311
+ const spans = Array.from(canvasElement.querySelectorAll('span'))
2312
+ const sequentialLabel = spans.find(span => span.textContent?.trim() === 'Sequential')
2313
+ const paletteContainer = sequentialLabel?.nextElementSibling
2314
+
2315
+ // Try both li and button elements for palette selection
2316
+ const palettes = Array.from(paletteContainer?.querySelectorAll('li, button') || []) as HTMLElement[]
2317
+
2318
+ // Select a different palette (skip the first one as it might be selected)
2319
+ const alternativePalette = palettes.find((element, idx) => idx > 0 && !element.classList.contains('selected'))
2320
+ if (alternativePalette) {
2321
+ await userEvent.click(alternativePalette)
2322
+
2323
+ // Wait for changes to apply, then check for modal
2324
+ await new Promise(resolve => setTimeout(resolve, 100))
2325
+
2326
+ // Check if modal appeared and handle it
2327
+ const modal = canvasElement.querySelector('.modal-overlay')
2328
+ if (modal) {
2329
+ const confirmButton = Array.from(canvasElement.querySelectorAll('button')).find(btn =>
2330
+ btn.textContent?.includes('Convert to New Palette')
2331
+ )
2332
+ if (confirmButton) {
2333
+ await userEvent.click(confirmButton)
2334
+ // Wait for modal to close and changes to apply
2335
+ await new Promise(resolve => setTimeout(resolve, 500))
2336
+ }
2337
+ }
2338
+ }
2339
+ },
2340
+ (before, after) => {
2341
+ // After selecting a different palette, the map fill colors should change
2342
+ return before.firstColor !== '' && after.firstColor !== '' && before.firstColor !== after.firstColor
2343
+ }
2344
+ )
2345
+
2346
+ // ==========================================================================
2347
+ // TEST: Map Color Palette - Reverse Order
2348
+ // ==========================================================================
2349
+ // The reverse toggle is an InputToggle component which renders as a div slider
2350
+ // Find it via the hidden checkbox with name containing "isReversed"
2351
+ const reversePaletteCheckbox = canvasElement.querySelector('input[name*="isReversed"]') as HTMLInputElement
2352
+ const reversePaletteToggle = reversePaletteCheckbox?.parentElement as HTMLElement
2353
+
2354
+ expect(reversePaletteToggle).toBeTruthy()
2355
+
2356
+ await performAndAssert(
2357
+ 'Map Color Palette → Reverse palette order',
2358
+ () => {
2359
+ // Check the fill colors of map paths
2360
+ const geoPaths = canvasElement.querySelectorAll('path.single-geo')
2361
+ const firstPath = geoPaths[0] as SVGPathElement
2362
+ const fillAttr = firstPath?.getAttribute('fill') || ''
2363
+ const fillStyle = firstPath?.style?.fill || ''
2364
+ const computedFill = firstPath ? window.getComputedStyle(firstPath).fill : ''
2365
+ const firstColor = fillAttr || fillStyle || computedFill
2366
+ return {
2367
+ firstColor,
2368
+ pathCount: geoPaths.length
2369
+ }
2370
+ },
2371
+ async () => {
2372
+ await userEvent.click(reversePaletteToggle)
2373
+ // No modal should appear - reversing doesn't trigger conversion modal
2374
+ },
2375
+ (before, after) => {
2376
+ // After reversing, the first color should be different
2377
+ return before.firstColor !== '' && after.firstColor !== '' && before.firstColor !== after.firstColor
2378
+ }
2379
+ )
2380
+
2381
+ // ==========================================================================
2382
+ // TEST: Geocode Circle Size
2383
+ // ==========================================================================
2384
+ const geocodeCircleSizeInput = Array.from(canvasElement.querySelectorAll('input[type="number"]')).find(input => {
2385
+ const label = input.closest('label')
2386
+ return label?.textContent?.includes('Geocode Circle Size')
2387
+ }) as HTMLInputElement
2388
+
2389
+ expect(geocodeCircleSizeInput).toBeTruthy()
2390
+
2391
+ await performAndAssert(
2392
+ 'Geocode Circle Size → Set to 15',
2393
+ () => {
2394
+ // Check the actual visualization - DC appears as a glyph on the map
2395
+ // The glyph class element is a path or circle, look for actual circle element
2396
+ const geoPoints = canvasElement.querySelectorAll('g.geo-point')
2397
+ const firstGeoPoint = geoPoints[0]
2398
+ const circle = firstGeoPoint?.querySelector('circle')
2399
+ const r = circle?.getAttribute('r') || ''
2400
+ // Also check path elements that might have size in their d attribute
2401
+ const path = firstGeoPoint?.querySelector('path')
2402
+ const d = path?.getAttribute('d') || ''
2403
+ return {
2404
+ r,
2405
+ d,
2406
+ inputValue: geocodeCircleSizeInput.value
2407
+ }
2408
+ },
2409
+ async () => {
2410
+ await userEvent.clear(geocodeCircleSizeInput)
2411
+ await userEvent.type(geocodeCircleSizeInput, '15')
2412
+ await userEvent.tab() // Trigger blur/change event
2413
+ },
2414
+ (before, after) => {
2415
+ // Verify the input value changed to 15
2416
+ expect(after.inputValue).toBe('15')
2417
+ // After changing the size, either the radius or path data should change
2418
+ return (
2419
+ (before.r !== '' && after.r !== '' && before.r !== after.r) ||
2420
+ (before.d !== '' && after.d !== '' && before.d !== after.d)
2421
+ )
2422
+ }
2423
+ )
2424
+
2425
+ // ==========================================================================
2426
+ // TEST: Default City Style
2427
+ // ==========================================================================
2428
+ const cityStyleSelect = Array.from(canvasElement.querySelectorAll('select')).find(select => {
2429
+ const label = select.closest('label')
2430
+ return label?.textContent?.includes('Default City Style')
2431
+ }) as HTMLSelectElement
2432
+
2433
+ if (cityStyleSelect) {
2434
+ await performAndAssert(
2435
+ 'Default City Style → Change to Triangle',
2436
+ () => {
2437
+ // Check the actual visualization - look for glyph shapes
2438
+ const circles = canvasElement.querySelectorAll('.visx-glyph-circle')
2439
+ const triangles = canvasElement.querySelectorAll('.visx-glyph-triangle')
2440
+ return {
2441
+ circleCount: circles.length,
2442
+ triangleCount: triangles.length
2443
+ }
2444
+ },
2445
+ async () => {
2446
+ await userEvent.selectOptions(cityStyleSelect, 'triangle')
2447
+ },
2448
+ (before, after) => {
2449
+ // After changing to triangle, should have triangles instead of circles
2450
+ return before.triangleCount !== after.triangleCount || before.circleCount !== after.circleCount
2451
+ }
2452
+ )
2453
+ }
2454
+
2455
+ // ==========================================================================
2456
+ // TEST: Label (Optional)
2457
+ // ==========================================================================
2458
+ const cityStyleLabelInput = Array.from(canvasElement.querySelectorAll('input[type="text"]')).find(input => {
2459
+ const label = input.closest('label')
2460
+ return label?.textContent?.includes('Label (Optional)')
2461
+ }) as HTMLInputElement
2462
+
2463
+ if (cityStyleLabelInput) {
2464
+ await performAndAssert(
2465
+ 'Label (Optional) → Set custom label',
2466
+ () => {
2467
+ // Check if the label appears in the legend
2468
+ const legend = canvasElement.querySelector('.legends')
2469
+ const legendText = legend?.textContent || ''
2470
+ return {
2471
+ legendText
2472
+ }
2473
+ },
2474
+ async () => {
2475
+ await userEvent.clear(cityStyleLabelInput)
2476
+ await userEvent.type(cityStyleLabelInput, 'City Locations')
2477
+ },
2478
+ (before, after) => {
2479
+ // After setting the label, it should appear in the legend
2480
+ return after.legendText.includes('City Locations')
2481
+ }
2482
+ )
2483
+ }
2484
+ }
2485
+ }
2486
+
2487
+ /**
2488
+ * Pattern Settings Section Tests
2489
+ * Tests controls in the Pattern Settings accordion (only visible for US maps)
2490
+ */
2491
+ export const PatternSettingsSectionTests: Story = {
2492
+ args: {
2493
+ config: usaStateGradientConfig,
2494
+ isEditor: true
2495
+ },
2496
+ play: async ({ canvasElement }) => {
2497
+ const canvas = within(canvasElement)
2498
+
2499
+ // Wait for editor to load
2500
+ await waitForEditor(canvas)
2501
+
2502
+ // ==========================================================================
2503
+ // TEST: Wait for Pattern Settings accordion to load (US topo JSON)
2504
+ // ==========================================================================
2505
+ let patternSettingsButton: HTMLElement | null = null
2506
+
2507
+ await performAndAssert(
2508
+ 'Pattern Settings → Wait for accordion to appear',
2509
+ () => {
2510
+ // Check if Pattern Settings accordion exists
2511
+ const buttons = Array.from(canvasElement.querySelectorAll('.accordion__button'))
2512
+ const button = buttons.find(btn => btn.textContent?.includes('Pattern Settings')) as HTMLElement
2513
+ if (button) {
2514
+ patternSettingsButton = button
2515
+ }
2516
+ return {
2517
+ accordionExists: !!button
2518
+ }
2519
+ },
2520
+ async () => {
2521
+ // No action needed - just let performAndAssert's built-in polling wait for the accordion
2522
+ },
2523
+ (before, after) => {
2524
+ // After waiting, the accordion should exist
2525
+ return after.accordionExists
2526
+ }
2527
+ )
2528
+
2529
+ // ==========================================================================
2530
+ // TEST: Open Pattern Settings accordion and verify controls appear
2531
+ // ==========================================================================
2532
+ await performAndAssert(
2533
+ 'Pattern Settings → Open accordion',
2534
+ () => {
2535
+ // Check if the accordion panel is expanded (aria-expanded attribute)
2536
+ const isExpanded = patternSettingsButton.getAttribute('aria-expanded') === 'true'
2537
+ // Also check if the Add Geo Pattern button is visible
2538
+ const buttons = Array.from(canvasElement.querySelectorAll('button'))
2539
+ const addPatternButton = buttons.find(btn => btn.textContent?.includes('Add Geo Pattern'))
2540
+ return {
2541
+ isExpanded,
2542
+ hasAddButton: !!addPatternButton
2543
+ }
2544
+ },
2545
+ async () => {
2546
+ await userEvent.click(patternSettingsButton)
2547
+ },
2548
+ (before, after) => {
2549
+ // After clicking, accordion should be expanded and button should be visible
2550
+ return !before.isExpanded && after.isExpanded && after.hasAddButton
2551
+ }
2552
+ )
2553
+
2554
+ // ==========================================================================
2555
+ // TEST: Add a geo pattern and verify pattern appears on map
2556
+ // ==========================================================================
2557
+ await performAndAssert(
2558
+ 'Pattern Settings → Add geo pattern',
2559
+ () => {
2560
+ // Look for pattern elements in ALL SVGs (there might be multiple)
2561
+ // Pattern definitions are <pattern> elements, and they're used by paths with fill="url(#...)"
2562
+ const allSvgs = canvasElement.querySelectorAll('svg')
2563
+ let patternDefCount = 0
2564
+ let patternPathCount = 0
2565
+
2566
+ allSvgs.forEach(svg => {
2567
+ const patternDefs = svg.querySelectorAll('pattern')
2568
+ patternDefCount += patternDefs.length
2569
+
2570
+ const allPaths = Array.from(svg.querySelectorAll('path'))
2571
+ const patternPaths = allPaths.filter(path => {
2572
+ const fill = path.getAttribute('fill')
2573
+ return fill && fill.startsWith('url(#')
2574
+ })
2575
+ patternPathCount += patternPaths.length
2576
+ })
2577
+
2578
+ return {
2579
+ patternDefCount,
2580
+ patternPathCount,
2581
+ svgCount: allSvgs.length
2582
+ }
2583
+ },
2584
+ async () => {
2585
+ // Click "Add Geo Pattern" button
2586
+ const buttons = Array.from(canvasElement.querySelectorAll('button'))
2587
+ const addPatternButton = buttons.find(btn => btn.textContent?.includes('Add Geo Pattern'))
2588
+ if (!addPatternButton) {
2589
+ throw new Error('Add Geo Pattern button not found')
2590
+ }
2591
+ await userEvent.click(addPatternButton)
2592
+ },
2593
+ (before, after) => {
2594
+ // After clicking, pattern definitions and paths should appear on all states
2595
+ // Default pattern is 'circles' with empty dataKey, which matches all states (undefined === undefined)
2596
+ return after.patternDefCount > before.patternDefCount && after.patternPathCount > before.patternPathCount
2597
+ }
2598
+ )
2599
+
2600
+ // ==========================================================================
2601
+ // TEST: Change pattern color to #111
2602
+ // ==========================================================================
2603
+ // First, open the pattern accordion
2604
+ let patternAccordionButton: HTMLElement | null = null
2605
+ await performAndAssert(
2606
+ 'Pattern Settings → Open pattern accordion',
2607
+ () => {
2608
+ const allButtons = Array.from(canvasElement.querySelectorAll('.accordion__button'))
2609
+ const button = allButtons.find(btn => btn.textContent?.includes('Select Column')) as HTMLElement
2610
+ if (button) {
2611
+ patternAccordionButton = button
2612
+ }
2613
+ return {
2614
+ isExpanded: button ? button.getAttribute('aria-expanded') === 'true' : false
2615
+ }
2616
+ },
2617
+ async () => {
2618
+ if (!patternAccordionButton) {
2619
+ throw new Error('Pattern accordion not found')
2620
+ }
2621
+ await userEvent.click(patternAccordionButton)
2622
+ },
2623
+ (before, after) => {
2624
+ return !before.isExpanded && after.isExpanded
2625
+ }
2626
+ )
2627
+
2628
+ // Now change the pattern color
2629
+ await performAndAssert(
2630
+ 'Pattern Settings → Change pattern color to #111',
2631
+ () => {
2632
+ // Check pattern color in the SVG - look for circle elements inside pattern definitions
2633
+ const allSvgs = canvasElement.querySelectorAll('svg')
2634
+ let patternCircleColors: string[] = []
2635
+
2636
+ allSvgs.forEach(svg => {
2637
+ const patterns = svg.querySelectorAll('pattern')
2638
+ patterns.forEach(pattern => {
2639
+ const circles = pattern.querySelectorAll('circle')
2640
+ circles.forEach(circle => {
2641
+ const fill = circle.getAttribute('fill')
2642
+ if (fill) patternCircleColors.push(fill)
2643
+ })
2644
+ })
2645
+ })
2646
+
2647
+ return {
2648
+ patternColors: patternCircleColors
2649
+ }
2650
+ },
2651
+ async () => {
2652
+ // Find the pattern color input and change it to #111
2653
+ const input = canvasElement.querySelector('input[name="patternColor"]') as HTMLInputElement
2654
+ if (!input) {
2655
+ throw new Error('Pattern color input not found')
2656
+ }
2657
+ await userEvent.clear(input)
2658
+ // Use paste to enter the entire value at once, avoiding intermediate contrast check warnings
2659
+ await userEvent.paste('#111')
2660
+ // Blur to commit the change
2661
+ await userEvent.tab()
2662
+ },
2663
+ (before, after) => {
2664
+ // Pattern circles should now have fill color #111
2665
+ return after.patternColors.some(color => color === '#111')
2666
+ }
2667
+ )
2668
+
2669
+ // ==========================================================================
2670
+ // TEST: Set dataKey to STATE and dataValue to Colorado
2671
+ // ==========================================================================
2672
+ await performAndAssert(
2673
+ 'Pattern Settings → Set dataKey/dataValue to STATE/Colorado',
2674
+ () => {
2675
+ // Count pattern paths (paths with fill="url(#...)")
2676
+ const allSvgs = canvasElement.querySelectorAll('svg')
2677
+ let patternPathCount = 0
2678
+
2679
+ allSvgs.forEach(svg => {
2680
+ const allPaths = Array.from(svg.querySelectorAll('path'))
2681
+ const patternPaths = allPaths.filter(path => {
2682
+ const fill = path.getAttribute('fill')
2683
+ return fill && fill.startsWith('url(#')
2684
+ })
2685
+ patternPathCount += patternPaths.length
2686
+ })
2687
+
2688
+ return {
2689
+ patternPathCount
2690
+ }
2691
+ },
2692
+ async () => {
2693
+ // Select "STATE" from the dataKey dropdown
2694
+ const dataKeySelect = canvasElement.querySelector('select[name^="pattern-dataKey--"]') as HTMLSelectElement
2695
+ if (!dataKeySelect) {
2696
+ throw new Error('DataKey select not found')
2697
+ }
2698
+ await userEvent.selectOptions(dataKeySelect, 'STATE')
2699
+
2700
+ // Type "Colorado" in the dataValue input
2701
+ const dataValueInput = canvasElement.querySelector('input[id^="pattern-dataValue--"]') as HTMLInputElement
2702
+ if (!dataValueInput) {
2703
+ throw new Error('DataValue input not found')
2704
+ }
2705
+ await userEvent.clear(dataValueInput)
2706
+ await userEvent.type(dataValueInput, 'Colorado')
2707
+ // Tab to commit the change
2708
+ await userEvent.tab()
2709
+ },
2710
+ (before, after) => {
2711
+ // After setting to Colorado only, pattern paths should decrease significantly
2712
+ // Before: all states (~50+), After: just Colorado (should be 1-2)
2713
+ return after.patternPathCount < before.patternPathCount && after.patternPathCount > 0
2714
+ }
2715
+ )
2716
+
2717
+ // ==========================================================================
2718
+ // TEST: Set label to "Colorado Pattern"
2719
+ // ==========================================================================
2720
+ await performAndAssert(
2721
+ 'Pattern Settings → Set label to "Colorado Pattern"',
2722
+ () => {
2723
+ // Check if the label appears in the legend
2724
+ const legendText = canvasElement.textContent || ''
2725
+ return {
2726
+ hasLabelInLegend: legendText.includes('Colorado Pattern')
2727
+ }
2728
+ },
2729
+ async () => {
2730
+ // Find the label input - it's in a label element containing "Label (optional)"
2731
+ const labels = Array.from(canvasElement.querySelectorAll('label'))
2732
+ const labelElement = labels.find(l => l.textContent?.includes('Label (optional)'))
2733
+ const labelInput = labelElement?.querySelector('input[type="text"]') as HTMLInputElement
2734
+
2735
+ if (!labelInput) {
2736
+ throw new Error('Label input not found')
2737
+ }
2738
+ await userEvent.clear(labelInput)
2739
+ await userEvent.type(labelInput, 'Colorado Pattern')
2740
+ await userEvent.tab()
2741
+ },
2742
+ (before, after) => {
2743
+ // After setting the label, it should appear in the legend
2744
+ return !before.hasLabelInLegend && after.hasLabelInLegend
2745
+ }
2746
+ )
2747
+
2748
+ // ==========================================================================
2749
+ // TEST: Change pattern type to "lines"
2750
+ // ==========================================================================
2751
+ await performAndAssert(
2752
+ 'Pattern Settings → Change pattern type to lines',
2753
+ () => {
2754
+ // Check what type of pattern elements exist - lines patterns have <line> elements
2755
+ const allSvgs = canvasElement.querySelectorAll('svg')
2756
+ let hasCirclePattern = false
2757
+ let hasLinePattern = false
2758
+
2759
+ allSvgs.forEach(svg => {
2760
+ const patterns = svg.querySelectorAll('pattern')
2761
+ patterns.forEach(pattern => {
2762
+ if (pattern.querySelector('circle')) hasCirclePattern = true
2763
+ if (pattern.querySelector('line')) hasLinePattern = true
2764
+ })
2765
+ })
2766
+
2767
+ return {
2768
+ hasCirclePattern,
2769
+ hasLinePattern
2770
+ }
2771
+ },
2772
+ async () => {
2773
+ // Find the pattern type dropdown and select "lines"
2774
+ const patternTypeSelect = canvasElement.querySelector('select[name^="pattern-type--"]') as HTMLSelectElement
2775
+ if (!patternTypeSelect) {
2776
+ throw new Error('Pattern type select not found')
2777
+ }
2778
+ await userEvent.selectOptions(patternTypeSelect, 'lines')
2779
+ },
2780
+ (before, after) => {
2781
+ // Before: has circle pattern, no line pattern
2782
+ // After: has line pattern (may or may not still have circle pattern from other elements)
2783
+ return before.hasCirclePattern && !before.hasLinePattern && after.hasLinePattern
2784
+ }
2785
+ )
2786
+
2787
+ // ==========================================================================
2788
+ // TEST: Change pattern size to "large"
2789
+ // ==========================================================================
2790
+ await performAndAssert(
2791
+ 'Pattern Settings → Change pattern size to large',
2792
+ () => {
2793
+ // Check the pattern element's width/height attributes
2794
+ const allSvgs = canvasElement.querySelectorAll('svg')
2795
+ let patternSizes: number[] = []
2796
+
2797
+ allSvgs.forEach(svg => {
2798
+ const patterns = svg.querySelectorAll('pattern')
2799
+ patterns.forEach(pattern => {
2800
+ const width = pattern.getAttribute('width')
2801
+ if (width) patternSizes.push(parseFloat(width))
2802
+ })
2803
+ })
2804
+
2805
+ return {
2806
+ patternSizes
2807
+ }
2808
+ },
2809
+ async () => {
2810
+ // Find the pattern size dropdown and select "large"
2811
+ const patternSizeSelect = canvasElement.querySelector('select[name^="pattern-size--"]') as HTMLSelectElement
2812
+ if (!patternSizeSelect) {
2813
+ throw new Error('Pattern size select not found')
2814
+ }
2815
+ await userEvent.selectOptions(patternSizeSelect, 'large')
2816
+ },
2817
+ (before, after) => {
2818
+ // After changing to large, pattern sizes should increase
2819
+ const beforeMax = Math.max(...before.patternSizes)
2820
+ const afterMax = Math.max(...after.patternSizes)
2821
+ return afterMax > beforeMax
2822
+ }
2823
+ )
2824
+ }
2825
+ }
2826
+
2827
+ /**
2828
+ * Text Annotations Section Tests
2829
+ * Tests controls in the Text Annotations accordion
2830
+ */
2831
+ export const TextAnnotationsSectionTests: Story = {
2832
+ args: {
2833
+ config: usaStateGradientConfig,
2834
+ isEditor: true
2835
+ },
2836
+ play: async ({ canvasElement }) => {
2837
+ const canvas = within(canvasElement)
2838
+
2839
+ // Wait for editor to load
2840
+ await waitForEditor(canvas)
2841
+
2842
+ // Open the Text Annotations accordion
2843
+ await openAccordion(canvas, 'Text Annotations')
2844
+
2845
+ // ==========================================================================
2846
+ // TEST: Add Annotation
2847
+ // ==========================================================================
2848
+ const addAnnotationButton = Array.from(canvasElement.querySelectorAll('button')).find(btn => {
2849
+ return btn.textContent?.includes('Add Annotation')
2850
+ }) as HTMLButtonElement
2851
+
2852
+ expect(addAnnotationButton).toBeTruthy()
2853
+
2854
+ await performAndAssert(
2855
+ 'Add Annotation → Click to add default annotation',
2856
+ () => {
2857
+ // Check the actual visualization - annotations appear as divs with aria-label
2858
+ // Default annotation text is "New Annotation"
2859
+ const annotationElements = canvasElement.querySelectorAll('div[aria-label*="Annotation text"]')
2860
+ return {
2861
+ annotationCount: annotationElements.length
2862
+ }
2863
+ },
2864
+ async () => {
2865
+ await userEvent.click(addAnnotationButton)
2866
+ },
2867
+ (before, after) => {
2868
+ // After clicking, a new annotation should appear in the visualization
2869
+ return after.annotationCount > before.annotationCount
2870
+ }
2871
+ )
2872
+
2873
+ // ==========================================================================
2874
+ // TEST: Change Annotation Text
2875
+ // ==========================================================================
2876
+ // Wait for the annotation sub-accordion to appear and find the button with "New Annotation" or "Select Column"
2877
+ await waitForPresence('.cove-accordion__button', canvasElement)
2878
+ const annotationAccordionButtons = canvasElement.querySelectorAll('.cove-accordion__button')
2879
+ const annotationAccordionButton = Array.from(annotationAccordionButtons).find(
2880
+ btn => btn.textContent?.includes('New Annotation') || btn.textContent?.includes('Select Column')
2881
+ ) as HTMLElement
2882
+
2883
+ expect(annotationAccordionButton).toBeTruthy()
2884
+
2885
+ // Open the annotation's sub-accordion
2886
+ await userEvent.click(annotationAccordionButton)
2887
+
2888
+ // Find the annotation text textarea
2889
+ const annotationTextarea = Array.from(canvasElement.querySelectorAll('textarea')).find(textarea => {
2890
+ const label = textarea.closest('label')
2891
+ return label?.textContent?.includes('Annotation Text')
2892
+ }) as HTMLTextAreaElement
2893
+
2894
+ expect(annotationTextarea).toBeTruthy()
2895
+
2896
+ await performAndAssert(
2897
+ 'Annotation Text → Change to "Important Note"',
2898
+ () => {
2899
+ // Check the actual visualization - the annotation div should contain the text
2900
+ const annotationDivs = canvasElement.querySelectorAll('div[aria-label*="Annotation text"]')
2901
+ const texts = Array.from(annotationDivs).map(div => div.innerHTML)
2902
+ return {
2903
+ annotationTexts: texts.join('|')
2904
+ }
2905
+ },
2906
+ async () => {
2907
+ // Select all text and replace it
2908
+ await userEvent.click(annotationTextarea)
2909
+ await userEvent.keyboard('{Control>}a{/Control}')
2910
+ await userEvent.type(annotationTextarea, 'Important Note')
2911
+ await userEvent.tab() // Trigger blur to commit the change
2912
+ },
2913
+ (before, after) => {
2914
+ // After changing the text, the annotation should display the new text
2915
+ return after.annotationTexts.includes('Important Note')
2916
+ }
2917
+ )
2918
+
2919
+ // ==========================================================================
2920
+ // TEST: Delete Annotation
2921
+ // ==========================================================================
2922
+ const deleteButton = Array.from(canvasElement.querySelectorAll('button')).find(btn => {
2923
+ return btn.textContent?.includes('Delete Annotation')
2924
+ }) as HTMLButtonElement
2925
+
2926
+ expect(deleteButton).toBeTruthy()
2927
+
2928
+ await performAndAssert(
2929
+ 'Delete Annotation → Remove annotation from map',
2930
+ () => {
2931
+ // Check the visualization - count the annotations
2932
+ const annotationDivs = canvasElement.querySelectorAll('div[aria-label*="Annotation text"]')
2933
+ return {
2934
+ annotationCount: annotationDivs.length
2935
+ }
2936
+ },
2937
+ async () => {
2938
+ await userEvent.click(deleteButton)
2939
+ },
2940
+ (before, after) => {
2941
+ // After deleting, annotation count should decrease
2942
+ return after.annotationCount < before.annotationCount
2943
+ }
2944
+ )
2945
+ }
2946
+ }
2947
+
2948
+ // ============================================================================
2949
+ // SMALL MULTIPLES SECTION TESTS
2950
+ // ============================================================================
2951
+
2952
+ export const SmallMultiplesSectionTests: Story = {
2953
+ name: 'Small Multiples Section Tests',
2954
+ parameters: {},
2955
+ args: {
2956
+ config: {
2957
+ ...wastewaterMapSmallMultiplesConfig,
2958
+ general: {
2959
+ ...wastewaterMapSmallMultiplesConfig.general,
2960
+ title: 'Map Small Multiples Test'
2961
+ },
2962
+ smallMultiples: {
2963
+ ...wastewaterMapSmallMultiplesConfig.smallMultiples,
2964
+ mode: ''
2965
+ }
2966
+ },
2967
+ isEditor: true
2968
+ },
2969
+ play: async ({ canvasElement }) => {
2970
+ const canvas = within(canvasElement)
2971
+
2972
+ await waitForEditor(canvas)
2973
+ await waitForPresence('.map-container', canvasElement)
2974
+
2975
+ await openAccordion(canvas, 'Small Multiples')
2976
+
2977
+ // ============================================================================
2978
+ // TEST: Enable Small Multiples Mode
2979
+ // Verifies: Map visualization changes from single map to multiple maps
2980
+ // ============================================================================
2981
+
2982
+ const getMapTileCount = () => {
2983
+ const smallMultiplesContainer = canvasElement.querySelector('.small-multiples-container')
2984
+ const tiles = canvasElement.querySelectorAll('.small-multiples-grid > .small-multiple-tile')
2985
+
2986
+ return {
2987
+ hasSmallMultiplesContainer: !!smallMultiplesContainer,
2988
+ tileCount: tiles.length
2989
+ }
2990
+ }
2991
+
2992
+ const tileByColumnSelect = canvas.getByLabelText(/tile by column/i) as HTMLSelectElement
2993
+
2994
+ await performAndAssert(
2995
+ 'Enable Small Multiples Mode - Map splits into multiple tiles',
2996
+ getMapTileCount,
2997
+ async () => {
2998
+ await userEvent.selectOptions(tileByColumnSelect, 'pathogen')
2999
+ },
3000
+ (before, after) => {
3001
+ return before.tileCount === 0 && after.tileCount === 3
3002
+ }
3003
+ )
3004
+
3005
+ // ============================================================================
3006
+ // TEST: Tiles Per Row Desktop
3007
+ // Verifies: Grid layout changes from 3 tiles per row to 2 tiles per row
3008
+ // ============================================================================
3009
+
3010
+ const getTilesInFirstRow = () => {
3011
+ const tiles = Array.from(canvasElement.querySelectorAll('.small-multiples-grid > .small-multiple-tile'))
3012
+ if (tiles.length === 0) return { tilesInFirstRow: 0 }
3013
+
3014
+ const firstTileTop = tiles[0].getBoundingClientRect().top
3015
+ const tilesInFirstRow = tiles.filter(tile => {
3016
+ const tileTop = tile.getBoundingClientRect().top
3017
+ return Math.abs(tileTop - firstTileTop) < 5
3018
+ }).length
3019
+
3020
+ return { tilesInFirstRow }
3021
+ }
3022
+
3023
+ const tilesPerRowInput = canvas.getByLabelText(/tiles per row \(desktop\)/i) as HTMLInputElement
3024
+
3025
+ await performAndAssert(
3026
+ 'Tiles Per Row Desktop - Layout changes from 3 to 2 tiles per row',
3027
+ getTilesInFirstRow,
3028
+ async () => {
3029
+ await userEvent.clear(tilesPerRowInput)
3030
+ await userEvent.type(tilesPerRowInput, '2')
3031
+ tilesPerRowInput.blur()
3032
+ },
3033
+ (before, after) => {
3034
+ return before.tilesInFirstRow === 3 && after.tilesInFirstRow === 2
3035
+ }
3036
+ )
3037
+
3038
+ // ============================================================================
3039
+ // TEST: Tile Order
3040
+ // Verifies: Changing tile order from custom to descending reverses tiles
3041
+ // ============================================================================
3042
+
3043
+ const getTileTitles = () => {
3044
+ const tiles = canvasElement.querySelectorAll('.small-multiples-grid > .small-multiple-tile')
3045
+ const titles = Array.from(tiles).map(tile => {
3046
+ const titleElement = tile.querySelector('.tile-title')
3047
+ return titleElement?.textContent?.trim() || ''
3048
+ })
3049
+ return { titles }
3050
+ }
3051
+
3052
+ const tileOrderSelect = canvas.getByLabelText(/tile order/i) as HTMLSelectElement
3053
+
3054
+ await performAndAssert(
3055
+ 'Tile Order - Descending sorts tiles alphabetically descending',
3056
+ getTileTitles,
3057
+ async () => {
3058
+ await userEvent.selectOptions(tileOrderSelect, 'desc')
3059
+ },
3060
+ (before, after) => {
3061
+ return before.titles[0] === 'Influenza AAA' && after.titles[0] === 'RSV' && after.titles[1] === 'Influenza AAA'
3062
+ }
3063
+ )
3064
+
3065
+ // ============================================================================
3066
+ // TEST: Custom Tile Title
3067
+ // Verifies: Custom tile title appears in visualization
3068
+ // ============================================================================
3069
+
3070
+ const covid19TitleInput = canvasElement.querySelector('input[placeholder="COVID-19"]') as HTMLInputElement
3071
+
3072
+ await performAndAssert(
3073
+ 'Custom Tile Title - Changes tile display name',
3074
+ getTileTitles,
3075
+ async () => {
3076
+ if (covid19TitleInput) {
3077
+ await userEvent.clear(covid19TitleInput)
3078
+ await userEvent.type(covid19TitleInput, 'Custom COVID Title')
3079
+ covid19TitleInput.blur()
3080
+ }
3081
+ },
3082
+ (before, after) => {
3083
+ return after.titles.includes('Custom COVID Title') && !before.titles.includes('Custom COVID Title')
3084
+ }
3085
+ )
3086
+ }
3087
+ }
3088
+
3089
+ // ==========================================================================
3090
+ // MULTI-COUNTRY MAP TESTS
3091
+ // ==========================================================================
3092
+
3093
+ export const MultiCountryWorldMapTests: Story = {
3094
+ args: {
3095
+ ...DEFAULT_ARGS,
3096
+ config: {
3097
+ ...multiCountryConfig,
3098
+ general: {
3099
+ ...multiCountryConfig.general,
3100
+ countriesPicked: [] // Start with no countries selected for testing
3101
+ }
3102
+ }
3103
+ },
3104
+ play: async ({ canvasElement }) => {
3105
+ const canvas = within(canvasElement)
3106
+
3107
+ await waitForEditor(canvas)
3108
+ await waitForPresence('.map-container', canvasElement)
3109
+
3110
+ // Ensure we're testing a world map - Type accordion should be open by default
3111
+ await openAccordion(canvas, 'Type')
3112
+
3113
+ // Wait for world map to load (topology loads asynchronously)
3114
+ // The config already starts with geoType: 'world', so we just need to wait for it to render
3115
+ await waitForPresence('path[data-country-code]', canvasElement)
3116
+
3117
+ // Wait for the country selector to appear (only shows for world maps)
3118
+ await waitForPresence('.cove-multiselect', canvasElement)
3119
+
3120
+ // Stay in Type accordion section - this is where multi-country controls are located
3121
+
3122
+ // ==========================================================================
3123
+ // Helper functions to capture visual state
3124
+ // ==========================================================================
3125
+ const getCountryVisualState = () => {
3126
+ const mapContainer = canvasElement.querySelector('.map-container')
3127
+ const allCountryPaths = canvasElement.querySelectorAll('path[data-country-code]')
3128
+ const visibleCountries = Array.from(allCountryPaths).filter(
3129
+ path => !path.classList.contains('hidden') && !path.classList.contains('grayed-out')
3130
+ )
3131
+ const grayedCountries = Array.from(allCountryPaths).filter(path => path.classList.contains('grayed-out'))
3132
+ const hiddenCountries = Array.from(allCountryPaths).filter(path => {
3133
+ const hasHiddenClass = path.classList.contains('hidden')
3134
+ const computedStyle = window.getComputedStyle(path as Element)
3135
+ const isDisplayNone = computedStyle.display === 'none'
3136
+ return hasHiddenClass || isDisplayNone
3137
+ })
3138
+
3139
+ return {
3140
+ totalCountries: allCountryPaths.length,
3141
+ visibleCountries: visibleCountries.length,
3142
+ grayedCountries: grayedCountries.length,
3143
+ hiddenCountries: hiddenCountries.length,
3144
+ mapClasses: mapContainer ? Array.from(mapContainer.classList) : [],
3145
+ hasMultiCountryClass: mapContainer?.classList.contains('multi-country-selected') || false,
3146
+ selectedCountryCodes: Array.from(
3147
+ new Set(
3148
+ Array.from(visibleCountries)
3149
+ .map(path => path.getAttribute('data-country-code'))
3150
+ .filter(Boolean)
3151
+ )
3152
+ )
3153
+ }
3154
+ }
3155
+
3156
+ // ==========================================================================
3157
+ // TEST: Country Multi-Select Dropdown Interaction
3158
+ // ==========================================================================
3159
+ const countryMultiSelect = Array.from(canvasElement.querySelectorAll('.cove-multiselect')).find(ms => {
3160
+ const parentLabel = ms.closest('label')
3161
+ const labelSpan = parentLabel?.querySelector('span')
3162
+ return labelSpan?.textContent?.includes('Countries Selector')
3163
+ }) as HTMLElement
3164
+ expect(countryMultiSelect).toBeTruthy()
3165
+
3166
+ // Get the expand button to open the dropdown
3167
+ const expandButton = countryMultiSelect.querySelector('button[aria-label="Expand"]') as HTMLButtonElement
3168
+ expect(expandButton).toBeTruthy()
3169
+
3170
+ // Test selecting first country (France)
3171
+ await performAndAssert(
3172
+ 'Multi-Country → Select France',
3173
+ getCountryVisualState,
3174
+ async () => {
3175
+ // Open the dropdown
3176
+ await userEvent.click(expandButton)
3177
+ // Find and click France option
3178
+ const franceOption = Array.from(countryMultiSelect.querySelectorAll('li[role="option"]')).find(li =>
3179
+ li.textContent?.includes('France')
3180
+ ) as HTMLElement
3181
+ expect(franceOption).toBeTruthy()
3182
+ await userEvent.click(franceOption)
3183
+ },
3184
+ (before, after) => {
3185
+ // When countries are selected, map should show multi-country state
3186
+ return (
3187
+ after.hasMultiCountryClass &&
3188
+ after.selectedCountryCodes.includes('FRA') &&
3189
+ after.visibleCountries < before.totalCountries // Some countries should be filtered
3190
+ )
3191
+ }
3192
+ )
3193
+
3194
+ // Test selecting second country (Germany)
3195
+ await performAndAssert(
3196
+ 'Multi-Country → Add Germany to selection',
3197
+ getCountryVisualState,
3198
+ async () => {
3199
+ // Expand dropdown again
3200
+ const expandBtn = countryMultiSelect.querySelector('button[aria-label="Expand"]') as HTMLButtonElement
3201
+ await userEvent.click(expandBtn)
3202
+ // Find and click Germany option
3203
+ const germanyOption = Array.from(countryMultiSelect.querySelectorAll('li[role="option"]')).find(li =>
3204
+ li.textContent?.includes('Germany')
3205
+ ) as HTMLElement
3206
+ expect(germanyOption).toBeTruthy()
3207
+ await userEvent.click(germanyOption)
3208
+ },
3209
+ (before, after) => {
3210
+ // Both France and Germany should be selected
3211
+ return (
3212
+ after.selectedCountryCodes.includes('FRA') &&
3213
+ after.selectedCountryCodes.includes('DEU') &&
3214
+ after.selectedCountryCodes.length === 2
3215
+ )
3216
+ }
3217
+ )
3218
+
3219
+ // Test adding a third country (Italy)
3220
+ await performAndAssert(
3221
+ 'Multi-Country → Add Italy to selection',
3222
+ getCountryVisualState,
3223
+ async () => {
3224
+ // Expand dropdown again
3225
+ const expandBtn = countryMultiSelect.querySelector('button[aria-label="Expand"]') as HTMLButtonElement
3226
+ await userEvent.click(expandBtn)
3227
+ // Find and click Italy option
3228
+ const italyOption = Array.from(countryMultiSelect.querySelectorAll('li[role="option"]')).find(li =>
3229
+ li.textContent?.includes('Italy')
3230
+ ) as HTMLElement
3231
+ expect(italyOption).toBeTruthy()
3232
+ await userEvent.click(italyOption)
3233
+ },
3234
+ (before, after) => {
3235
+ // All three countries should be selected
3236
+ return (
3237
+ after.selectedCountryCodes.includes('FRA') &&
3238
+ after.selectedCountryCodes.includes('DEU') &&
3239
+ after.selectedCountryCodes.includes('ITA') &&
3240
+ after.selectedCountryCodes.length === 3
3241
+ )
3242
+ }
3243
+ )
3244
+
3245
+ // ==========================================================================
3246
+ // TEST: Hide Unselected Countries Toggle (Grey-out vs Hide behavior)
3247
+ // ==========================================================================
3248
+ // Ensure map has fully rendered with country paths before testing toggle
3249
+ await waitForPresence('path[data-country-code]', canvasElement)
3250
+
3251
+ // InputToggle renders as a div with a hidden checkbox
3252
+ const hideUnselectedToggle = Array.from(
3253
+ canvasElement.querySelectorAll('input[name*="hideUnselectedCountries"]')
3254
+ ).find(input => {
3255
+ const label = input.closest('label') || input.parentElement?.querySelector('label')
3256
+ return label?.textContent?.includes('Hide Unselected Countries')
3257
+ }) as HTMLInputElement
3258
+ expect(hideUnselectedToggle).toBeTruthy()
3259
+
3260
+ // By default (unchecked), unselected countries should be grayed out
3261
+ // First, ensure the toggle is unchecked (hideUnselectedCountries = false, so countries are grayed)
3262
+ if (hideUnselectedToggle.checked) {
3263
+ await userEvent.click(hideUnselectedToggle)
3264
+ await new Promise(resolve => setTimeout(resolve, 100))
3265
+ }
3266
+
3267
+ const beforeState = getCountryVisualState()
3268
+
3269
+ // Now check it (should hide unselected countries)
3270
+ await userEvent.click(hideUnselectedToggle)
3271
+ await new Promise(resolve => setTimeout(resolve, 100)) // Give time for re-render
3272
+
3273
+ const afterState = getCountryVisualState()
3274
+
3275
+ const testResult =
3276
+ afterState.hiddenCountries > beforeState.hiddenCountries &&
3277
+ afterState.grayedCountries === 0 && // No grayed countries when hiding
3278
+ afterState.selectedCountryCodes.length === 3 // Only our 3 selected countries (by unique ISO codes)
3279
+
3280
+ expect(testResult).toBe(true)
3281
+
3282
+ // Test unchecking "Hide Unselected Countries" again (should grey-out unselected countries)
3283
+ await performAndAssert(
3284
+ 'Hide Unselected Countries → Grey-out unselected countries',
3285
+ getCountryVisualState,
3286
+ async () => {
3287
+ await userEvent.click(hideUnselectedToggle)
3288
+ },
3289
+ (before, after) => {
3290
+ // When not hiding (default), unselected countries should be grayed out
3291
+ return (
3292
+ after.grayedCountries > before.grayedCountries &&
3293
+ after.hiddenCountries === 0 && // No hidden countries when showing grayed
3294
+ after.selectedCountryCodes.length === 3 // Our 3 selected countries remain visible (by unique ISO codes)
3295
+ )
3296
+ }
3297
+ )
3298
+
3299
+ // ==========================================================================
3300
+ // TEST: Map Centering and Bounds Changes
3301
+ // ==========================================================================
3302
+ await performAndAssert(
3303
+ 'Multi-Country Selection → Map centers on selected countries',
3304
+ getCountryVisualState,
3305
+ async () => {
3306
+ // Clear selection and select different countries (Japan, Australia) to test centering
3307
+ // Click expand button
3308
+ const expandBtn = countryMultiSelect.querySelector('button[aria-label="Expand"]') as HTMLButtonElement
3309
+ await userEvent.click(expandBtn)
3310
+
3311
+ // Clear existing selections first by clicking remove buttons
3312
+ const removeButtons = countryMultiSelect.querySelectorAll('button[aria-label="Remove"]')
3313
+ for (const button of Array.from(removeButtons)) {
3314
+ await userEvent.click(button as HTMLButtonElement)
3315
+ }
3316
+
3317
+ // Select Japan
3318
+ await userEvent.click(expandBtn) // Reopen dropdown
3319
+ const japanOption = Array.from(countryMultiSelect.querySelectorAll('li[role="option"]')).find(li =>
3320
+ li.textContent?.includes('Japan')
3321
+ ) as HTMLElement
3322
+ expect(japanOption).toBeTruthy()
3323
+ await userEvent.click(japanOption)
3324
+
3325
+ // Select Australia
3326
+ await userEvent.click(expandBtn) // Reopen dropdown
3327
+ const australiaOption = Array.from(countryMultiSelect.querySelectorAll('li[role="option"]')).find(li =>
3328
+ li.textContent?.includes('Australia')
3329
+ ) as HTMLElement
3330
+ expect(australiaOption).toBeTruthy()
3331
+ await userEvent.click(australiaOption)
3332
+
3333
+ // Wait for map to re-render with new selection
3334
+ await new Promise(resolve => setTimeout(resolve, 300))
3335
+ },
3336
+ (before, after) => {
3337
+ // Verify that the selected countries changed (Japan and Australia instead of France, Germany, Italy)
3338
+ // The centering happens automatically via useCountryZoom hook
3339
+ return (
3340
+ after.selectedCountryCodes.includes('JPN') &&
3341
+ after.selectedCountryCodes.includes('AUS') &&
3342
+ !after.selectedCountryCodes.includes('FRA') &&
3343
+ after.selectedCountryCodes.length === 2
3344
+ )
3345
+ }
3346
+ )
3347
+
3348
+ // ==========================================================================
3349
+ // TEST: Clear Country Selection (Reset to full world map)
3350
+ // ==========================================================================
3351
+ await performAndAssert(
3352
+ 'Multi-Country → Clear all selections',
3353
+ getCountryVisualState,
3354
+ async () => {
3355
+ // Remove all selected countries using the remove buttons
3356
+ const removeButtons = countryMultiSelect.querySelectorAll('button[aria-label="Remove"]')
3357
+ for (const button of Array.from(removeButtons)) {
3358
+ await userEvent.click(button as HTMLButtonElement)
3359
+ }
3360
+ },
3361
+ (before, after) => {
3362
+ // When all countries are cleared, should return to normal world map state
3363
+ // Note: selectedCountryCodes will contain ALL countries since all are visible (not grayed/hidden)
3364
+ return (
3365
+ !after.hasMultiCountryClass &&
3366
+ after.grayedCountries === 0 &&
3367
+ after.hiddenCountries === 0 &&
3368
+ after.visibleCountries === after.totalCountries // All countries visible
3369
+ )
3370
+ }
3371
+ )
3372
+
3373
+ // ==========================================================================
3374
+ // TEST: Country Selection with Data Integration
3375
+ // ==========================================================================
3376
+ await performAndAssert(
3377
+ 'Multi-Country Data → Selected countries show data values',
3378
+ getCountryVisualState,
3379
+ async () => {
3380
+ // Select countries that should have data (France, Germany, Italy - these have data in the config)
3381
+ const expandBtn = countryMultiSelect.querySelector('button[aria-label="Expand"]') as HTMLButtonElement
3382
+
3383
+ // Select France
3384
+ await userEvent.click(expandBtn)
3385
+ const franceOption = Array.from(countryMultiSelect.querySelectorAll('li[role="option"]')).find(li =>
3386
+ li.textContent?.includes('France')
3387
+ ) as HTMLElement
3388
+
3389
+ expect(franceOption).toBeTruthy()
3390
+ await userEvent.click(franceOption)
3391
+
3392
+ // Select Germany
3393
+ await userEvent.click(expandBtn)
3394
+ const germanyOption = Array.from(countryMultiSelect.querySelectorAll('li[role="option"]')).find(li =>
3395
+ li.textContent?.includes('Germany')
3396
+ ) as HTMLElement
3397
+
3398
+ expect(germanyOption).toBeTruthy()
3399
+ await userEvent.click(germanyOption)
3400
+
3401
+ // Select Italy
3402
+ await userEvent.click(expandBtn)
3403
+ const italyOption = Array.from(countryMultiSelect.querySelectorAll('li[role="option"]')).find(li =>
3404
+ li.textContent?.includes('Italy')
3405
+ ) as HTMLElement
3406
+
3407
+ expect(italyOption).toBeTruthy()
3408
+ await userEvent.click(italyOption)
3409
+
3410
+ // Wait for data to be applied
3411
+ await new Promise(resolve => setTimeout(resolve, 300))
3412
+ },
3413
+ (before, after) => {
3414
+ // Verify that the countries with data are correctly selected
3415
+ // (Data rendering and legend display are separate from multi-country selection functionality)
3416
+ return (
3417
+ after.selectedCountryCodes.includes('FRA') &&
3418
+ after.selectedCountryCodes.includes('DEU') &&
3419
+ after.selectedCountryCodes.includes('ITA') &&
3420
+ after.hasMultiCountryClass &&
3421
+ after.selectedCountryCodes.length === 3
3422
+ )
3423
+ }
3424
+ )
3425
+ }
3426
+ }