@automattic/charts 3.2.0 → 4.1.0

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/CHANGELOG.md +47 -0
  2. package/SECURITY.md +1 -0
  3. package/TOKENS.md +53 -58
  4. package/dist/index.cjs +1483 -664
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.css +93 -29
  7. package/dist/index.d.cts +233 -247
  8. package/dist/index.d.ts +233 -247
  9. package/dist/index.js +1484 -667
  10. package/dist/index.js.map +1 -1
  11. package/package.json +7 -6
  12. package/src/charts/area-chart/area-chart.tsx +39 -25
  13. package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
  14. package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
  15. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
  16. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
  17. package/src/charts/area-chart/types.ts +4 -7
  18. package/src/charts/bar-chart/bar-chart.tsx +2 -1
  19. package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
  20. package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
  21. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -0
  22. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
  23. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
  24. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
  25. package/src/charts/geo-chart/geo-chart.module.scss +1 -0
  26. package/src/charts/geo-chart/geo-chart.tsx +5 -10
  27. package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
  28. package/src/charts/heatmap-chart/heatmap-chart.module.scss +52 -16
  29. package/src/charts/heatmap-chart/heatmap-chart.tsx +89 -37
  30. package/src/charts/heatmap-chart/index.ts +2 -1
  31. package/src/charts/heatmap-chart/private/build-calendar-data.ts +126 -89
  32. package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
  33. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
  34. package/src/charts/heatmap-chart/private/index.ts +1 -1
  35. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
  36. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +3 -0
  37. package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
  38. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +142 -12
  39. package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
  40. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
  41. package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
  42. package/src/charts/heatmap-chart/types.ts +8 -2
  43. package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
  44. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  45. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
  46. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
  47. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
  48. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
  49. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
  50. package/src/charts/leaderboard-chart/types.ts +2 -2
  51. package/src/charts/line-chart/line-chart.module.scss +10 -18
  52. package/src/charts/line-chart/line-chart.tsx +128 -26
  53. package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
  54. package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
  55. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
  56. package/src/charts/line-chart/test/line-chart.test.tsx +174 -41
  57. package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
  58. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
  59. package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
  60. package/src/charts/line-chart/types.ts +17 -3
  61. package/src/charts/pie-chart/pie-chart.module.scss +13 -1
  62. package/src/charts/pie-chart/pie-chart.tsx +30 -40
  63. package/src/charts/pie-chart/test/pie-chart.test.tsx +59 -12
  64. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
  65. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
  66. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
  67. package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
  68. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  69. package/src/charts/private/test/axis-formatting.test.tsx +85 -0
  70. package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
  71. package/src/charts/private/test/time-axis-options.test.ts +267 -0
  72. package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
  73. package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
  74. package/src/charts/private/test/time-axis.test.ts +9 -69
  75. package/src/charts/private/time-axis-options.ts +106 -0
  76. package/src/charts/private/time-axis.ts +318 -136
  77. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  78. package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
  79. package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
  80. package/src/charts/sparkline/types.ts +1 -1
  81. package/src/components/legend/private/base-legend.module.scss +9 -5
  82. package/src/components/tooltip/accessible-tooltip.tsx +21 -17
  83. package/src/components/tooltip/base-tooltip.module.scss +3 -3
  84. package/src/components/tooltip/private/bounded-tooltip.tsx +227 -0
  85. package/src/components/tooltip/test/accessible-tooltip.test.tsx +30 -4
  86. package/src/components/tooltip/test/bounded-tooltip.test.tsx +225 -0
  87. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +379 -0
  88. package/src/components/tooltip/xy-chart-tooltip.tsx +326 -0
  89. package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
  90. package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
  91. package/src/hooks/test/use-chart-margin.test.tsx +173 -3
  92. package/src/hooks/test/use-xychart-theme.test.tsx +16 -3
  93. package/src/hooks/use-chart-data-transform.ts +10 -3
  94. package/src/hooks/use-chart-margin.tsx +54 -9
  95. package/src/hooks/use-xychart-theme.ts +25 -9
  96. package/src/index.ts +8 -0
  97. package/src/providers/chart-context/global-charts-provider.tsx +37 -38
  98. package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
  99. package/src/providers/chart-context/index.ts +1 -0
  100. package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
  101. package/src/providers/chart-context/private/series-palette.ts +3 -13
  102. package/src/providers/chart-context/test/{sparse-theme.test.ts → catalog-pointers.test.ts} +22 -10
  103. package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
  104. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -25
  105. package/src/providers/chart-context/themes.ts +7 -52
  106. package/src/providers/chart-context/types.ts +3 -0
  107. package/src/styles/chart-scope.scss +39 -62
  108. package/src/styles/test/chart-scope.test.ts +19 -47
  109. package/src/test-utils/series-fixtures.ts +31 -0
  110. package/src/test-utils/zoned-parsing-suite.ts +90 -0
  111. package/src/types.ts +79 -166
  112. package/src/utils/bucket-info.ts +87 -0
  113. package/src/utils/color-utils.ts +0 -27
  114. package/src/utils/date-formatting.ts +157 -0
  115. package/src/utils/date-parsing.ts +103 -52
  116. package/src/utils/get-edge-tick-widths.ts +33 -0
  117. package/src/utils/index.ts +4 -0
  118. package/src/utils/test/bucket-info.test.ts +185 -0
  119. package/src/utils/test/color-utils.test.ts +1 -48
  120. package/src/utils/test/date-formatting.test.ts +89 -0
  121. package/src/utils/test/date-parsing.auckland.test.ts +6 -0
  122. package/src/utils/test/date-parsing.santiago.test.ts +6 -0
  123. package/src/utils/test/date-parsing.test.ts +16 -1
  124. package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
  125. package/src/utils/test/get-styles.test.ts +10 -7
  126. package/src/utils/test/merge-themes.test.ts +18 -46
  127. package/src/utils/warn-once.ts +23 -0
  128. package/src/visx/types.ts +56 -5
  129. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
  130. package/src/providers/chart-context/private/theme-override-vars.ts +0 -131
  131. package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -93
  132. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
  133. package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -183
  134. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -104
  135. package/src/styles/test/wpds-fallbacks.test.ts +0 -131
