@cdc/map 4.25.10 → 4.25.11

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