@automattic/charts 4.13.0 → 4.15.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 (56) hide show
  1. package/CHANGELOG.md +34 -29
  2. package/TOKENS.md +4 -1
  3. package/dist/index.cjs +1069 -1620
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +33 -1
  6. package/dist/index.d.cts +60 -15
  7. package/dist/index.d.ts +60 -15
  8. package/dist/index.js +1072 -1575
  9. package/dist/index.js.map +1 -1
  10. package/package.json +11 -10
  11. package/src/charts/area-chart/area-chart.tsx +29 -20
  12. package/src/charts/area-chart/types.ts +3 -1
  13. package/src/charts/bar-chart/bar-chart.module.scss +38 -0
  14. package/src/charts/bar-chart/bar-chart.tsx +159 -56
  15. package/src/charts/bar-chart/private/band-tooltip.tsx +40 -5
  16. package/src/charts/bar-chart/private/bar-layer.tsx +66 -13
  17. package/src/charts/bar-chart/private/category.ts +25 -0
  18. package/src/charts/bar-chart/private/comparison-bars-geometry.ts +13 -1
  19. package/src/charts/bar-chart/private/index.ts +3 -1
  20. package/src/charts/bar-chart/private/selected-bar-ring.tsx +17 -6
  21. package/src/charts/bar-chart/private/stack-layout.ts +99 -0
  22. package/src/charts/bar-chart/private/stacked-bar-series.tsx +145 -0
  23. package/src/charts/bar-chart/private/test/stack-layout.test.ts +81 -0
  24. package/src/charts/bar-chart/private/test/value-domain.test.ts +10 -0
  25. package/src/charts/bar-chart/private/use-bar-chart-options.ts +35 -11
  26. package/src/charts/bar-chart/private/{use-overlap-slots.ts → use-bar-slots.ts} +36 -38
  27. package/src/charts/bar-chart/private/use-bar-stops.ts +19 -19
  28. package/src/charts/bar-chart/private/value-domain.ts +10 -8
  29. package/src/charts/bar-chart/test/bar-chart-stacked.test.tsx +546 -0
  30. package/src/charts/bar-chart/test/bar-chart.test.tsx +75 -0
  31. package/src/charts/bar-chart/test/use-bar-legend-items.test.tsx +18 -0
  32. package/src/charts/bar-chart/types.ts +3 -1
  33. package/src/charts/bar-chart/use-bar-colors.ts +12 -0
  34. package/src/charts/bar-chart/use-bar-legend-items.ts +4 -2
  35. package/src/charts/bar-list-chart/bar-list-chart.tsx +2 -0
  36. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +1 -1
  37. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +26 -9
  38. package/src/charts/leaderboard-chart/private/resolve-leaderboard-entries.ts +53 -0
  39. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +41 -0
  40. package/src/charts/leaderboard-chart/test/resolve-leaderboard-entries.test.ts +146 -0
  41. package/src/charts/leaderboard-chart/types.ts +6 -0
  42. package/src/charts/line-chart/line-chart.tsx +55 -26
  43. package/src/charts/line-chart/test/line-chart.test.tsx +86 -0
  44. package/src/charts/line-chart/types.ts +3 -1
  45. package/src/charts/private/test/compact-when-short.test.tsx +170 -0
  46. package/src/charts/private/test/tick-decimals.test.tsx +95 -0
  47. package/src/charts/private/tick-decimals.tsx +132 -0
  48. package/src/charts/private/use-compact-when-short.ts +18 -0
  49. package/src/components/legend/private/base-legend.tsx +13 -8
  50. package/src/components/legend/test/legend.test.tsx +125 -0
  51. package/src/components/legend/types.ts +9 -1
  52. package/src/providers/chart-context/themes.ts +4 -0
  53. package/src/styles/chart-scope.scss +1 -0
  54. package/src/types.ts +40 -9
  55. package/src/charts/private/test/whole-number-ticks.test.tsx +0 -57
  56. package/src/charts/private/whole-number-ticks.tsx +0 -79
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "4.13.0",
3
+ "version": "4.15.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": {
@@ -63,8 +63,8 @@
63
63
  "typecheck": "tsgo --noEmit"
64
64
  },
