@automattic/charts 4.1.1 → 4.3.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 (97) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/TOKENS.md +1 -1
  3. package/dist/index.cjs +1335 -348
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +30 -1
  6. package/dist/index.d.cts +129 -8
  7. package/dist/index.d.ts +129 -8
  8. package/dist/index.js +1336 -351
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -8
  11. package/src/charts/area-chart/area-chart.tsx +42 -10
  12. package/src/charts/area-chart/private/overlays.tsx +1 -2
  13. package/src/charts/area-chart/private/validate-data.ts +2 -3
  14. package/src/charts/area-chart/test/area-chart.test.tsx +72 -1
  15. package/src/charts/bar-chart/bar-chart.tsx +137 -70
  16. package/src/charts/bar-chart/index.ts +1 -1
  17. package/src/charts/bar-chart/private/band-highlight.tsx +87 -0
  18. package/src/charts/bar-chart/private/band-scale.ts +13 -0
  19. package/src/charts/bar-chart/private/band-tooltip.tsx +138 -0
  20. package/src/charts/bar-chart/private/comparison-bars.tsx +5 -10
  21. package/src/charts/bar-chart/private/index.ts +3 -0
  22. package/src/charts/bar-chart/private/test/band-highlight.test.tsx +135 -0
  23. package/src/charts/bar-chart/private/test/band-tooltip.test.ts +26 -0
  24. package/src/charts/bar-chart/private/test/value-domain.test.ts +100 -0
  25. package/src/charts/bar-chart/private/truncated-tick-component.tsx +1 -1
  26. package/src/charts/bar-chart/private/use-bar-chart-options.ts +13 -40
  27. package/src/charts/bar-chart/private/value-domain.ts +90 -0
  28. package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +11 -23
  29. package/src/charts/bar-chart/test/bar-chart.test.tsx +1058 -19
  30. package/src/charts/bar-chart/types.ts +39 -0
  31. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -3
  32. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
  33. package/src/charts/conversion-funnel-chart/types.ts +4 -2
  34. package/src/charts/geo-chart/types.ts +4 -2
  35. package/src/charts/heatmap-chart/build-calendar-data.ts +5 -11
  36. package/src/charts/heatmap-chart/build-month-calendar-data.ts +124 -0
  37. package/src/charts/heatmap-chart/heatmap-chart.module.scss +32 -0
  38. package/src/charts/heatmap-chart/heatmap-chart.tsx +191 -114
  39. package/src/charts/heatmap-chart/index.ts +6 -0
  40. package/src/charts/heatmap-chart/private/civil-day.ts +26 -0
  41. package/src/charts/heatmap-chart/private/column-groups.ts +71 -0
  42. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +19 -3
  43. package/src/charts/heatmap-chart/private/index.ts +2 -0
  44. package/src/charts/heatmap-chart/private/keyboard-navigation.ts +201 -0
  45. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +36 -0
  46. package/src/charts/heatmap-chart/test/build-month-calendar-data.test.ts +194 -0
  47. package/src/charts/heatmap-chart/test/column-groups.test.ts +83 -0
  48. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +428 -2
  49. package/src/charts/heatmap-chart/test/keyboard-navigation.test.ts +25 -0
  50. package/src/charts/heatmap-chart/test/use-month-calendar-heatmap-data.test.tsx +90 -0
  51. package/src/charts/heatmap-chart/test/zoned-month-calendar-data.test.ts +23 -0
  52. package/src/charts/heatmap-chart/types.ts +59 -3
  53. package/src/charts/heatmap-chart/use-month-calendar-heatmap-data.ts +34 -0
  54. package/src/charts/leaderboard-chart/types.ts +13 -14
  55. package/src/charts/line-chart/line-chart.tsx +149 -36
  56. package/src/charts/line-chart/private/index.ts +1 -0
  57. package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
  58. package/src/charts/line-chart/private/line-chart-glyph.tsx +17 -3
  59. package/src/charts/line-chart/private/nearest-pointer-events.tsx +155 -0
  60. package/src/charts/line-chart/test/line-chart.test.tsx +604 -9
  61. package/src/charts/line-chart/types.ts +4 -3
  62. package/src/charts/private/readings.ts +34 -0
  63. package/src/charts/private/test/readings.test.ts +29 -0
  64. package/src/charts/private/test/whole-number-ticks.test.tsx +57 -0
  65. package/src/charts/private/time-axis-options.ts +1 -1
  66. package/src/charts/private/whole-number-ticks.tsx +79 -0
  67. package/src/charts/private/with-responsive/with-responsive.tsx +2 -2
  68. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +1 -3
  69. package/src/charts/private/x-zoom/x-zoom.tsx +1 -2
  70. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +124 -0
  71. package/src/components/legend/hooks/use-chart-legend-items.ts +60 -2
  72. package/src/components/legend/private/base-legend.tsx +37 -22
  73. package/src/components/legend/test/legend.test.tsx +84 -0
  74. package/src/components/legend/types.ts +2 -0
  75. package/src/components/legend/utils/index.ts +0 -1
  76. package/src/components/tooltip/accessible-tooltip.tsx +128 -41
  77. package/src/components/tooltip/base-tooltip.module.scss +6 -1
  78. package/src/components/tooltip/private/bounded-tooltip.tsx +12 -6
  79. package/src/components/tooltip/test/accessible-tooltip.test.tsx +128 -0
  80. package/src/components/tooltip/test/bounded-tooltip.test.tsx +17 -0
  81. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +7 -0
  82. package/src/components/tooltip/xy-chart-tooltip.tsx +9 -10
  83. package/src/hooks/test/use-chart-margin.test.tsx +35 -0
  84. package/src/hooks/test/use-element-size.test.tsx +13 -13
  85. package/src/hooks/use-chart-margin.tsx +7 -2
  86. package/src/hooks/use-xychart-theme.ts +1 -1
  87. package/src/index.ts +7 -1
  88. package/src/providers/chart-context/global-charts-provider.tsx +1 -1
  89. package/src/providers/chart-context/test/chart-context.test.tsx +4 -4
  90. package/src/providers/chart-context/themes.ts +1 -0
  91. package/src/styles/test/chart-scope.test.ts +1 -1
  92. package/src/types.ts +14 -13
  93. package/src/utils/format-metric-value.ts +4 -4
  94. package/src/utils/get-edge-tick-widths.ts +1 -1
  95. package/src/utils/get-styles.ts +1 -1
  96. package/src/visx/types.ts +7 -10
  97. package/src/components/legend/utils/label-transform-factory.ts +0 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "4.1.1",
