@cdc/chart 4.25.10 → 4.26.1

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