@automattic/charts 3.2.0 → 4.0.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 (109) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/TOKENS.md +53 -58
  3. package/dist/index.cjs +1012 -542
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +72 -29
  6. package/dist/index.d.cts +151 -179
  7. package/dist/index.d.ts +151 -179
  8. package/dist/index.js +1013 -544
  9. package/dist/index.js.map +1 -1
  10. package/package.json +6 -6
  11. package/src/charts/area-chart/area-chart.tsx +39 -25
  12. package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
  13. package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
  14. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
  15. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
  16. package/src/charts/area-chart/types.ts +4 -7
  17. package/src/charts/bar-chart/bar-chart.tsx +2 -1
  18. package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
  19. package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
  20. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -0
  21. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
  22. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
  23. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
  24. package/src/charts/geo-chart/geo-chart.module.scss +1 -0
  25. package/src/charts/geo-chart/geo-chart.tsx +5 -10
  26. package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
  27. package/src/charts/heatmap-chart/heatmap-chart.module.scss +21 -16
  28. package/src/charts/heatmap-chart/heatmap-chart.tsx +45 -28
  29. package/src/charts/heatmap-chart/private/build-calendar-data.ts +3 -0
  30. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
  31. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +51 -12
  32. package/src/charts/heatmap-chart/types.ts +2 -2
  33. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  34. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
  35. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
  36. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
  37. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
  38. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
  39. package/src/charts/line-chart/line-chart.module.scss +10 -18
  40. package/src/charts/line-chart/line-chart.tsx +93 -24
  41. package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
  42. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
  43. package/src/charts/line-chart/test/line-chart.test.tsx +27 -3
  44. package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
  45. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
  46. package/src/charts/line-chart/types.ts +4 -1
  47. package/src/charts/pie-chart/pie-chart.module.scss +13 -1
  48. package/src/charts/pie-chart/pie-chart.tsx +30 -40
  49. package/src/charts/pie-chart/test/pie-chart.test.tsx +56 -0
  50. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
  51. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
  52. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
  53. package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
  54. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  55. package/src/charts/private/test/axis-formatting.test.tsx +85 -0
  56. package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
  57. package/src/charts/private/test/time-axis-options.test.ts +267 -0
  58. package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
  59. package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
  60. package/src/charts/private/test/time-axis.test.ts +9 -69
  61. package/src/charts/private/time-axis-options.ts +106 -0
  62. package/src/charts/private/time-axis.ts +318 -136
  63. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  64. package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
  65. package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
  66. package/src/charts/sparkline/types.ts +1 -1
  67. package/src/components/legend/private/base-legend.module.scss +9 -5
  68. package/src/components/tooltip/accessible-tooltip.tsx +13 -15
  69. package/src/components/tooltip/base-tooltip.module.scss +3 -3
  70. package/src/components/tooltip/private/bounded-tooltip.tsx +172 -0
  71. package/src/components/tooltip/test/accessible-tooltip.test.tsx +2 -3
  72. package/src/components/tooltip/test/bounded-tooltip.test.tsx +129 -0
  73. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +279 -0
  74. package/src/components/tooltip/xy-chart-tooltip.tsx +288 -0
  75. package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
  76. package/src/hooks/test/use-xychart-theme.test.tsx +16 -3
  77. package/src/hooks/use-xychart-theme.ts +25 -9
  78. package/src/index.ts +4 -0
  79. package/src/providers/chart-context/global-charts-provider.tsx +37 -38
  80. package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
  81. package/src/providers/chart-context/index.ts +1 -0
  82. package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
  83. package/src/providers/chart-context/private/series-palette.ts +3 -13
  84. package/src/providers/chart-context/test/{sparse-theme.test.ts → catalog-pointers.test.ts} +22 -10
  85. package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
  86. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -25
  87. package/src/providers/chart-context/themes.ts +7 -52
  88. package/src/providers/chart-context/types.ts +3 -0
  89. package/src/styles/chart-scope.scss +39 -62
  90. package/src/styles/test/chart-scope.test.ts +19 -47
  91. package/src/test-utils/series-fixtures.ts +31 -0
  92. package/src/types.ts +79 -166
  93. package/src/utils/bucket-info.ts +87 -0
  94. package/src/utils/color-utils.ts +0 -27
  95. package/src/utils/date-formatting.ts +174 -0
  96. package/src/utils/index.ts +3 -0
  97. package/src/utils/test/bucket-info.test.ts +185 -0
  98. package/src/utils/test/color-utils.test.ts +1 -48
  99. package/src/utils/test/date-formatting.test.ts +89 -0
  100. package/src/utils/test/get-styles.test.ts +10 -7
  101. package/src/utils/test/merge-themes.test.ts +18 -46
  102. package/src/visx/types.ts +29 -2
  103. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
  104. package/src/providers/chart-context/private/theme-override-vars.ts +0 -131
  105. package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -93
  106. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
  107. package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -183
  108. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -104
  109. 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
  } );