@@ -1,7 +1,7 @@
1
1
  .leaderboardChart {
2
2
  transition:
3
- opacity var(--wpds-motion-duration-lg, 300ms)
4
- var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
3
+ opacity var(--wpds-motion-duration-lg)
4
+ var(--wpds-motion-easing-subtle);
5
5
 
6
6
  &--responsive {
7
7
  height: 100%;
@@ -49,7 +49,7 @@
49
49
  }
50
50
 
51
51
  .label {
52
- padding-left: var(--wpds-dimension-padding-sm, 8px);
52
+ padding-left: var(--wpds-dimension-padding-sm);
53
53
  }
54
54
  }
55
55
 
@@ -58,7 +58,7 @@
58
58
  min-height: 6px;
59
59
  // Keeps a sliver of bar visible for tiny shares, and stops the interactive
60
60
  // row's fixed-pixel hover inset from collapsing a narrow bar to nothing.
61
- min-width: var(--wpds-dimension-size-5xs, 4px);
61
+ min-width: var(--wpds-dimension-size-5xs);
62
62
 
63
63
  border-radius: var(--a8c-charts-border-radius-leaderboard-bar);
64
64
  z-index: -1; /* places it behind the label */
@@ -97,6 +97,18 @@
97
97
  font-variant-numeric: tabular-nums;
98
98
  min-width: 3ch;
99
99
  text-align: end;
100
+
101
+ &--down {
102
+ color: var(--a8c-charts-color-trend-down);
103
+ }
104
+
105
+ &--neutral {
106
+ color: var(--a8c-charts-color-trend-neutral);
107
+ }
108
+
109
+ &--up {
110
+ color: var(--a8c-charts-color-trend-up);
111
+ }
100
112
  }
101
113
 
102
114
  .deltaPlaceholder {
@@ -108,10 +120,10 @@
108
120
  }
109
121
 
110
122
  .emptyState {
111
- padding: var(--wpds-dimension-padding-3xl, 32px) var(--wpds-dimension-padding-lg, 16px);
123
+ padding: var(--wpds-dimension-padding-3xl) var(--wpds-dimension-padding-lg);
112
124
  text-align: center;
113
125
  color: var(--a8c-charts-color-label-secondary);
114
- font-size: var(--wpds-typography-font-size-md, 13px);
126
+ font-size: var(--wpds-typography-font-size-md);
115
127
  font-style: italic;
116
128
  }
117
129
 
@@ -133,7 +145,7 @@
133
145
  // interactive row's outline (and its inset offset) stay equal. A Sass var,
134
146
  // not a custom property: a compile-time constant scoped to this module, so it
135
147
  // needs no runtime cascade and never lands on the DOM as an unprefixed var.
136
- $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-focus, 2px));
148
+ $focus-ring-width: var(--wpds-border-width-focus);
137
149
 
