@automattic/charts 4.11.1 → 4.13.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 (73) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/index.cjs +1412 -787
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +59 -25
  5. package/dist/index.d.cts +64 -120
  6. package/dist/index.d.ts +64 -120
  7. package/dist/index.js +1400 -765
  8. package/dist/index.js.map +1 -1
  9. package/package.json +9 -9
  10. package/src/charts/area-chart/area-chart.tsx +16 -6
  11. package/src/charts/area-chart/test/area-chart.test.tsx +42 -9
  12. package/src/charts/bar-chart/bar-chart.tsx +143 -142
  13. package/src/charts/bar-chart/index.ts +3 -0
  14. package/src/charts/bar-chart/private/band-tooltip.tsx +41 -15
  15. package/src/charts/bar-chart/private/bar-layer.tsx +132 -0
  16. package/src/charts/bar-chart/private/comparison-bars-geometry.ts +22 -34
  17. package/src/charts/bar-chart/private/comparison-bars.tsx +9 -18
  18. package/src/charts/bar-chart/private/comparison-constants.ts +4 -10
  19. package/src/charts/bar-chart/private/grouped-bars.ts +68 -0
  20. package/src/charts/bar-chart/private/index.ts +7 -1
  21. package/src/charts/bar-chart/private/overlap-bars.ts +183 -0
  22. package/src/charts/bar-chart/private/selected-bar-ring.tsx +84 -0
  23. package/src/charts/bar-chart/private/slot-tooltip-members.tsx +28 -0
  24. package/src/charts/bar-chart/private/slotted-bar-series.tsx +84 -0
  25. package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +4 -11
  26. package/src/charts/bar-chart/private/test/comparison-bars.test.tsx +2 -3
  27. package/src/charts/bar-chart/private/test/grouped-bars.test.ts +87 -0
  28. package/src/charts/bar-chart/private/test/overlap-bars.test.ts +159 -0
  29. package/src/charts/bar-chart/private/use-bar-stops.ts +87 -0
  30. package/src/charts/bar-chart/private/use-overlap-slots.ts +213 -0
  31. package/src/charts/bar-chart/private/value-domain.ts +0 -10
  32. package/src/charts/bar-chart/test/bar-chart-overlap.test.tsx +347 -0
  33. package/src/charts/bar-chart/test/bar-chart.test.tsx +181 -19
  34. package/src/charts/bar-chart/test/use-bar-legend-items.test.tsx +81 -0
  35. package/src/charts/bar-chart/types.ts +3 -0
  36. package/src/charts/bar-chart/use-bar-colors.ts +46 -0
  37. package/src/charts/bar-chart/use-bar-legend-items.ts +87 -0
  38. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +24 -11
  39. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +235 -182
  40. package/src/charts/conversion-funnel-chart/index.ts +2 -0
  41. package/src/charts/conversion-funnel-chart/private/index.ts +1 -0
  42. package/src/charts/conversion-funnel-chart/private/use-funnel-selection.ts +9 -47
  43. package/src/charts/conversion-funnel-chart/private/use-roving-step-focus.ts +83 -0
  44. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +312 -92
  45. package/src/charts/conversion-funnel-chart/test/use-funnel-selection.test.ts +23 -188
  46. package/src/charts/conversion-funnel-chart/types.ts +22 -0
  47. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +41 -1
  48. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +101 -30
  49. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +47 -0
  50. package/src/charts/line-chart/line-chart.tsx +25 -18
  51. package/src/charts/line-chart/test/line-chart.test.tsx +240 -1
  52. package/src/charts/pie-chart/pie-chart.tsx +7 -2
  53. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +7 -2
  54. package/src/charts/private/pie-keyboard-navigation/index.ts +0 -1
  55. package/src/charts/private/{pie-keyboard-navigation/pie-keyboard-navigation.module.scss → selected-ring/selected-ring.module.scss} +11 -6
  56. package/src/charts/private/selected-ring/selected-ring.tsx +73 -0
  57. package/src/charts/private/{pie-keyboard-navigation/test/selected-segment-ring.test.ts → selected-ring/test/selected-ring.test.ts} +1 -1
  58. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +20 -0
  59. package/src/components/legend/hooks/use-chart-legend-items.ts +27 -3
  60. package/src/components/tooltip/private/at-nearest-date.ts +49 -0
  61. package/src/components/tooltip/private/keyboard-stops.ts +67 -0
  62. package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +10 -2
  63. package/src/components/tooltip/test/keyboard-stops.test.ts +36 -0
  64. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +135 -1
  65. package/src/components/tooltip/xy-chart-tooltip.tsx +11 -14
  66. package/src/index.ts +10 -2
  67. package/src/providers/chart-context/global-charts-provider.tsx +2 -0
  68. package/src/providers/chart-context/test/chart-context.test.tsx +0 -13
  69. package/src/providers/chart-context/themes.ts +0 -1
  70. package/src/providers/chart-context/types.ts +2 -0
  71. package/src/test-utils/selected-bar-ring.ts +14 -0
  72. package/src/types.ts +14 -5
  73. package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +0 -47
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "4.11.1",
3
+ "version": "4.13.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": {
@@ -66,7 +66,7 @@
66
66
  "@automattic/number-formatters": "^1.2.12",
67
67
  "@babel/runtime": "7.29.7",
68
68
  "@date-fns/tz": "1.5.0",
69
- "@react-spring/web": "10.1.1",
69
+ "@react-spring/web": "10.1.2",
70
70
  "@visx/annotation": "^4.0.0",
71
71
  "@visx/axis": "^4.0.0",
72
72
  "@visx/curve": "^4.0.0",
@@ -98,8 +98,8 @@
98
98
  "devDependencies": {
99
99
  "@automattic/jetpack-webpack-config": "workspace:*",
100
100
  "@babel/core": "^7.29.7",
101
- "@storybook/addon-docs": "10.4.6",
102
- "@storybook/react": "10.4.6",
101
+ "@storybook/addon-docs": "10.6.3",
102
+ "@storybook/react": "10.6.3",
103
103
  "@testing-library/dom": "^10.0.0",
104
104
  "@testing-library/jest-dom": "^6.0.0",
105
105
  "@testing-library/react": "^16.0.0",
@@ -107,8 +107,8 @@
107
107
  "@tsdown/css": "0.23.1",
108
108
  "@types/jest": "^30.0.0",
109
109
  "@types/node": "^24.12.0",
110
- "@types/react": "18.3.31",
111
- "@types/react-dom": "18.3.7",
110
+ "@types/react": "19.3.0",
111
+ "@types/react-dom": "19.3.0",
112
112
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
113
113
  "@visx/glyph": "4.0.0",
114
114
  "@wordpress/components": "42.0.0",
@@ -120,10 +120,10 @@
120
120
  "jest-extended": "7.0.0",
121
121
  "lightningcss": "^1.33.0",
122
122
  "magic-string": "0.30.21",
123
- "react": "18.3.1",
124
- "react-dom": "18.3.1",
123
+ "react": "19.3.0",
124
+ "react-dom": "19.3.0",
125
125
  "sass-embedded": "1.97.3",
126
- "storybook": "10.4.6",
126
+ "storybook": "10.6.3",
127
127
  "tsdown": "0.23.1",
128
128
  "typescript": "6.0.3"
129
129
  },
@@ -13,6 +13,7 @@ import {
13
13
  } from 'react';
14
14
  import { Legend, useChartLegendItems } from '../../components/legend';
15
15
  import { XYChartTooltip, useKeyboardNavigation } from '../../components/tooltip';
16
+ import { getKeyboardStops } from '../../components/tooltip/private/keyboard-stops';
16
17
  import {
17
18
  useXYChartTheme,
18
19
  useChartDataTransform,
@@ -144,14 +145,22 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
144
145
  [ seriesWithVisibility ]
145
146
  );
146
147
 
148
+ const visibleDataSeries = useMemo(
149
+ () => dataSorted.filter( series => isSeriesVisible( series.label ) ),
150
+ [ dataSorted, isSeriesVisible ]
151
+ );
152
+
147
153
  // A normalized stack (expand/wiggle/silhouette) turns whole-number data into
148
154
  // fractions, so the filter is skipped for every offset but 'none'.
149
155
  const hasWholeNumberValues = useMemo(
150
- () =>
151
- ( ! stacked || stackOffset === 'none' ) &&
152
- hasOnlyWholeNumbers( dataSorted.filter( series => isSeriesVisible( series.label ) ) ),
153
- [ dataSorted, isSeriesVisible, stacked, stackOffset ]
156
+ () => ( ! stacked || stackOffset === 'none' ) && hasOnlyWholeNumbers( visibleDataSeries ),
157
+ [ visibleDataSeries, stacked, stackOffset ]
158
+ );
159
+ const visibleSeriesKey = useMemo(
160
+ () => JSON.stringify( visibleDataSeries.map( series => series.label ) ),
161
+ [ visibleDataSeries ]
154
162
  );
163
+ const stops = useMemo( () => getKeyboardStops( visibleDataSeries ), [ visibleDataSeries ] );
155
164
 
156
165
  const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
157
166
  selectedIndex,
@@ -159,7 +168,8 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
159
168
  isNavigating,
160
169
  setIsNavigating,
161
170
  chartRef,
162
- totalPoints: dataSorted[ 0 ]?.data.length || 0,
171
+ totalPoints: stops.count,
172
+ visibleSeriesKey,
163
173
  } );