@@ -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,25 +24,36 @@ 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';
@@ -58,16 +69,52 @@ 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 `RenderTooltipParams< DataPointDate >`, plus the chart's optional `bucketInfo`.
67
112
  * @return Tooltip JSX, or `null` when no datum is hovered.
68
113
  */
69
- export const renderDefaultTooltip = ( params: RenderTooltipParams< DataPointDate > ) => {
70
- const { tooltipData } = params;
114
+ export const renderDefaultTooltip = (
115
+ params: RenderTooltipParams< DataPointDate > & { bucketInfo?: BucketInfo }
116
+ ) => {
117
+ const { tooltipData, bucketInfo } = params;
71
118
  const nearestDatum = tooltipData?.nearestDatum?.datum;
72
119
  if ( ! nearestDatum ) return null;
73
120
 
@@ -81,7 +128,10 @@ export const renderDefaultTooltip = ( params: RenderTooltipParams< DataPointDate
81
128
  return (
82
129
  <div className={ styles[ 'line-chart__tooltip' ] }>
83
130
  <div className={ styles[ 'line-chart__tooltip-date' ] }>
84
- { nearestDatum.date?.toLocaleDateString() }
131
+ <TooltipDate
132
+ date={ nearestDatum.date }
133
+ displayResolution={ bucketInfo?.displayResolution ?? 'day' }
134
+ />
85
135
  </div>
86
136
  { tooltipPoints.map( point => (
87
137
  <Stack
@@ -194,10 +244,11 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
194
244
  const legendShape = legend.shape ?? 'line';
195
245
  const legendPosition = legend.position ?? 'bottom';
196
246
 
197
- const providerTheme = useGlobalChartsTheme();
247
+ const formatting = useChartFormatting();
198
248
  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;
249
+ // A gradient stop reads its color as a string, so it has to be resolved. Read back what
250
+ // `useXYChartTheme` already resolved rather than paying another getComputedStyle per render.
251
+ const resolvedBackgroundColor = theme.backgroundColor;
201
252
  const chartId = useChartId( providedChartId );
202
253
  const hiddenSeries = useDefaultHiddenSeries( chartId, defaultHiddenSeries );
203
254
  const isSeriesVisible = useCallback(
@@ -306,18 +357,18 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
306
357
  } );
307
358
 
308
359
  const chartOptions = useMemo( () => {
309
- const { tickResolution, tickFormat, ...xAxisOptions } = options?.axis?.x ?? {};
310
- const formatter = tickFormat || getFormatter( dataSorted, tickResolution );
311
-
312
360
  return {
313
361
  axis: {
314
- x: {
315
- orientation: 'bottom' as const,
316
- numTicks: guessOptimalNumTicks( dataSorted, width, formatter ),
317
- tickFormat: formatter,
318
- display: true,
319
- ...xAxisOptions,
320
- },
362
+ x: buildTimeAxisOptions( {
363
+ dataSorted,
364
+ width,
365
+ axisOptions: options?.axis?.x,
366
+ scaleDomain: options?.xScale?.domain,
367
+ zoomDomain: zoom.domain,
368
+ formatting,
369
+ // A hidden line is unmounted, so visx scales to the rest.
370
+ isSeriesRendered: series => isSeriesVisible( series.label ),
371
+ } ),
321
372
  y: {
322
373
  orientation: 'left' as const,
323
374
  numTicks: 4,
@@ -339,7 +390,18 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
339
390
  ...options?.yScale,
340
391
  },
341
392
  };
342
- }, [ options, dataSorted, width, zoom.domain, stableYDomain ] );
393
+ }, [ options, dataSorted, width, zoom.domain, stableYDomain, formatting, isSeriesVisible ] );
394
+
395
+ // Classified from the rendered series, like the axis above: a hidden
396
+ // hourly line must not leave the tooltip naming an hour the axis dropped.
397
+ const bucketInfo = useMemo(
398
+ () =>
399
+ getBucketInfo(
400
+ dataSorted.filter( series => isSeriesVisible( series.label ) ),
401
+ options?.axis?.x?.tickResolution
402
+ ),
403
+ [ dataSorted, isSeriesVisible, options?.axis?.x?.tickResolution ]
404
+ );
343
405
 
344
406
  const tooltipRenderGlyph = useMemo( () => {
345
407
  return ( props: GlyphProps< DataPointDate > ) => {
@@ -411,7 +473,14 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
411
473
  yAccessor: ( d: DataPointDate ) => d?.value,
412
474
  };
413
475
 
414
- // Create a custom renderTooltip that includes focus capability
476
+ // Augments every renderTooltip call with the chart's bucket classification,
477
+ // default or custom, so a heading keyed on it can't disagree with the axis.
478
+ const tooltipRenderer = useMemo(
479
+ () => ( params: RenderTooltipParams< DataPointDate > ) =>
480
+ renderTooltip( { ...params, bucketInfo } ),
481
+ [ renderTooltip, bucketInfo ]
482
+ );
483
+
415
484
  if ( error ) {
416
485
  return <div className={ clsx( 'line-chart', styles[ 'line-chart' ] ) }>{ error }</div>;
417
486
  }
@@ -472,7 +541,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
472
541
  onBlur={ onChartBlur }
473
542
  >
474
543
  { chartHeight > 0 && (
475
- <div ref={ chartRef } style={ { position: 'relative' } }>
544
+ <div ref={ chartRef } className={ plotStyles[ 'xy-plot' ] }>
476
545
  { zoomable && zoom.domain && <ZoomResetButton onClick={ zoom.reset } /> }
477
546
  <XYChart
478
547
  theme={ theme }
@@ -609,7 +678,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
609
678
  snapTooltipToDatumX
610
679
  snapTooltipToDatumY
611
680
  showSeriesGlyphs
612
- renderTooltip={ renderTooltip }
681
+ renderTooltip={ tooltipRenderer }
613
682
  renderGlyph={ tooltipRenderGlyph }
614
683
  glyphStyle={ glyphStyle }
615
684
  showVerticalCrosshair={ withTooltipCrosshairs?.showVertical }
@@ -9,11 +9,13 @@ import {
9
9
  import { DataContext } from '@visx/xychart';
10
10
  import merge from 'deepmerge';
11
11
  import { useContext, useRef, useEffect, useState, useMemo } from 'react';
12
- import { useChartScopeElement, useGlobalChartsTheme } from '../../../providers';
13
- import { isSafari, resolveCssVariable } from '../../../utils';
12
+ import { useGlobalChartsTheme } from '../../../providers';
13
+ import { ANNOTATION_POINTERS } from '../../../providers/chart-context/private/catalog-pointers';
14
+ import { isSafari } from '../../../utils';
14
15
  import LineChartAnnotationLabelWithPopover, {
15
16
  POPOVER_BUTTON_SIZE,
16
17
  } from './line-chart-annotation-label-popover';
18
+ import type { AnnotationStyles } from '../../../types';
17
19
  import type { LineChartAnnotationProps } from '../types';
18
20
  import type { LabelProps } from '@visx/annotation';
19
21
  import type { TextProps } from '@visx/text';
@@ -152,17 +154,22 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
152
154
  renderLabelPopover,
153
155
  } ) => {
154
156
  const providerTheme = useGlobalChartsTheme();
155
- const scopeElement = useChartScopeElement();
156
157
  const { xScale, yScale } = useContext( DataContext ) || {};
157
158
  const labelRef = useRef< SVGGElement >( null );
158
159
  const [ height, setHeight ] = useState< number | null >( null );
159
160
 
160
- // Deep merge styles to preserve nested object properties
161
- const styles = merge( providerTheme.annotationStyles ?? {}, datumStyles ?? {} );
162
-
163
- // visx annotation parts apply these colours as SVG presentation attributes, where CSS var() cannot resolve. Resolve against the chart's scope element, never :root.
164
- const resolveColor = ( value?: string ): string | undefined =>
165
- value ? resolveCssVariable( value, scopeElement ) ?? value : value;
161
+ // Deep merge to preserve nested object properties. Pointers first, so a per-datum color wins.
162
+ // Memoized because the result is spread into the visx annotation parts: a fresh object every
163
+ // render would give each of them new props whether or not a style changed.
164
+ const styles = useMemo(
165
+ () =>
166
+ merge.all( [
167
+ ANNOTATION_POINTERS,
168
+ providerTheme.annotationStyles ?? {},
169
+ datumStyles ?? {},
170
+ ] ) as AnnotationStyles,
171
+ [ providerTheme.annotationStyles, datumStyles ]
172
+ );
166
173
 
167
174
  // Measure the label height once after initial render
168
175
  useEffect( () => {
@@ -269,14 +276,8 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
269
276
  return (
270
277
  <g data-testid={ testId }>
271
278
  <Annotation x={ x } y={ y } dx={ dx } dy={ dy }>
272
- <Connector { ...styles?.connector } stroke={ resolveColor( styles?.connector?.stroke ) } />
273
- { subjectType === 'circle' && (
274
- <CircleSubject
275
- { ...styles?.circleSubject }
276
- fill={ resolveColor( styles?.circleSubject?.fill ) }
277
- stroke={ resolveColor( styles?.circleSubject?.stroke ) }
278
- />
279
- ) }
279
+ <Connector { ...styles?.connector } />
280
+ { subjectType === 'circle' && <CircleSubject { ...styles?.circleSubject } /> }
280
281
  { subjectType === 'line-vertical' && (
281
282
  <LineSubject
282
283
  min={ yMax }
@@ -312,8 +313,6 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
312
313
  title={ title }
313
314
  subtitle={ subtitle }
314
315
  { ...styles?.label }
315
- anchorLineStroke={ resolveColor( styles?.label?.anchorLineStroke ) }
316
- backgroundFill={ resolveColor( styles?.label?.backgroundFill ) }
317
316
  { ...labelPosition }
318
317
  horizontalAnchor={ getHorizontalAnchor( subjectType, isFlippedHorizontally ) }
319
318
  verticalAnchor={ getVerticalAnchor(