138
150
  .row {
139
151
  display: grid;
@@ -169,16 +181,16 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
169
181
  // value slide is given an RTL override below. No row background.
170
182
  .bar {
171
183
  transition:
172
- opacity var(--wpds-motion-duration-sm, 100ms)
173
- var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1)),
174
- width var(--wpds-motion-duration-sm, 100ms)
175
- var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
184
+ opacity var(--wpds-motion-duration-sm)
185
+ var(--wpds-motion-easing-subtle),
186
+ width var(--wpds-motion-duration-sm)
187
+ var(--wpds-motion-easing-subtle);
176
188
  }
177
189
 
178
190
  .valueContainer {
179
191
  transition:
180
- transform var(--wpds-motion-duration-sm, 100ms)
181
- var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
192
+ transform var(--wpds-motion-duration-sm)
193
+ var(--wpds-motion-easing-subtle);
182
194
  }
183
195
 
184
196
  &:hover,
@@ -186,7 +198,7 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
186
198
 
187
199
  // Fixed reserve for the chevron — the value slides by it in full, and the
188
200
  // bar width inset (see leaderboard-chart.tsx) scales it by the bar's share.
189
- --a8c-charts-dimension-leaderboard-bar-hover-inset: var(--wpds-dimension-gap-2xl, 32px);
201
+ --a8c-charts-dimension-leaderboard-bar-hover-inset: var(--wpds-dimension-gap-2xl);
190
202
 
191
203
  .bar {
192
204
  opacity: 0.5;
@@ -208,10 +220,10 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
208
220
  }
209
221
 
