@cdc/chart 4.25.10 → 4.25.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/{cdcchart-1a1724a1.es.js → cdcchart-dgT_1dIT.es.js} +136 -151
  2. package/dist/cdcchart.js +36258 -34658
  3. package/examples/feature/__data__/planet-example-data.json +1 -1
  4. package/examples/feature/boxplot/valid-boxplot.csv +38 -17
  5. package/examples/private/DEV-11825.json +573 -0
  6. package/examples/private/na.json +913 -0
  7. package/examples/private/test-data.csv +28 -0
  8. package/index.html +2 -121
  9. package/package.json +4 -4
  10. package/src/CdcChart.tsx +8 -11
  11. package/src/CdcChartComponent.tsx +256 -87
  12. package/src/_stories/Chart.Combo.stories.tsx +18 -0
  13. package/src/_stories/Chart.Forecast.stories.tsx +36 -0
  14. package/src/_stories/Chart.HTMLInDataTable.stories.tsx +520 -0
  15. package/src/_stories/Chart.Patterns.stories.tsx +2 -1
  16. package/src/_stories/Chart.PreserveDecimals.stories.tsx +220 -0
  17. package/src/_stories/Chart.SmallMultiples.stories.tsx +47 -0
  18. package/src/_stories/ChartAnnotation.stories.tsx +6 -3
  19. package/src/_stories/ChartBar.Editor.stories.tsx +3580 -0
  20. package/src/_stories/ChartEditor.Editor.stories.tsx +658 -0
  21. package/src/_stories/ChartEditor.stories.tsx +1 -2
  22. package/src/_stories/_mock/combo.json +451 -0
  23. package/src/_stories/_mock/editor-test-configs.json +376 -0
  24. package/src/_stories/_mock/editor-test-datasets.json +477 -0
  25. package/src/_stories/_mock/editor-tests/bar-chart-editor-test.json +255 -0
  26. package/src/_stories/_mock/editor-tests/bar-chart-general-test.json +267 -0
  27. package/src/_stories/_mock/editor-tests/bar-chart-test.json +237 -0
  28. package/src/_stories/_mock/forecast_combo_with_gaps.json +913 -0
  29. package/src/_stories/_mock/pie_config.json +257 -62
  30. package/src/_stories/_mock/small_multiples/small_multiples_bars.json +1944 -0
  31. package/src/_stories/_mock/small_multiples/small_multiples_big_data_bars.json +1114 -0
  32. package/src/_stories/_mock/small_multiples/small_multiples_lines.json +2646 -0
  33. package/src/_stories/_mock/small_multiples/small_multiples_lines_colors.json +1305 -0
  34. package/src/_stories/_mock/small_multiples/small_multiples_stacked_bars.json +1936 -0
  35. package/src/components/Annotations/components/findNearestDatum.ts +6 -41
  36. package/src/components/AreaChart/components/AreaChart.Stacked.jsx +10 -6
  37. package/src/components/AreaChart/index.tsx +1 -2
  38. package/src/components/BarChart/components/BarChart.Horizontal.tsx +4 -4
  39. package/src/components/BarChart/components/BarChart.Vertical.tsx +3 -2
  40. package/src/components/BoxPlot/helpers/index.ts +3 -3
  41. package/src/components/Brush/BrushChart.tsx +1 -1
  42. package/src/components/EditorPanel/EditorPanel.tsx +199 -190
  43. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +96 -111
  44. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +19 -1
  45. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +102 -55
  46. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +54 -49
  47. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +422 -0
  48. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +75 -21
  49. package/src/components/EditorPanel/components/Panels/index.tsx +3 -1
  50. package/src/components/EditorPanel/editor-panel.scss +0 -20
  51. package/src/components/EditorPanel/useEditorPermissions.ts +7 -15
  52. package/src/components/Forecasting/Forecasting.tsx +139 -21
  53. package/src/components/Legend/Legend.Component.tsx +16 -9
  54. package/src/components/Legend/helpers/createFormatLabels.tsx +181 -181
  55. package/src/components/Legend/helpers/getLegendClasses.ts +0 -1
  56. package/src/components/LineChart/LineChartProps.ts +0 -3
  57. package/src/components/LineChart/helpers.ts +1 -1
  58. package/src/components/LineChart/index.tsx +36 -13
  59. package/src/components/LinearChart.tsx +75 -80
  60. package/src/components/Regions/components/Regions.tsx +3 -24
  61. package/src/components/Sankey/types/index.ts +1 -1
  62. package/src/components/SmallMultiples/SmallMultipleTile.tsx +198 -0
  63. package/src/components/SmallMultiples/SmallMultiples.css +32 -0
  64. package/src/components/SmallMultiples/SmallMultiples.tsx +271 -0
  65. package/src/components/SmallMultiples/index.ts +2 -0
  66. package/src/data/initial-state.js +13 -1
  67. package/src/helpers/buildForecastPaletteOptions.ts +0 -38
  68. package/src/helpers/getColorScale.ts +10 -0
  69. package/src/{hooks/useMinMax.ts → helpers/getMinMax.ts} +14 -7
  70. package/src/helpers/getYAxisAutoPadding.ts +53 -0
  71. package/src/helpers/smallMultiplesHelpers.ts +529 -0
  72. package/src/hooks/useProgrammaticTooltip.ts +96 -0
  73. package/src/hooks/useScales.ts +88 -34
  74. package/src/hooks/useSmallMultipleSynchronization.ts +59 -0
  75. package/src/hooks/useTooltip.tsx +60 -15
  76. package/src/scss/main.scss +1 -80
  77. package/src/store/chart.actions.ts +2 -0
  78. package/src/store/chart.reducer.ts +4 -0
  79. package/src/types/ChartConfig.ts +24 -6
  80. package/src/types/ChartContext.ts +3 -0
  81. package/src/_stories/_mock/pie_data.json +0 -218
  82. package/src/components/AreaChart/components/AreaChart.jsx +0 -109
  83. package/src/helpers/sort.ts +0 -7
  84. package/src/hooks/useActiveElement.js +0 -19
  85. package/src/hooks/useChartClasses.js +0 -41