164
174
 
165
175
  // Computed from the full data set (ignoring legend visibility) so the y-axis stays
@@ -526,7 +536,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
526
536
  keyboardFocusedClassName={
527
537
  styles[ 'area-chart__tooltip--keyboard-focused' ]
528
538
  }
529
- series={ dataSorted }
539
+ series={ visibleDataSeries }
530
540
  />
531
541
  <HoverGlyphs
532
542
  visibleSeries={ visibleSeries }
@@ -879,12 +879,6 @@ describe( 'AreaChart', () => {
879
879
  } );
880
880
 
881
881
  test( 'renders glyphs only for series with a datum at the hovered x (mismatched x-domains)', async () => {
882
- // Each series' data[0] is a different date. Keyboard nav fires
883
- // showTooltip for every series at its own data[selectedIndex];
884
- // the LAST one wins as the nearestDatum, so the resolved hover
885
- // date is Series B's. HoverGlyphs should:
886
- // - Skip Series A (no datum at Series B's date) — cumulative += 0
887
- // - Render a glyph for Series B
888
882
  renderWithProvider( {
889
883
  data: [
890
884
  {
@@ -901,9 +895,8 @@ describe( 'AreaChart', () => {
901
895
 
902
896
  const glyphs = screen.getAllByTestId( /^area-chart-hover-glyph-/ );
903
897
  expect( glyphs ).toHaveLength( 1 );
904
- // Only Series B (index 1) has a datum at the hovered date.
905
- expect( screen.getByTestId( 'area-chart-hover-glyph-1' ) ).toBeInTheDocument();
906
- expect( screen.queryByTestId( 'area-chart-hover-glyph-0' ) ).not.toBeInTheDocument();
898
+ expect( screen.getByTestId( 'area-chart-hover-glyph-0' ) ).toBeInTheDocument();
899
+ expect( screen.queryByTestId( 'area-chart-hover-glyph-1' ) ).not.toBeInTheDocument();
907
900
  } );
908
901
  } );
909
902
 
@@ -1055,4 +1048,44 @@ describe( 'AreaChart', () => {
1055
1048
  expect( screen.getAllByRole( 'button' )[ 1 ] ).toHaveAttribute( 'aria-pressed', 'true' );
1056
1049
  } );
1057
1050
  } );
1051
+
1052
+ it( 'gives no keyboard stop to a date only a hidden series has', async () => {
1053
+ const user = userEvent.setup();
1054
+
1055
+ render(
1056
+ <GlobalChartsProvider>
1057
+ <AreaChartUnresponsive
1058
+ width={ 500 }
1059
+ height={ 300 }
1060
+ stacked={ false }
1061
+ defaultHiddenSeries={ [ 'Series B' ] }
1062
+ chartId="test-hidden-only-date-area"
1063
+ data={ [
1064
+ {
1065
+ label: 'Series A',
1066
+ data: [
1067
+ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
1068
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
1069
+ ],
1070
+ },
1071
+ {
1072
+ label: 'Series B',
1073
+ data: [
1074
+ { date: new Date( '2024-01-01' ), value: 5, label: 'Jan 1' },
1075
+ { date: new Date( '2024-01-03' ), value: 15, label: 'Jan 3' },
1076
+ ],
1077
+ },
1078
+ ] }
1079
+ />
1080
+ </GlobalChartsProvider>
1081
+ );
1082
+
1083
+ screen.getByRole( 'application', { name: /area chart/i } ).focus();
1084
+ await user.keyboard( '{ArrowRight}{ArrowRight}{ArrowRight}' );
1085
+
1086
+ await expect( screen.findByRole( 'tooltip' ) ).resolves.toHaveAttribute(
1087
+ 'data-testid',
1088
+ 'chart-tooltip-1'
1089
+ );
1090
+ } );
1058
1091
  } );