65
65
  "dependencies": {
66
- "@automattic/number-formatters": "^1.2.12",
67
- "@babel/runtime": "7.29.7",
66
+ "@automattic/number-formatters": "^1.3.0",
67
+ "@babel/runtime": "7.29.10",
68
68
  "@date-fns/tz": "1.5.0",
69
69
  "@react-spring/web": "10.1.2",
70
70
  "@visx/annotation": "^4.0.0",
@@ -83,10 +83,10 @@
83
83
  "@visx/tooltip": "^4.0.0",
84
84
  "@visx/vendor": "^4.0.0",
85
85
  "@visx/xychart": "^4.0.0",
86
- "@wordpress/i18n": "^6.0.0",
87
- "@wordpress/icons": "^18.0.0",
88
- "@wordpress/theme": "2.3.0",
89
- "@wordpress/ui": "0.24.0",
86
+ "@wordpress/i18n": "^7.0.0",
87
+ "@wordpress/icons": "^20.0.0",
88
+ "@wordpress/theme": "4.0.0",
89
+ "@wordpress/ui": "0.26.0",
90
90
  "clsx": "2.1.1",
91
91
  "date-fns": "^4.1.0",
92
92
  "deepmerge": "4.3.1",
@@ -111,9 +111,10 @@
111
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
- "@wordpress/components": "42.0.0",
115
- "@wordpress/element": "9.0.0",
116
- "@wordpress/private-apis": "2.0.0",
114
+ "@wordpress/components": "44.0.0",
115
+ "@wordpress/element": "10.1.0",
116
+ "@wordpress/private-apis": "3.1.0",
117
+ "@wordpress/theme-build-tools": "1.0.0",
117
118
  "babel-jest": "30.5.2",
118
119
  "identity-obj-proxy": "^3.0.0",
119
120
  "jest": "30.5.2",
@@ -36,9 +36,10 @@ import { useChartChildren } from '../private/chart-composition';
36
36
  import { ChartInstanceContext, type ChartInstanceRef } from '../private/chart-instance-context';
37
37
  import { ChartLayout } from '../private/chart-layout';
38
38
  import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
39
+ import { hasOnlyWholeNumbers, TickDecimals } from '../private/tick-decimals';
39
40
  import { getCurveType } from '../private/time-axis';
40
41
  import { buildTimeAxisOptions } from '../private/time-axis-options';
41
- import { hasOnlyWholeNumbers, WholeNumberTicks } from '../private/whole-number-ticks';
42
+ import { useCompactWhenShort } from '../private/use-compact-when-short';
42
43
  import { withResponsive } from '../private/with-responsive';
43
44
  import { useXZoom, ZoomResetButton, ZoomSelectionRect, ZoomClip } from '../private/x-zoom';
44
45
  import plotStyles from '../private/xy-plot/xy-plot.module.scss';
@@ -82,6 +83,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
82
83
  children,
83
84
  gridVisibility,
84
85
  gap = 'md',
86
+ compactWhenShort = false,
85
87
  },
86
88
  ref
87
89
  ) => {
@@ -109,6 +111,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
109
111
  } );
110
112
 
111
113
  const { legendChildren, nonLegendChildren } = useChartChildren( children, 'AreaChart' );
114
+ const { isCompact, compactMargin } = useCompactWhenShort( compactWhenShort, height );
112
115
  const [ measuredChartHeight, setMeasuredChartHeight ] = useState< number | undefined >();
113
116
 
114
117
  const handleContentHeightChange = useCallback(
@@ -227,22 +230,26 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
227
230
  const chartOptions = useMemo( () => {
228
231
  return {
229
232
  axis: {
230
- x: buildTimeAxisOptions( {
231
- dataSorted,
232
- width,
233
- axisOptions: options?.axis?.x,
234
- scaleDomain: options?.xScale?.domain,
235
- zoomDomain: zoom.domain,
236
- formatting,
237
- // No `isSeriesRendered`: a hidden area stays mounted with a zeroed
238
- // yAccessor, so it still contributes to the x domain.
239
- } ),
233
+ x: {
234
+ ...buildTimeAxisOptions( {
235
+ dataSorted,
236
+ width,
237
+ axisOptions: options?.axis?.x,
238
+ scaleDomain: options?.xScale?.domain,
239
+ zoomDomain: zoom.domain,
240
+ formatting,
241
+ // No `isSeriesRendered`: a hidden area stays mounted with a zeroed
242
+ // yAccessor, so it still contributes to the x domain.
243
+ } ),
244
+ ...( isCompact && { display: false } ),
245
+ },
240
246
  y: {
241
247
  orientation: 'left' as const,
242
248
  numTicks: 4,
243
249
  tickFormat: formatNumberCompact as TickFormatter< number >,
244
250
  display: true,
245
251
  ...options?.axis?.y,
252
+ ...( isCompact && { display: false } ),
246
253
  },
247
254
  },
248
255
  xScale: {
@@ -259,7 +266,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
259
266
  ...options?.yScale,
260
267
  },
261
268
  };
262
- }, [ options, dataSorted, width, stacked, fixedYDomain, zoom.domain, formatting ] );
269
+ }, [ options, dataSorted, width, stacked, fixedYDomain, zoom.domain, formatting, isCompact ] );
263
270
 