@@ -0,0 +1,658 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { within, userEvent, expect } from 'storybook/test'
3
+ import { performAndAssert, waitForEditor, openAccordion, MIN_ANIMATION_DELAY_MS } from '@cdc/core/helpers/testing'
4
+
5
+ import Chart from '../CdcChartComponent'
6
+ import mockScatterPlot from './_mock/scatterplot_mock.json'
7
+ import smallMultiplesLinesColorsConfig from './_mock/small_multiples/small_multiples_lines_colors.json'
8
+
9
+ const meta: Meta<typeof Chart> = {
10
+ title: 'Components/Templates/Chart/Editor Tests',
11
+ component: Chart
12
+ }
13
+
14
+ type Story = StoryObj<typeof Chart>
15
+
16
+ // Simple test - just check if chart loads without any interactions
17
+ export const GeneralSectionTests: Story = {
18
+ name: 'General Section Tests',
19
+ parameters: {
20
+ test: {
21
+ timeout: 30000 // 30 second timeout for comprehensive test
22
+ }
23
+ },
24
+ args: {
25
+ config: {
26
+ ...mockScatterPlot,
27
+ visualizationType: 'Bar',
28
+ title: 'Bar Chart Editor Test',
29
+ orientation: 'vertical',
30
+ xAxis: {
31
+ ...mockScatterPlot.xAxis,
32
+ type: 'categorical',
33
+ sortDates: false
34
+ },
35
+ yAxis: {
36
+ ...mockScatterPlot.yAxis,
37
+ type: 'linear'
38
+ },
39
+ series: mockScatterPlot.series.map(s => ({
40
+ ...s,
41
+ type: 'Bar'
42
+ }))
43
+ },
44
+ isEditor: true
45
+ },
46
+ play: async ({ canvasElement }) => {
47
+ const canvas = within(canvasElement)
48
+
49
+ // Wait for editor to load
50
+ await waitForEditor(canvas)
51
+
52
+ // Wait for chart to fully render
53
+ if (MIN_ANIMATION_DELAY_MS > 0) {
54
+ await new Promise(resolve => setTimeout(resolve, MIN_ANIMATION_DELAY_MS))
55
+ }
56
+
57
+ // Open General accordion
58
+ await openAccordion(canvas, 'General')
59
+
60
+ // Wait for accordion to open
61
+ if (MIN_ANIMATION_DELAY_MS > 0) {
62
+ await new Promise(resolve => setTimeout(resolve, MIN_ANIMATION_DELAY_MS))
63
+ }
64
+
65
+ // ============================================================================
66
+ // TEST: Chart Height Change
67
+ // Verifies: Chart visualization height changes when height input is modified
68
+ // ============================================================================
69
+ const getChartVisualizationHeight = () => {
70
+ // Target the tooltip-boundary div which directly wraps just the chart SVG
71
+ const chartContainer = canvasElement.querySelector('.tooltip-boundary')
72
+ // Target the main chart SVG specifically (not brush or other SVGs)
73
+ const chartSvg =
74
+ canvasElement.querySelector('.tooltip-boundary svg[role="img"]') ||
75
+ canvasElement.querySelector('svg.linear') ||
76
+ canvasElement.querySelector('.cdc-open-viz-module svg:not(.brush-container svg)')
77
+ return {
78
+ containerHeight: chartContainer?.getBoundingClientRect().height || 0,
79
+ svgHeight: parseInt(chartSvg?.getAttribute('height') || '0', 10),
80
+ containerExists: Boolean(chartContainer),
81
+ svgExists: Boolean(chartSvg)
82
+ }
83
+ }
84
+
85
+ const chartHeightInput = canvasElement.querySelector(
86
+ 'input[name*="height"]:not([name*="mobile"]), input[name*="vertical"]'
87
+ ) as HTMLInputElement
88
+ expect(chartHeightInput).toBeTruthy()
89
+
90
+ await performAndAssert(
91
+ 'Chart Height Change',
92
+ getChartVisualizationHeight,
93
+ async () => {
94
+ await userEvent.clear(chartHeightInput)
95
+ await userEvent.type(chartHeightInput, '600', { delay: 50 })
96
+ chartHeightInput.blur()
97
+
98
+ // Try triggering form submission by pressing Enter
99
+ await userEvent.type(chartHeightInput, '{Enter}')
100
+
101
+ // Wait longer for chart to re-render with proper timing
102
+ if (MIN_ANIMATION_DELAY_MS > 0) {
103
+ await new Promise(resolve => setTimeout(resolve, MIN_ANIMATION_DELAY_MS * 4))
104
+ }
105
+ },
106
+ (before, after) => {
107
+ // Test that the visualization height changed (could increase or decrease)
108
+ const heightChanged = Math.abs(after.containerHeight - before.containerHeight) > 10
109
+
110
+ const svgExists = after.svgExists && before.svgExists
111
+ return heightChanged && svgExists && after.containerExists
112
+ }
113
+ )
114
+
115
+ // ============================================================================
116
+ // NOTE: Mobile Height Testing
117
+ // Mobile Height requires manual verification due to responsive behavior:
118
+ // 1. Open this story in Storybook browser
119
+ // 2. Set Mobile Height to a different value (e.g., 300px)
120
+ // 3. Use browser dev tools to simulate mobile viewport (< 768px width)
121
+ // 4. Verify chart uses mobile height instead of regular height
122
+ // The mobile height input field is confirmed to exist and accept values.
123
+ // ============================================================================
124
+
125
+ // ============================================================================
126
+ // Title Tests - Universal fields supported by all chart types
127
+ // ============================================================================
128
+
129
+ // Test 1: Title Text Change
130
+ const getTitleText = () => {
131
+ // Look for the chart title using the specific class from CdcChartComponent.tsx line 913
132
+ const titleElement = canvasElement.querySelector('.chart-title')
133
+ const result = {
134
+ titleExists: Boolean(titleElement),
135
+ titleText: titleElement?.textContent?.trim() || '',
136
+ titleVisible: titleElement ? window.getComputedStyle(titleElement).display !== 'none' : false
137
+ }
138
+ return result
139
+ }
140
+
141
+ const titleInput = canvasElement.querySelector('input[name="title"], input#title') as HTMLInputElement
142
+ expect(titleInput).toBeTruthy()
143
+
144
+ await performAndAssert(
145
+ 'Title Text Change',
146
+ getTitleText,
147
+ async () => {
148
+ await userEvent.clear(titleInput)
149
+ await userEvent.type(titleInput, 'Updated Chart Title', { delay: 50 })
150
+ titleInput.blur()
151
+ // Add delay to make the change visible in Storybook
152
+ if (MIN_ANIMATION_DELAY_MS > 0) {
153
+ await new Promise(resolve => setTimeout(resolve, MIN_ANIMATION_DELAY_MS))
154
+ }
155
+ },
156
+ (before, after) => {
157
+ // Test that the title text in the chart updated
158
+ const titleChanged = after.titleText !== before.titleText
159
+ const titleExists = after.titleExists && before.titleExists
160
+ const newTitleCorrect = after.titleText.includes('Updated Chart Title')
161
+ return titleChanged && titleExists && newTitleCorrect
162
+ }
163
+ )
164
+
165
+ // Test 2: Show Title Toggle
166
+ const showTitleCheckbox = canvasElement.querySelector('input[name="showTitle"]') as HTMLInputElement
167
+ expect(showTitleCheckbox).toBeTruthy()
168
+
169
+ await performAndAssert(
170
+ 'Show Title Toggle',
171
+ getTitleText,
172
+ async () => {
173
+ // Toggle the show title checkbox (assuming it starts checked)
174
+ await userEvent.click(showTitleCheckbox)
175
+ // Add delay to make the change visible in Storybook
176
+ if (MIN_ANIMATION_DELAY_MS > 0) {
177
+ await new Promise(resolve => setTimeout(resolve, MIN_ANIMATION_DELAY_MS))
178
+ }
179
+ },
180
+ (before, after) => {
181
+ // Test that the title visibility changed in the chart
182
+ // When showTitle is toggled off, the title element is removed from DOM entirely
183
+ const titleWasVisible = before.titleExists && before.titleVisible
184
+ const titleIsNowHidden = !after.titleExists || !after.titleVisible
185
+ return titleWasVisible && titleIsNowHidden
186
+ }
187
+ )
188
+
189
+ // ============================================================================
190
+ // Super Title Tests
191
+ // ============================================================================
192
+
193
+ // IMPORTANT: Re-enable "Show Title" first (it was toggled off in previous test)
194
+ const showTitleCheckboxForSuperTitle = canvasElement.querySelector('input[name="showTitle"]') as HTMLInputElement
195
+ if (showTitleCheckboxForSuperTitle && !showTitleCheckboxForSuperTitle.checked) {
196
+ await userEvent.click(showTitleCheckboxForSuperTitle)
197
+ if (MIN_ANIMATION_DELAY_MS > 0) {
198
+ await new Promise(resolve => setTimeout(resolve, MIN_ANIMATION_DELAY_MS))
199
+ }
200
+ }
201
+
202
+ // Test 1: Super Title Text Change
203
+ const getSuperTitleText = () => {
204
+ // Super title is part of the .chart-title component but rendered separately
205
+ const titleContainer = canvasElement.querySelector('.chart-title')
206
+ const superTitleElement =
207
+ titleContainer?.querySelector('.super-title, [class*="super"]') || titleContainer?.children[0] // First child might be super title
208
+
209
+ return {
210
+ superTitleExists: Boolean(superTitleElement),
211
+ superTitleText: superTitleElement?.textContent?.trim() || '',
212
+ titleContainerExists: Boolean(titleContainer)
213
+ }
214
+ }
215
+
216
+ // Find the Super Title input using multiple fallback strategies
217
+ const superTitleInput = (() => {
218
+ // Strategy 1: Find by name attribute containing superTitle
219
+ const byName = canvasElement.querySelector('input[name*="superTitle"], textarea[name*="superTitle"]')
220
+ if (byName) return byName
221
+
222
+ // Strategy 2: Find input within a label that contains "Super Title"
223
+ const inputs = Array.from(canvasElement.querySelectorAll('input, textarea'))
224
+ for (const input of inputs) {
225
+ const label = input.closest('label')
226
+ const labelText = label?.textContent?.trim()
227
+ if (label && labelText && labelText.includes('Super Title')) {
228
+ return input
229
+ }
230
+ }
231
+
232
+ return null
233
+ })() as HTMLInputElement
234
+
235
+ if (superTitleInput) {
236
+ await performAndAssert(
237
+ 'Super Title Text Change',
238
+ getSuperTitleText,
239
+ async () => {
240
+ await userEvent.clear(superTitleInput)
241
+ await userEvent.type(superTitleInput, 'Updated Super Title', { delay: 50 })
242
+ superTitleInput.blur()
243
+ if (MIN_ANIMATION_DELAY_MS > 0) {
244
+ await new Promise(resolve => setTimeout(resolve, MIN_ANIMATION_DELAY_MS))
245
+ }
246
+ },
247
+ (before, after) => {
248
+ const superTitleChanged = after.superTitleText !== before.superTitleText
249
+ const newSuperTitleCorrect = after.superTitleText.includes('Updated Super Title')
250
+ return superTitleChanged && newSuperTitleCorrect && after.titleContainerExists
251
+ }
252
+ )
253
+ }
254
+
255
+ // ============================================================================
256
+ // Message (Intro Text) Tests
257
+ // ============================================================================
258
+
259
+ // Test 1: Message Text Change
260
+ const getMessageText = () => {
261
+ const messageElement = canvasElement.querySelector('.introText')
262
+ return {
263
+ messageExists: Boolean(messageElement),
264
+ messageText: messageElement?.textContent?.trim() || '',
265
+ messageVisible: messageElement ? window.getComputedStyle(messageElement).display !== 'none' : false
266
+ }
267
+ }
268
+
269
+ // Find the Message/Intro Text textarea using multiple fallback strategies
270
+ const messageInput = (() => {
271
+ // Strategy 1: Find by name attribute containing introText
272
+ const byName = canvasElement.querySelector('textarea[name*="introText"]')
273
+ if (byName) return byName
274
+
275
+ // Strategy 2: Find textarea within a label that contains "Message"
276
+ const textareas = Array.from(canvasElement.querySelectorAll('textarea'))
277
+ for (const textarea of textareas) {
278
+ const label = textarea.closest('label')
279
+ const labelText = label?.textContent?.trim()
280
+ if (label && labelText && (labelText.includes('Message') || labelText.includes('Intro Text'))) {
281
+ return textarea
282
+ }
283
+ }
284
+
285
+ // Strategy 3: Find by proximity to "Message" text
286
+ const labels = Array.from(canvasElement.querySelectorAll('label'))
287
+ for (const label of labels) {
288
+ const labelText = label.textContent?.trim()
289
+ if (labelText && labelText.includes('Message')) {
290
+ const textarea = label.querySelector('textarea')
291
+ if (textarea) return textarea
292
+ }
293
+ }
294
+
295
+ return null
296
+ })() as HTMLTextAreaElement
297
+ expect(messageInput).toBeTruthy()
298
+
299
+ await performAndAssert(
300
+ 'Message Text Change',
301
+ getMessageText,
302
+ async () => {
303
+ await userEvent.clear(messageInput)
304
+ await userEvent.type(messageInput, 'This is an updated message for the chart.', { delay: 30 })
305
+ messageInput.blur()
306
+ if (MIN_ANIMATION_DELAY_MS > 0) {
307
+ await new Promise(resolve => setTimeout(resolve, MIN_ANIMATION_DELAY_MS))
308
+ }
309
+ },
310
+ (before, after) => {
311
+ const messageChanged = after.messageText !== before.messageText
312
+ const messageVisible = after.messageExists && after.messageVisible
313
+ const newMessageCorrect = after.messageText.includes('This is an updated message')
314
+ return messageChanged && messageVisible && newMessageCorrect
315
+ }
316
+ )
317
+
318
+ // ============================================================================
319
+ // Subtext/Citation Tests
320
+ // ============================================================================
321
+
322
+ // Test 1: Subtext Text Change
323
+ const getSubtextText = () => {
324
+ // Subtext is typically rendered as .subtext or similar class
325
+ const subtextElement = canvasElement.querySelector('.subtext, .section-subtext, [class*="subtext"]')
326
+ return {
327
+ subtextExists: Boolean(subtextElement),
328
+ subtextText: subtextElement?.textContent?.trim() || '',
329
+ subtextVisible: subtextElement ? window.getComputedStyle(subtextElement).display !== 'none' : false
330
+ }
331
+ }
332
+
333
+ // Find the Subtext/Citation textarea
334
+ const subtextInput = (() => {
335
+ // Strategy 1: Find by name attribute containing description
336
+ const byName = canvasElement.querySelector('textarea[name*="description"]')
337
+ if (byName) return byName
338
+
339
+ // Strategy 2: Find textarea within a label that contains "Subtext" or "Citation"
340
+ const textareas = Array.from(canvasElement.querySelectorAll('textarea'))
341
+ for (const textarea of textareas) {
342
+ const label = textarea.closest('label')
343
+ const labelText = label?.textContent?.trim()
344
+ if (label && labelText && (labelText.includes('Subtext') || labelText.includes('Citation'))) {
345
+ return textarea
346
+ }
347
+ }
348
+
349
+ return null
350
+ })() as HTMLTextAreaElement
351
+
352
+ if (subtextInput) {
353
+ await performAndAssert(
354
+ 'Subtext Text Change',
355
+ getSubtextText,
356
+ async () => {
357
+ await userEvent.clear(subtextInput)
358
+ await userEvent.type(subtextInput, 'Updated subtext and citation information.', { delay: 30 })
359
+ subtextInput.blur()
360
+ if (MIN_ANIMATION_DELAY_MS > 0) {
361
+ await new Promise(resolve => setTimeout(resolve, MIN_ANIMATION_DELAY_MS))
362
+ }
363
+ },
364
+ (before, after) => {
365
+ const subtextChanged = after.subtextText !== before.subtextText
366
+ const subtextVisible = after.subtextExists && after.subtextVisible
367
+ const newSubtextCorrect = after.subtextText.includes('Updated subtext')
368
+ return subtextChanged && subtextVisible && newSubtextCorrect
369
+ }
370
+ )
371
+ }
372
+
373
+ // ============================================================================
374
+ // Footnotes Tests
375
+ // ============================================================================
376
+
377
+ // Test 1: Footnotes Text Change
378
+ const getFootnotesText = () => {
379
+ // Footnotes could be rendered in various ways - check multiple selectors
380
+ const footnotesElement = canvasElement.querySelector(
381
+ '.footnotes, .footnote, [class*="footnote"], .chart-footnotes'
382
+ )
383
+ return {
384
+ footnotesExists: Boolean(footnotesElement),
385
+ footnotesText: footnotesElement?.textContent?.trim() || '',
386
+ footnotesVisible: footnotesElement ? window.getComputedStyle(footnotesElement).display !== 'none' : false
387
+ }
388
+ }
389
+
390
+ // Find the Footnotes textarea
391
+ const footnotesInput = (() => {
392
+ // Strategy 1: Find by name attribute containing footnotes
393
+ const byName = canvasElement.querySelector('textarea[name*="footnotes"], textarea[name*="Footnotes"]')
394
+ if (byName) return byName
395
+
396
+ // Strategy 2: Find textarea within a label that contains "Footnotes"
397
+ const textareas = Array.from(canvasElement.querySelectorAll('textarea'))
398
+ for (const textarea of textareas) {
399
+ const label = textarea.closest('label')
400
+ const labelText = label?.textContent?.trim()
401
+ if (label && labelText && labelText.includes('Footnotes')) {
402
+ return textarea
403
+ }
404
+ }
405
+
406
+ return null
407
+ })() as HTMLTextAreaElement
408
+
409
+ if (footnotesInput) {
410
+ await performAndAssert(
411
+ 'Footnotes Text Change',
412
+ getFootnotesText,
413
+ async () => {
414
+ await userEvent.clear(footnotesInput)
415
+ await userEvent.type(footnotesInput, 'Updated footnotes with important data disclaimers.', { delay: 30 })
416
+ footnotesInput.blur()
417
+ if (MIN_ANIMATION_DELAY_MS > 0) {
418
+ await new Promise(resolve => setTimeout(resolve, MIN_ANIMATION_DELAY_MS))
419
+ }
420
+ },
421
+ (before, after) => {
422
+ const footnotesChanged = after.footnotesText !== before.footnotesText
423
+ const footnotesVisible = after.footnotesExists && after.footnotesVisible
424
+ const newFootnotesCorrect = after.footnotesText.includes('Updated footnotes')
425
+ return footnotesChanged && footnotesVisible && newFootnotesCorrect
426
+ }
427
+ )
428
+ }
429
+
430
+ // Add final delay to see the end state
431
+ if (MIN_ANIMATION_DELAY_MS > 0) {
432
+ await new Promise(resolve => setTimeout(resolve, MIN_ANIMATION_DELAY_MS))
433
+ }
434
+ }
435
+ }
436
+
437
+ // NOTE: Data Series tests have been moved to Bar/Bar.Editor.stories.tsx
438
+ // since series behavior is chart-type-specific
439
+
440
+ // ============================================================================
441
+ // SMALL MULTIPLES SECTION TESTS
442
+ // Tests the Small Multiples accordion section following best practices:
443
+ // - Tests visualization output changes, not control state
444
+ // - Uses performAndAssert pattern for all interactions
445
+ // - Tests specific visual changes in small multiples layout and tiles
446
+ // - Focuses on testing what reliably works
447
+ // ============================================================================
448
+
449
+ export const SmallMultiplesSectionTests: Story = {
450
+ name: 'Small Multiples Section Tests',
451
+ parameters: {},
452
+ args: {
453
+ config: {
454
+ ...smallMultiplesLinesColorsConfig,
455
+ title: 'Line Chart Small Multiples Test',
456
+ smallMultiples: {
457
+ ...smallMultiplesLinesColorsConfig.smallMultiples,
458
+ mode: '',
459
+ showAreaUnderLine: false
460
+ }
461
+ },
462
+ isEditor: true
463
+ },
464
+ play: async ({ canvasElement }) => {
465
+ const canvas = within(canvasElement)
466
+
467
+ await waitForEditor(canvas)
468
+
469
+ await openAccordion(canvas, 'Small Multiples')
470
+
471
+ // ============================================================================
472
+ // TEST: Enable Small Multiples Mode
473
+ // Verifies: Chart visualization changes from single chart to multiple tiles
474
+ // ============================================================================
475
+
476
+ const getChartCounts = () => {
477
+ const chartContainer = canvasElement.querySelector('.cove-component__content, .chart-container, .visualization')
478
+ const tiles = canvasElement.querySelectorAll('.small-multiple-tile, [class*="tile"]')
479
+ const svgElements = chartContainer?.querySelectorAll('svg[role="img"], svg.chart') || []
480
+
481
+ return {
482
+ svgCount: svgElements.length,
483
+ tileCount: tiles.length
484
+ }
485
+ }
486
+
487
+ const modeSelect = canvas.getByLabelText(/tile mode/i) as HTMLSelectElement
488
+
489
+ await performAndAssert(
490
+ 'Enable Small Multiples Mode - Chart splits into multiple tiles',
491
+ getChartCounts,
492
+ async () => {
493
+ await userEvent.selectOptions(modeSelect, 'by-series')
494
+ },
495
+ (before, after) => {
496
+ return before.svgCount === 1 && before.tileCount === 0 && after.svgCount > 1 && after.tileCount > 1
497
+ }
498
+ )
499
+
500
+ // ============================================================================
501
+ // TEST: Tiles Per Row Desktop
502
+ // Verifies: Grid layout changes from 3 tiles per row to 2 tiles per row
503
+ // ============================================================================
504
+
505
+ const getTilesInFirstRow = () => {
506
+ const tiles = Array.from(canvasElement.querySelectorAll('.small-multiples-grid > .small-multiple-tile'))
507
+ if (tiles.length === 0) return { tilesInFirstRow: 0 }
508
+
509
+ const firstTileTop = tiles[0].getBoundingClientRect().top
510
+ const tilesInFirstRow = tiles.filter(tile => {
511
+ const tileTop = tile.getBoundingClientRect().top
512
+ return Math.abs(tileTop - firstTileTop) < 5
513
+ }).length
514
+
515
+ return { tilesInFirstRow }
516
+ }
517
+
518
+ const tilesPerRowInput = canvas.getByLabelText(/tiles per row/i) as HTMLInputElement
519
+
520
+ await performAndAssert(
521
+ 'Tiles Per Row Desktop - Layout changes from 3 to 2 tiles per row',
522
+ getTilesInFirstRow,
523
+ async () => {
524
+ await userEvent.clear(tilesPerRowInput)
525
+ await userEvent.type(tilesPerRowInput, '2')
526
+ tilesPerRowInput.blur()
527
+ },
528
+ (before, after) => {
529
+ return before.tilesInFirstRow === 3 && after.tilesInFirstRow === 2
530
+ }
531
+ )
532
+
533
+ // ============================================================================
534
+ // TEST: Tile Order
535
+ // Verifies: Changing tile order from ascending to descending reverses tile titles
536
+ // ============================================================================
537
+
538
+ const getTileTitles = () => {
539
+ const tiles = canvasElement.querySelectorAll('.small-multiples-grid > .small-multiple-tile')
540
+ const titles = Array.from(tiles).map(tile => {
541
+ const titleElement = tile.querySelector('.tile-title')
542
+ return titleElement?.textContent?.trim() || ''
543
+ })
544
+ return { titles }
545
+ }
546
+
547
+ const tileOrderSelect = canvas.getByLabelText(/tile order/i) as HTMLSelectElement
548
+
549
+ await performAndAssert(
550
+ 'Tile Order - Descending reverses tile titles',
551
+ getTileTitles,
552
+ async () => {
553
+ await userEvent.selectOptions(tileOrderSelect, 'desc')
554
+ },
555
+ (before, after) => {
556
+ const beforeTitles = before.titles.join(',')
557
+ const afterTitlesReversed = after.titles.slice().reverse().join(',')
558
+ return beforeTitles === afterTitlesReversed && before.titles.length > 0
559
+ }
560
+ )
561
+
562
+ // ============================================================================
563
+ // TEST: Color Mode
564
+ // Verifies: Same color mode makes all line colors match
565
+ // ============================================================================
566
+
567
+ const getLineColors = () => {
568
+ const svgElements = canvasElement.querySelectorAll('svg[role="img"], svg.chart')
569
+ const lineColors = Array.from(svgElements).map(svg => {
570
+ const linePath = svg.querySelector('path[class*="visx-linepath"], path[stroke]')
571
+ return linePath?.getAttribute('stroke') || ''
572
+ })
573
+ const allSameColor = lineColors.length > 1 && lineColors.every(color => color === lineColors[0])
574
+ return { lineColors, allSameColor }
575
+ }
576
+
577
+ const colorModeSelect = canvas.getByLabelText(/color mode/i) as HTMLSelectElement
578
+
579
+ await performAndAssert(
580
+ 'Color Mode - Same color makes all lines match',
581
+ getLineColors,
582
+ async () => {
583
+ await userEvent.selectOptions(colorModeSelect, 'same')
584
+ },
585
+ (before, after) => {
586
+ return before.allSameColor === false && after.allSameColor === true
587
+ }
588
+ )
589
+
590
+ // ============================================================================
591
+ // TEST: Independent Y-Axis
592
+ // Verifies: Y-axis scales differ across tiles when enabled
593
+ // ============================================================================
594
+
595
+ const getYAxisScales = () => {
596
+ const svgElements = canvasElement.querySelectorAll('svg[role="img"], svg.chart')
597
+ const yAxisMaxValues = Array.from(svgElements).map(svg => {
598
+ const yAxisTicks = svg.querySelectorAll('g.visx-axis-left g.visx-axis-tick text, g[class*="axis"] text')
599
+ const tickValues = Array.from(yAxisTicks)
600
+ .map(tick => parseFloat(tick.textContent || '0'))
601
+ .filter(val => !isNaN(val))
602
+ return tickValues.length > 0 ? Math.max(...tickValues) : 0
603
+ })
604
+
605
+ const allSameScale = yAxisMaxValues.length > 1 && yAxisMaxValues.every(val => val === yAxisMaxValues[0])
606
+ const hasDifferentScales = yAxisMaxValues.length > 1 && !allSameScale
607
+
608
+ return {
609
+ yAxisMaxValues,
610
+ allSameScale,
611
+ hasDifferentScales
612
+ }
613
+ }
614
+
615
+ const independentYAxisCheckbox = canvas.getByLabelText(/independent y-axis scales/i) as HTMLInputElement
616
+
617
+ await performAndAssert(
618
+ 'Independent Y-Axis Toggle - Y-axis scales become different across tiles',
619
+ getYAxisScales,
620
+ async () => {
621
+ await userEvent.click(independentYAxisCheckbox)
622
+ },
623
+ (before, after) => {
624
+ return before.allSameScale === true && after.hasDifferentScales === true
625
+ }
626
+ )
627
+
628
+ // ============================================================================
629
+ // TEST: Shade Area Under Lines
630
+ // Verifies: Area path elements appear under lines when enabled
631
+ // ============================================================================
632
+
633
+ const getAreaUnderLineCount = () => {
634
+ const svgElements = canvasElement.querySelectorAll('svg[role="img"], svg.chart')
635
+ let areaPathCount = 0
636
+ svgElements.forEach(svg => {
637
+ const areaPaths = svg.querySelectorAll('path[fill-opacity="0.3"]')
638
+ areaPathCount += areaPaths.length
639
+ })
640
+ return { areaPathCount }
641
+ }
642
+
643
+ const shadeAreaCheckbox = canvas.getByLabelText(/shade area under lines/i) as HTMLInputElement
644
+
645
+ await performAndAssert(
646
+ 'Shade Area Under Lines - Area paths appear when enabled',
647
+ getAreaUnderLineCount,
648
+ async () => {
649
+ await userEvent.click(shadeAreaCheckbox)
650
+ },
651
+ (before, after) => {
652
+ return before.areaPathCount === 0 && after.areaPathCount > 0
653
+ }
654
+ )
655
+ }
656
+ }
657
+
658
+ export default meta
@@ -2,7 +2,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { userEvent, within } from 'storybook/test'
3
3
  import Chart from '../CdcChartComponent'
4
4
  import pieChartExample from './_mock/pie_config.json'
5
- import pieData from './_mock/pie_data.json'
6
5
  import urlFilterExample from './_mock/url_filter.json'
7
6
  import mockScatterPlot from './_mock/scatterplot_mock.json'
8
7
 
@@ -15,7 +14,7 @@ type Story = StoryObj<typeof Chart>
15
14
 
16
15
  export const Primary: Story = {
17
16
  args: {
18
- config: { ...pieChartExample, data: pieData, columns: { someCol: { name: 'females', showInViz: true } } },
17
+ config: { ...pieChartExample },
19
18
  isEditor: true
20
19
  }
21
20
  }