@cdc/map 4.26.3 → 4.26.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CONFIG.md +268 -0
  2. package/README.md +74 -24
  3. package/dist/cdcmap-CY9IcPSi.es.js +6 -0
  4. package/dist/cdcmap-DlpiY3fQ.es.js +4 -0
  5. package/dist/cdcmap.js +29168 -27482
  6. package/examples/{testing-layer-2.json → __data__/testing-layer-2.json} +1 -1
  7. package/examples/{testing-layer.json → __data__/testing-layer.json} +1 -1
  8. package/examples/county-hsa-toggle.json +51993 -0
  9. package/examples/custom-map-layers.json +2 -2
  10. package/examples/default-county.json +6 -3
  11. package/examples/minimal-example.json +73 -0
  12. package/examples/private/annotation-bug.json +2 -2
  13. package/examples/private/css-issue.json +314 -0
  14. package/examples/private/region-breaking.json +1639 -0
  15. package/examples/private/test1.json +27247 -0
  16. package/package.json +4 -4
  17. package/src/CdcMapComponent.tsx +107 -14
  18. package/src/_stories/CdcMap.AltText.stories.tsx +122 -0
  19. package/src/_stories/CdcMap.Editor.ColumnsSectionTests.stories.tsx +600 -0
  20. package/src/_stories/CdcMap.Editor.DataTableSectionTests.stories.tsx +404 -0
  21. package/src/_stories/CdcMap.Editor.FiltersSectionTests.stories.tsx +229 -0
  22. package/src/_stories/CdcMap.Editor.GeneralSectionTests.stories.tsx +262 -0
  23. package/src/_stories/CdcMap.Editor.LegendSectionTests.stories.tsx +541 -0
  24. package/src/_stories/CdcMap.Editor.MultiCountryWorldMapTests.stories.tsx +359 -0
  25. package/src/_stories/CdcMap.Editor.PatternSettingsSectionTests.stories.tsx +516 -0
  26. package/src/_stories/CdcMap.Editor.SmallMultiplesSectionTests.stories.tsx +165 -0
  27. package/src/_stories/CdcMap.Editor.TextAnnotationsSectionTests.stories.tsx +145 -0
  28. package/src/_stories/CdcMap.Editor.TypeSectionTests.stories.tsx +312 -0
  29. package/src/_stories/CdcMap.Editor.VisualSectionTests.stories.tsx +359 -0
  30. package/src/_stories/CdcMap.Editor.ZoomControlsTests.stories.tsx +88 -0
  31. package/src/_stories/CdcMap.FocusVisibility.stories.tsx +87 -0
  32. package/src/_stories/CdcMap.HiddenMount.stories.tsx +69 -0
  33. package/src/_stories/CdcMap.ResetBehavior.stories.tsx +32 -0
  34. package/src/_stories/CdcMap.Zoom.stories.tsx +111 -0
  35. package/src/_stories/{CdcMap.stories.tsx → CdcMap.smoke.stories.tsx} +60 -0
  36. package/src/_stories/_mock/alt_text_metadata.json +65 -0
  37. package/src/_stories/_mock/legends/legend-tests.json +3 -3
  38. package/src/_stories/_mock/world-bubble-reset.json +138 -0
  39. package/src/_stories/_mock/world-data-zoom-filters.json +166 -0
  40. package/src/components/Annotation/AnnotationList.tsx +1 -1
  41. package/src/components/BubbleList.tsx +13 -0
  42. package/src/components/EditorPanel/components/EditorPanel.tsx +637 -382
  43. package/src/components/EditorPanel/components/HexShapeSettings.tsx +1 -1
  44. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +112 -117
  45. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +26 -13
  46. package/src/components/EditorPanel/components/editorPanel.styles.css +22 -2
  47. package/src/components/FilterControls.tsx +21 -0
  48. package/src/components/Legend/components/Legend.tsx +3 -3
  49. package/src/components/Legend/components/LegendItem.Hex.tsx +4 -2
  50. package/src/components/SmallMultiples/SmallMultiples.tsx +2 -2
  51. package/src/components/SmallMultiples/SynchronizedTooltip.tsx +1 -1
  52. package/src/components/UsaMap/components/UsaMap.County.tsx +309 -108
  53. package/src/components/UsaMap/components/UsaMap.Region.tsx +5 -2
  54. package/src/components/UsaMap/components/UsaMap.SingleState.tsx +33 -10
  55. package/src/components/UsaMap/components/UsaMap.State.tsx +10 -3
  56. package/src/components/UsaMap/data/cb_2019_us_county_20m.json +75817 -1
  57. package/src/components/UsaMap/data/hsa_fips_mapping.json +3144 -0
  58. package/src/components/WorldMap/WorldMap.tsx +37 -4
  59. package/src/components/WorldMap/data/world-topo.json +1 -1
  60. package/src/components/ZoomableGroup.tsx +23 -3
  61. package/src/components/filterControls.styles.css +6 -0
  62. package/src/data/initial-state.js +3 -0
  63. package/src/data/supported-counties.json +1 -1
  64. package/src/helpers/countyTerritories.ts +38 -0
  65. package/src/helpers/dataTableHelpers.ts +35 -6
  66. package/src/helpers/generateRuntimeFilters.ts +2 -1
  67. package/src/helpers/handleMapAriaLabels.ts +45 -30
  68. package/src/helpers/shouldAutoResetSingleStateZoom.ts +22 -0
  69. package/src/helpers/tests/countyTerritories.test.ts +87 -0
  70. package/src/helpers/tests/handleMapAriaLabels.test.ts +71 -0
  71. package/src/helpers/tests/shouldAutoResetSingleStateZoom.test.ts +71 -0
  72. package/src/hooks/useApplyTooltipsToGeo.tsx +7 -4
  73. package/src/hooks/useGeoClickHandler.ts +13 -1
  74. package/src/hooks/useMapLayers.tsx +1 -1
  75. package/src/hooks/useStateZoom.tsx +39 -20
  76. package/src/hooks/useTooltip.test.tsx +2 -16
  77. package/src/hooks/useTooltip.ts +18 -7
  78. package/src/index.jsx +5 -2
  79. package/src/scss/main.scss +6 -21
  80. package/src/scss/map.scss +20 -0
  81. package/src/store/map.actions.ts +5 -2
  82. package/src/store/map.reducer.ts +12 -3
  83. package/src/test/CdcMap.test.jsx +24 -0
  84. package/src/types/MapConfig.ts +11 -0
  85. package/src/types/MapContext.ts +6 -1
  86. package/topojson-updater/README.txt +1 -1
  87. package/dist/cdcmap-vr9HZwRt.es.js +0 -6
  88. package/examples/__data__/city-state-data.json +0 -668
  89. package/examples/city-state.json +0 -434
  90. package/examples/default-world-data.json +0 -1450
  91. package/examples/new-cities.json +0 -656
  92. package/src/_stories/CdcMap.Editor.stories.tsx +0 -3648
  93. package/topojson-updater/package-lock.json +0 -223
  94. /package/src/_stories/{CdcMap.ColumnWrap.stories.tsx → CdcMap.ColumnWrap.smoke.stories.tsx} +0 -0
  95. /package/src/_stories/{CdcMap.Defaults.stories.tsx → CdcMap.Defaults.smoke.stories.tsx} +0 -0
  96. /package/src/_stories/{CdcMap.DistrictOfColumbia.stories.tsx → CdcMap.DistrictOfColumbia.smoke.stories.tsx} +0 -0
  97. /package/src/_stories/{CdcMap.Filters.stories.tsx → CdcMap.Filters.smoke.stories.tsx} +0 -0
  98. /package/src/_stories/{CdcMap.Legend.Gradient.stories.tsx → CdcMap.Legend.Gradient.smoke.stories.tsx} +0 -0
  99. /package/src/_stories/{CdcMap.Legend.stories.tsx → CdcMap.Legend.smoke.stories.tsx} +0 -0
  100. /package/src/_stories/{CdcMap.Patterns.stories.tsx → CdcMap.Patterns.smoke.stories.tsx} +0 -0
  101. /package/src/_stories/{CdcMap.SmallMultiples.stories.tsx → CdcMap.SmallMultiples.smoke.stories.tsx} +0 -0
  102. /package/src/_stories/{CdcMap.Table.stories.tsx → CdcMap.Table.smoke.stories.tsx} +0 -0
  103. /package/src/_stories/{CdcMap.ZeroColor.stories.tsx → CdcMap.ZeroColor.smoke.stories.tsx} +0 -0
  104. /package/src/_stories/{GoogleMap.stories.tsx → GoogleMap.smoke.stories.tsx} +0 -0
  105. /package/src/_stories/{UsaMap.NoData.stories.tsx → UsaMap.NoData.smoke.stories.tsx} +0 -0