3
+ "version": "4.3.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": {
@@ -65,7 +65,7 @@
65
65
  "dependencies": {
66
66
  "@automattic/number-formatters": "^1.2.9",
67
67
  "@babel/runtime": "7.29.7",
68
- "@date-fns/tz": "1.4.1",
68
+ "@date-fns/tz": "1.5.0",
69
69
  "@react-spring/web": "10.1.1",
70
70
  "@visx/annotation": "^4.0.0",
71
71
  "@visx/axis": "^4.0.0",
@@ -84,9 +84,9 @@
84
84
  "@visx/vendor": "^4.0.0",
85
85
  "@visx/xychart": "^4.0.0",
86
86
  "@wordpress/i18n": "^6.0.0",
87
- "@wordpress/icons": "^15.0.0",
88
- "@wordpress/theme": "2.1.1-next.v.202608281122.0",
89
- "@wordpress/ui": "0.21.0",
87
+ "@wordpress/icons": "^16.0.0",
88
+ "@wordpress/theme": "2.1.1-next.v.202609031004.0",
89
+ "@wordpress/ui": "0.22.0",
90
90
  "clsx": "2.1.1",
91
91
  "date-fns": "^4.1.0",
92
92
  "deepmerge": "4.3.1",
@@ -111,9 +111,9 @@
111
111
  "@types/react-dom": "18.3.7",
112
112
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
113
113
  "@visx/glyph": "4.0.0",
114
- "@wordpress/components": "40.0.0",
115
- "@wordpress/element": "8.6.0",
116
- "@wordpress/private-apis": "1.54.0",
114
+ "@wordpress/components": "40.1.0",
115
+ "@wordpress/element": "8.7.0",
116
+ "@wordpress/private-apis": "1.55.0",
117
117
  "babel-jest": "30.5.1",
118
118
  "identity-obj-proxy": "^3.0.0",
119
119
  "jest": "30.5.1",
@@ -37,6 +37,7 @@ import { ChartLayout } from '../private/chart-layout';
37
37
  import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
38
38
  import { getCurveType } from '../private/time-axis';
39
39
  import { buildTimeAxisOptions } from '../private/time-axis-options';
40
+ import { hasOnlyWholeNumbers, WholeNumberTicks } from '../private/whole-number-ticks';
40
41
  import { withResponsive } from '../private/with-responsive';
41
42
  import { useXZoom, ZoomResetButton, ZoomSelectionRect, ZoomClip } from '../private/x-zoom';
42
43
  import plotStyles from '../private/xy-plot/xy-plot.module.scss';
@@ -142,6 +143,15 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
142
143
  [ seriesWithVisibility ]
143
144
  );
144
145
 
146
+ // A normalized stack (expand/wiggle/silhouette) turns whole-number data into
147
+ // fractions, so the filter is skipped for every offset but 'none'.
148
+ const hasWholeNumberValues = useMemo(
149
+ () =>
150
+ ( ! stacked || stackOffset === 'none' ) &&
151
+ hasOnlyWholeNumbers( dataSorted.filter( series => isSeriesVisible( series.label ) ) ),
152
+ [ dataSorted, isSeriesVisible, stacked, stackOffset ]
153
+ );
154
+
145
155
  const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
146
156
  selectedIndex,
147
157
  setSelectedIndex,
@@ -250,8 +260,9 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
250
260
  withGlyph: false,
251
261
  glyphSize: 0,
252
262
  collapseGroups: legend.collapseGroups ?? false,
263
+ comparisonItem: legend.comparisonItem ?? false,
253
264
  } ),
