@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "3.2.0",
3
+ "version": "4.0.0",
4
4
  "description": "Display charts within Automattic products.",
5
5
  "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
6
6
  "bugs": {
@@ -84,8 +84,8 @@
84
84
  "@visx/xychart": "^4.0.0",
85
85
  "@wordpress/i18n": "^6.0.0",
86
86
  "@wordpress/icons": "^15.0.0",
87
- "@wordpress/theme": "1.2.0",
88
- "@wordpress/ui": "0.20.0",
87
+ "@wordpress/theme": "2.1.1-next.v.202608281122.0",
88
+ "@wordpress/ui": "0.21.0",
89
89
  "clsx": "2.1.1",
90
90
  "date-fns": "^4.1.0",
91
91
  "deepmerge": "4.3.1",
@@ -110,9 +110,9 @@
110
110
  "@types/react-dom": "18.3.7",
111
111
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
112
112
  "@visx/glyph": "4.0.0",
113
- "@wordpress/components": "37.0.0",
114
- "@wordpress/element": "8.3.0",
115
- "@wordpress/private-apis": "1.53.0",
113
+ "@wordpress/components": "40.0.0",
114
+ "@wordpress/element": "8.6.0",
115
+ "@wordpress/private-apis": "1.54.0",
116
116
  "babel-jest": "30.4.1",
117
117
  "identity-obj-proxy": "^3.0.0",
118
118
  "jest": "30.4.2",
@@ -22,21 +22,24 @@ import {
22
22
  import {
23
23
  GlobalChartsProvider,
24
24
  GlobalChartsContext,
25
+ useChartFormatting,
25
26
  useChartId,
26
27
  useChartRegistration,
27
28
  useGlobalChartsContext,
28
- useGlobalChartsTheme,
29
29
  } from '../../providers';
30
30
  import { useDefaultHiddenSeries } from '../../providers/chart-context/hooks/use-default-hidden-series';
31
31
  import { attachSubComponents } from '../../utils';
32
+ import { getBucketInfo } from '../../utils/bucket-info';
32
33
  import { renderDefaultTooltip } from '../line-chart';
33
34
  import { useChartChildren } from '../private/chart-composition';
34
35
  import { ChartInstanceContext, type ChartInstanceRef } from '../private/chart-instance-context';
35
36
  import { ChartLayout } from '../private/chart-layout';
36
37
  import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
37
- import { getCurveType, getFormatter, guessOptimalNumTicks } from '../private/time-axis';
38
+ import { getCurveType } from '../private/time-axis';
39
+ import { buildTimeAxisOptions } from '../private/time-axis-options';
38
40
  import { withResponsive } from '../private/with-responsive';
39
41
  import { useXZoom, ZoomResetButton, ZoomSelectionRect, ZoomClip } from '../private/x-zoom';
42
+ import plotStyles from '../private/xy-plot/xy-plot.module.scss';
40
43
  import styles from './area-chart.module.scss';
41
44
  import { AreaChartScalesRef, HoverGlyphs, validateData } from './private';
42
45
  import type { AreaChartProps } from './types';
@@ -71,8 +74,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
71
74
  onPointerMove,
72
75
  onPointerOut,
73
76
  zoomable = false,
74
- rescaleYOnVisibilityChange,
75
- rescaleYOnLegendToggle,
77
+ rescaleYOnVisibilityChange = true,
76
78
  defaultHiddenSeries,
77
79
  children,
78
80
  gridVisibility,
@@ -84,10 +86,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
84
86
  const legendShape = legend.shape ?? 'rect';
85
87
  const legendPosition = legend.position ?? 'bottom';
86
88
 
87
- // New prop wins; fall back to the deprecated `rescaleYOnLegendToggle`; default to rescaling.
88
- const rescaleYOnVisibility = rescaleYOnVisibilityChange ?? rescaleYOnLegendToggle ?? true;
89
-
90
- const providerTheme = useGlobalChartsTheme();
89
+ const formatting = useChartFormatting();
91
90
  const theme = useXYChartTheme( data );
92
91
  const chartId = useChartId( providedChartId );
93
92
  const hiddenSeries = useDefaultHiddenSeries( chartId, defaultHiddenSeries );
@@ -160,7 +159,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
160
159
  // around zero); letting visx derive the domain is correct there.
161
160
  const fixedYDomain = useMemo< [ number, number ] | undefined >( () => {
162
161
  if (
163
- rescaleYOnVisibility ||
162
+ rescaleYOnVisibilityChange ||
164
163
  ! dataSorted.length ||
165
164
  ! dataSorted[ 0 ].data.length ||
166
165
  ( stacked && stackOffset !== 'none' )
@@ -202,21 +201,21 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
202
201
  }
203
202
  if ( max === -Infinity ) return undefined;
204
203
  return [ Math.min( 0, min ), max ];
205
- }, [ dataSorted, stacked, stackOffset, rescaleYOnVisibility ] );
204
+ }, [ dataSorted, stacked, stackOffset, rescaleYOnVisibilityChange ] );
206
205
 