264
271
  const defaultMargin = useChartMargin( height, chartOptions, dataSorted, theme );
265
272
 
@@ -357,7 +364,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
357
364
  return <div className={ clsx( 'area-chart', styles[ 'area-chart' ] ) }>{ error }</div>;
358
365
  }
359
366
 
360
- const legendElement = showLegend && (
367
+ const legendElement = showLegend && ! isCompact && (
361
368
  <Legend
362
369
  orientation={ legend.orientation ?? 'horizontal' }
363
370
  alignment={ legend.alignment ?? 'center' }
@@ -416,7 +423,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
416
423
  <ChartLayout
417
424
  legendPosition={ legendPosition }
418
425
  legendElement={ legendElement }
419
- legendChildren={ legendChildren }
426
+ legendChildren={ isCompact ? [] : legendChildren }
420
427
  gap={ gap }
421
428
  className={ clsx(
422
429
  'area-chart',
@@ -451,7 +458,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
451
458
  theme={ theme }
452
459
  width={ width }
453
460
  height={ chartHeight }
454
- margin={ { ...defaultMargin, ...margin } }
461
+ margin={ { ...defaultMargin, ...compactMargin, ...margin } }
455
462
  xScale={ chartOptions.xScale }
456
463
  yScale={ chartOptions.yScale }
457
464
  onPointerDown={ zoom.handlers.onPointerDown }
@@ -463,18 +470,20 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
463
470
  { /* With every series hidden the value scale collapses, so the grid and axes
464
471
  are dropped while the empty state stands in — otherwise they render
465
472
  squished at the top. */ }
466
- <WholeNumberTicks
473
+ <TickDecimals
467
474
  axis="y"
468
475
  numTicks={ chartOptions.axis.y.numTicks }
469
- enabled={
476
+ decimals={
470
477
  hasWholeNumberValues &&
471
478
  ! chartOptions.axis.y.tickValues &&
472
479
  ! options?.yScale?.domain
480
+ ? 0
481
+ : undefined
473
482
  }
474
483
  >
475
484
  { tickValues => (
476
485
  <>
477
- { ! allSeriesHidden && gridVisibility !== 'none' && (
486
+ { ! allSeriesHidden && gridVisibility !== 'none' && ! isCompact && (
478
487
  <Grid
479
488
  columns={ false }
480
489
  numTicks={ chartOptions.axis.y.numTicks }
@@ -492,7 +501,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
492
501
  ) }
493
502
  </>
494
503
  ) }
495
- </WholeNumberTicks>
504
+ </TickDecimals>
496
505
 
497
506
  { allSeriesHidden ? (
498
507
  <SvgEmptyState
@@ -501,7 +510,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
501
510
  width={ width }
502
511
  height={ chartHeight }
503
512
  >
504
- { getAllHiddenMessage( legendInteractive, 'series' ) }
513
+ { getAllHiddenMessage( legendInteractive && ! isCompact, 'series' ) }
505
514
  </SvgEmptyState>
506
515
  ) : null }
507
516
 
@@ -5,12 +5,14 @@ import type {
5
5
  SeriesData,
6
6
  SeriesChartLegendConfig,
7
7
  SeriesVisibilityProps,
8
+ CompactWhenShortProps,
8
9
  } from '../../types';
9
10
  import type { RenderTooltipParams } from '../../visx/types';
10
11
  import type { CurveType } from '../line-chart/types';
11
12
  import type { ReactNode } from 'react';
12
13
 
13
- export interface AreaChartProps extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps {
14
+ export interface AreaChartProps
15
+ extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps, CompactWhenShortProps {
14
16
  /**
15
17
  * Legend configuration. Supports `collapseGroups` on top of the shared options.
16
18
  */
@@ -50,4 +50,42 @@
50
50
  transform: scaleX(1);
51
51
  }
52
52
  }
53
+
54
+ // A stack rises as one bar and rounds only its outer end, keeping seams flush.
55
+ &--stacked :global(.visx-bar-group) rect {
56
+ rx: 0;
57
+ animation: none;
58
+ }
59
+
60
+ &--stacked :global(.visx-bar-group) rect[data-stack-end="top"] {
61
+ clip-path: inset(0 round var(--a8c-charts-border-radius-bar-chart) var(--a8c-charts-border-radius-bar-chart) 0 0);
62
+ }
63
+
64
+ &--stacked :global(.visx-bar-group) rect[data-stack-end="right"] {
65
+ clip-path: inset(0 round 0 var(--a8c-charts-border-radius-bar-chart) var(--a8c-charts-border-radius-bar-chart) 0);
66
+ }
67
+
68
+ &--stacked :global(.visx-bar-group) rect[data-stack-end="bottom"] {
69
+ clip-path: inset(0 round 0 0 var(--a8c-charts-border-radius-bar-chart) var(--a8c-charts-border-radius-bar-chart));
70
+ }
71
+
72
+ &--stacked :global(.visx-bar-group) rect[data-stack-end="left"] {
73
+ clip-path: inset(0 round var(--a8c-charts-border-radius-bar-chart) 0 0 var(--a8c-charts-border-radius-bar-chart));
74
+ }
75
+
76
+ // A stack grows from the zero line, set inline as `transform-origin`;
77
+ // with negatives, the group box's own edge is not that line.
78
+ &--stacked#{&}--animated :global(.visx-bar-group) {
79
+ transform-box: view-box;
80
+ animation:
81
+ rise var(--a8c-charts-motion-duration-entrance)
82
+ var(--a8c-charts-motion-easing-entrance) both;
83
+ }
84
+
85
+ &--stacked#{&}--animated-horizontal :global(.visx-bar-group) {
86
+ transform-box: view-box;
87
+ animation:
88
+ stretch var(--a8c-charts-motion-duration-entrance)
89
+ var(--a8c-charts-motion-easing-entrance) both;
90
+ }
53
91
  }
@@ -27,7 +27,8 @@ import { ChartInstanceContext } from '../private/chart-instance-context';
27
27
  import { ChartLayout } from '../private/chart-layout';
28
28
  import { formatReading, isInvalidReading } from '../private/readings';
29
29
  import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
30
- import { hasOnlyWholeNumbers, WholeNumberTicks } from '../private/whole-number-ticks';
30
+ import { getTickDecimals, hasOnlyWholeNumbers, TickDecimals } from '../private/tick-decimals';
31
+ import { useCompactWhenShort } from '../private/use-compact-when-short';
31
32
  import { withResponsive } from '../private/with-responsive';
32
33
  import plotStyles from '../private/xy-plot/xy-plot.module.scss';
33
34
  import styles from './bar-chart.module.scss';
@@ -45,10 +46,11 @@ import {
45
46
  isBarRendered,
46
47
  SlotTooltipMembers,
47
48
  useBarStops,
48
- useOverlapSlots,
49
+ useBarSlots,
49
50
  getSelectedSlot,
50
51
  isSameCategory,
51
52
  membersAt,
53
+ getDrawnSeries,
52
54
  } from './private';
53
55
  import { useBarColors } from './use-bar-colors';
54
56
  import { useBarLegendItems } from './use-bar-legend-items';
@@ -133,10 +135,37 @@ const BarChartInternal: FC< BarChartProps > = ( {
133
135
  animation,
134
136
  children,
135
137
  gap = 'md',
138
+ compactWhenShort = false,
136
139
  onPointerDown,
137
140
  onPointerUp,
138
141
  onDatumActivate,
139
142
  } ) => {
143
+ if ( layout === 'stacked' && showZeroValues ) {
144
+ warnOnce(
145
+ 'bar-chart-stacked-zero-values',
146
+ 'BarChart: showZeroValues has no effect in a stacked layout.'
147
+ );
148
+ }
149
+ if ( layout === 'stacked' && data?.some( series => series.options?.type === 'comparison' ) ) {
150
+ warnOnce(
151
+ 'bar-chart-stacked-comparison',
152
+ 'BarChart: a stacked layout does not draw comparison series yet.'
153
+ );
154
+ }
155
+ const valueScaleOptions = orientation === 'horizontal' ? options.xScale : options.yScale;
156
+ if ( layout === 'stacked' && valueScaleOptions?.type && valueScaleOptions.type !== 'linear' ) {
157
+ warnOnce(
158
+ `bar-chart-stacked-${ valueScaleOptions.type }-scale`,
159
+ `BarChart: a stacked layout draws on a linear value scale, so the ${ valueScaleOptions.type } scale is ignored.`
160
+ );
161
+ }
162
+ if ( layout === 'stacked' && valueScaleOptions?.zero === false && ! valueScaleOptions.domain ) {
163
+ warnOnce(
164
+ 'bar-chart-stacked-zero',
165
+ 'BarChart: every stack starts at zero, so zero: false has no effect in a stacked layout.'
166
+ );
167
+ }
168
+
140
169
  if ( ! withTooltips && ( withBandHighlight || onBandHighlightChange ) ) {
141
170
  warnOnce(
142
171
  'bar-chart-band-highlight-without-tooltips',
@@ -150,6 +179,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
150
179
  const legendComparisonSeriesItems = legend.comparisonSeriesItems;
151
180
  const horizontal = orientation === 'horizontal';
152
181
  const isOverlap = layout === 'overlap';
182
+ const isStacked = layout === 'stacked';
153
183
  const chartId = useChartId( providedChartId );
154
184
  const hiddenSeries = useDefaultHiddenSeries( chartId, defaultHiddenSeries );
155
185
  const isSeriesVisible = useCallback(
@@ -158,12 +188,17 @@ const BarChartInternal: FC< BarChartProps > = ( {
158
188
  );
159
189
  const theme = useXYChartTheme( data );
160
190
 
161
- const dataSorted = useChartDataTransform( data );
191
+ const transformedData = useChartDataTransform( data );
192
+ // A stacked layout does not draw comparisons yet, so they must not shade a primary's color or join the legend either.
193
+ const dataSorted = useMemo(
194
+ () => getDrawnSeries( transformedData, layout ),
195
+ [ layout, transformedData ]
196
+ );
162
197
 
163
198
  // Transform data to add a small value for zero bars to make them visible
164
199
  // For vertical bars, height determines bar pixel height; for horizontal bars, width does
165
200
  const dataWithVisibleZeros = useZeroValueDisplay( dataSorted, {
166
- enabled: showZeroValues,
201
+ enabled: showZeroValues && ! isStacked,
167
202
  valueAxisLength: horizontal ? width : height,
168
203
  } );
169
204
 
@@ -206,35 +241,24 @@ const BarChartInternal: FC< BarChartProps > = ( {
206
241
  [ dataSorted, isSeriesRendered ]
207
242
  );
208
243
 
209
- const chartOptions = useBarChartOptions(
210
- dataWithVisibleZeros,
211
- horizontal,
212
- options,
213
- isSeriesRendered
244
+ const { isCompact, compactMargin } = useCompactWhenShort(
245
+ compactWhenShort && ! horizontal,
246
+ height
214
247
  );
215
- const valueAxis = horizontal ? chartOptions.axis.x : chartOptions.axis.y;
216
- const callerValueDomain = horizontal ? options.xScale?.domain : options.yScale?.domain;
217
- const wholeNumberTicksProps = {
218
- axis: horizontal ? ( 'x' as const ) : ( 'y' as const ),
219
- numTicks: valueAxis.numTicks,
220
- enabled: hasWholeNumberValues && ! valueAxis.tickValues && ! callerValueDomain,
221
- };
222
- const defaultMargin = useChartMargin( height, chartOptions, dataSorted, theme, horizontal );
223
- const chartRef = useRef< HTMLDivElement >( null );
224
-
225
- // Process children for composition API (Legend, etc.)
226
- const { legendChildren, nonLegendChildren } = useChartChildren( children, 'BarChart' );
227
- const [ measuredChartHeight, setMeasuredChartHeight ] = useState< number | undefined >();
228
-
229
- const handleContentHeightChange = useCallback(
230
- ( contentHeight: number ) => {
231
- const chartHeight = contentHeight > 0 ? contentHeight : height;
232
- setMeasuredChartHeight( chartHeight );
233
- },
234
- [ height ]
248
+ const resolvedOptions = useMemo(
249
+ () =>
250
+ isCompact
251
+ ? {
252
+ ...options,
253
+ axis: {
254
+ ...options.axis,
255
+ x: { ...options.axis?.x, display: false },
256
+ y: { ...options.axis?.y, display: false },
257
+ },
258
+ }
259
+ : options,
260
+ [ isCompact, options ]
235
261
  );
236
- const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >( undefined );
237
- const [ isNavigating, setIsNavigating ] = useState( false );
238
262
 
239
263
  // Add visibility information from the shared legend state.
240
264
  const seriesWithVisibility = useMemo(
@@ -261,7 +285,39 @@ const BarChartInternal: FC< BarChartProps > = ( {
261
285
  [ seriesWithVisibility ]
262
286
  );
263
287
 
264
- const overlapSlots = useOverlapSlots( isOverlap, seriesWithVisibility );
288
+ const barSlots = useBarSlots( layout, seriesWithVisibility );
289
+
290
+ const chartOptions = useBarChartOptions(
291
+ dataWithVisibleZeros,
292
+ horizontal,
293
+ resolvedOptions,
294
+ isSeriesRendered,
295
+ barSlots?.kind === 'stacked' ? barSlots.stack : undefined
296
+ );
297
+ const valueAxis = horizontal ? chartOptions.axis.x : chartOptions.axis.y;
298
+ const callerValueDomain = horizontal ? options.xScale?.domain : options.yScale?.domain;
299
+ const valueTickDecimals = getTickDecimals( hasWholeNumberValues, chartOptions.valueTickDecimals );
300
+ const tickDecimalsProps = {
301
+ axis: horizontal ? ( 'x' as const ) : ( 'y' as const ),
302
+ numTicks: valueAxis.numTicks,
303
+ decimals: ! valueAxis.tickValues && ! callerValueDomain ? valueTickDecimals : undefined,
304
+ };
305
+ const defaultMargin = useChartMargin( height, chartOptions, dataSorted, theme, horizontal );
306
+ const chartRef = useRef< HTMLDivElement >( null );
307
+
308
+ // Process children for composition API (Legend, etc.)
309
+ const { legendChildren, nonLegendChildren } = useChartChildren( children, 'BarChart' );
310
+ const [ measuredChartHeight, setMeasuredChartHeight ] = useState< number | undefined >();
311
+
312
+ const handleContentHeightChange = useCallback(
313
+ ( contentHeight: number ) => {
314
+ const chartHeight = contentHeight > 0 ? contentHeight : height;
315
+ setMeasuredChartHeight( chartHeight );
316
+ },
317
+ [ height ]
318
+ );
319
+ const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >( undefined );
320
+ const [ isNavigating, setIsNavigating ] = useState( false );
265
321
 
266
322
  const primaryKeys = useMemo(
267
323
  () => primaryEntries.map( ( { series } ) => series.label ),
@@ -269,7 +325,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
269
325
  );
270
326
 
271
327
  // 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 );
328
+ const stops = useBarStops( barSlots, primaryEntries );
273
329
  const { barAt, stopOf } = stops;
274
330
 
275
331
  const activateSelectedBar = useCallback(
@@ -305,8 +361,8 @@ const BarChartInternal: FC< BarChartProps > = ( {
305
361
  );
306
362
 
307
363
  const comparisonEntries = useMemo( () => {
308
- // An overlap layout draws its comparisons as slotted bars, not as shadows.
309
- if ( isOverlap ) {
364
+ // An overlap layout draws its comparisons as slotted bars, and a stacked one does not draw them yet.
365
+ if ( barSlots ) {
310
366
  return [];
311
367
  }
312
368
 
@@ -333,7 +389,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
333
389
  entries.push( { series, index, primaryKey: primary.label } );
334
390
  } );
335
391
  return entries;
336
- }, [ isOverlap, seriesWithVisibility, primaryEntries, primaryKeys ] );
392
+ }, [ barSlots, seriesWithVisibility, primaryEntries, primaryKeys ] );
337
393
 
338
394
  // Comparison widthFactor (how much wider the shadow is than the primary) drives both the
339
395
  // shadow width (in ComparisonBars) and the primary narrowing.
@@ -429,6 +485,45 @@ const BarChartInternal: FC< BarChartProps > = ( {
429
485
  [ chartOptions.tooltip, dataSorted ]
430
486
  );
431
487
 
488
+ // Lists the stack last series first, a null reading as "No data", then the total.
489
+ const renderStackedTooltip = useCallback(
490
+ ( { tooltipData }: RenderTooltipParams< DataPointDate > ) => {
491
+ const nearestDatum = tooltipData?.nearestDatum?.datum;
492
+ if ( ! nearestDatum || barSlots?.kind !== 'stacked' ) return null;
493
+
494
+ const datumByKey = tooltipData?.datumByKey ?? {};
495
+ const categoryLabel = chartOptions.tooltip.labelFormatter(
496
+ nearestDatum.label || ( nearestDatum.date ? nearestDatum.date.getTime() : 0 ),
497
+ 0,
498
+ []
499
+ );
500
+
501
+ const rows = [ ...barSlots.entries ].reverse().flatMap( ( { series } ) => {
502
+ const datum = datumByKey[ series.label ]?.datum;
503
+ return datum ? [ { label: series.label, datum } ] : [];
504
+ } );
505
+ const readings = rows.filter( ( { datum } ) => isBarRendered( datum ) ).length;
506
+ const [ negative, positive ] = barSlots.stack.extentAt( nearestDatum );
507
+
508
+ return (
509
+ <div className={ styles[ 'bar-chart__tooltip' ] }>
510
+ <div className={ styles[ 'bar-chart__tooltip-header' ] }>{ categoryLabel }</div>
511
+ { rows.map( ( { label, datum } ) => (
512
+ <Fragment key={ label }>
513
+ { renderTooltipRow( label, formatReading( datum.value ) ) }
514
+ </Fragment>
515
+ ) ) }
516
+ { readings > 1 &&
517
+ renderTooltipRow(
518
+ __( 'Total', 'jetpack-charts' ),
519
+ formatReading( negative + positive )
520
+ ) }
521
+ </div>
522
+ );
523
+ },
524
+ [ chartOptions.tooltip, barSlots ]
525
+ );
526
+
432
527
  const renderDefaultTooltip = useCallback(
433
528
  ( { tooltipData }: RenderTooltipParams< DataPointDate > ) => {
434
529
  const nearestDatum = tooltipData?.nearestDatum?.datum;
@@ -525,35 +620,40 @@ const BarChartInternal: FC< BarChartProps > = ( {
525
620
  [ chartId ]
526
621
  );
527
622
 
528
- const overlapBandSlots = useMemo(
529
- () => overlapSlots && { keys: overlapSlots.slotKeys, slotOf: overlapSlots.slotOf },
530
- [ overlapSlots ]
623
+ const barBandSlots = useMemo(
624
+ () =>
625
+ barSlots && {
626
+ keys: barSlots.slotKeys,
627
+ slotOf: barSlots.slotOf,
628
+ stack: barSlots.kind === 'stacked' ? barSlots.stack : undefined,
629
+ },
630
+ [ barSlots ]
531
631
  );
532
632
 
533
633
  const selectedSlot = useMemo(
534
- () => getSelectedSlot( overlapSlots, selectedIndex ),
535
- [ overlapSlots, selectedIndex ]
634
+ () => getSelectedSlot( barSlots, selectedIndex ),
635
+ [ barSlots, selectedIndex ]
536
636
  );
537
637
 
538
638
  const selectedGroup = useMemo( () => {
539
- const stop = overlapSlots || selectedIndex === undefined ? undefined : barAt( selectedIndex );
639
+ const stop = barSlots || selectedIndex === undefined ? undefined : barAt( selectedIndex );
540
640
  if ( ! stop ) {
541
641
  return undefined;
542
642
  }
543
643
  return membersAt( primaryEntries, stop.datum, stop.key );
544
- }, [ overlapSlots, selectedIndex, barAt, primaryEntries ] );
644
+ }, [ barSlots, selectedIndex, barAt, primaryEntries ] );
545
645
 
546
646
  const selectedMembers = selectedSlot?.members ?? selectedGroup;
547
647
 
548
648
  const selectedBar = useMemo( (): SelectedBar | undefined => {
549
- if ( overlapSlots ) {
649
+ if ( barSlots ) {
550
650
  return selectedSlot;
551
651
  }
552
652
  const bar = selectedIndex === undefined ? undefined : barAt( selectedIndex );
553
653
  return bar && isBarRendered( bar.datum )
554
654
  ? { data: [ bar.datum ], slotKey: bar.key, thickness: 1 }
555
655
  : undefined;
556
- }, [ overlapSlots, selectedSlot, selectedIndex, barAt ] );
656
+ }, [ barSlots, selectedSlot, selectedIndex, barAt ] );
557
657
 
558
658
  // Validate data first
559
659
  const error = validateData( dataSorted );
@@ -583,10 +683,10 @@ const BarChartInternal: FC< BarChartProps > = ( {
583
683
  return <div className={ clsx( 'bar-chart', styles[ 'bar-chart' ] ) }>{ error }</div>;
584
684
  }
585
685
 
586
- const gridVisibility = gridVisibilityProp ?? chartOptions.gridVisibility;
686
+ const gridVisibility = isCompact ? 'none' : ( gridVisibilityProp ?? chartOptions.gridVisibility );
587
687
 
588
688
  const legendPosition = legend.position ?? 'bottom';
589
- const legendElement = showLegend && (
689
+ const legendElement = showLegend && ! isCompact && (
590
690
  <Legend
591
691
  orientation={ legend.orientation ?? 'horizontal' }
592
692
  position={ legendPosition }
@@ -614,12 +714,13 @@ const BarChartInternal: FC< BarChartProps > = ( {
614
714
  <ChartLayout
615
715
  legendPosition={ legendPosition }
616
716
  legendElement={ legendElement }
617
- legendChildren={ legendChildren }
717
+ legendChildren={ isCompact ? [] : legendChildren }
618
718
  gap={ gap }
619
719
  className={ clsx(
620
720
  'bar-chart',
621
721
  styles[ 'bar-chart' ],
622
722
  {
723
+ [ styles[ 'bar-chart--stacked' ] ]: isStacked,
623
724
  [ styles[ `bar-chart--animated${ horizontal ? '-horizontal' : '' }` ] ]:
624
725
  animation && ! prefersReducedMotion,
625
726
  },
@@ -654,6 +755,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
654
755
  height={ chartHeight }
655
756
  margin={ {
656
757
  ...defaultMargin,
758
+ ...compactMargin,
657
759
  ...margin,
658
760
  } }
659
761
  xScale={ xScale }
@@ -670,7 +772,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
670
772
  ) }
671
773
 
672
774
  { ! allSeriesHidden && (
673
- <WholeNumberTicks { ...wholeNumberTicksProps }>
775
+ <TickDecimals { ...tickDecimalsProps }>
674
776
  { valueTicks => (
675
777
  <>
676
778
  { /* Visx forwards tickValues to its grid primitives but omits it from GridProps. */ }
@@ -696,7 +798,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
696
798
  />
697
799
  </>
698
800
  ) }
699
- </WholeNumberTicks>
801
+ </TickDecimals>
700
802
  ) }
701
803
 
702
804
  { withPatterns && (
@@ -719,7 +821,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
719
821
  width={ width }
720
822
  height={ chartHeight }
721
823
  >
722
- { getAllHiddenMessage( legendInteractive, 'series' ) }
824
+ { getAllHiddenMessage( legendInteractive && ! isCompact, 'series' ) }
723
825
  </SvgEmptyState>
724
826
  ) : null }
725
827
 
@@ -740,7 +842,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
740
842
 
741
843
  <BarLayer
742
844
  chartId={ chartId }
743
- overlapSlots={ overlapSlots }
845
+ barSlots={ barSlots }
744
846
  selectedBar={ selectedBar }
745
847
  primaryEntries={ primaryEntries }
746
848
  primaryKeys={ primaryKeys }
@@ -754,8 +856,8 @@ const BarChartInternal: FC< BarChartProps > = ( {
754
856
  { /* Do not reorder: for one key the last showTooltip wins, so this must run after the primary series. */ }
755
857
  { ( withTooltips || onPointerDown || onPointerUp ) && (
756
858
  <BandTooltip
757
- keys={ overlapSlots?.keys ?? primaryKeys }
758
- slots={ overlapBandSlots }
859
+ keys={ barSlots?.keys ?? primaryKeys }
860
+ slots={ barBandSlots }
759
861
  groupPadding={ groupPadding }
760
862
  withTooltips={ withTooltips }
761
863
  onPointerDown={ onPointerDown }
@@ -768,7 +870,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
768
870
  visx collapses the domain and the axes render squished at the top. Drop them
769
871
  while the empty state stands in. */ }
770
872
  { ! allSeriesHidden && (
771
- <WholeNumberTicks { ...wholeNumberTicksProps }>
873
+ <TickDecimals { ...tickDecimalsProps }>
772
874
  { valueTicks => (
773
875
  <>
774
876
  { chartOptions.axis.x.display !== false && (
@@ -789,7 +891,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
789
891
  ) }
790
892
  </>
791
893
  ) }
792
- </WholeNumberTicks>
894
+ </TickDecimals>
793
895
  ) }
794
896
 
795
897
  { withTooltips && selectedMembers && (
@@ -805,6 +907,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
805
907
  snapTooltipToDatumY
806
908
  renderTooltip={
807
909
  renderTooltip ||
910
+ ( isStacked && renderStackedTooltip ) ||
808
911
  ( isOverlap ? renderOverlapTooltip : renderDefaultTooltip )
809
912
  }
810
913
  selectedIndex={ selectedIndex }