254
- [ legend.collapseGroups ]
265
+ [ legend.collapseGroups, legend.comparisonItem ]
255
266
  );
256
267
  const legendItems = useChartLegendItems( dataSorted, legendOptions, legendShape );
257
268
 
@@ -438,15 +449,36 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
438
449
  { /* With every series hidden the value scale collapses, so the grid and axes
439
450
  are dropped while the empty state stands in — otherwise they render
440
451
  squished at the top. */ }
441
- { ! allSeriesHidden && gridVisibility !== 'none' && (
442
- <Grid columns={ false } numTicks={ 4 } />
443
- ) }
444
- { ! allSeriesHidden && chartOptions.axis.x.display && (
445
- <Axis { ...chartOptions.axis.x } />
446
- ) }
447
- { ! allSeriesHidden && chartOptions.axis.y.display && (
448
- <Axis { ...chartOptions.axis.y } />
449
- ) }
452
+ <WholeNumberTicks
453
+ axis="y"
454
+ numTicks={ chartOptions.axis.y.numTicks }
455
+ enabled={
456
+ hasWholeNumberValues &&
457
+ ! chartOptions.axis.y.tickValues &&
458
+ ! options?.yScale?.domain
459
+ }
460
+ >
461
+ { tickValues => (
462
+ <>
463
+ { ! allSeriesHidden && gridVisibility !== 'none' && (
464
+ <Grid
465
+ columns={ false }
466
+ numTicks={ chartOptions.axis.y.numTicks }
467
+ { ...{ tickValues: tickValues ?? chartOptions.axis.y.tickValues } }
468
+ />
469
+ ) }
470
+ { ! allSeriesHidden && chartOptions.axis.x.display && (
471
+ <Axis { ...chartOptions.axis.x } />
472
+ ) }
473
+ { ! allSeriesHidden && chartOptions.axis.y.display && (
474
+ <Axis
475
+ { ...chartOptions.axis.y }
476
+ { ...( tickValues ? { tickValues } : {} ) }
477
+ />
478
+ ) }
479
+ </>
480
+ ) }
481
+ </WholeNumberTicks>
450
482
 