210
222
  &:focus-visible {
211
- border-radius: var(--wpds-border-radius-sm, 2px);
223
+ border-radius: var(--wpds-border-radius-sm);
212
224
  outline:
213
225
  $focus-ring-width solid
214
- var(--wpds-color-stroke-focus, var(--wp-admin-theme-color, #3858e9));
226
+ var(--wpds-color-stroke-focus);
215
227
  // Inset by its own width so the outer edge hugs the row boundary: the
216
228
  // outline then sits in the shared row padding, clear of the bar, and stays
217
229
  // inside the overflow:auto container that would clip an outset ring.
@@ -221,15 +233,15 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
221
233
 
222
234
  .chevron {
223
235
  position: absolute;
224
- inset-inline-end: var(--wpds-dimension-padding-xs, 4px);
236
+ inset-inline-end: var(--wpds-dimension-padding-xs);
225
237
  top: 0;
226
238
  bottom: 0;
227
239
  margin-block: auto;
228
240
  opacity: 0;
229
241
  color: var(--a8c-charts-color-label-secondary);
230
242
  transition:
231
- opacity var(--wpds-motion-duration-sm, 100ms)
232
- var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
243
+ opacity var(--wpds-motion-duration-sm)
244
+ var(--wpds-motion-easing-subtle);
233
245
  pointer-events: none;
234
246
 
235
247
  // The glyph points toward the inline end, so flip it to point left in RTL.
@@ -26,6 +26,15 @@ import styles from './leaderboard-chart.module.scss';
26
26
  import type { LeaderboardChartProps } from './types';
27
27
  import type { LeaderboardEntry } from '../../types';
28
28
 
29
+ // Handed to `Grid` as a chain rather than set in this component's stylesheet: `Grid` writes
30
+ // its own gaps through an Emotion class, which a module class of equal specificity would not
31
+ // reliably outrank. The chain lands on the element and resolves there.
32
+ const ROW_GAP = 'var(--a8c-charts-dimension-leaderboard-row-gap, 12px)';
33
+ const COLUMN_GAP = 'var(--a8c-charts-dimension-leaderboard-column-gap, 4px)';
34
+
35
+ /** Indexed by `Math.sign( delta ) + 1`, so [negative, neutral, positive]. */
36
+ const DELTA_TREND_CLASS = [ 'deltaValue--down', 'deltaValue--neutral', 'deltaValue--up' ] as const;
37
+
29
38
  /**
30
39
  * Default value formatter using formatMetricValue
31
40
  *
@@ -198,22 +207,15 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
198
207
 
199
208
  // Process children to extract compound components
200
209
  const { legendChildren, nonLegendChildren } = useChartChildren( children, 'LeaderboardChart' );
201
- const {
202
- labelSpacing,
203
- rowGap,
204
- columnGap,
205
- primaryColor: settingsPrimaryColor,
206
- secondaryColor: settingsSecondaryColor,
207
- deltaColors,
208
- } = leaderboardChartSettings;
210
+ const { labelSpacing } = leaderboardChartSettings;
209
211
  const { getElementStyles, isSeriesVisible } = useGlobalChartsContext();
210
212
  const { color: resolvedPrimaryColor } = getElementStyles( {
211
213
  index: 0,
212
- overrideColor: primaryColor || settingsPrimaryColor,
214
+ overrideColor: primaryColor,
213
215
  } );
214
216
  const { color: resolvedSecondaryColor } = getElementStyles( {
215
217
  index: 1,
216
- overrideColor: secondaryColor || settingsSecondaryColor,
218
+ overrideColor: secondaryColor,
217
219
  } );
218
220
 
219
221
  // Create legend items using the custom hook
@@ -292,6 +294,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
292
294
  legendElement={ false }
293
295
  legendChildren={ legendChildren }
294
296
  className={ clsx(
297
+ 'leaderboard-chart',
295
298
  styles.leaderboardChart,
296
299
  {
297
300
  [ styles[ 'leaderboardChart--responsive' ] ]: ! propWidth && ! propHeight,
@@ -336,6 +339,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
336
339
  legendElement={ legendElement }
337
340
  legendChildren={ legendChildren }
338
341
  className={ clsx(
342
+ 'leaderboard-chart',
339
343
  styles.leaderboardChart,
340
344
  {
341
345
  [ styles[ 'leaderboardChart--responsive' ] ]: ! propWidth && ! propHeight,
@@ -372,8 +376,8 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
372
376
  ) : (
373
377
  <Grid
374
378
  templateColumns="minmax(0, 1fr) auto"
375
- rowGap={ rowGap }
376
- columnGap={ columnGap }
379
+ rowGap={ ROW_GAP }
380
+ columnGap={ COLUMN_GAP }
377
381
  data-leaderboard-grid
378
382
  >
379
383
  { data.map( ( entry, rowIndex ) => {
@@ -389,8 +393,11 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
389
393
  const hasDelta = hasDeltaValue( entry );
390
394
  const showComparisonValue = showComparisonColumn && hasDelta;
391
395
  const showComparisonPlaceholder = showComparisonColumn && ! hasDelta;
392
- const colorIndex = showComparisonValue ? Math.sign( entry.delta ) + 1 : 1;
393
- const deltaColor = deltaColors[ colorIndex ];
396
+ // Math.sign gives -1/0/1; the placeholder has no delta to read, so it takes neutral.
397
+ const deltaTrendClass =
398
+ styles[
399
+ DELTA_TREND_CLASS[ showComparisonValue ? Math.sign( entry.delta ) + 1 : 1 ]
400
+ ];
394
401
 
395
402
  const rowCells = (
396
403
  <>
@@ -417,15 +424,18 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
417
424
  { isPrimaryVisible && <Text>{ valueFormatter( entry.currentValue ) }</Text> }
418
425
 
419
426
  { showComparisonValue && (
420
- <Text className={ styles.deltaValue } style={ { color: deltaColor } }>
427
+ <Text className={ clsx( styles.deltaValue, deltaTrendClass ) }>
421
428
  { deltaFormatter( entry.delta ) }
422
429
  </Text>
423
430
  ) }
424
431
 
425
432
  { showComparisonPlaceholder && (
426
433
  <Text
427
- className={ clsx( styles.deltaValue, styles.deltaPlaceholder ) }
428
- style={ { color: deltaColor } }
434
+ className={ clsx(
435
+ styles.deltaValue,
436
+ styles.deltaPlaceholder,
437
+ deltaTrendClass
438
+ ) }
429
439
  >
430
440
  <span aria-hidden="true">—</span>
431
441
  <VisuallyHidden as="span">
@@ -665,4 +665,18 @@ describe( 'LeaderboardChart', () => {
665
665
  expect( screen.getAllByRole( 'button' ) ).toHaveLength( 1 );
666
666
  } );
667
667
  } );
668
+
669
+ describe( 'grid gaps', () => {
670
+ // eslint-disable-next-line testing-library/no-node-access -- The grid is rendered by `Grid` from @wordpress/components, which takes no test id.
671
+ const grid = () => document.querySelector( '[data-leaderboard-grid]' );
672
+
673
+ it( 'hands the catalog role to the grid', () => {
674
+ render( <LeaderboardChart data={ mockData } /> );
675
+
676
+ expect( grid() ).toHaveStyle( {
677
+ gridRowGap: 'var(--a8c-charts-dimension-leaderboard-row-gap, 12px)',
678
+ gridColumnGap: 'var(--a8c-charts-dimension-leaderboard-column-gap, 4px)',
679
+ } );
680
+ } );
681
+ } );
668
682
  } );
@@ -113,14 +113,8 @@ describe( 'useLeaderboardLegendItems', () => {
113
113
  } );
114
114
 
115
115
  describe( 'Color handling', () => {
116
- it( 'should use default theme colors', () => {
117
- const customTheme = {
118
- leaderboardChart: {
119
- primaryColor: '#FF0000',
120
- secondaryColor: '#00FF00',
121
- },
122
- };
123
- const wrapper = createWrapper( customTheme );
116
+ it( 'gives both legend items a color from the palette', () => {
117
+ const wrapper = createWrapper();
124
118
  const { result } = renderHook(
125
119
  () =>
126
120
  useLeaderboardLegendItems( {
@@ -131,7 +125,6 @@ describe( 'useLeaderboardLegendItems', () => {
131
125
  { wrapper }
132
126
  );
133
127
 
134
- // Note: The actual color will be resolved by the context, but we can check structure
135
128
  expect( result.current[ 0 ].color ).toBeTruthy();
136
129
  expect( result.current[ 1 ].color ).toBeTruthy();
137
130
  } );
@@ -36,9 +36,9 @@ export interface LeaderboardChartProps
36
36
  secondaryColor?: string;
37
37
 
38
38
  /**
39
- * Formatter for values
39
+ * Formatter for values. May return an element, e.g. to attach a tooltip.
40
40
  */
41
- valueFormatter?: ( value: number ) => string;
41
+ valueFormatter?: ( value: number ) => ReactNode;
42
42
 
43
43
  /**
44
44
  * Formatter for delta values
@@ -21,21 +21,21 @@
21
21
  &__annotation-label-popover {
22
22
  background: var(--a8c-charts-color-surface);
23
23
  color: var(--a8c-charts-color-label);
24
- padding: var(--wpds-dimension-padding-sm, 8px);
24
+ padding: var(--wpds-dimension-padding-sm);
25
25
  }
26
26
 
27
27
  &__tooltip-date {
28
- font-weight: var(--wpds-typography-font-weight-emphasis, 600);
29
- padding-bottom: var(--wpds-dimension-padding-md, 12px);
28
+ font-weight: var(--wpds-typography-font-weight-emphasis);
29
+ padding-bottom: var(--wpds-dimension-padding-md);
30
30
  }
31
31
 
32
32
  &__tooltip-row {
33
- padding: var(--wpds-dimension-padding-xs, 4px) 0;
33
+ padding: var(--wpds-dimension-padding-xs) 0;
34
34
  }
35
35
 
36
36
  &__tooltip-label {
37
- font-weight: var(--wpds-typography-font-weight-emphasis, 600);
38
- padding-right: var(--wpds-dimension-padding-lg, 16px);
37
+ font-weight: var(--wpds-typography-font-weight-emphasis);
38
+ padding-right: var(--wpds-dimension-padding-lg);
39
39
  }
40
40
 
41
41
  &__annotations-overlay {
@@ -64,14 +64,14 @@
64
64
  &__annotation-label-popover {
65
65
  min-width: 125px;
66
66
  border: none;
67
- border-radius: var(--wpds-border-radius-md, 4px);
68
- font-size: var(--wpds-typography-font-size-md, 13px);
67
+ border-radius: var(--wpds-border-radius-md);
68
+ font-size: var(--wpds-typography-font-size-md);
69
69
  box-shadow: var(--a8c-charts-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005);
70
70
  position: fixed;
71
71
  // Without margin set, the popover has margin:auto which upsets the
72
72
  // positioning relative to the trigger button. A gap token is appropriate
73
73
  // because it creates a gap to the trigger button.
74
- margin: var(--wpds-dimension-gap-sm, 8px);
74
+ margin: var(--wpds-dimension-gap-sm);
75
75
  visibility: hidden;
76
76
 
77
77
  &--visible {
@@ -85,7 +85,7 @@
85
85
  }
86
86
 
87
87
  &__annotation-label-popover-content {
88
- padding: var(--wpds-dimension-padding-sm, 8px);
88
+ padding: var(--wpds-dimension-padding-sm);
89
89
  }
90
90
 
91
91
  &__annotation-label-popover-close-button {
@@ -101,14 +101,6 @@
101
101
  }
102
102
  }
103
103
 
104
- :global(.visx-tooltip-glyph) {
105
-
106
- svg {
107
- width: 10px;
108
- height: 10px;
109
- }
110
- }
111
-
112
104
  @keyframes rise {
113
105
 
114
106
  from {
@@ -24,30 +24,41 @@ import {
24
24
  import {
25
25
  GlobalChartsProvider,
26
26
  GlobalChartsContext,
27
+ useChartFormatting,
27
28
  useChartId,
28
29
  useChartRegistration,
29
30
  useGlobalChartsContext,
30
- useGlobalChartsTheme,
31
31
  } from '../../providers';
32
32
  import { useDefaultHiddenSeries } from '../../providers/chart-context/hooks/use-default-hidden-series';
33
33
  import { attachSubComponents } from '../../utils';
34
+ import { getBucketInfo } from '../../utils/bucket-info';
35
+ import { createDateFormatter } from '../../utils/date-formatting';
34
36
  import { useChartChildren } from '../private/chart-composition';
35
37
  import { ChartInstanceContext, type ChartInstanceRef } from '../private/chart-instance-context';
36
38
  import { ChartLayout } from '../private/chart-layout';
37
39
  import { DefaultGlyph } from '../private/default-glyph';
38
40
  import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
39
- import { getCurveType, getFormatter, guessOptimalNumTicks } from '../private/time-axis';
41
+ import { getCurveType } from '../private/time-axis';
42
+ import { buildTimeAxisOptions } from '../private/time-axis-options';
40
43
  import { withResponsive } from '../private/with-responsive';
41
44
  import { useXZoom, ZoomResetButton, ZoomSelectionRect, ZoomClip } from '../private/x-zoom';
45
+ import plotStyles from '../private/xy-plot/xy-plot.module.scss';
42
46
  import styles from './line-chart.module.scss';
43
47
  import { LineChartAnnotation, LineChartAnnotationsOverlay, LineChartGlyph } from './private';
44
48
  import type { RenderLineGlyphProps, LineChartProps, TooltipDatum } from './types';
45
- import type { DataPoint, DataPointDate, SeriesData, Optional } from '../../types';
49
+ import type {
50
+ BucketInfo,
51
+ DataPoint,
52
+ DataPointDate,
53
+ SeriesData,
54
+ Optional,
55
+ TickResolution,
56
+ } from '../../types';
46
57
  import type { RenderTooltipParams } from '../../visx/types';
47
58
  import type { ResponsiveConfig } from '../private/with-responsive';
48
59
  import type { TickFormatter } from '@visx/axis';
49
60
  import type { GlyphProps } from '@visx/xychart';
50
- import type { FC, Ref } from 'react';
61
+ import type { CSSProperties, FC, Ref } from 'react';
51
62
 
52
63
  const defaultRenderGlyph = < Datum extends object >( props: RenderLineGlyphProps< Datum > ) => {
53
64
  return <DefaultGlyph { ...props } key={ props.key } />;
@@ -58,16 +69,54 @@ const toNumber = ( val?: number | string | null ): number | undefined => {
58
69
  return isNaN( num ) ? undefined : num;
59
70
  };
60
71
 
72
+ // No date-part options, which is what `Intl` defaults to a numeric date for —
73
+ // the heading the tooltip printed with `toLocaleDateString()` before it could
74
+ // be told a locale and a zone.
75
+ const TOOLTIP_DATE: Intl.DateTimeFormatOptions = {};
76
+
77
+ // Only the hour case adds to the plain date heading. A coarser bucket keeps the
78
+ // day: 'month' covers any spacing from 28 days up, so dropping it would give two
79
+ // distinct points one heading.
80
+ const TOOLTIP_FORMAT_BY_RESOLUTION: Record<
81
+ Exclude< TickResolution, 'week' >,
82
+ Intl.DateTimeFormatOptions
83
+ > = {
84
+ hour: { year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric' },
85
+ day: TOOLTIP_DATE,
86
+ month: TOOLTIP_DATE,
87
+ year: TOOLTIP_DATE,
88
+ };
89
+
90
+ // A component rather than a call, because `renderDefaultTooltip` is a plain
91
+ // function a consumer may pass around: the context has to be read where the
92
+ // heading renders, not where the tooltip is built.
93
+ const TooltipDate: FC< { date?: Date; displayResolution: Exclude< TickResolution, 'week' > } > = ( {
94
+ date,
95
+ displayResolution,
96
+ } ) => {
97
+ const formatting = useChartFormatting();
98
+ const format = useMemo(
99
+ () => createDateFormatter( TOOLTIP_FORMAT_BY_RESOLUTION[ displayResolution ], formatting ),
100
+ [ displayResolution, formatting ]
101
+ );
102
+
103
+ return <>{ date ? format( date ) : null }</>;
104
+ };
105
+
61
106
  /**
62
107
  * Default visx-tooltip render that prints the hovered date as a heading and
63
108
  * one row per visible series (label + formatted value), sorted descending by
64
109
  * value. Reused by AreaChart, which has the same multi-series shape.
65
110
  *
66
- * @param params - visx `RenderTooltipParams< DataPointDate >`.
111
+ * @param params - visx tooltip data and the chart's optional `bucketInfo`.
112
+ * @param contentStyle - Explicit tooltip content color overrides.
67
113
  * @return Tooltip JSX, or `null` when no datum is hovered.
68
114
  */
69
- export const renderDefaultTooltip = ( params: RenderTooltipParams< DataPointDate > ) => {
70
- const { tooltipData } = params;
115
+ export const renderDefaultTooltip = (
116
+ params: RenderTooltipParams< DataPointDate > & { bucketInfo?: BucketInfo },
117
+ contentStyle?: Pick< CSSProperties, 'color' | 'background' | 'backgroundColor' >
118
+ ) => {
119
+ const { tooltipData, bucketInfo } = params;
71
120
  const nearestDatum = tooltipData?.nearestDatum?.datum;
72
121
  if ( ! nearestDatum ) return null;
73
122
 
@@ -79,9 +128,16 @@ export const renderDefaultTooltip = ( params: RenderTooltipParams< DataPointDate
79
128
  .sort( ( a, b ) => b.value - a.value );
80
129
 
81
130
  return (
82
- <div className={ styles[ 'line-chart__tooltip' ] }>
131
+ <div
132
+ className={ styles[ 'line-chart__tooltip' ] }
133
+ data-testid="line-chart-tooltip-content"
134
+ style={ contentStyle }
135
+ >
83
136
  <div className={ styles[ 'line-chart__tooltip-date' ] }>
84
- { nearestDatum.date?.toLocaleDateString() }
137
+ <TooltipDate
138
+ date={ nearestDatum.date }
139
+ displayResolution={ bucketInfo?.displayResolution ?? 'day' }
140
+ />
85
141
  </div>
86
142
  { tooltipPoints.map( point => (
87
143
  <Stack
@@ -171,6 +227,8 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
171
227
  smoothing = true,
172
228
  curveType,
173
229
  renderTooltip = renderDefaultTooltip,
230
+ tooltipPlacement,
231
+ tooltipStyle,
174
232
  withStartGlyphs = false,
175
233
  withEndGlyphs = false,
176
234
  animation,
@@ -194,10 +252,11 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
194
252
  const legendShape = legend.shape ?? 'line';
195
253
  const legendPosition = legend.position ?? 'bottom';
196
254
 
197
- const providerTheme = useGlobalChartsTheme();
255
+ const formatting = useChartFormatting();
198
256
  const theme = useXYChartTheme( data );
199
- // Gradient stops apply this as an SVG attribute, where CSS var() cannot resolve. useXYChartTheme has already resolved the same role inside its memo, against the chart's scope element, so read it back rather than paying another getComputedStyle on every render.
200
- const resolvedBackgroundColor = theme.backgroundColor ?? providerTheme.backgroundColor;
257
+ // A gradient stop reads its color as a string, so it has to be resolved. Read back what
258
+ // `useXYChartTheme` already resolved rather than paying another getComputedStyle per render.
259
+ const resolvedBackgroundColor = theme.backgroundColor;
201
260
  const chartId = useChartId( providedChartId );
202
261
  const hiddenSeries = useDefaultHiddenSeries( chartId, defaultHiddenSeries );
203
262
  const isSeriesVisible = useCallback(
@@ -303,21 +362,22 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
303
362
  chartRef,
304
363
  totalPoints: dataSorted[ 0 ]?.data.length || 0,
305
364
  onActivate: activateSelectedPoint,
365
+ preventTooltipScroll: tooltipPlacement === 'below-axis',
306
366
  } );
307
367
 
308
368
  const chartOptions = useMemo( () => {
309
- const { tickResolution, tickFormat, ...xAxisOptions } = options?.axis?.x ?? {};
310
- const formatter = tickFormat || getFormatter( dataSorted, tickResolution );
311
-
312
369
  return {
313
370
  axis: {
314
- x: {
315
- orientation: 'bottom' as const,
316
- numTicks: guessOptimalNumTicks( dataSorted, width, formatter ),
317
- tickFormat: formatter,
318
- display: true,
319
- ...xAxisOptions,
320
- },
371
+ x: buildTimeAxisOptions( {
372
+ dataSorted,
373
+ width,
374
+ axisOptions: options?.axis?.x,
375
+ scaleDomain: options?.xScale?.domain,
376
+ zoomDomain: zoom.domain,
377
+ formatting,
378
+ // A hidden line is unmounted, so visx scales to the rest.
379
+ isSeriesRendered: series => isSeriesVisible( series.label ),
380
+ } ),
321
381
  y: {
322
382
  orientation: 'left' as const,
323
383
  numTicks: 4,
@@ -339,7 +399,18 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
339
399
  ...options?.yScale,
340
400
  },
341
401
  };
342
- }, [ options, dataSorted, width, zoom.domain, stableYDomain ] );
402
+ }, [ options, dataSorted, width, zoom.domain, stableYDomain, formatting, isSeriesVisible ] );
403
+
404
+ // Classified from the rendered series, like the axis above: a hidden
405
+ // hourly line must not leave the tooltip naming an hour the axis dropped.
406
+ const bucketInfo = useMemo(
407
+ () =>
408
+ getBucketInfo(
409
+ dataSorted.filter( series => isSeriesVisible( series.label ) ),
410
+ options?.axis?.x?.tickResolution
411
+ ),
412
+ [ dataSorted, isSeriesVisible, options?.axis?.x?.tickResolution ]
413
+ );
343
414
 
344
415
  const tooltipRenderGlyph = useMemo( () => {
345
416
  return ( props: GlyphProps< DataPointDate > ) => {
@@ -411,7 +482,34 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
411
482
  yAccessor: ( d: DataPointDate ) => d?.value,
412
483
  };
413
484
 
414
- // Create a custom renderTooltip that includes focus capability
485
+ const resolvedTooltipStyle = useMemo( () => {
486
+ if ( renderTooltip !== renderDefaultTooltip || ! tooltipStyle ) return tooltipStyle;
487
+ if ( ! tooltipStyle.color || tooltipStyle.background || tooltipStyle.backgroundColor ) {
488
+ return tooltipStyle;
489
+ }
490
+ return {
491
+ backgroundColor: 'var(--a8c-charts-color-tooltip-surface, rgb(0 0 0 / 85%))',
492
+ ...tooltipStyle,
493
+ };
494
+ }, [ renderTooltip, tooltipStyle ] );
495
+
496
+ // Augments every renderTooltip call with the chart's bucket classification,
497
+ // default or custom, so a heading keyed on it can't disagree with the axis.
498
+ const tooltipRenderer = useMemo(
499
+ () => ( params: RenderTooltipParams< DataPointDate > ) =>
500
+ renderTooltip === renderDefaultTooltip
501
+ ? renderDefaultTooltip(
502
+ { ...params, bucketInfo },
503
+ {
504
+ color: tooltipStyle?.color,
505
+ background: tooltipStyle?.background,
506
+ backgroundColor: tooltipStyle?.backgroundColor,
507
+ }
508
+ )
509
+ : renderTooltip( { ...params, bucketInfo } ),
510
+ [ renderTooltip, bucketInfo, tooltipStyle ]
511
+ );
512
+
415
513
  if ( error ) {
416
514
  return <div className={ clsx( 'line-chart', styles[ 'line-chart' ] ) }>{ error }</div>;
417
515
  }
@@ -472,7 +570,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
472
570
  onBlur={ onChartBlur }
473
571
  >
474
572
  { chartHeight > 0 && (
475
- <div ref={ chartRef } style={ { position: 'relative' } }>
573
+ <div ref={ chartRef } className={ plotStyles[ 'xy-plot' ] }>
476
574
  { zoomable && zoom.domain && <ZoomResetButton onClick={ zoom.reset } /> }
477
575
  <XYChart
478
576
  theme={ theme }
@@ -607,13 +705,17 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
607
705
  <AccessibleTooltip
608
706
  detectBounds
609
707
  snapTooltipToDatumX
708
+ tooltipPlacement={ tooltipPlacement }
709
+ style={ resolvedTooltipStyle }
610
710
  snapTooltipToDatumY
611
711
  showSeriesGlyphs
612
- renderTooltip={ renderTooltip }
712
+ renderTooltip={ tooltipRenderer }
613
713
  renderGlyph={ tooltipRenderGlyph }
614
714
  glyphStyle={ glyphStyle }
615
715
  showVerticalCrosshair={ withTooltipCrosshairs?.showVertical }
616
716
  showHorizontalCrosshair={ withTooltipCrosshairs?.showHorizontal }
717
+ verticalCrosshairStyle={ withTooltipCrosshairs?.verticalStyle }
718
+ horizontalCrosshairStyle={ withTooltipCrosshairs?.horizontalStyle }
617
719
  selectedIndex={ selectedIndex }
618
720
  tooltipRef={ tooltipRef }
619
721
  keyboardFocusedClassName={