@cdc/map 4.26.3 → 4.26.4

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