451
483
  { allSeriesHidden ? (
452
484
  <SvgEmptyState
@@ -57,8 +57,7 @@ export const HoverGlyphs: FC< {
57
57
  const yScale = dataContext?.yScale as ScaleFn | undefined;
58
58
  const tooltipOpen = tooltipContext?.tooltipOpen;
59
59
  const nearestDatum = tooltipContext?.tooltipData?.nearestDatum?.datum as
60
- | DataPointDate
61
- | undefined;
60
+ DataPointDate | undefined;
62
61
 
63
62
  if (
64
63
  ! tooltipOpen ||
@@ -1,4 +1,5 @@
1
1
  import { __ } from '@wordpress/i18n';
2
+ import { isInvalidReading } from '../../private/readings';
2
3
  import type { DataPoint, DataPointDate, SeriesData } from '../../../types';
3
4
 
4
5
  /**
@@ -19,9 +20,7 @@ export const validateData = ( data: SeriesData[] ) => {
19
20
  const hasInvalidData = data.some( series =>
20
21
  series.data.some(
21
22
  ( point: DataPointDate | DataPoint ) =>
22
- isNaN( point.value as number ) ||
23
- point.value === null ||
24
- point.value === undefined ||
23
+ isInvalidReading( point.value, { allowMissing: false } ) ||
25
24
  ( 'date' in point && point.date && isNaN( point.date.getTime() ) )
26
25
  )
27
26
  );
@@ -1,4 +1,4 @@
1
- import { render, screen, act } from '@testing-library/react';
1
+ import { render, screen, within, act } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { createRef } from 'react';
4
4
  import { GlobalChartsProvider } from '../../../providers';
@@ -188,6 +188,77 @@ describe( 'AreaChart', () => {
188
188
  } );
189
189
  } );
190
190
 
191
+ describe( 'Y-Axis Ticks', () => {
192
+ test( 'labels a whole-number range smaller than the tick count once per whole number', () => {
193
+ renderWithProvider( {
194
+ stacked: false,
195
+ data: [
196
+ {
197
+ label: 'Series A',
198
+ data: [ 0, 1, 1, 0 ].map( ( value, i ) => ( {
199
+ date: new Date( 2024, i + 2, 1 ),
200
+ value,
201
+ } ) ),
202
+ },
203
+ ],
204
+ } );
205
+
206
+ const chart = screen.getByRole( 'grid', { name: /area chart/i } );
207
+ const ticks = within( chart )
208
+ .getAllByText( /^-?[\d.,]+$/ )
209
+ .map( el => el.textContent );
210
+ expect( ticks.sort() ).toEqual( [ '0', '1' ] );
211
+ } );
212
+
213
+ test( 'keeps every tick on a y domain the caller pinned', () => {
214
+ renderWithProvider( {
215
+ stacked: false,
216
+ data: [
217
+ {
218
+ label: 'Series A',
219
+ data: [ 0, 0, 0 ].map( ( value, i ) => ( {
220
+ date: new Date( 2024, i + 2, 1 ),
221
+ value,
222
+ } ) ),
223
+ },
224
+ ],
225
+ options: {
226
+ yScale: { domain: [ 0, 1 ] },
227
+ axis: { y: { tickFormat: ( value: number ) => `${ Math.round( value * 100 ) }%` } },
228
+ },
229
+ } );
230
+
231
+ const chart = screen.getByRole( 'grid', { name: /area chart/i } );
232
+ expect( within( chart ).getAllByText( /^\d+%$/ ) ).toHaveLength( 6 );
233
+ } );
234
+
235
+ test( 'keeps fractional ticks for a normalized stack even when the data is whole numbers', () => {
236
+ renderWithProvider( {
237
+ stacked: true,
238
+ stackOffset: 'expand',
239
+ data: [
240
+ {
241
+ label: 'Series A',
242
+ data: [ 0, 1, 1, 0 ].map( ( value, i ) => ( {
243
+ date: new Date( 2024, i + 2, 1 ),
244
+ value,
245
+ } ) ),
246
+ },
247
+ {
248
+ label: 'Series B',
249
+ data: [ 1, 0, 0, 1 ].map( ( value, i ) => ( {
250
+ date: new Date( 2024, i + 2, 1 ),
251
+ value,
252
+ } ) ),
253
+ },
254
+ ],
255
+ } );
256
+
257
+ const chart = screen.getByRole( 'grid', { name: /area chart/i } );
258
+ expect( within( chart ).getAllByText( /^-?[\d.,]+$/ ).length ).toBeGreaterThan( 2 );
259
+ } );
260
+ } );
261
+
191
262
  describe( 'Stacking', () => {
192
263
  test( 'is stacked by default', () => {
193
264
  renderWithProvider();
@@ -1,4 +1,3 @@
1
- import { formatNumber } from '@automattic/number-formatters';
2
1
  import { PatternLines, PatternCircles, PatternWaves, PatternHexagons } from '@visx/pattern';
3
2
  import { Axis, BarSeries, BarGroup, Grid, XYChart } from '@visx/xychart';
4
3
  import { __, sprintf } from '@wordpress/i18n';
@@ -22,46 +21,37 @@ import {
22
21
  } from '../../providers';
23
22
  import { useDefaultHiddenSeries } from '../../providers/chart-context/hooks/use-default-hidden-series';
24
23
  import { attachSubComponents } from '../../utils';
24
+ import { warnOnce } from '../../utils/warn-once';
25
25
  import { useChartChildren } from '../private/chart-composition';
26
26
  import { ChartInstanceContext } from '../private/chart-instance-context';
27
27
  import { ChartLayout } from '../private/chart-layout';
28
+ import { formatReading, isInvalidReading } from '../private/readings';
28
29
  import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
30
+ import { hasOnlyWholeNumbers, WholeNumberTicks } from '../private/whole-number-ticks';
29
31
  import { withResponsive } from '../private/with-responsive';
30
32
  import plotStyles from '../private/xy-plot/xy-plot.module.scss';
31
33
  import styles from './bar-chart.module.scss';
32
34
  import {
33
35
  useBarChartOptions,
36
+ BandHighlight,
37
+ BandTooltip,
34
38
  ComparisonBars,
35
39
  DEFAULT_COMPARISON_WIDTH_FACTOR,
36
40
  COMPARISON_INNER_GAP,
37
41
  MAX_GROUP_PADDING,
38
42
  COMPARISON_TICK_GAP_FACTOR,
39
43
  BASE_BAND_PADDING_INNER,
44
+ countRenderedBars,
45
+ isBarRendered,
40
46
  } from './private';
41
47
  import type { ComparisonSeriesEntry } from './private';
42
- import type {
43
- BaseChartProps,
44
- DataPointDate,
45
- SeriesData,
46
- SeriesChartLegendConfig,
47
- SeriesVisibilityProps,
48
- Optional,
49
- } from '../../types';
48
+ import type { BarChartProps } from './types';
49
+ import type { DataPointDate, SeriesData, Optional } from '../../types';
50
50
  import type { RenderTooltipParams } from '../../visx/types';
51
51
  import type { ResponsiveConfig } from '../private/with-responsive';
52
- import type { FC, ReactNode, ComponentType } from 'react';
53
-
54
- export interface BarChartProps extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps {
55
- /**
56
- * Legend configuration. Supports `collapseGroups` on top of the shared options.
57
- */
58
- legend?: SeriesChartLegendConfig;
59
- renderTooltip?: ( params: RenderTooltipParams< DataPointDate > ) => ReactNode;
60
- orientation?: 'horizontal' | 'vertical';
61
- withPatterns?: boolean;
62
- showZeroValues?: boolean;
63
- children?: ReactNode;
64
- }
52
+ import type { FC, ComponentType } from 'react';
53
+
54
+ export type { BarChartProps, BandHighlightSelection } from './types';
65
55
 
66
56
  // Base props type with optional responsive properties
67
57
  type BarChartBaseProps = Optional< BarChartProps, 'width' | 'height' | 'size' >;
@@ -77,20 +67,18 @@ type BarChartResponsiveComponent = FC< BarChartBaseProps & ResponsiveConfig > &
77
67
 
78
68
  // Validation function similar to LineChart
79
69
  const validateData = ( data: SeriesData[] ) => {
80
- if ( ! data?.length ) return 'No data available';
70
+ if ( ! data?.length ) return __( 'No data available', 'jetpack-charts' );
81
71
 
82
72
  const hasInvalidData = data.some( series =>
83
73
  series.data.some(
84
74
  point =>
85
- isNaN( point.value as number ) ||
86
- point.value === null ||
87
- point.value === undefined ||
75
+ isInvalidReading( point.value, { allowMissing: true } ) ||
88
76
  ( ! point.label &&
89
77
  ( ! ( 'date' in point && point.date ) || isNaN( point.date.getTime() ) ) )
90
78
  )
91
79
  );
92
80
 
93
- if ( hasInvalidData ) return 'Invalid data';
81
+ if ( hasInvalidData ) return __( 'Invalid data', 'jetpack-charts' );
94
82
  return null;
95
83
  };
96
84
 
@@ -121,10 +109,14 @@ const BarChartInternal: FC< BarChartProps > = ( {
121
109
  legend = {},
122
110
  gridVisibility: gridVisibilityProp,
123
111
  renderTooltip,
112
+ tooltipPlacement,
113
+ tooltipAnchorTop,
124
114
  options = {},
125
115
  orientation = 'vertical',
126
116
  withPatterns = false,
127
117
  showZeroValues = false,
118
+ withBandHighlight = false,
119
+ onBandHighlightChange,
128
120
  defaultHiddenSeries,
129
121
  animation,
130
122
  children,
@@ -133,8 +125,16 @@ const BarChartInternal: FC< BarChartProps > = ( {
133
125
  onPointerUp,
134
126
  onDatumActivate,
135
127
  } ) => {
128
+ if ( ! withTooltips && ( withBandHighlight || onBandHighlightChange ) ) {
129
+ warnOnce(
130
+ 'bar-chart-band-highlight-without-tooltips',
131
+ 'BarChart: withBandHighlight and onBandHighlightChange require withTooltips.'
132
+ );
133
+ }
134
+
136
135
  const legendInteractive = legend.interactive ?? false;
137
136
  const legendCollapseGroups = legend.collapseGroups ?? false;
137
+ const legendComparisonItem = legend.comparisonItem ?? false;
138
138
  const horizontal = orientation === 'horizontal';
139
139
  const chartId = useChartId( providedChartId );
140
140
  const hiddenSeries = useDefaultHiddenSeries( chartId, defaultHiddenSeries );
@@ -155,8 +155,8 @@ const BarChartInternal: FC< BarChartProps > = ( {
155
155
 
156
156
  // Create legend items using the reusable hook
157
157
  const legendOptions = useMemo(
158
- () => ( { collapseGroups: legendCollapseGroups } ),
159
- [ legendCollapseGroups ]
158
+ () => ( { collapseGroups: legendCollapseGroups, comparisonItem: legendComparisonItem } ),
159
+ [ legendCollapseGroups, legendComparisonItem ]
160
160
  );
161
161
  const legendItems = useChartLegendItems( dataSorted, legendOptions );
162
162
 
@@ -170,12 +170,24 @@ const BarChartInternal: FC< BarChartProps > = ( {
170
170
  [ isSeriesVisible ]
171
171
  );
172
172
 
173
+ const hasWholeNumberValues = useMemo(
174
+ () => hasOnlyWholeNumbers( dataSorted.filter( isSeriesRendered ) ),
175
+ [ dataSorted, isSeriesRendered ]
176
+ );
177
+
173
178
  const chartOptions = useBarChartOptions(
174
179
  dataWithVisibleZeros,
175
180
  horizontal,
176
181
  options,
177
182
  isSeriesRendered
178
183
  );
184
+ const valueAxis = horizontal ? chartOptions.axis.x : chartOptions.axis.y;
185
+ const callerValueDomain = horizontal ? options.xScale?.domain : options.yScale?.domain;
186
+ const wholeNumberTicksProps = {
187
+ axis: horizontal ? ( 'x' as const ) : ( 'y' as const ),
188
+ numTicks: valueAxis.numTicks,
189
+ enabled: hasWholeNumberValues && ! valueAxis.tickValues && ! callerValueDomain,
190
+ };
179
191
  const defaultMargin = useChartMargin( height, chartOptions, dataSorted, theme, horizontal );
180
192
  const chartRef = useRef< HTMLDivElement >( null );
181
193
 
@@ -193,13 +205,6 @@ const BarChartInternal: FC< BarChartProps > = ( {
193
205
  const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >( undefined );
194
206
  const [ isNavigating, setIsNavigating ] = useState( false );
195
207
 
196
- // Comparison series have no .visx-bar elements; count only primary series so
197
- // keyboard navigation doesn't cycle phantom indices into comparison-only slots.
198
- const primarySeriesForNav = dataWithVisibleZeros.filter( s => s.options?.type !== 'comparison' );
199
- const totalPoints =
200
- Math.max( 0, ...primarySeriesForNav.map( s => s.data?.length || 0 ) ) *
201
- primarySeriesForNav.length;
202
-
203
208
  // Add visibility information from the shared legend state.
204
209
  const seriesWithVisibility = useMemo(
205
210
  () =>
@@ -225,6 +230,9 @@ const BarChartInternal: FC< BarChartProps > = ( {
225
230
  [ seriesWithVisibility ]
226
231
  );
227
232
 
233
+ const totalPoints =
234
+ Math.max( 0, ...primaryEntries.map( e => e.series.data.length ) ) * primaryEntries.length;
235
+
228
236
  const primaryKeys = useMemo(
229
237
  () => primaryEntries.map( ( { series } ) => series.label ),
230
238
  [ primaryEntries ]
@@ -259,6 +267,8 @@ const BarChartInternal: FC< BarChartProps > = ( {
259
267
  [ primaryEntries, onDatumActivate ]
260
268
  );
261
269
 
270
+ const visibleSeriesKey = useMemo( () => JSON.stringify( primaryKeys ), [ primaryKeys ] );
271
+
262
272
  const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
263
273
  selectedIndex,
264
274
  setSelectedIndex,
@@ -267,6 +277,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
267
277
  chartRef,
268
278
  totalPoints,
269
279
  onActivate: activateSelectedBar,
280
+ visibleSeriesKey,
270
281
  } );
271
282
 
272
283
  const comparisonEntries = useMemo( () => {
@@ -342,10 +353,10 @@ const BarChartInternal: FC< BarChartProps > = ( {
342
353
  }, [ comparisonEntries.length, chartOptions.xScale, chartOptions.yScale, horizontal ] );
343
354
 
344
355
  const getBarBackground = useCallback(
345
- ( index: number ) => () =>
356
+ ( index: number ) => ( datum: DataPointDate ) =>
346
357
  withPatterns
347
358
  ? `url(#${ getPatternId( chartId, index ) })`
348
- : getElementStyles( { data: dataSorted[ index ], index } ).color,
359
+ : ( datum.color ?? getElementStyles( { data: dataSorted[ index ], index } ).color ),
349
360
  [ withPatterns, getElementStyles, dataSorted, chartId ]
350
361
  );
351
362
 
@@ -383,14 +394,14 @@ const BarChartInternal: FC< BarChartProps > = ( {
383
394
 
384
395
  // With a paired comparison value, show the category as the header and one row
385
396
  // per period (primary + comparison).
386
- if ( comparisonEntry && comparisonDatum && comparisonDatum.value != null ) {
397
+ if ( comparisonEntry && comparisonDatum ) {
387
398
  return (
388
399
  <div className={ styles[ 'bar-chart__tooltip' ] }>
389
400
  <div className={ styles[ 'bar-chart__tooltip-header' ] }>{ categoryLabel }</div>
390
- { renderTooltipRow( primaryKey, formatNumber( nearestDatum.value as number ) ) }
401
+ { renderTooltipRow( primaryKey, formatReading( nearestDatum.value ) ) }
391
402
  { renderTooltipRow(
392
403
  comparisonEntry.series.label,
393
- formatNumber( comparisonDatum.value as number )
404
+ formatReading( comparisonDatum.value )
394
405
  ) }
395
406
  </div>
396
407
  );
@@ -399,7 +410,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
399
410
  return (
400
411
  <div className={ styles[ 'bar-chart__tooltip' ] }>
401
412
  <div className={ styles[ 'bar-chart__tooltip-header' ] }>{ primaryKey }</div>
402
- { renderTooltipRow( categoryLabel, formatNumber( nearestDatum.value as number ) ) }
413
+ { renderTooltipRow( categoryLabel, formatReading( nearestDatum.value ) ) }
403
414
  </div>
404
415
  );
405
416
  },
@@ -461,34 +472,33 @@ const BarChartInternal: FC< BarChartProps > = ( {
461
472
  const createKeyboardHighlightStyle = useCallback( () => {
462
473
  if ( selectedIndex === undefined ) return '';
463
474
 
464
- // Use only primary entries — comparison series have no .visx-bar elements so
475
+ // Use only primary entries: comparison series have no .visx-bar elements, so
465
476
  // their indices must not appear in the nth-child selector.
466
477
  // Pattern: [series1[0], series2[0], series3[0], series1[1], series2[1], series3[1], ...]
467
478
  const primaryCount = primaryEntries.length;
468
- const maxDataPoints = Math.max( ...primaryEntries.map( e => e.series.data.length ) );
469
- const dataPointIndex = Math.floor( selectedIndex / primaryCount );
470
- const seriesIndex = selectedIndex % primaryCount;
471
-
472
- // Only highlight if we're within valid bounds
473
- if ( dataPointIndex >= maxDataPoints || seriesIndex >= primaryCount ) {
479
+ if ( ! primaryCount ) {
474
480
  return '';
475
481
  }
476
-
482
+ const dataPointIndex = Math.floor( selectedIndex / primaryCount );
483
+ const seriesIndex = selectedIndex % primaryCount;
477
484
  const seriesData = primaryEntries[ seriesIndex ]?.series;
478
- if ( ! seriesData || dataPointIndex >= seriesData.data.length ) {
485
+ const datum = seriesData?.data[ dataPointIndex ];
486
+
487
+ if ( ! seriesData || ! datum || ! isBarRendered( datum ) ) {
479
488
  return '';
480
489
  }
481
490
 
482
- // Based on the DOM structure analysis:
483
- // - All bars are in a single .visx-bar-group
484
- // - Bars are ordered as: [series1[0], series1[1], series2[0], series2[1], ...]
485
- // - So we need to calculate the actual bar index in the DOM
486
- const actualBarIndex = seriesIndex * maxDataPoints + dataPointIndex;
491
+ // The single .visx-bar-group holds series after series, and only the points visx drew a bar for.
492
+ const domBarIndex =
493
+ primaryEntries
494
+ .slice( 0, seriesIndex )
495
+ .reduce( ( count, { series } ) => count + countRenderedBars( series.data ), 0 ) +
496
+ countRenderedBars( seriesData.data.slice( 0, dataPointIndex ) );
487
497
 
488
498
  // Use a CSS class selector instead of ID since useId() generates invalid CSS ID characters
489
499
  const generatedStyles = `
490
500
  .bar-chart[data-chart-id="bar-chart-${ chartId }"] .visx-bar-group .visx-bar:nth-child(${
491
- actualBarIndex + 1
501
+ domBarIndex + 1
492
502
  }) {
493
503
  stroke: #005fcc;
494
504
  stroke-width: 2px;
@@ -581,6 +591,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
581
591
  return (
582
592
  <div
583
593
  role="grid"
594
+ ref={ chartRef }
584
595
  aria-label={ __( 'Bar chart', 'jetpack-charts' ) }
585
596
  tabIndex={ 0 }
586
597
  onKeyDown={ onChartKeyDown }
@@ -588,7 +599,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
588
599
  onBlur={ onChartBlur }
589
600
  >
590
601
  { chartHeight > 0 && (
591
- <div ref={ chartRef } className={ plotStyles[ 'xy-plot' ] }>
602
+ <div className={ plotStyles[ 'xy-plot' ] }>
592
603
  <XYChart
593
604
  theme={ theme }
594
605
  width={ width }
@@ -600,18 +611,46 @@ const BarChartInternal: FC< BarChartProps > = ( {
600
611
  xScale={ xScale }
601
612
  yScale={ yScale }
602
613
  horizontal={ horizontal }
603
- onPointerDown={ onPointerDown }
604
- onPointerUp={ onPointerUp }
605
614
  pointerEventsDataKey="nearest"
606
615
  >
607
- { ! allSeriesHidden && (
608
- <Grid
609
- columns={ gridVisibility.includes( 'y' ) }
610
- rows={ gridVisibility.includes( 'x' ) }
611
- numTicks={ 4 }
616
+ { withTooltips && ( withBandHighlight || onBandHighlightChange ) && (
617
+ <BandHighlight
618
+ visible={ withBandHighlight }
619
+ horizontal={ horizontal }
620
+ onChange={ onBandHighlightChange }
612
621
  />
613
622
  ) }
614
623
 
624
+ { ! allSeriesHidden && (
625
+ <WholeNumberTicks { ...wholeNumberTicksProps }>
626
+ { valueTicks => (
627
+ <>
628
+ { /* Visx forwards tickValues to its grid primitives but omits it from GridProps. */ }
629
+ <Grid
630
+ columns={ gridVisibility.includes( 'y' ) }
631
+ rows={ false }
632
+ numTicks={ chartOptions.axis.x.numTicks }
633
+ { ...{
634
+ tickValues:
635
+ ( horizontal ? valueTicks : undefined ) ??
636
+ chartOptions.axis.x.tickValues,
637
+ } }
638
+ />
639
+ <Grid
640
+ columns={ false }
641
+ rows={ gridVisibility.includes( 'x' ) }
642
+ numTicks={ chartOptions.axis.y.numTicks }
643
+ { ...{
644
+ tickValues:
645
+ ( horizontal ? undefined : valueTicks ) ??
646
+ chartOptions.axis.y.tickValues,
647
+ } }
648
+ />
649
+ </>
650
+ ) }
651
+ </WholeNumberTicks>
652
+ ) }
653
+
615
654
  { withPatterns && (
616
655
  <>
617
656
  <defs data-testid="bar-chart-patterns">
@@ -633,7 +672,11 @@ const BarChartInternal: FC< BarChartProps > = ( {
633
672
  </>
634
673
  ) }
635
674
 
636
- { highlightedBarStyle && <style>{ highlightedBarStyle }</style> }
675
+ { highlightedBarStyle && (
676
+ <style data-testid="bar-chart-keyboard-highlight">
677
+ { highlightedBarStyle }
678
+ </style>
679
+ ) }
637
680
 
638
681
  { allSeriesHidden ? (
639
682
  <SvgEmptyState
@@ -673,19 +716,43 @@ const BarChartInternal: FC< BarChartProps > = ( {
673
716
  />
674
717
  ) ) }
675
718
  </BarGroup>
719
+ { /* Do not reorder: for one key the last showTooltip wins, so this must run after BarGroup. */ }
720
+ { ( withTooltips || onPointerDown || onPointerUp ) && (
721
+ <BandTooltip
722
+ keys={ primaryKeys }
723
+ groupPadding={ groupPadding }
724
+ withTooltips={ withTooltips }
725
+ onPointerDown={ onPointerDown }
726
+ onPointerUp={ onPointerUp }
727
+ />
728
+ ) }
676
729
 
677
730
  { /* With every series hidden there is no data to build the value scale from, so
678
731
  visx collapses the domain and the axes render squished at the top. Drop them
679
732
  while the empty state stands in. */ }
680
733
  { ! allSeriesHidden && (
681
- <>
682
- <Axis { ...chartOptions.axis.x } />
683
- <Axis { ...chartOptions.axis.y } />
684
- </>
734
+ <WholeNumberTicks { ...wholeNumberTicksProps }>
735
+ { valueTicks => (
736
+ <>
737
+ <Axis
738
+ { ...chartOptions.axis.x }
739
+ { ...( horizontal && valueTicks ? { tickValues: valueTicks } : {} ) }
740
+ />
741
+ <Axis
742
+ { ...chartOptions.axis.y }
743
+ { ...( ! horizontal && valueTicks
744
+ ? { tickValues: valueTicks }
745
+ : {} ) }
746
+ />
747
+ </>
748
+ ) }
749
+ </WholeNumberTicks>
685
750
  ) }
686
751
 
687
752
  { withTooltips && (
688
753
  <AccessibleTooltip
754
+ tooltipPlacement={ tooltipPlacement }
755
+ tooltipAnchorTop={ tooltipAnchorTop }
689
756
  detectBounds
690
757
  snapTooltipToDatumX
691
758
  snapTooltipToDatumY
@@ -1,2 +1,2 @@
1
1
  export { default as BarChart, BarChartUnresponsive } from './bar-chart';
2
- export type { BarChartProps } from './bar-chart';
2
+ export type { BarChartProps, BandHighlightSelection } from './types';