207
206
  const chartOptions = useMemo( () => {
208
- const { tickResolution, tickFormat, ...xAxisOptions } = options?.axis?.x ?? {};
209
- const formatter = tickFormat || getFormatter( dataSorted, tickResolution );
210
-
211
207
  return {
212
208
  axis: {
213
- x: {
214
- orientation: 'bottom' as const,
215
- numTicks: guessOptimalNumTicks( dataSorted, width, formatter ),
216
- tickFormat: formatter,
217
- display: true,
218
- ...xAxisOptions,
219
- },
209
+ x: buildTimeAxisOptions( {
210
+ dataSorted,
211
+ width,
212
+ axisOptions: options?.axis?.x,
213
+ scaleDomain: options?.xScale?.domain,
214
+ zoomDomain: zoom.domain,
215
+ formatting,
216
+ // No `isSeriesRendered`: a hidden area stays mounted with a zeroed
217
+ // yAccessor, so it still contributes to the x domain.
218
+ } ),
220
219
  y: {
221
220
  orientation: 'left' as const,
222
221
  numTicks: 4,
@@ -239,7 +238,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
239
238
  ...options?.yScale,
240
239
  },
241
240
  };
242
- }, [ options, dataSorted, width, stacked, fixedYDomain, zoom.domain ] );
241
+ }, [ options, dataSorted, width, stacked, fixedYDomain, zoom.domain, formatting ] );
243
242
 
244
243
  const defaultMargin = useChartMargin( height, chartOptions, dataSorted, theme );
245
244
 
@@ -284,10 +283,24 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
284
283
  () => new Set( seriesWithVisibility.filter( s => s.isVisible ).map( s => s.series.label ) ),
285
284
  [ seriesWithVisibility ]
286
285
  );
286
+
287
+ // Classified from the visible series, not the axis's: a hidden area stays in
288
+ // the x domain for its animation, but the tooltip below drops its data, so a
289
+ // heading naming that series' bucket would name one no visible datum has.
290
+ const bucketInfo = useMemo(
291
+ () =>
292
+ getBucketInfo(
293
+ dataSorted.filter( series => visibleLabels.has( series.label ) ),
294
+ options?.axis?.x?.tickResolution
295
+ ),
296
+ [ dataSorted, visibleLabels, options?.axis?.x?.tickResolution ]
297
+ );
287
298
  const filteredRenderTooltip = useCallback(
288
299
  ( params: Parameters< typeof renderTooltip >[ 0 ] ) => {
289
300
  const datumByKey = params?.tooltipData?.datumByKey;
290
- if ( ! datumByKey ) return renderTooltip( params );
301
+ if ( ! datumByKey ) {
302
+ return renderTooltip( { ...params, bucketInfo } );
303
+ }
291
304
  const filtered = Object.fromEntries(
292
305
  Object.entries( datumByKey ).filter( ( [ key ] ) => visibleLabels.has( key ) )
293
306
  );
@@ -302,6 +315,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
302
315
  : { ...Object.values( filtered )[ 0 ], distance: nearestDatum?.distance ?? 0 };
303
316
  return renderTooltip( {
304
317
  ...params,
318
+ bucketInfo,
305
319
  tooltipData: {
306
320
  ...params.tooltipData,
307
321
  datumByKey: filtered,
@@ -309,7 +323,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
309
323
  } as typeof params.tooltipData,
310
324
  } );
311
325
  },
312
- [ renderTooltip, visibleLabels ]
326
+ [ renderTooltip, visibleLabels, bucketInfo ]
313
327
  );
