@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,600 @@
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 ColumnsSectionTests: Story = {
27
+ args: {
28
+ ...DEFAULT_ARGS
29
+ },
30
+ play: async ({ canvasElement }) => {
31
+ const setTextFieldValue = (input: HTMLInputElement | HTMLTextAreaElement, value: string) => {
32
+ const prototype =
33
+ input instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
34
+ const valueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
35
+ valueSetter?.call(input, value)
36
+ input.dispatchEvent(new Event('input', { bubbles: true }))
37
+ input.dispatchEvent(new Event('change', { bubbles: true }))
38
+ }
39
+ const canvas = within(canvasElement)
40
+
41
+ await waitForEditor(canvas)
42
+ await waitForPresence('.map-container', canvasElement)
43
+
44
+ await openAccordion(canvas, 'Columns')
45
+
46
+ // ==========================================================================
47
+ // TEST: Geography column select
48
+ // Verifies: Changing geo column to invalid data makes map gray and legend shows "No data"
49
+ // ==========================================================================
50
+ const columnsAccordionButton = canvas
51
+ .getAllByRole('button', { name: /^Columns$/i })
52
+ .find(button => button.closest('[data-accordion-component="AccordionItem"], .accordion__item'))
53
+ const columnsAccordion = columnsAccordionButton?.closest(
54
+ '[data-accordion-component="AccordionItem"], .accordion__item'
55
+ )
56
+ expect(columnsAccordion).toBeTruthy()
57
+ const geoColumnLabel = Array.from(columnsAccordion?.querySelectorAll('label') || []).find(label => {
58
+ const columnHeading = label.querySelector('.edit-label.column-heading')
59
+ return columnHeading?.textContent?.includes('Geography')
60
+ })
61
+ const geoSelect = geoColumnLabel?.querySelector('select') as HTMLSelectElement
62
+ expect(geoSelect).toBeTruthy()
63
+
64
+ const getMapAndLegendState = () => {
65
+ const legendContainer = canvasElement.querySelector('.legend-container')
66
+ const legendSvg = legendContainer?.querySelector('svg')
67
+ const gradientStops = legendSvg?.querySelectorAll('linearGradient stop')
68
+
69
+ // Gradient legend has color stops when data is valid
70
+ const hasGradientStops = (gradientStops?.length || 0) > 0
71
+
72
+ return {
73
+ legendHTML: legendContainer?.innerHTML || '',
74
+ hasGradientStops: hasGradientStops,
75
+ gradientStopCount: gradientStops?.length || 0
76
+ }
77
+ }
78
+
79
+ // Change to a column that's NOT valid geography data (Rate instead of STATE)
80
+ await performAndAssert(
81
+ 'Geography Column → Change to invalid column',
82
+ getMapAndLegendState,
83
+ async () => {
84
+ await userEvent.selectOptions(geoSelect, 'Rate')
85
+ },
86
+ (before, after) => {
87
+ // Legend gradient should lose color stops when geo column is invalid
88
+ return before.hasGradientStops && !after.hasGradientStops
89
+ }
90
+ )
91
+
92
+ // Change back to valid geography column
93
+ await performAndAssert(
94
+ 'Geography Column → Change back to valid column',
95
+ getMapAndLegendState,
96
+ async () => {
97
+ await userEvent.selectOptions(geoSelect, 'STATE')
98
+ },
99
+ (before, after) => {
100
+ // Legend gradient should regain color stops when geo column is valid
101
+ return !before.hasGradientStops && after.hasGradientStops
102
+ }
103
+ )
104
+
105
+ // ==========================================================================
106
+ // TEST: Hide Geography Column Name in Tooltip checkbox
107
+ // Verifies: Tooltip content changes when geography label is hidden/shown
108
+ // ==========================================================================
109
+ const hideGeoLabel = Array.from(columnsAccordion?.querySelectorAll('label') || []).find(label =>
110
+ label.textContent?.includes('Hide Geography Column Name in Tooltip')
111
+ )
112
+ const hideGeoCheckbox = hideGeoLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement
113
+ expect(hideGeoCheckbox).toBeTruthy()
114
+
115
+ const getTooltipContent = () => {
116
+ const geoGroups = Array.from(canvasElement.querySelectorAll('g.geo-group')) as SVGGElement[]
117
+ const tooltipHtml =
118
+ geoGroups.map(group => group.getAttribute('data-tooltip-html') || '').find(html => html.trim().length > 0) || ''
119
+
120
+ return {
121
+ tooltipContent: tooltipHtml,
122
+ hasTooltipHeading: tooltipHtml.includes('tooltip-heading'),
123
+ hasStatePrefix: tooltipHtml.includes('State:'),
124
+ usesStrongGeoName: tooltipHtml.startsWith('<strong>')
125
+ }
126
+ }
127
+
128
+ await performAndAssert(
129
+ 'Wait for geography tooltip to populate',
130
+ getTooltipContent,
131
+ async () => {},
132
+ (_before, after) => after.tooltipContent.length > 0
133
+ )
134
+
135
+ await performAndAssert(
136
+ 'Hide Geography Column Name → Enable',
137
+ getTooltipContent,
138
+ async () => {
139
+ await userEvent.click(hideGeoCheckbox)
140
+ },
141
+ (before, after) => {
142
+ // Hidden geography labels now render as a standalone <strong> name instead of a tooltip heading.
143
+ return before.tooltipContent.length > 0 && after.usesStrongGeoName && !after.hasTooltipHeading
144
+ }
145
+ )
146
+
147
+ await performAndAssert(
148
+ 'Hide Geography Column Name → Disable',
149
+ getTooltipContent,
150
+ async () => {
151
+ await userEvent.click(hideGeoCheckbox)
152
+ },
153
+ (before, after) => {
154
+ // Re-enabling restores the heading-based tooltip format.
155
+ return (
156
+ before.usesStrongGeoName &&
157
+ after.hasTooltipHeading &&
158
+ (after.hasStatePrefix || after.tooltipContent.length > 0)
159
+ )
160
+ }
161
+ )
162
+
163
+ // ==========================================================================
164
+ // TEST: Geography Label field
165
+ // Verifies: Custom geography label appears in tooltip instead of default "State:"
166
+ // ==========================================================================
167
+ const geoLabelInput = Array.from(columnsAccordion?.querySelectorAll('input[type="text"]') || []).find(input => {
168
+ const label = input.closest('label')
169
+ return label?.textContent?.includes('Geography Label')
170
+ }) as HTMLInputElement
171
+ expect(geoLabelInput).toBeTruthy()
172
+
173
+ await performAndAssert(
174
+ 'Geography Label → Add custom label',
175
+ getTooltipContent,
176
+ async () => {
177
+ geoLabelInput.scrollIntoView({ block: 'center' })
178
+ setTextFieldValue(geoLabelInput, 'Region')
179
+ },
180
+ (before, after) => {
181
+ // Custom label "Region:" should appear in tooltip
182
+ return !before.tooltipContent.includes('Region:') && after.tooltipContent.includes('Region:')
183
+ }
184
+ )
185
+
186
+ // ==========================================================================
187
+ // TEST: Data Column select
188
+ // Verifies: Changing data column changes tooltip data values
189
+ // ==========================================================================
190
+ const dataColumnLabel = Array.from(columnsAccordion?.querySelectorAll('label') || []).find(label =>
191
+ label.textContent?.includes('Data Column')
192
+ )
193
+ const dataColumnSelect = dataColumnLabel?.querySelector('select') as HTMLSelectElement
194
+ expect(dataColumnSelect).toBeTruthy()
195
+
196
+ const getDataTooltipContent = () => {
197
+ const geoGroup = canvasElement.querySelector('g.geo-group') as SVGGElement
198
+ const tooltipHtml = geoGroup?.getAttribute('data-tooltip-html') || ''
199
+ return {
200
+ tooltipContent: tooltipHtml
201
+ }
202
+ }
203
+
204
+ await performAndAssert(
205
+ 'Data Column → Change to STATE column',
206
+ getDataTooltipContent,
207
+ async () => {
208
+ await userEvent.selectOptions(dataColumnSelect, 'STATE')
209
+ },
210
+ (before, after) => {
211
+ // Tooltip should show STATE values (e.g., state names) instead of Rate values (numbers)
212
+ const beforeHasNumbers = /\d+/.test(before.tooltipContent)
213
+ const afterHasStateNames = /Alabama|Alaska|Arizona|Arkansas|California/.test(after.tooltipContent)
214
+ return beforeHasNumbers && afterHasStateNames
215
+ }
216
+ )
217
+
218
+ // Change back to Rate
219
+ await performAndAssert(
220
+ 'Data Column → Change back to Rate',
221
+ getDataTooltipContent,
222
+ async () => {
223
+ await userEvent.selectOptions(dataColumnSelect, 'Rate')
224
+ },
225
+ (before, after) => {
226
+ // Tooltip should show numeric Rate values again
227
+ const beforeHasStateNames = /Alabama|Alaska|Arizona|Arkansas|California/.test(before.tooltipContent)
228
+ const afterHasNumbers = /\d+/.test(after.tooltipContent)
229
+ return beforeHasStateNames && afterHasNumbers
230
+ }
231
+ )
232
+
233
+ // ==========================================================================
234
+ // TEST: Hide Data Column Name in Tooltip
235
+ // Verifies: Data column label disappears from tooltip when hidden
236
+ // ==========================================================================
237
+ const hideDataLabel = Array.from(columnsAccordion?.querySelectorAll('label') || []).find(label =>
238
+ label.textContent?.includes('Hide Data Column Name in Tooltip')
239
+ )
240
+ const hideDataCheckbox = hideDataLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement
241
+ expect(hideDataCheckbox).toBeTruthy()
242
+
243
+ await performAndAssert(
244
+ 'Hide Data Column Name → Enable',
245
+ getDataTooltipContent,
246
+ async () => {
247
+ await userEvent.click(hideDataCheckbox)
248
+ },
249
+ (before, after) => {
250
+ // The label prefix (e.g., "Rate:" or similar) should be removed
251
+ // Before should have a colon pattern indicating a label, after should have less structure
252
+ const beforeHasLabelStructure = before.tooltipContent.includes(':</li>') || before.tooltipContent.includes(': ')
253
+ const afterHasLessStructure = after.tooltipContent.length < before.tooltipContent.length
254
+ return beforeHasLabelStructure && afterHasLessStructure
255
+ }
256
+ )
257
+
258
+ await performAndAssert(
259
+ 'Hide Data Column Name → Disable',
260
+ getDataTooltipContent,
261
+ async () => {
262
+ await userEvent.click(hideDataCheckbox)
263
+ },
264
+ (before, after) => {
265
+ // The label prefix should reappear
266
+ const beforeHasLessStructure = before.tooltipContent.length < after.tooltipContent.length
267
+ const afterHasLabelStructure = after.tooltipContent.includes(':</li>') || after.tooltipContent.includes(': ')
268
+ return beforeHasLessStructure && afterHasLabelStructure
269
+ }
270
+ )
271
+
272
+ // ==========================================================================
273
+ // TEST: Data Label field
274
+ // Verifies: Custom data label appears in tooltip
275
+ // ==========================================================================
276
+ const dataLabelInputs = Array.from(columnsAccordion?.querySelectorAll('input') || [])
277
+ const dataLabelInput = dataLabelInputs.find(input => {
278
+ const label = input.closest('label')
279
+ return label?.textContent?.includes('Data Label')
280
+ }) as HTMLInputElement
281
+ expect(dataLabelInput).toBeTruthy()
282
+
283
+ await performAndAssert(
284
+ 'Data Label → Add custom label',
285
+ getDataTooltipContent,
286
+ async () => {
287
+ dataLabelInput.scrollIntoView({ block: 'center' })
288
+ setTextFieldValue(dataLabelInput, 'Value')
289
+ },
290
+ (before, after) => {
291
+ // Custom label "Value:" should appear in tooltip
292
+ return !before.tooltipContent.includes('Value:') && after.tooltipContent.includes('Value:')
293
+ }
294
+ )
295
+
296
+ // ==========================================================================
297
+ // TEST: Prefix field
298
+ // Verifies: Prefix appears before data values in both tooltip and legend
299
+ // ==========================================================================
300
+ const prefixInputs = Array.from(columnsAccordion?.querySelectorAll('input') || [])
301
+ const prefixInput = prefixInputs.find(input => {
302
+ const label = input.closest('label')
303
+ return label?.textContent?.includes('Prefix')
304
+ }) as HTMLInputElement
305
+ expect(prefixInput).toBeTruthy()
306
+
307
+ const getPrefixVisualization = () => {
308
+ const geoGroup = canvasElement.querySelector('g.geo-group') as SVGGElement
309
+ const tooltipHtml = geoGroup?.getAttribute('data-tooltip-html') || ''
310
+ const legendContainer = canvasElement.querySelector('.legend-container')
311
+ const legendSvg = legendContainer?.querySelector('svg')
312
+ const legendText = legendSvg?.textContent || ''
313
+
314
+ return {
315
+ tooltipHasPrefix: tooltipHtml.includes('$'),
316
+ legendHasPrefix: legendText.includes('$'),
317
+ tooltipContent: tooltipHtml,
318
+ legendContent: legendText
319
+ }
320
+ }
321
+
322
+ await performAndAssert(
323
+ 'Prefix → Add dollar sign',
324
+ getPrefixVisualization,
325
+ async () => {
326
+ prefixInput.scrollIntoView({ block: 'center' })
327
+ setTextFieldValue(prefixInput, '$')
328
+ },
329
+ (before, after) => {
330
+ // Dollar sign should appear in both tooltip and legend
331
+ return !before.tooltipHasPrefix && after.tooltipHasPrefix && !before.legendHasPrefix && after.legendHasPrefix
332
+ }
333
+ )
334
+
335
+ // ==========================================================================
336
+ // TEST: Suffix field
337
+ // Verifies: Suffix appears after data values in both tooltip and legend
338
+ // ==========================================================================
339
+ const suffixInputs = Array.from(columnsAccordion?.querySelectorAll('input') || [])
340
+ const suffixInput = suffixInputs.find(input => {
341
+ const label = input.closest('label')
342
+ return label?.textContent?.includes('Suffix')
343
+ }) as HTMLInputElement
344
+ expect(suffixInput).toBeTruthy()
345
+
346
+ const getSuffixVisualization = () => {
347
+ const geoGroup = canvasElement.querySelector('g.geo-group') as SVGGElement
348
+ const tooltipHtml = geoGroup?.getAttribute('data-tooltip-html') || ''
349
+ const legendContainer = canvasElement.querySelector('.legend-container')
350
+ const legendSvg = legendContainer?.querySelector('svg')
351
+ const legendText = legendSvg?.textContent || ''
352
+
353
+ return {
354
+ tooltipHasSuffix: tooltipHtml.includes('%'),
355
+ legendHasSuffix: legendText.includes('%'),
356
+ tooltipContent: tooltipHtml,
357
+ legendContent: legendText
358
+ }
359
+ }
360
+
361
+ await performAndAssert(
362
+ 'Suffix → Add percent sign',
363
+ getSuffixVisualization,
364
+ async () => {
365
+ suffixInput.scrollIntoView({ block: 'center' })
366
+ setTextFieldValue(suffixInput, '%')
367
+ },
368
+ (before, after) => {
369
+ // Percent sign should appear in both tooltip and legend
370
+ return !before.tooltipHasSuffix && after.tooltipHasSuffix && !before.legendHasSuffix && after.legendHasSuffix
371
+ }
372
+ )
373
+
374
+ // ==========================================================================
375
+ // TEST: Round field
376
+ // Verifies: Changing rounding adds decimal places in both tooltip and legend
377
+ // ==========================================================================
378
+ const roundInputs = Array.from(columnsAccordion?.querySelectorAll('input') || [])
379
+ const roundInput = roundInputs.find(input => {
380
+ const label = input.closest('label')
381
+ return label?.textContent?.includes('Round')
382
+ }) as HTMLInputElement
383
+ expect(roundInput).toBeTruthy()
384
+
385
+ const getRoundingVisualization = () => {
386
+ const geoGroup = canvasElement.querySelector('g.geo-group') as SVGGElement
387
+ const tooltipHtml = geoGroup?.getAttribute('data-tooltip-html') || ''
388
+ const legendContainer = canvasElement.querySelector('.legend-container')
389
+ const legendSvg = legendContainer?.querySelector('svg')
390
+ const legendText = legendSvg?.textContent || ''
391
+
392
+ // Check for 2 decimal places pattern (e.g., "12.34", "0.50", etc.)
393
+ const twoDecimalPattern = /\d+\.\d{2}/
394
+
395
+ return {
396
+ tooltipHasTwoDecimals: twoDecimalPattern.test(tooltipHtml),
397
+ legendHasTwoDecimals: twoDecimalPattern.test(legendText),
398
+ tooltipContent: tooltipHtml,
399
+ legendContent: legendText
400
+ }
401
+ }
402
+
403
+ await performAndAssert(
404
+ 'Round → Set to 2 decimal places',
405
+ getRoundingVisualization,
406
+ async () => {
407
+ roundInput.scrollIntoView({ block: 'center' })
408
+ setTextFieldValue(roundInput, '2')
409
+ },
410
+ (before, after) => {
411
+ // Numbers should now show 2 decimal places in both tooltip and legend
412
+ return (
413
+ !before.tooltipHasTwoDecimals &&
414
+ after.tooltipHasTwoDecimals &&
415
+ !before.legendHasTwoDecimals &&
416
+ after.legendHasTwoDecimals
417
+ )
418
+ }
419
+ )
420
+
421
+ // ==========================================================================
422
+ // TEST: Show in Data Table checkbox
423
+ // Verifies: Primary column appears/disappears in the data table
424
+ // ==========================================================================
425
+ const showInTableLabel = Array.from(columnsAccordion?.querySelectorAll('label') || []).find(label =>
426
+ label.textContent?.includes('Show in Data Table')
427
+ )
428
+ const showInTableCheckbox = showInTableLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement
429
+ expect(showInTableCheckbox).toBeTruthy()
430
+
431
+ const getDataTableState = () => {
432
+ const dataTable = canvasElement.querySelector('.data-table-container, table')
433
+ const tableHeaders = Array.from(dataTable?.querySelectorAll('th') || [])
434
+ const tableHeaderText = tableHeaders.map(th => th.textContent?.trim() || '')
435
+
436
+ // The primary map column renders as "Location" in this story's data table.
437
+ const hasPrimaryColumn = tableHeaderText.some(text => text.includes('Location'))
438
+
439
+ return {
440
+ hasDataTable: Boolean(dataTable),
441
+ tableHeaders: tableHeaderText,
442
+ hasPrimaryColumn: hasPrimaryColumn
443
+ }
444
+ }
445
+
446
+ // Test config has dataTable: true for primary column
447
+ await performAndAssert(
448
+ 'Show in Data Table → Disable',
449
+ getDataTableState,
450
+ async () => {
451
+ await userEvent.click(showInTableCheckbox)
452
+ },
453
+ (before, after) => {
454
+ // Primary column should disappear from data table headers.
455
+ return before.hasPrimaryColumn && !after.hasPrimaryColumn
456
+ }
457
+ )
458
+
459
+ await performAndAssert(
460
+ 'Show in Data Table → Enable',
461
+ getDataTableState,
462
+ async () => {
463
+ await userEvent.click(showInTableCheckbox)
464
+ },
465
+ (before, after) => {
466
+ // Primary column should reappear in data table headers.
467
+ return !before.hasPrimaryColumn && after.hasPrimaryColumn
468
+ }
469
+ )
470
+
471
+ // ==========================================================================
472
+ // TEST: Show in Tooltips checkbox
473
+ // Verifies: Primary column data appears/disappears in tooltips
474
+ // ==========================================================================
475
+ const showInTooltipsLabel = Array.from(columnsAccordion?.querySelectorAll('label') || []).find(label =>
476
+ label.textContent?.includes('Show in Tooltips')
477
+ )
478
+ const showInTooltipsCheckbox = showInTooltipsLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement
479
+ expect(showInTooltipsCheckbox).toBeTruthy()
480
+
481
+ const getTooltipDataState = () => {
482
+ const geoGroup = canvasElement.querySelector('g.geo-group') as SVGGElement
483
+ const tooltipHtml = geoGroup?.getAttribute('data-tooltip-html') || ''
484
+
485
+ const hasPrimaryLocationField = tooltipHtml.includes('Location:')
486
+ const hasValueField = tooltipHtml.includes('Value:')
487
+
488
+ return {
489
+ tooltipContent: tooltipHtml,
490
+ hasPrimaryLocationField: hasPrimaryLocationField,
491
+ hasValueField: hasValueField
492
+ }
493
+ }
494
+
495
+ // Test config has tooltip: true for primary column
496
+ await performAndAssert(
497
+ 'Show in Tooltips → Disable',
498
+ getTooltipDataState,
499
+ async () => {
500
+ await userEvent.click(showInTooltipsCheckbox)
501
+ },
502
+ (before, after) => {
503
+ // Primary tooltip field should be enabled.
504
+ return !before.hasPrimaryLocationField && after.hasPrimaryLocationField && after.hasValueField
505
+ }
506
+ )
507
+
508
+ await performAndAssert(
509
+ 'Show in Tooltips → Enable',
510
+ getTooltipDataState,
511
+ async () => {
512
+ await userEvent.click(showInTooltipsCheckbox)
513
+ },
514
+ (before, after) => {
515
+ // Primary tooltip field should be disabled again.
516
+ return before.hasPrimaryLocationField && !after.hasPrimaryLocationField && after.hasValueField
517
+ }
518
+ )
519
+
520
+ // ==========================================================================
521
+ // TEST: Add Special Class button
522
+ // Verifies: New special class item appears in legend
523
+ // ==========================================================================
524
+ const addSpecialClassButton = Array.from(columnsAccordion?.querySelectorAll('button') || []).find(button =>
525
+ button.textContent?.includes('Add Special Class')
526
+ ) as HTMLButtonElement
527
+ expect(addSpecialClassButton).toBeTruthy()
528
+
529
+ const getSpecialClassVisualization = () => {
530
+ const legendContainer = canvasElement.querySelector('.legend-container')
531
+ const legendSvg = legendContainer?.querySelector('svg')
532
+ const gradientStops = legendSvg?.querySelectorAll('linearGradient stop')
533
+ const gradientRects = legendSvg?.querySelectorAll('g.visx-group rect')
534
+
535
+ // Get all stop colors to detect changes
536
+ const stopColors = Array.from(gradientStops || []).map(stop => (stop as SVGStopElement).getAttribute('style'))
537
+
538
+ // Get all rect fills to detect new colors
539
+ const rectFills = Array.from(gradientRects || []).map(rect => (rect as SVGRectElement).getAttribute('fill'))
540
+
541
+ return {
542
+ gradientStopCount: gradientStops?.length || 0,
543
+ gradientRectCount: gradientRects?.length || 0,
544
+ stopColors: stopColors,
545
+ rectFills: rectFills,
546
+ legendHTML: legendContainer?.innerHTML || ''
547
+ }
548
+ }
549
+
550
+ await performAndAssert(
551
+ 'Add Special Class → Configure with STATE=Alabama',
552
+ getSpecialClassVisualization,
553
+ async () => {
554
+ // Click the Add Special Class button
555
+ await userEvent.click(addSpecialClassButton)
556
+
557
+ // Find the newly added special class fields
558
+ const specialClassSection = Array.from(columnsAccordion?.querySelectorAll('.edit-block') || []).find(block =>
559
+ block.textContent?.includes('Special Class 1')
560
+ )
561
+
562
+ // Get all selects in the special class section
563
+ // First select is Data Key, second is Value
564
+ const selects = Array.from(specialClassSection?.querySelectorAll('select') || [])
565
+ const dataKeySelect = selects[0] as HTMLSelectElement
566
+ const valueSelect = selects[1] as HTMLSelectElement
567
+
568
+ // Set Data Key to STATE
569
+ await userEvent.selectOptions(dataKeySelect, 'STATE')
570
+
571
+ // Wait for the Value select to populate with options based on STATE column
572
+ await new Promise(resolve => setTimeout(resolve, 200))
573
+
574
+ // Select "Alabama" from the Value select
575
+ await userEvent.selectOptions(valueSelect, 'Alabama')
576
+
577
+ // Find and fill in the Label field
578
+ const labelInput = Array.from(specialClassSection?.querySelectorAll('input') || []).find(input => {
579
+ const label = input.closest('label')
580
+ return label?.textContent?.includes('Label')
581
+ }) as HTMLInputElement
582
+
583
+ labelInput.scrollIntoView({ block: 'center' })
584
+ setTextFieldValue(labelInput, 'Alabama')
585
+ },
586
+ (before, after) => {
587
+ // Check that "Alabama" text appears in the legend
588
+ const alabamaInLegend = after.legendHTML.includes('Alabama')
589
+
590
+ // Check that the special class gray color appears
591
+ const hasGrayColor = after.rectFills.includes('#b4b4b4')
592
+
593
+ // Legend should change
594
+ const legendChanged = after.legendHTML !== before.legendHTML
595
+
596
+ return alabamaInLegend && hasGrayColor && legendChanged
597
+ }
598
+ )
599
+ }
600
+ }