@@ -0,0 +1,516 @@
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 PatternSettingsSectionTests: Story = {
27
+ args: {
28
+ config: usaStateGradientConfig,
29
+ isEditor: true
30
+ },
31
+ play: async ({ canvasElement }) => {
32
+ const canvas = within(canvasElement)
33
+
34
+ // Wait for editor to load
35
+ await waitForEditor(canvas)
36
+
37
+ // ==========================================================================
38
+ // TEST: Wait for Pattern Settings accordion to load (US topo JSON)
39
+ // ==========================================================================
40
+ let patternSettingsButton: HTMLElement | null = null
41
+
42
+ await performAndAssert(
43
+ 'Pattern Settings → Wait for accordion to appear',
44
+ () => {
45
+ // Check if Pattern Settings accordion exists
46
+ const buttons = Array.from(canvasElement.querySelectorAll('.accordion__button'))
47
+ const button = buttons.find(btn => btn.textContent?.includes('Pattern Settings')) as HTMLElement
48
+ if (button) {
49
+ patternSettingsButton = button
50
+ }
51
+ return {
52
+ accordionExists: !!button
53
+ }
54
+ },
55
+ async () => {
56
+ // No action needed - just let performAndAssert's built-in polling wait for the accordion
57
+ },
58
+ (before, after) => {
59
+ // After waiting, the accordion should exist
60
+ return after.accordionExists
61
+ }
62
+ )
63
+
64
+ // ==========================================================================
65
+ // TEST: Open Pattern Settings accordion and verify controls appear
66
+ // ==========================================================================
67
+ await performAndAssert(
68
+ 'Pattern Settings → Open accordion',
69
+ () => {
70
+ // Check if the accordion panel is expanded (aria-expanded attribute)
71
+ const isExpanded = patternSettingsButton.getAttribute('aria-expanded') === 'true'
72
+ // Also check if the Add Geo Pattern button is visible
73
+ const buttons = Array.from(canvasElement.querySelectorAll('button'))
74
+ const addPatternButton = buttons.find(btn => btn.textContent?.includes('Add Geo Pattern'))
75
+ return {
76
+ isExpanded,
77
+ hasAddButton: !!addPatternButton
78
+ }
79
+ },
80
+ async () => {
81
+ await userEvent.click(patternSettingsButton)
82
+ },
83
+ (before, after) => {
84
+ // After clicking, accordion should be expanded and button should be visible
85
+ return !before.isExpanded && after.isExpanded && after.hasAddButton
86
+ }
87
+ )
88
+
89
+ // ==========================================================================
90
+ // TEST: Add a geo pattern and verify pattern appears on map
91
+ // ==========================================================================
92
+ await performAndAssert(
93
+ 'Pattern Settings → Add geo pattern',
94
+ () => {
95
+ // Look for pattern elements in ALL SVGs (there might be multiple)
96
+ // Pattern definitions are <pattern> elements, and they're used by paths with fill="url(#...)"
97
+ const allSvgs = canvasElement.querySelectorAll('svg')
98
+ let patternDefCount = 0
99
+ let patternPathCount = 0
100
+
101
+ allSvgs.forEach(svg => {
102
+ const patternDefs = svg.querySelectorAll('pattern')
103
+ patternDefCount += patternDefs.length
104
+
105
+ const allPaths = Array.from(svg.querySelectorAll('path'))
106
+ const patternPaths = allPaths.filter(path => {
107
+ const fill = path.getAttribute('fill')
108
+ return fill && fill.startsWith('url(#')
109
+ })
110
+ patternPathCount += patternPaths.length
111
+ })
112
+
113
+ return {
114
+ patternDefCount,
115
+ patternPathCount,
116
+ svgCount: allSvgs.length
117
+ }
118
+ },
119
+ async () => {
120
+ // Click "Add Geo Pattern" button
121
+ const buttons = Array.from(canvasElement.querySelectorAll('button'))
122
+ const addPatternButton = buttons.find(btn => btn.textContent?.includes('Add Geo Pattern'))
123
+ if (!addPatternButton) {
124
+ throw new Error('Add Geo Pattern button not found')
125
+ }
126
+ await userEvent.click(addPatternButton)
127
+ },
128
+ (before, after) => {
129
+ // After clicking, at minimum a new pattern definition should be created.
130
+ // Pattern paths may remain unchanged until dataKey/dataValue are configured.
131
+ return after.patternDefCount > before.patternDefCount
132
+ }
133
+ )
134
+
135
+ // ==========================================================================
136
+ // TEST: Change pattern color to #111
137
+ // ==========================================================================
138
+ // First, open the pattern accordion
139
+ let patternAccordionButton: HTMLElement | null = null
140
+ await performAndAssert(
141
+ 'Pattern Settings → Open pattern accordion',
142
+ () => {
143
+ const allButtons = Array.from(canvasElement.querySelectorAll('.accordion__button'))
144
+ const button = allButtons.find(btn => btn.textContent?.includes('Select Column')) as HTMLElement
145
+ if (button) {
146
+ patternAccordionButton = button
147
+ }
148
+ return {
149
+ isExpanded: button ? button.getAttribute('aria-expanded') === 'true' : false
150
+ }
151
+ },
152
+ async () => {
153
+ if (!patternAccordionButton) {
154
+ throw new Error('Pattern accordion not found')
155
+ }
156
+ await userEvent.click(patternAccordionButton)
157
+ },
158
+ (before, after) => {
159
+ return !before.isExpanded && after.isExpanded
160
+ }
161
+ )
162
+
163
+ // Now change the pattern color
164
+ await performAndAssert(
165
+ 'Pattern Settings → Change pattern color to #111',
166
+ () => {
167
+ // Check pattern color in the SVG - look for circle elements inside pattern definitions
168
+ const allSvgs = canvasElement.querySelectorAll('svg')
169
+ let patternCircleColors: string[] = []
170
+
171
+ allSvgs.forEach(svg => {
172
+ const patterns = svg.querySelectorAll('pattern')
173
+ patterns.forEach(pattern => {
174
+ const circles = pattern.querySelectorAll('circle')
175
+ circles.forEach(circle => {
176
+ const fill = circle.getAttribute('fill')
177
+ if (fill) patternCircleColors.push(fill)
178
+ })
179
+ })
180
+ })
181
+
182
+ return {
183
+ patternColors: patternCircleColors
184
+ }
185
+ },
186
+ async () => {
187
+ // Find the pattern color input and change it to #111
188
+ const input = canvasElement.querySelector('input[name="patternColor"]') as HTMLInputElement
189
+ if (!input) {
190
+ throw new Error('Pattern color input not found')
191
+ }
192
+ await userEvent.clear(input)
193
+ // Use paste to enter the entire value at once, avoiding intermediate contrast check warnings
194
+ await userEvent.paste('#111')
195
+ // Blur to commit the change
196
+ await userEvent.tab()
197
+ },
198
+ (before, after) => {
199
+ // Pattern circles should now have fill color #111
200
+ return after.patternColors.some(color => color === '#111')
201
+ }
202
+ )
203
+
204
+ // ==========================================================================
205
+ // TEST: Set dataKey to STATE and dataValue to Colorado
206
+ // ==========================================================================
207
+ await performAndAssert(
208
+ 'Pattern Settings → Set dataKey/dataValue to STATE/Colorado',
209
+ () => {
210
+ // Count pattern paths (paths with fill="url(#...)")
211
+ const allSvgs = canvasElement.querySelectorAll('svg')
212
+ let patternPathCount = 0
213
+
214
+ allSvgs.forEach(svg => {
215
+ const allPaths = Array.from(svg.querySelectorAll('path'))
216
+ const patternPaths = allPaths.filter(path => {
217
+ const fill = path.getAttribute('fill')
218
+ return fill && fill.startsWith('url(#')
219
+ })
220
+ patternPathCount += patternPaths.length
221
+ })
222
+
223
+ return {
224
+ patternPathCount
225
+ }
226
+ },
227
+ async () => {
228
+ // Select "STATE" from the dataKey dropdown
229
+ const dataKeySelect = canvasElement.querySelector('select[name^="pattern-dataKey--"]') as HTMLSelectElement
230
+ if (!dataKeySelect) {
231
+ throw new Error('DataKey select not found')
232
+ }
233
+ await userEvent.selectOptions(dataKeySelect, 'STATE')
234
+
235
+ // Type "Colorado" in the dataValue input
236
+ const dataValueInput = canvasElement.querySelector('input[id^="pattern-dataValue--"]') as HTMLInputElement
237
+ if (!dataValueInput) {
238
+ throw new Error('DataValue input not found')
239
+ }
240
+ await userEvent.clear(dataValueInput)
241
+ await userEvent.type(dataValueInput, 'Colorado')
242
+ // Tab to commit the change
243
+ await userEvent.tab()
244
+ },
245
+ (before, after) => {
246
+ // After setting STATE/Colorado, at least one matching patterned path should appear.
247
+ return after.patternPathCount > before.patternPathCount && after.patternPathCount > 0
248
+ }
249
+ )
250
+
251
+ // ==========================================================================
252
+ // TEST: Numeric dataValue matching and hover persistence
253
+ // ==========================================================================
254
+ await performAndAssert(
255
+ 'Pattern Settings → Set numeric dataKey/dataValue (Rate/55)',
256
+ () => {
257
+ const allSvgs = canvasElement.querySelectorAll('svg')
258
+ let patternPathCount = 0
259
+
260
+ allSvgs.forEach(svg => {
261
+ const allPaths = Array.from(svg.querySelectorAll('path'))
262
+ patternPathCount += allPaths.filter(path => path.getAttribute('fill')?.startsWith('url(#')).length
263
+ })
264
+
265
+ return { patternPathCount }
266
+ },
267
+ async () => {
268
+ const dataKeySelect = canvasElement.querySelector('select[name^="pattern-dataKey--"]') as HTMLSelectElement
269
+ const dataValueInput = canvasElement.querySelector('input[id^="pattern-dataValue--"]') as HTMLInputElement
270
+ if (!dataKeySelect || !dataValueInput) throw new Error('Pattern data controls not found')
271
+
272
+ await userEvent.selectOptions(dataKeySelect, 'Rate')
273
+ await userEvent.clear(dataValueInput)
274
+ await userEvent.type(dataValueInput, '55')
275
+ await userEvent.tab()
276
+ },
277
+ (before, after) => after.patternPathCount > 0
278
+ )
279
+
280
+ await performAndAssert(
281
+ 'Pattern Settings → Broad match with blank dataKey (value 55)',
282
+ () => {
283
+ const allSvgs = canvasElement.querySelectorAll('svg')
284
+ let patternPathCount = 0
285
+
286
+ allSvgs.forEach(svg => {
287
+ const allPaths = Array.from(svg.querySelectorAll('path'))
288
+ patternPathCount += allPaths.filter(path => path.getAttribute('fill')?.startsWith('url(#')).length
289
+ })
290
+
291
+ return { patternPathCount }
292
+ },
293
+ async () => {
294
+ const dataKeySelect = canvasElement.querySelector('select[name^="pattern-dataKey--"]') as HTMLSelectElement
295
+ const dataValueInput = canvasElement.querySelector('input[id^="pattern-dataValue--"]') as HTMLInputElement
296
+ if (!dataKeySelect || !dataValueInput) throw new Error('Pattern data controls not found')
297
+
298
+ await userEvent.selectOptions(dataKeySelect, '')
299
+ await userEvent.clear(dataValueInput)
300
+ await userEvent.type(dataValueInput, '55')
301
+ await userEvent.tab()
302
+ },
303
+ (before, after) => after.patternPathCount > 0
304
+ )
305
+
306
+ await performAndAssert(
307
+ 'Pattern Settings → Specific match beats broad match',
308
+ () => {
309
+ const allSvgs = canvasElement.querySelectorAll('svg')
310
+ const patternTypeById: Record<string, 'lines' | 'circles' | 'waves' | 'unknown'> = {}
311
+ const appliedRatePatternTypes = new Set<string>()
312
+
313
+ allSvgs.forEach(svg => {
314
+ const patterns = svg.querySelectorAll('pattern')
315
+ patterns.forEach(pattern => {
316
+ const patternId = pattern.getAttribute('id')
317
+ if (!patternId) return
318
+
319
+ if (pattern.querySelector('circle')) patternTypeById[patternId] = 'circles'
320
+ else if (pattern.querySelector('line')) patternTypeById[patternId] = 'lines'
321
+ else if (pattern.querySelector('path')) patternTypeById[patternId] = 'waves'
322
+ else patternTypeById[patternId] = 'unknown'
323
+ })
324
+
325
+ const ratePatternNodes = Array.from(svg.querySelectorAll('path.pattern-geoKey--Rate')).filter(node =>
326
+ node.getAttribute('fill')?.startsWith('url(#')
327
+ )
328
+
329
+ ratePatternNodes.forEach(node => {
330
+ const fill = node.getAttribute('fill')
331
+ const match = fill?.match(/^url\(#(.+)\)$/)
332
+ const patternId = match?.[1]
333
+ if (!patternId) return
334
+ appliedRatePatternTypes.add(patternTypeById[patternId] || 'unknown')
335
+ })
336
+ })
337
+
338
+ return {
339
+ hasRateCircle: appliedRatePatternTypes.has('circles'),
340
+ hasRateWave: appliedRatePatternTypes.has('waves')
341
+ }
342
+ },
343
+ async () => {
344
+ const firstDataKey = canvasElement.querySelector('select[name="pattern-dataKey--0"]') as HTMLSelectElement
345
+ const firstDataValue = canvasElement.querySelector('input[id="pattern-dataValue--0"]') as HTMLInputElement
346
+ const firstPatternType = canvasElement.querySelector('select[name="pattern-type--0"]') as HTMLSelectElement
347
+ if (!firstDataKey || !firstDataValue || !firstPatternType) {
348
+ throw new Error('First pattern controls not found')
349
+ }
350
+ await userEvent.selectOptions(firstDataKey, 'Rate')
351
+ await userEvent.clear(firstDataValue)
352
+ await userEvent.type(firstDataValue, '55')
353
+ await userEvent.selectOptions(firstPatternType, 'circles')
354
+
355
+ const buttons = Array.from(canvasElement.querySelectorAll('button'))
356
+ const addPatternButton = buttons.find(btn => btn.textContent?.includes('Add Geo Pattern'))
357
+ if (!addPatternButton) throw new Error('Add Geo Pattern button not found')
358
+ await userEvent.click(addPatternButton)
359
+
360
+ const accordionButtons = Array.from(canvasElement.querySelectorAll('.accordion__button'))
361
+ const selectColumnButtons = accordionButtons.filter(btn => btn.textContent?.includes('Select Column'))
362
+ const secondPatternAccordionButton = selectColumnButtons[selectColumnButtons.length - 1] as HTMLElement
363
+ if (!secondPatternAccordionButton) throw new Error('Second pattern accordion not found')
364
+ await userEvent.click(secondPatternAccordionButton)
365
+
366
+ const secondDataKey = canvasElement.querySelector('select[name="pattern-dataKey--1"]') as HTMLSelectElement
367
+ const secondDataValue = canvasElement.querySelector('input[id="pattern-dataValue--1"]') as HTMLInputElement
368
+ const secondPatternType = canvasElement.querySelector('select[name="pattern-type--1"]') as HTMLSelectElement
369
+
370
+ if (!secondDataKey || !secondDataValue || !secondPatternType) {
371
+ throw new Error('Second pattern controls not found')
372
+ }
373
+
374
+ await userEvent.selectOptions(secondDataKey, '')
375
+ await userEvent.clear(secondDataValue)
376
+ await userEvent.type(secondDataValue, '55')
377
+ await userEvent.selectOptions(secondPatternType, 'waves')
378
+ },
379
+ (before, after) => after.hasRateCircle && !after.hasRateWave
380
+ )
381
+
382
+ await performAndAssert(
383
+ 'Pattern Settings → Pattern remains after hover',
384
+ () => {
385
+ const allSvgs = canvasElement.querySelectorAll('svg')
386
+ let patternPathCount = 0
387
+
388
+ allSvgs.forEach(svg => {
389
+ const allPaths = Array.from(svg.querySelectorAll('path'))
390
+ patternPathCount += allPaths.filter(path => path.getAttribute('fill')?.startsWith('url(#')).length
391
+ })
392
+
393
+ return { patternPathCount }
394
+ },
395
+ async () => {
396
+ const geoGroup = canvasElement.querySelector('.geo-group') as HTMLElement
397
+ if (!geoGroup) throw new Error('Geo group not found for hover test')
398
+ await userEvent.hover(geoGroup)
399
+ },
400
+ (before, after) => after.patternPathCount === before.patternPathCount && after.patternPathCount > 0
401
+ )
402
+
403
+ // ==========================================================================
404
+ // TEST: Set label to "Colorado Pattern"
405
+ // ==========================================================================
406
+ await performAndAssert(
407
+ 'Pattern Settings → Set label to "Colorado Pattern"',
408
+ () => {
409
+ // Check if the label appears in the legend
410
+ const legendText = canvasElement.textContent || ''
411
+ return {
412
+ hasLabelInLegend: legendText.includes('Colorado Pattern')
413
+ }
414
+ },
415
+ async () => {
416
+ // Find the label input - it's in a label element containing "Label (optional)"
417
+ const labels = Array.from(canvasElement.querySelectorAll('label'))
418
+ const labelElement = labels.find(l => l.textContent?.includes('Label (optional)'))
419
+ const labelInput = labelElement?.querySelector('input[type="text"]') as HTMLInputElement
420
+
421
+ if (!labelInput) {
422
+ throw new Error('Label input not found')
423
+ }
424
+ await userEvent.clear(labelInput)
425
+ await userEvent.type(labelInput, 'Colorado Pattern')
426
+ await userEvent.tab()
427
+ },
428
+ (before, after) => {
429
+ // After setting the label, it should appear in the legend
430
+ return !before.hasLabelInLegend && after.hasLabelInLegend
431
+ }
432
+ )
433
+
434
+ // ==========================================================================
435
+ // TEST: Change pattern type to "lines"
436
+ // ==========================================================================
437
+ await performAndAssert(
438
+ 'Pattern Settings → Change pattern type to lines',
439
+ () => {
440
+ // Check what type of pattern elements exist - lines patterns have <line> elements
441
+ const allSvgs = canvasElement.querySelectorAll('svg')
442
+ let hasCirclePattern = false
443
+ let hasLinePattern = false
444
+
445
+ allSvgs.forEach(svg => {
446
+ const patterns = svg.querySelectorAll('pattern')
447
+ patterns.forEach(pattern => {
448
+ if (pattern.querySelector('circle')) hasCirclePattern = true
449
+ if (pattern.querySelector('line')) hasLinePattern = true
450
+ })
451
+ })
452
+
453
+ return {
454
+ hasCirclePattern,
455
+ hasLinePattern
456
+ }
457
+ },
458
+ async () => {
459
+ // Find the pattern type dropdown and select "lines"
460
+ const patternTypeSelect = canvasElement.querySelector('select[name^="pattern-type--"]') as HTMLSelectElement
461
+ if (!patternTypeSelect) {
462
+ throw new Error('Pattern type select not found')
463
+ }
464
+ await userEvent.selectOptions(patternTypeSelect, 'lines')
465
+ },
466
+ (before, after) => {
467
+ // Before: has circle pattern, no line pattern
468
+ // After: has line pattern (may or may not still have circle pattern from other elements)
469
+ return before.hasCirclePattern && !before.hasLinePattern && after.hasLinePattern
470
+ }
471
+ )
472
+
473
+ // ==========================================================================
474
+ // TEST: Change pattern size to "large"
475
+ // ==========================================================================
476
+ await performAndAssert(
477
+ 'Pattern Settings → Change pattern size to large',
478
+ () => {
479
+ // Check the pattern element's width/height attributes
480
+ const allSvgs = canvasElement.querySelectorAll('svg')
481
+ let patternSizes: number[] = []
482
+
483
+ allSvgs.forEach(svg => {
484
+ const patterns = svg.querySelectorAll('pattern')
485
+ patterns.forEach(pattern => {
486
+ const width = pattern.getAttribute('width')
487
+ if (width) patternSizes.push(parseFloat(width))
488
+ })
489
+ })
490
+
491
+ return {
492
+ patternSizes
493
+ }
494
+ },
495
+ async () => {
496
+ // Find the pattern size dropdown and select "large"
497
+ const patternSizeSelect = canvasElement.querySelector('select[name^="pattern-size--"]') as HTMLSelectElement
498
+ if (!patternSizeSelect) {
499
+ throw new Error('Pattern size select not found')
500
+ }
501
+ await userEvent.selectOptions(patternSizeSelect, 'large')
502
+ },
503
+ (before, after) => {
504
+ // After changing to large, pattern sizes should increase
505
+ const beforeMax = Math.max(...before.patternSizes)
506
+ const afterMax = Math.max(...after.patternSizes)
507
+ return afterMax > beforeMax
508
+ }
509
+ )
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Text Annotations Section Tests
515
+ * Tests controls in the Text Annotations accordion
516
+ */
@@ -0,0 +1,165 @@
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 SmallMultiplesSectionTests: Story = {
27
+ name: 'Small Multiples Section Tests',
28
+ parameters: {},
29
+ args: {
30
+ config: {
31
+ ...wastewaterMapSmallMultiplesConfig,
32
+ general: {
33
+ ...wastewaterMapSmallMultiplesConfig.general,
34
+ title: 'Map Small Multiples Test'
35
+ },
36
+ smallMultiples: {
37
+ ...wastewaterMapSmallMultiplesConfig.smallMultiples,
38
+ mode: ''
39
+ }
40
+ },
41
+ isEditor: true
42
+ },
43
+ play: async ({ canvasElement }) => {
44
+ const canvas = within(canvasElement)
45
+
46
+ await waitForEditor(canvas)
47
+ await waitForPresence('.map-container', canvasElement)
48
+
49
+ await openAccordion(canvas, 'Small Multiples')
50
+
51
+ // ============================================================================
52
+ // TEST: Enable Small Multiples Mode
53
+ // Verifies: Map visualization changes from single map to multiple maps
54
+ // ============================================================================
55
+
56
+ const getMapTileCount = () => {
57
+ const smallMultiplesContainer = canvasElement.querySelector('.small-multiples-container')
58
+ const tiles = canvasElement.querySelectorAll('.small-multiples-grid > .small-multiple-tile')
59
+
60
+ return {
61
+ hasSmallMultiplesContainer: !!smallMultiplesContainer,
62
+ tileCount: tiles.length
63
+ }
64
+ }
65
+
66
+ const tileByColumnSelect = canvas.getByLabelText(/tile by column/i) as HTMLSelectElement
67
+
68
+ await performAndAssert(
69
+ 'Enable Small Multiples Mode - Map splits into multiple tiles',
70
+ getMapTileCount,
71
+ async () => {
72
+ await userEvent.selectOptions(tileByColumnSelect, 'pathogen')
73
+ },
74
+ (before, after) => {
75
+ return before.tileCount === 0 && after.tileCount === 3
76
+ }
77
+ )
78
+
79
+ // ============================================================================
80
+ // TEST: Tiles Per Row Desktop
81
+ // Verifies: Grid layout changes from 3 tiles per row to 2 tiles per row
82
+ // ============================================================================
83
+
84
+ const getTilesInFirstRow = () => {
85
+ const tiles = Array.from(canvasElement.querySelectorAll('.small-multiples-grid > .small-multiple-tile'))
86
+ if (tiles.length === 0) return { tilesInFirstRow: 0 }
87
+
88
+ const firstTileTop = tiles[0].getBoundingClientRect().top
89
+ const tilesInFirstRow = tiles.filter(tile => {
90
+ const tileTop = tile.getBoundingClientRect().top
91
+ return Math.abs(tileTop - firstTileTop) < 5
92
+ }).length
93
+
94
+ return { tilesInFirstRow }
95
+ }
96
+
97
+ const tilesPerRowInput = canvas.getByLabelText(/tiles per row \(desktop\)/i) as HTMLInputElement
98
+
99
+ await performAndAssert(
100
+ 'Tiles Per Row Desktop - Layout changes from 3 to 2 tiles per row',
101
+ getTilesInFirstRow,
102
+ async () => {
103
+ await userEvent.clear(tilesPerRowInput)
104
+ await userEvent.type(tilesPerRowInput, '2')
105
+ tilesPerRowInput.blur()
106
+ },
107
+ (before, after) => {
108
+ return before.tilesInFirstRow === 3 && after.tilesInFirstRow === 2
109
+ }
110
+ )
111
+
112
+ // ============================================================================
113
+ // TEST: Tile Order
114
+ // Verifies: Changing tile order from custom to descending reverses tiles
115
+ // ============================================================================
116
+
117
+ const getTileTitles = () => {
118
+ const tiles = canvasElement.querySelectorAll('.small-multiples-grid > .small-multiple-tile')
119
+ const titles = Array.from(tiles).map(tile => {
120
+ const titleElement = tile.querySelector('.tile-title')
121
+ return titleElement?.textContent?.trim() || ''
122
+ })
123
+ return { titles }
124
+ }
125
+
126
+ const tileOrderSelect = canvas.getByLabelText(/tile order/i) as HTMLSelectElement
127
+
128
+ await performAndAssert(
129
+ 'Tile Order - Descending sorts tiles alphabetically descending',
130
+ getTileTitles,
131
+ async () => {
132
+ await userEvent.selectOptions(tileOrderSelect, 'desc')
133
+ },
134
+ (before, after) => {
135
+ return before.titles[0] === 'Influenza AAA' && after.titles[0] === 'RSV' && after.titles[1] === 'Influenza AAA'
136
+ }
137
+ )
138
+
139
+ // ============================================================================
140
+ // TEST: Custom Tile Title
141
+ // Verifies: Custom tile title appears in visualization
142
+ // ============================================================================
143
+
144
+ const covid19TitleInput = canvasElement.querySelector('input[placeholder="COVID-19"]') as HTMLInputElement
145
+
146
+ await performAndAssert(
147
+ 'Custom Tile Title - Changes tile display name',
148
+ getTileTitles,
149
+ async () => {
150
+ if (covid19TitleInput) {
151
+ await userEvent.clear(covid19TitleInput)
152
+ await userEvent.type(covid19TitleInput, 'Custom COVID Title')
153
+ covid19TitleInput.blur()
154
+ }
155
+ },
156
+ (before, after) => {
157
+ return after.titles.includes('Custom COVID Title') && !before.titles.includes('Custom COVID Title')
158
+ }
159
+ )
160
+ }
161
+ }
162
+
163
+ // ==========================================================================
164
+ // MULTI-COUNTRY MAP TESTS
165
+ // ==========================================================================