314
328
 
315
329
  // Defaults that depend on stacked vs overlapping mode.
@@ -405,7 +419,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
405
419
  onBlur={ onChartBlur }
406
420
  >
407
421
  { chartHeight > 0 && (
408
- <div ref={ chartRef } style={ { position: 'relative' } }>
422
+ <div ref={ chartRef } className={ plotStyles[ 'xy-plot' ] }>
409
423
  { zoomable && zoom.domain && <ZoomResetButton onClick={ zoom.reset } /> }
410
424
  <XYChart
411
425
  theme={ theme }
@@ -483,7 +497,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
483
497
  stackOffset={ stackOffset }
484
498
  getElementStyles={ getElementStyles }
485
499
  // useXYChartTheme resolved this role inside its memo, against the chart's scope element; reading it back avoids a getComputedStyle on every render.
486
- strokeColor={ theme.backgroundColor ?? providerTheme.backgroundColor }
500
+ strokeColor={ theme.backgroundColor }
487
501
  />
488
502
  </>
489
503
  ) }
@@ -317,7 +317,7 @@ describe( 'AreaChart', () => {
317
317
  expect( afterToggleDomain![ 1 ] ).toBeLessThan( initialDomain![ 1 ] );
318
318
  } );
319
319
 
320
- test( 'y-axis stays pinned for unstacked area when rescaleYOnLegendToggle is false', async () => {
320
+ test( 'y-axis stays pinned for unstacked area when rescaleYOnVisibilityChange is false', async () => {
321
321
  // Exercises the non-stacked branch of fixedYDomain, which scans the
322
322
  // raw min/max across all series rather than summing stack columns.
323
323
  const user = userEvent.setup();
@@ -330,7 +330,7 @@ describe( 'AreaChart', () => {
330
330
  chartId="test-interactive-domain-pin-unstacked"
331
331
  legend={ { interactive: true } }
332
332
  stacked={ false }
333
- rescaleYOnLegendToggle={ false }
333
+ rescaleYOnVisibilityChange={ false }
334
334
  ref={ ref }
335
335
  />
336
336
  </GlobalChartsProvider>
@@ -351,35 +351,6 @@ describe( 'AreaChart', () => {
351
351
  expect( afterToggleDomain ).toEqual( initialDomain );
352
352
  } );
353
353
 
354
- test( 'y-axis stays pinned when rescaleYOnLegendToggle is false', async () => {
355
- const user = userEvent.setup();
356
- const ref = createRef< ChartInstanceRef >();
357
- render(
358
- <GlobalChartsProvider>
359
- <AreaChartUnresponsive
360
- { ...defaultProps }
361
- showLegend
362
- chartId="test-interactive-domain-pin"
363
- legend={ { interactive: true } }
364
- rescaleYOnLegendToggle={ false }
365
- ref={ ref }
366
- />
367
- </GlobalChartsProvider>
368
- );
369
-
370
- const initialDomain = (
371
- ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
372
- )?.domain();
373
- expect( initialDomain ).toBeDefined();
374
-
375
- await user.click( screen.getByText( 'Series A' ) );
376
-
377
- const afterToggleDomain = (
378
- ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
379
- )?.domain();
380
- expect( afterToggleDomain ).toEqual( initialDomain );
381
- } );
382
-
383
354
  test( 'y-axis stays pinned when rescaleYOnVisibilityChange is false', async () => {
384
355
  const user = userEvent.setup();
385
356
  const ref = createRef< ChartInstanceRef >();
@@ -388,7 +359,7 @@ describe( 'AreaChart', () => {
388
359
  <AreaChartUnresponsive
389
360
  { ...defaultProps }
390
361
  showLegend
391
- chartId="test-interactive-domain-pin-new"
362
+ chartId="test-interactive-domain-pin"
392
363
  legend={ { interactive: true } }
393
364
  rescaleYOnVisibilityChange={ false }
394
365
  ref={ ref }
@@ -607,7 +578,7 @@ describe( 'AreaChart', () => {
607
578
  chartId="test-interactive-negative"
608
579
  showLegend
609
580
  legend={ { interactive: true } }
610
- rescaleYOnLegendToggle={ false }
581
+ rescaleYOnVisibilityChange={ false }
611
582
  data={ [
612
583
  {
613
584
  label: 'Pos',
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @jest-environment <rootDir>/tests/environment-los-angeles.mjs
3
+ */
4
+ import { render } from '@testing-library/react';
5
+ import { GlobalChartsProvider } from '../../../providers';
6
+ import AreaChart from '../area-chart';
7
+
8
+ const mockRefCallback = jest.fn();
9
+ jest.mock( '../../../hooks/use-element-size', () => ( {
10
+ useElementSize: () => [ mockRefCallback, 500, 300 ],
11
+ } ) );
12
+
13
+ const monthly = ( label: string, count: number ) => ( {
14
+ label,
15
+ data: Array.from( { length: count }, ( _, index ) => ( {
16
+ date: new Date( Date.UTC( 2026, index, 1, 12 ) ),
17
+ value: index,
18
+ } ) ),
19
+ options: {},
20
+ } );
21
+
22
+ const xAxisTickOffsets = ( container: HTMLElement ) => {
23
+ /* eslint-disable testing-library/no-node-access -- A tick's position is an SVG attribute no query reaches. */
24
+ const xAxis = container.querySelectorAll( '.visx-axis' )[ 0 ];
25
+ return Array.from( xAxis.querySelectorAll( '.visx-axis-tick text' ) ).map( tick =>
26
+ Number( tick.getAttribute( 'x' ) )
27
+ );
28
+ /* eslint-enable testing-library/no-node-access */
29
+ };
30
+
31
+ const renderChart = ( defaultHiddenSeries?: string[] ) =>
32
+ render(
33
+ <GlobalChartsProvider locale="en-US" timeZone="Asia/Tokyo">
34
+ <AreaChart
35
+ width={ 500 }
36
+ height={ 300 }
37
+ data={ [ monthly( 'Long', 12 ), monthly( 'Short', 3 ) ] }
38
+ defaultHiddenSeries={ defaultHiddenSeries }
39
+ />
40
+ </GlobalChartsProvider>
41
+ );
42
+
43
+ describe( 'area chart tick placement', () => {
44
+ it( 'keeps ticks spanning the full domain when a series is hidden', () => {
45
+ const shown = xAxisTickOffsets( renderChart().container );
46
+ const hidden = xAxisTickOffsets( renderChart( [ 'Long' ] ).container );
47
+
48
+ expect( hidden ).toEqual( shown );
49
+ expect( hidden.length ).toBeGreaterThan( 1 );
50
+ for ( const offset of hidden ) {
51
+ expect( offset ).toBeGreaterThanOrEqual( 0 );
52
+ expect( offset ).toBeLessThanOrEqual( 500 );
53
+ }
54
+ } );
55
+ } );
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @jest-environment <rootDir>/tests/environment-los-angeles.mjs
3
+ */
4
+ import { render, screen } from '@testing-library/react';
5
+ import { GlobalChartsProvider } from '../../../providers';
6
+ import AreaChart from '../area-chart';
7
+ import type { DataPointDate } from '../../../types';
8
+ import type { RenderTooltipParams } from '../../../visx/types';
9
+ import type { ReactNode } from 'react';
10
+
11
+ const mockRefCallback = jest.fn();
12
+ jest.mock( '../../../hooks/use-element-size', () => ( {
13
+ useElementSize: () => [ mockRefCallback, 500, 300 ],
14
+ } ) );
15
+
16
+ // Captures AreaChart's `filteredRenderTooltip` so its early-return branch can be
17
+ // called directly: visx always sets `datumByKey` when it opens a tooltip.
18
+ type CapturedRenderTooltip = ( params: RenderTooltipParams< DataPointDate > ) => ReactNode;
19
+ let capturedRenderTooltip: CapturedRenderTooltip | undefined;
20
+ jest.mock( '../../../components/tooltip', () => ( {
21
+ ...jest.requireActual( '../../../components/tooltip' ),
22
+ AccessibleTooltip: ( props: { renderTooltip?: CapturedRenderTooltip } ) => {
23
+ capturedRenderTooltip = props.renderTooltip;
24
+ return null;
25
+ },
26
+ } ) );
27
+
28
+ // 09:30 on the Tokyo calendar day.
29
+ const morning = new Date( '2026-08-02T00:30:00Z' );
30
+
31
+ const datumA = { key: 'Series A', datum: { date: morning, value: 10 } as DataPointDate, index: 0 };
32
+ const datumB = { key: 'Series B', datum: { date: morning, value: 5 } as DataPointDate, index: 0 };
33
+
34
+ // Full de-DE/Asia/Tokyo heading for `morning`: pins the date style and the hour together.
35
+ const EXPECTED_HEADING = '2.8.2026, 09 Uhr';
36
+
37
+ const renderHourlyChart = ( defaultHiddenSeries?: string[] ) =>
38
+ render(
39
+ <GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
40
+ <AreaChart
41
+ width={ 500 }
42
+ height={ 300 }
43
+ options={ { axis: { x: { tickResolution: 'hour' } } } }
44
+ defaultHiddenSeries={ defaultHiddenSeries }
45
+ data={ [
46
+ { label: 'Series A', data: [ datumA.datum ], options: {} },
47
+ { label: 'Series B', data: [ datumB.datum ], options: {} },
48
+ ] }
49
+ />
50
+ </GlobalChartsProvider>
51
+ );
52
+
53
+ const renderCaptured = ( params: RenderTooltipParams< DataPointDate > ) =>
54
+ render(
55
+ <GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
56
+ <div data-testid="captured-tooltip">{ capturedRenderTooltip!( params ) }</div>
57
+ </GlobalChartsProvider>
58
+ );
59
+
60
+ describe( "AreaChart's filteredRenderTooltip", () => {
61
+ beforeEach( () => {
62
+ capturedRenderTooltip = undefined;
63
+ } );
64
+
65
+ it( 'names the hour on the early-return path, before datumByKey exists', () => {
66
+ renderHourlyChart();
67
+
68
+ const params = {
69
+ tooltipData: { nearestDatum: datumA, datumByKey: undefined },
70
+ } as unknown as RenderTooltipParams< DataPointDate >;
71
+ renderCaptured( params );
72
+
73
+ expect( screen.getByTestId( 'captured-tooltip' ) ).toHaveTextContent( EXPECTED_HEADING );
74
+ } );
75
+
76
+ it( 'names the hour on the filtered path, with a hidden series', () => {
77
+ renderHourlyChart( [ 'Series B' ] );
78
+
79
+ const params = {
80
+ tooltipData: { nearestDatum: datumA, datumByKey: { 'Series A': datumA, 'Series B': datumB } },
81
+ } as unknown as RenderTooltipParams< DataPointDate >;
82
+ renderCaptured( params );
83
+
84
+ expect( screen.getByTestId( 'captured-tooltip' ) ).toHaveTextContent( EXPECTED_HEADING );
85
+ } );
86
+ } );
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @jest-environment <rootDir>/tests/environment-los-angeles.mjs
3
+ */
4
+ import { render, screen } from '@testing-library/react';
5
+ import userEvent from '@testing-library/user-event';
6
+ import { GlobalChartsProvider } from '../../../providers';
7
+ import AreaChart from '../area-chart';
8
+
9
+ const mockRefCallback = jest.fn();
10
+ jest.mock( '../../../hooks/use-element-size', () => ( {
11
+ useElementSize: () => [ mockRefCallback, 500, 300 ],
12
+ } ) );
13
+
14
+ // 09:30 on the Tokyo calendar day.
15
+ const morning = new Date( '2026-08-02T00:30:00Z' );
16
+
17
+ describe( 'area chart tooltip bucket info', () => {
18
+ it( 'names the hour on a mounted chart', async () => {
19
+ const user = userEvent.setup();
20
+ render(
21
+ <GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
22
+ <AreaChart
23
+ width={ 500 }
24
+ height={ 300 }
25
+ options={ { axis: { x: { tickResolution: 'hour' } } } }
26
+ data={ [ { label: 'Series A', data: [ { date: morning, value: 10 } ], options: {} } ] }
27
+ />
28
+ </GlobalChartsProvider>
29
+ );
30
+
31
+ screen.getByRole( 'grid', { name: /area chart/i } ).focus();
32
+ await user.keyboard( '{ArrowRight}' );
33
+
34
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( '2.8.2026, 09 Uhr' );
35
+ } );
36
+
37
+ // The axis keeps a hidden area in its domain for the animation, but the
38
+ // tooltip drops its data, so the bucket has to follow the tooltip.
39
+ it( 'ignores a hidden series when naming the bucket', async () => {
40
+ const user = userEvent.setup();
41
+ const hourly = Array.from( { length: 4 }, ( _, index ) => ( {
42
+ date: new Date( Date.UTC( 2026, 7, 2, index ) ),
43
+ value: index,
44
+ } ) );
45
+ const daily = Array.from( { length: 3 }, ( _, index ) => ( {
46
+ date: new Date( Date.UTC( 2026, 7, 2 + index ) ),
47
+ value: index,
48
+ } ) );
49
+
50
+ render(
51
+ <GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
52
+ <AreaChart
53
+ width={ 500 }
54
+ height={ 300 }
55
+ defaultHiddenSeries={ [ 'Hourly' ] }
56
+ data={ [
57
+ { label: 'Hourly', data: hourly, options: {} },
58
+ { label: 'Daily', data: daily, options: {} },
59
+ ] }
60
+ />
61
+ </GlobalChartsProvider>
62
+ );
63
+
64
+ screen.getByRole( 'grid', { name: /area chart/i } ).focus();
65
+ await user.keyboard( '{ArrowRight}' );
66
+
67
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).not.toHaveTextContent( 'Uhr' );
68
+ } );
69
+ } );
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  BaseChartProps,
3
+ BucketInfo,
3
4
  DataPointDate,
4
5
  SeriesData,
5
6
  SeriesChartLegendConfig,
@@ -39,7 +40,9 @@ export interface AreaChartProps extends BaseChartProps< SeriesData[] >, SeriesVi
39
40
  /**
40
41
  * Custom tooltip renderer.
41
42
  */
42
- renderTooltip?: ( params: RenderTooltipParams< DataPointDate > ) => ReactNode;
43
+ renderTooltip?: (
44
+ params: RenderTooltipParams< DataPointDate > & { bucketInfo?: BucketInfo }
45
+ ) => ReactNode;
43
46
  /**
44
47
  * Whether to show crosshair lines in the tooltip.
45
48
  */
@@ -71,11 +74,5 @@ export interface AreaChartProps extends BaseChartProps< SeriesData[] >, SeriesVi
71
74
  * @default true
72
75
  */
73
76
  rescaleYOnVisibilityChange?: boolean;
74
- /**
75
- * @deprecated Use `rescaleYOnVisibilityChange`. The behaviour keys off series
76
- * visibility changing, not specifically a legend toggle. Still honoured when
77
- * `rescaleYOnVisibilityChange` is not set.
78
- */
79
- rescaleYOnLegendToggle?: boolean;
80
77
  children?: ReactNode;
81
78
  }
@@ -27,6 +27,7 @@ import { ChartInstanceContext } from '../private/chart-instance-context';
27
27
  import { ChartLayout } from '../private/chart-layout';
28
28
  import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
29
29
  import { withResponsive } from '../private/with-responsive';
30
+ import plotStyles from '../private/xy-plot/xy-plot.module.scss';
30
31
  import styles from './bar-chart.module.scss';
31
32
  import {
32
33
  useBarChartOptions,
@@ -587,7 +588,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
587
588
  onBlur={ onChartBlur }
588
589
  >
589
590
  { chartHeight > 0 && (
590
- <div ref={ chartRef }>
591
+ <div ref={ chartRef } className={ plotStyles[ 'xy-plot' ] }>
591
592
  <XYChart
592
593
  theme={ theme }
593
594
  width={ width }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @jest-environment <rootDir>/tests/environment-los-angeles.mjs
3
+ */
4
+ import { renderHook } from '@testing-library/react';
5
+ import { GlobalChartsProvider } from '../../../../providers';
6
+ import { useBarChartOptions } from '../use-bar-chart-options';
7
+ import type { SeriesData } from '../../../../types';
8
+ import type { ReactNode } from 'react';
9
+
10
+ // The runtime locale stays en-US: a bucket labeled in German, on Tokyo's
11
+ // calendar day, can only come from the provider.
12
+
13
+ // 15:30 UTC is Aug 2 in Los Angeles and Aug 3 in Tokyo.
14
+ const START = new Date( '2026-08-02T15:30:00Z' );
15
+
16
+ const daily: SeriesData[] = [
17
+ {
18
+ label: 'Series A',
19
+ data: Array.from( { length: 14 }, ( _, i ) => ( {
20
+ date: new Date( START.getTime() + i * 24 * 60 * 60 * 1000 ),
21
+ value: 10 + i,
22
+ } ) ),
23
+ options: {},
24
+ },
25
+ ];
26
+
27
+ const withProvider =
28
+ ( props: { locale?: string; timeZone?: string } = {} ) =>
29
+ ( { children }: { children: ReactNode } ) => (
30
+ <GlobalChartsProvider { ...props }>{ children }</GlobalChartsProvider>
31
+ );
32
+
33
+ const optionsFor = (
34
+ props?: { locale?: string; timeZone?: string },
35
+ options?: Parameters< typeof useBarChartOptions >[ 2 ]
36
+ ) =>
37
+ renderHook( () => useBarChartOptions( daily, false, options ), {
38
+ wrapper: withProvider( props ),
39
+ } ).result.current;
40
+
41
+ describe( 'useBarChartOptions with a host formatting context', () => {
42
+ it( 'labels the tooltip bucket in the provider locale and time zone', () => {
43
+ const { tooltip } = optionsFor( { locale: 'de-DE', timeZone: 'Asia/Tokyo' } );
44
+
45
+ expect( tooltip.labelFormatter( START.getTime(), 0, [] ) ).toBe( '3. August 2026' );
46
+ } );
47
+
48
+ it( 'labels a weekly bucket in the provider locale and time zone', () => {
49
+ const { tooltip } = optionsFor(
50
+ { locale: 'de-DE', timeZone: 'Asia/Tokyo' },
51
+ { axis: { x: { tickResolution: 'week' } } }
52
+ );
53
+
54
+ expect( tooltip.labelFormatter( START.getTime(), 0, [] ) ).toBe( 'Week of 3. August 2026' );
55
+ } );
56
+
57
+ it( 'labels the axis ticks in the provider locale and time zone', () => {
58
+ const { axis } = optionsFor( { locale: 'de-DE', timeZone: 'Asia/Tokyo' } );
59
+
60
+ expect( axis.x.tickFormat( START, 0, [] ) ).toBe( '3. Aug.' );
61
+ } );
62
+
63
+ it( 'falls back to the runtime locale and zone when the provider supplies none', () => {
64
+ const { tooltip, axis } = optionsFor();
65
+
66
+ expect( tooltip.labelFormatter( START.getTime(), 0, [] ) ).toBe( 'August 2, 2026' );
67
+ expect( axis.x.tickFormat( START, 0, [] ) ).toBe( 'Aug 2' );
68
+ } );
69
+ } );