@@ -1,9 +1,9 @@
1
1
  import { PatternLines, PatternCircles, PatternWaves, PatternHexagons } from '@visx/pattern';
2
- import { Axis, BarSeries, BarGroup, Grid, XYChart } from '@visx/xychart';
2
+ import { Axis, Grid, XYChart } from '@visx/xychart';
3
3
  import { __, sprintf } from '@wordpress/i18n';
4
4
  import clsx from 'clsx';
5
- import { useCallback, useContext, useState, useRef, useMemo } from 'react';
6
- import { Legend, useChartLegendItems } from '../../components/legend';
5
+ import { Fragment, useCallback, useContext, useState, useRef, useMemo } from 'react';
6
+ import { Legend } from '../../components/legend';
7
7
  import { XYChartTooltip, useKeyboardNavigation } from '../../components/tooltip';
8
8
  import {
9
9
  useXYChartTheme,
@@ -35,17 +35,24 @@ import {
35
35
  useBarChartOptions,
36
36
  BandHighlight,
37
37
  BandTooltip,
38
- ClassifiedBarSeries,
38
+ BarLayer,
39
39
  ComparisonBars,
40
40
  DEFAULT_COMPARISON_WIDTH_FACTOR,
41
41
  COMPARISON_INNER_GAP,
42
42
  MAX_GROUP_PADDING,
43
43
  COMPARISON_TICK_GAP_FACTOR,
44
44
  BASE_BAND_PADDING_INNER,
45
- countRenderedBars,
46
45
  isBarRendered,
46
+ SlotTooltipMembers,
47
+ useBarStops,
48
+ useOverlapSlots,
49
+ getSelectedSlot,
50
+ isSameCategory,
51
+ membersAt,
47
52
  } from './private';
48
- import type { ComparisonSeriesEntry } from './private';
53
+ import { useBarColors } from './use-bar-colors';
54
+ import { useBarLegendItems } from './use-bar-legend-items';
55
+ import type { ComparisonSeriesEntry, SelectedBar } from './private';
49
56
  import type { BarChartProps } from './types';
50
57
  import type { DataPointDate, SeriesData, Optional } from '../../types';
51
58
  import type { RenderTooltipParams } from '../../visx/types';
@@ -117,6 +124,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
117
124
  barClassName,
118
125
  options = {},
119
126
  orientation = 'vertical',
127
+ layout = 'grouped',
120
128
  withPatterns = false,
121
129
  showZeroValues = false,
122
130
  withBandHighlight = false,
@@ -141,6 +149,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
141
149
  const legendComparisonItem = legend.comparisonItem ?? false;
142
150
  const legendComparisonSeriesItems = legend.comparisonSeriesItems;
143
151
  const horizontal = orientation === 'horizontal';
152
+ const isOverlap = layout === 'overlap';
144
153
  const chartId = useChartId( providedChartId );
145
154
  const hiddenSeries = useDefaultHiddenSeries( chartId, defaultHiddenSeries );
146
155
  const isSeriesVisible = useCallback(
@@ -164,12 +173,25 @@ const BarChartInternal: FC< BarChartProps > = ( {
164
173
  collapseGroups: legendCollapseGroups,
165
174
  comparisonItem: legendComparisonItem,
166
175
  comparisonSeriesItems: legendComparisonSeriesItems,
176
+ layout,
167
177
  } ),
168
- [ legendCollapseGroups, legendComparisonItem, legendComparisonSeriesItems ]
178
+ [ legendCollapseGroups, legendComparisonItem, legendComparisonSeriesItems, layout ]
169
179
  );
170
- const legendItems = useChartLegendItems( dataSorted, legendOptions );
180
+ const legendItems = useBarLegendItems( dataSorted, legendOptions );
171
181
 
172
- const { getElementStyles } = useGlobalChartsContext();
182
+ const { getElementStyles, theme: providerTheme } = useGlobalChartsContext();
183
+ const getBarColors = useBarColors();
184
+ const barColors = useMemo(
185
+ () => getBarColors( dataSorted, { layout } ),
186
+ [ layout, getBarColors, dataSorted ]
187
+ );
188
+
189
+ if ( providerTheme.barChart?.barStyles?.comparison?.opacity !== undefined ) {
190
+ warnOnce(
191
+ 'bar-chart-comparison-opacity',
192
+ 'BarChart: theme.barChart.barStyles.comparison.opacity is deprecated. Comparison bars are opaque; leave it unset.'
193
+ );
194
+ }
173
195
 
174
196
  // A hidden series is unmounted, so it is not on the band scale either — the
175
197
  // axis has to choose its tick values from what is left. Visibility is owned by
@@ -239,45 +261,27 @@ const BarChartInternal: FC< BarChartProps > = ( {
239
261
  [ seriesWithVisibility ]
240
262
  );
241
263
 
242
- const totalPoints =
243
- Math.max( 0, ...primaryEntries.map( e => e.series.data.length ) ) * primaryEntries.length;
264
+ const overlapSlots = useOverlapSlots( isOverlap, seriesWithVisibility );
244
265
 
245
266
  const primaryKeys = useMemo(
246
267
  () => primaryEntries.map( ( { series } ) => series.label ),
247
268
  [ primaryEntries ]
248
269
  );
249
270
 
250
- // The keyboard-navigation index space and the highlight CSS both stride over primary
251
- // bars only; `XYChartTooltip` must use the same list, or its datum diverges from
252
- // the highlighted bar once a comparison series shifts the indices.
253
- const primarySeries = useMemo(
254
- () => primaryEntries.map( ( { series } ) => series ),
255
- [ primaryEntries ]
256
- );
271
+ // The keyboard index space, the highlight and `XYChartTooltip` must share these stops, or the tooltip's datum diverges from the highlighted bar.
272
+ const stops = useBarStops( overlapSlots, primaryEntries );
273
+ const { barAt, stopOf } = stops;
257
274
 
258
- // Walks the selected index the way the highlight style does — series-major
259
- // within each data point — so the bar handed on is the one outlined.
260
275
  const activateSelectedBar = useCallback(
261
- ( index: number ) => {
262
- const primaryCount = primaryEntries.length;
263
-
264
- if ( ! primaryCount ) {
265
- return;
266
- }
267
-
268
- const dataPointIndex = Math.floor( index / primaryCount );
269
- const series = primaryEntries[ index % primaryCount ]?.series;
270
- const datum = series?.data[ dataPointIndex ];
271
-
272
- if ( series && datum ) {
273
- onDatumActivate?.( { datum, index: dataPointIndex, key: series.label } );
276
+ ( stop: number ) => {
277
+ const bar = barAt( stop );
278
+ if ( bar ) {
279
+ onDatumActivate?.( bar );
274
280
  }
275
281
  },
276
- [ primaryEntries, onDatumActivate ]
282
+ [ barAt, onDatumActivate ]
277
283
  );
278
284
 
279
- const visibleSeriesKey = useMemo( () => JSON.stringify( primaryKeys ), [ primaryKeys ] );
280
-
281
285
  const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown, onChartPointerMove } =
282
286
  useKeyboardNavigation( {
283
287
  selectedIndex,
@@ -285,22 +289,27 @@ const BarChartInternal: FC< BarChartProps > = ( {
285
289
  isNavigating,
286
290
  setIsNavigating,
287
291
  chartRef,
288
- totalPoints,
292
+ totalPoints: stops.count,
289
293
  onActivate: activateSelectedBar,
290
- visibleSeriesKey,
294
+ visibleSeriesKey: stops.key,
291
295
  } );
292
296
 
293
297
  const handlePointerMove = useCallback(
294
298
  ( { key, index }: { key: string; index: number } ) => {
295
- const seriesIndex = primaryKeys.indexOf( key );
296
- if ( seriesIndex >= 0 ) {
297
- onChartPointerMove( index * primaryKeys.length + seriesIndex );
299
+ const stop = stopOf( key, index );
300
+ if ( stop !== undefined ) {
301
+ onChartPointerMove( stop );
298
302
  }
299
303
  },
300
- [ primaryKeys, onChartPointerMove ]
304
+ [ stopOf, onChartPointerMove ]
301
305
  );
302
306
 
303
307
  const comparisonEntries = useMemo( () => {
308
+ // An overlap layout draws its comparisons as slotted bars, not as shadows.
309
+ if ( isOverlap ) {
310
+ return [];
311
+ }
312
+
304
313
  const primaryByGroup = new Map< string | undefined, { label: string; index: number } >(
305
314
  primaryEntries.map( ( { series, index } ) => [
306
315
  series.group,
@@ -321,10 +330,10 @@ const BarChartInternal: FC< BarChartProps > = ( {
321
330
  if ( ! primary || ! primaryKeys.includes( primary.label ) ) {
322
331
  return;
323
332
  }
324
- entries.push( { series, index, primaryKey: primary.label, primaryIndex: primary.index } );
333
+ entries.push( { series, index, primaryKey: primary.label } );
325
334
  } );
326
335
  return entries;
327
- }, [ seriesWithVisibility, primaryEntries, primaryKeys ] );
336
+ }, [ isOverlap, seriesWithVisibility, primaryEntries, primaryKeys ] );
328
337
 
329
338
  // Comparison widthFactor (how much wider the shadow is than the primary) drives both the
330
339
  // shadow width (in ComparisonBars) and the primary narrowing.
@@ -376,18 +385,48 @@ const BarChartInternal: FC< BarChartProps > = ( {
376
385
  ( index: number ) => ( datum: DataPointDate ) =>
377
386
  withPatterns
378
387
  ? `url(#${ getPatternId( chartId, index ) })`
379
- : ( datum.color ?? getElementStyles( { data: dataSorted[ index ], index } ).color ),
380
- [ withPatterns, getElementStyles, dataSorted, chartId ]
388
+ : ( datum.color ?? barColors[ index ] ?? '' ),
389
+ [ withPatterns, barColors, chartId ]
381
390
  );
382
391
 
383
- // Comparison shadow fill: when patterns are on, reuse the paired primary's pattern so the
384
- // shadow reads as the same series; otherwise use the comparison series' resolved color.
385
392
  const resolveComparisonFill = useCallback(
386
- ( entry: ComparisonSeriesEntry ) =>
393
+ ( entry: ComparisonSeriesEntry, datum: DataPointDate ) =>
387
394
  withPatterns
388
- ? `url(#${ getPatternId( chartId, entry.primaryIndex ) })`
389
- : getElementStyles( { data: entry.series, index: entry.index } ).color,
390
- [ withPatterns, chartId, getElementStyles ]
395
+ ? `url(#${ getPatternId( chartId, entry.index ) })`
396
+ : ( datum.color ?? barColors[ entry.index ] ?? '' ),
397
+ [ withPatterns, chartId, barColors ]
398
+ );
399
+
400
+ // Lists every series with a point at the hovered category, in the order of the data.
401
+ const renderOverlapTooltip = useCallback(
402
+ ( { tooltipData }: RenderTooltipParams< DataPointDate > ) => {
403
+ const nearestDatum = tooltipData?.nearestDatum?.datum;
404
+ if ( ! nearestDatum ) return null;
405
+
406
+ const datumByKey = tooltipData?.datumByKey ?? {};
407
+ const categoryLabel = chartOptions.tooltip.labelFormatter(
408
+ nearestDatum.label || ( nearestDatum.date ? nearestDatum.date.getTime() : 0 ),
409
+ 0,
410
+ []
411
+ );
412
+
413
+ return (
414
+ <div className={ styles[ 'bar-chart__tooltip' ] }>
415
+ <div className={ styles[ 'bar-chart__tooltip-header' ] }>{ categoryLabel }</div>
416
+ { dataSorted.flatMap( ( { label } ) => {
417
+ const datum = datumByKey[ label ]?.datum;
418
+ return datum
419
+ ? [
420
+ <Fragment key={ label }>
421
+ { renderTooltipRow( label, formatReading( datum.value ) ) }
422
+ </Fragment>,
423
+ ]
424
+ : [];
425
+ } ) }
426
+ </div>
427
+ );
428
+ },
429
+ [ chartOptions.tooltip, dataSorted ]
391
430
  );
392
431
 
393
432
  const renderDefaultTooltip = useCallback(
@@ -405,12 +444,9 @@ const BarChartInternal: FC< BarChartProps > = ( {
405
444
  // Find the comparison value paired with the hovered primary series (same group)
406
445
  // at the same category, so the tooltip can show both periods at once.
407
446
  const comparisonEntry = comparisonEntries.find( entry => entry.primaryKey === primaryKey );
408
- const comparisonDatum = comparisonEntry?.series.data.find( point => {
409
- const p = point as DataPointDate;
410
- return nearestDatum.label != null
411
- ? p.label === nearestDatum.label
412
- : !! nearestDatum.date && !! p.date && p.date.getTime() === nearestDatum.date.getTime();
413
- } ) as DataPointDate | undefined;
447
+ const comparisonDatum = comparisonEntry?.series.data.find( point =>
448
+ isSameCategory( point as DataPointDate, nearestDatum )
449
+ ) as DataPointDate | undefined;
414
450
 
415
451
  // With a paired comparison value, show the category as the header and one row
416
452
  // per period (primary + comparison).
@@ -489,44 +525,35 @@ const BarChartInternal: FC< BarChartProps > = ( {
489
525
  [ chartId ]
490
526
  );
491
527
 
492
- const createKeyboardHighlightStyle = useCallback( () => {
493
- if ( selectedIndex === undefined ) return '';
528
+ const overlapBandSlots = useMemo(
529
+ () => overlapSlots && { keys: overlapSlots.slotKeys, slotOf: overlapSlots.slotOf },
530
+ [ overlapSlots ]
531
+ );
494
532
 
495
- // Use only primary entries: comparison series have no .visx-bar elements, so
496
- // their indices must not appear in the nth-child selector.
497
- // Pattern: [series1[0], series2[0], series3[0], series1[1], series2[1], series3[1], ...]
498
- const primaryCount = primaryEntries.length;
499
- if ( ! primaryCount ) {
500
- return '';
501
- }
502
- const dataPointIndex = Math.floor( selectedIndex / primaryCount );
503
- const seriesIndex = selectedIndex % primaryCount;
504
- const seriesData = primaryEntries[ seriesIndex ]?.series;
505
- const datum = seriesData?.data[ dataPointIndex ];
533
+ const selectedSlot = useMemo(
534
+ () => getSelectedSlot( overlapSlots, selectedIndex ),
535
+ [ overlapSlots, selectedIndex ]
536
+ );
506
537
 
507
- if ( ! seriesData || ! datum || ! isBarRendered( datum ) ) {
508
- return '';
538
+ const selectedGroup = useMemo( () => {
539
+ const stop = overlapSlots || selectedIndex === undefined ? undefined : barAt( selectedIndex );
540
+ if ( ! stop ) {
541
+ return undefined;
509
542
  }
543
+ return membersAt( primaryEntries, stop.datum, stop.key );
544
+ }, [ overlapSlots, selectedIndex, barAt, primaryEntries ] );
510
545
 
511
- // The single .visx-bar-group holds series after series, and only the points visx drew a bar for.
512
- const domBarIndex =
513
- primaryEntries
514
- .slice( 0, seriesIndex )
515
- .reduce( ( count, { series } ) => count + countRenderedBars( series.data ), 0 ) +
516
- countRenderedBars( seriesData.data.slice( 0, dataPointIndex ) );
517
-
518
- // Use a CSS class selector instead of ID since useId() generates invalid CSS ID characters
519
- const generatedStyles = `
520
- .bar-chart[data-chart-id="bar-chart-${ chartId }"] .visx-bar-group .visx-bar:nth-child(${
521
- domBarIndex + 1
522
- }) {
523
- stroke: #005fcc;
524
- stroke-width: 2px;
525
- }
526
- `;
546
+ const selectedMembers = selectedSlot?.members ?? selectedGroup;
527
547
 
528
- return generatedStyles;
529
- }, [ selectedIndex, primaryEntries, chartId ] );
548
+ const selectedBar = useMemo( (): SelectedBar | undefined => {
549
+ if ( overlapSlots ) {
550
+ return selectedSlot;
551
+ }
552
+ const bar = selectedIndex === undefined ? undefined : barAt( selectedIndex );
553
+ return bar && isBarRendered( bar.datum )
554
+ ? { data: [ bar.datum ], slotKey: bar.key, thickness: 1 }
555
+ : undefined;
556
+ }, [ overlapSlots, selectedSlot, selectedIndex, barAt ] );
530
557
 
531
558
  // Validate data first
532
559
  const error = validateData( dataSorted );
@@ -557,7 +584,6 @@ const BarChartInternal: FC< BarChartProps > = ( {
557
584
  }
558
585
 
559
586
  const gridVisibility = gridVisibilityProp ?? chartOptions.gridVisibility;
560
- const highlightedBarStyle = createKeyboardHighlightStyle();
561
587
 
562
588
  const legendPosition = legend.position ?? 'bottom';
563
589
  const legendElement = showLegend && (
@@ -676,30 +702,16 @@ const BarChartInternal: FC< BarChartProps > = ( {
676
702
  { withPatterns && (
677
703
  <>
678
704
  <defs data-testid="bar-chart-patterns">
679
- { dataSorted.map( ( seriesData, index ) =>
680
- renderPattern(
681
- index,
682
- getElementStyles( { data: seriesData, index } ).color
683
- )
684
- ) }
705
+ { barColors.map( ( color, index ) => renderPattern( index, color ) ) }
685
706
  </defs>
686
707
  <style>
687
- { dataSorted.map( ( seriesData, index ) =>
688
- createPatternBorderStyle(
689
- index,
690
- getElementStyles( { data: seriesData, index } ).color
691
- )
708
+ { barColors.map( ( color, index ) =>
709
+ createPatternBorderStyle( index, color )
692
710
  ) }
693
711
  </style>
694
712
  </>
695
713
  ) }
696
714
 
697
- { highlightedBarStyle && (
698
- <style data-testid="bar-chart-keyboard-highlight">
699
- { highlightedBarStyle }
700
- </style>
701
- ) }
702
-
703
715
  { allSeriesHidden ? (
704
716
  <SvgEmptyState
705
717
  x={ width / 2 }
@@ -726,40 +738,24 @@ const BarChartInternal: FC< BarChartProps > = ( {
726
738
  resolveFill={ resolveComparisonFill }
727
739
  />
728
740
 
729
- { barClassName ? (
730
- <g className="visx-bar-group">
731
- { primaryEntries.map( ( { series: seriesData, index } ) => (
732
- <ClassifiedBarSeries
733
- key={ seriesData.label }
734
- dataKey={ seriesData.label }
735
- data={ seriesData.data as DataPointDate[] }
736
- xAccessor={ chartOptions.accessors.xAccessor }
737
- yAccessor={ chartOptions.accessors.yAccessor }
738
- colorAccessor={ getBarBackground( index ) }
739
- barClassName={ barClassName }
740
- primaryKeys={ primaryKeys }
741
- groupPadding={ groupPadding }
742
- />
743
- ) ) }
744
- </g>
745
- ) : (
746
- <BarGroup padding={ groupPadding }>
747
- { primaryEntries.map( ( { series: seriesData, index } ) => (
748
- <BarSeries
749
- key={ seriesData?.label }
750
- dataKey={ seriesData?.label }
751
- data={ seriesData.data as DataPointDate[] }
752
- yAccessor={ chartOptions.accessors.yAccessor }
753
- xAccessor={ chartOptions.accessors.xAccessor }
754
- colorAccessor={ getBarBackground( index ) }
755
- />
756
- ) ) }
757
- </BarGroup>
758
- ) }
741
+ <BarLayer
742
+ chartId={ chartId }
743
+ overlapSlots={ overlapSlots }
744
+ selectedBar={ selectedBar }
745
+ primaryEntries={ primaryEntries }
746
+ primaryKeys={ primaryKeys }
747
+ getBarBackground={ getBarBackground }
748
+ barClassName={ barClassName }
749
+ groupPadding={ groupPadding }
750
+ horizontal={ horizontal }
751
+ xAccessor={ chartOptions.accessors.xAccessor }
752
+ yAccessor={ chartOptions.accessors.yAccessor }
753
+ />
759
754
  { /* Do not reorder: for one key the last showTooltip wins, so this must run after the primary series. */ }
760
755
  { ( withTooltips || onPointerDown || onPointerUp ) && (
761
756
  <BandTooltip
762
- keys={ primaryKeys }
757
+ keys={ overlapSlots?.keys ?? primaryKeys }
758
+ slots={ overlapBandSlots }
763
759
  groupPadding={ groupPadding }
764
760
  withTooltips={ withTooltips }
765
761
  onPointerDown={ onPointerDown }
@@ -796,6 +792,9 @@ const BarChartInternal: FC< BarChartProps > = ( {
796
792
  </WholeNumberTicks>
797
793
  ) }
798
794
 
795
+ { withTooltips && selectedMembers && (
796
+ <SlotTooltipMembers members={ selectedMembers } />
797
+ ) }
799
798
  { withTooltips && (
800
799
  <XYChartTooltip
801
800
  tooltipPlacement={ tooltipPlacement }
@@ -804,13 +803,15 @@ const BarChartInternal: FC< BarChartProps > = ( {
804
803
  detectBounds
805
804
  snapTooltipToDatumX
806
805
  snapTooltipToDatumY
807
- renderTooltip={ renderTooltip || renderDefaultTooltip }
806
+ renderTooltip={
807
+ renderTooltip ||
808
+ ( isOverlap ? renderOverlapTooltip : renderDefaultTooltip )
809
+ }
808
810
  selectedIndex={ selectedIndex }
809
811
  tooltipRef={ tooltipRef }
810
812
  keyboardFocusedClassName={
811
813
  styles[ 'bar-chart__tooltip--keyboard-focused' ]
812
814
  }
813
- series={ primarySeries }
814
815
  mode="individual"
815
816
  />
816
817
  ) }
@@ -1,2 +1,5 @@
1
1
  export { default as BarChart, BarChartUnresponsive } from './bar-chart';
2
+ export { useBarColors } from './use-bar-colors';
3
+ export { useBarLegendItems } from './use-bar-legend-items';
4
+ export type { BarLegendOptions } from './use-bar-legend-items';
2
5
  export type { BarChartProps, BandHighlightSelection } from './types';