@automattic/charts 1.11.0 → 1.12.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 (31) hide show
  1. package/CHANGELOG.md +10 -2
  2. package/TOKENS.md +12 -1
  3. package/dist/index.cjs +751 -97
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +11 -28
  6. package/dist/index.d.cts +58 -6
  7. package/dist/index.d.ts +58 -6
  8. package/dist/index.js +751 -97
  9. package/dist/index.js.map +1 -1
  10. package/package.json +2 -1
  11. package/src/charts/area-chart/area-chart.tsx +28 -8
  12. package/src/charts/area-chart/test/area-chart.test.tsx +102 -0
  13. package/src/charts/area-chart/types.ts +20 -6
  14. package/src/charts/bar-chart/bar-chart.tsx +33 -9
  15. package/src/charts/bar-chart/test/bar-chart.test.tsx +117 -0
  16. package/src/charts/line-chart/line-chart.tsx +40 -5
  17. package/src/charts/line-chart/test/line-chart.test.tsx +190 -1
  18. package/src/charts/line-chart/types.ts +14 -0
  19. package/src/charts/private/x-zoom/index.ts +1 -0
  20. package/src/charts/private/{test → x-zoom/test}/x-zoom.test.tsx +46 -3
  21. package/src/charts/private/x-zoom/x-zoom.module.scss +43 -0
  22. package/src/charts/private/{x-zoom.tsx → x-zoom/x-zoom.tsx} +52 -24
  23. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +152 -0
  24. package/src/components/legend/hooks/use-chart-legend-items.ts +118 -16
  25. package/src/components/legend/private/base-legend.tsx +21 -9
  26. package/src/components/legend/test/legend-config-types.test.tsx +32 -0
  27. package/src/components/legend/test/legend.test.tsx +121 -1
  28. package/src/components/legend/types.ts +6 -0
  29. package/src/index.ts +1 -0
  30. package/src/types.ts +16 -0
  31. package/src/charts/private/x-zoom.module.scss +0 -50
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "1.11.0",
3
+ "version": "1.12.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": {
@@ -105,6 +105,7 @@
105
105
  "@testing-library/user-event": "^14.4.3",
106
106
  "@tsdown/css": "0.22.2",
107
107
  "@types/jest": "^30.0.0",
108
+ "@types/node": "^24.12.0",
108
109
  "@types/react": "18.3.28",
109
110
  "@types/react-dom": "18.3.7",
110
111
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
@@ -70,7 +70,8 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
70
70
  onPointerMove,
71
71
  onPointerOut,
72
72
  zoomable = false,
73
- rescaleYOnLegendToggle = true,
73
+ rescaleYOnVisibilityChange,
74
+ rescaleYOnLegendToggle,
74
75
  children,
75
76
  gridVisibility,
76
77
  gap = 'md',
@@ -81,6 +82,9 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
81
82
  const legendShape = legend.shape ?? 'rect';
82
83
  const legendPosition = legend.position ?? 'bottom';
83
84
 
85
+ // New prop wins; fall back to the deprecated `rescaleYOnLegendToggle`; default to rescaling.
86
+ const rescaleYOnVisibility = rescaleYOnVisibilityChange ?? rescaleYOnLegendToggle ?? true;
87
+
84
88
  const providerTheme = useGlobalChartsTheme();
85
89
  const theme = useXYChartTheme( data );
86
90
  const chartId = useChartId( providedChartId );
@@ -147,12 +151,12 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
147
151
  // Computed from the full data set (ignoring legend visibility) so the y-axis stays
148
152
  // fixed when series are toggled off - otherwise visx auto-fits to the remaining
149
153
  // data and the chart's baseline appears to move. Opt-in via
150
- // `rescaleYOnLegendToggle={ false }`. Skipped for non-default stack offsets,
154
+ // `rescaleYOnVisibilityChange={ false }`. Skipped for non-default stack offsets,
151
155
  // which reshape the y-extent (`expand` -> [0,1], `wiggle`/`silhouette` -> centred
152
156
  // around zero); letting visx derive the domain is correct there.
153
157
  const fixedYDomain = useMemo< [ number, number ] | undefined >( () => {
154
158
  if (
155
- rescaleYOnLegendToggle ||
159
+ rescaleYOnVisibility ||
156
160
  ! legendInteractive ||
157
161
  ! dataSorted.length ||
158
162
  ! dataSorted[ 0 ].data.length ||
@@ -195,7 +199,7 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
195
199
  }
196
200
  if ( max === -Infinity ) return undefined;
197
201
  return [ Math.min( 0, min ), max ];
198
- }, [ dataSorted, stacked, stackOffset, legendInteractive, rescaleYOnLegendToggle ] );
202
+ }, [ dataSorted, stacked, stackOffset, legendInteractive, rescaleYOnVisibility ] );
199
203
 
200
204
  const chartOptions = useMemo( () => {
201
205
  const formatter = options?.axis?.x?.tickFormat || getFormatter( dataSorted );
@@ -238,7 +242,14 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
238
242
  const error = validateData( dataSorted );
239
243
  const isDataValid = ! error;
240
244
 
241
- const legendOptions = useMemo( () => ( { withGlyph: false, glyphSize: 0 } ), [] );
245
+ const legendOptions = useMemo(
246
+ () => ( {
247
+ withGlyph: false,
248
+ glyphSize: 0,
249
+ collapseGroups: legend.collapseGroups ?? false,
250
+ } ),
251
+ [ legend.collapseGroups ]
252
+ );
242
253
  const legendItems = useChartLegendItems( dataSorted, legendOptions, legendShape );
243
254
 
244
255
  const chartMetadata = useMemo(
@@ -405,9 +416,18 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
405
416
  onPointerOut={ onPointerOut }
406
417
  pointerEventsDataKey="nearest"
407
418
  >
408
- { gridVisibility !== 'none' && <Grid columns={ false } numTicks={ 4 } /> }
409
- { chartOptions.axis.x.display && <Axis { ...chartOptions.axis.x } /> }
410
- { chartOptions.axis.y.display && <Axis { ...chartOptions.axis.y } /> }
419
+ { /* With every series hidden the value scale collapses, so the grid and axes
420
+ are dropped while the empty state stands in — otherwise they render
421
+ squished at the top. */ }
422
+ { ! allSeriesHidden && gridVisibility !== 'none' && (
423
+ <Grid columns={ false } numTicks={ 4 } />
424
+ ) }
425
+ { ! allSeriesHidden && chartOptions.axis.x.display && (
426
+ <Axis { ...chartOptions.axis.x } />
427
+ ) }
428
+ { ! allSeriesHidden && chartOptions.axis.y.display && (
429
+ <Axis { ...chartOptions.axis.y } />
430
+ ) }
411
431
 
412
432
  { allSeriesHidden ? (
413
433
  <SvgEmptyState
@@ -333,6 +333,35 @@ describe( 'AreaChart', () => {
333
333
  expect( afterToggleDomain ).toEqual( initialDomain );
334
334
  } );
335
335
 
336
+ test( 'y-axis stays pinned when rescaleYOnVisibilityChange is false', async () => {
337
+ const user = userEvent.setup();
338
+ const ref = createRef< SingleChartRef >();
339
+ render(
340
+ <GlobalChartsProvider>
341
+ <AreaChartUnresponsive
342
+ { ...defaultProps }
343
+ showLegend
344
+ chartId="test-interactive-domain-pin-new"
345
+ legend={ { interactive: true } }
346
+ rescaleYOnVisibilityChange={ false }
347
+ ref={ ref }
348
+ />
349
+ </GlobalChartsProvider>
350
+ );
351
+
352
+ const initialDomain = (
353
+ ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
354
+ )?.domain();
355
+ expect( initialDomain ).toBeDefined();
356
+
357
+ await user.click( screen.getByText( 'Series A' ) );
358
+
359
+ const afterToggleDomain = (
360
+ ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
361
+ )?.domain();
362
+ expect( afterToggleDomain ).toEqual( initialDomain );
363
+ } );
364
+
336
365
  test( 'supports negative stacked values without clipping (with pinned Y)', () => {
337
366
  // The mixed-sign full-extent pin only kicks in when the consumer
338
367
  // opts into pinned-Y behavior; visx's natural domain derivation for
@@ -544,4 +573,77 @@ describe( 'AreaChart', () => {
544
573
  'url(#chart-zoom-clip-zoomtest)'
545
574
  );
546
575
  } );
576
+
577
+ describe( 'Legend group collapsing', () => {
578
+ const groupedData = [
579
+ {
580
+ label: 'Views',
581
+ group: 'views',
582
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
583
+ },
584
+ {
585
+ label: 'Views — previous',
586
+ group: 'views',
587
+ options: { type: 'comparison' as const },
588
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
589
+ },
590
+ ];
591
+
592
+ test( 'renders one legend item per series by default', () => {
593
+ renderWithProvider( { showLegend: true, data: groupedData } );
594
+
595
+ expect( screen.getByText( 'Views' ) ).toBeInTheDocument();
596
+ expect( screen.getByText( 'Views — previous' ) ).toBeInTheDocument();
597
+ } );
598
+
599
+ test( 'collapses a group to one item when legend.collapseGroups is set', () => {
600
+ renderWithProvider( {
601
+ showLegend: true,
602
+ legend: { collapseGroups: true },
603
+ data: groupedData,
604
+ } );
605
+
606
+ expect( screen.getByText( 'Views' ) ).toBeInTheDocument();
607
+ expect( screen.queryByText( 'Views — previous' ) ).not.toBeInTheDocument();
608
+ } );
609
+ } );
610
+
611
+ describe( 'All series hidden', () => {
612
+ it( 'drops the grid so it does not collapse when every series is hidden', async () => {
613
+ const user = userEvent.setup();
614
+
615
+ renderWithProvider( {
616
+ showLegend: true,
617
+ legend: { interactive: true },
618
+ chartId: 'area-hidden-axes',
619
+ data: [
620
+ {
621
+ label: 'Series A',
622
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
623
+ },
624
+ {
625
+ label: 'Series B',
626
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
627
+ },
628
+ ],
629
+ } );
630
+
631
+ // The visx grid has no role/testid, so query its internal class from within the plot.
632
+ /* eslint-disable testing-library/no-node-access */
633
+ const plot = screen.getByRole( 'grid' );
634
+ expect( plot.querySelector( '.visx-rows' ) ).toBeInTheDocument();
635
+
636
+ const buttons = screen.getAllByRole( 'button' );
637
+ await user.click( buttons[ 0 ] );
638
+ await user.click( buttons[ 1 ] );
639
+
640
+ // With no visible data the value scale collapses, so the grid is removed rather than
641
+ // rendered squished at the top behind the empty state.
642
+ expect( plot.querySelector( '.visx-rows' ) ).not.toBeInTheDocument();
643
+ /* eslint-enable testing-library/no-node-access */
644
+ expect(
645
+ screen.getByText( /all series are hidden.*click legend items to show data/i )
646
+ ).toBeInTheDocument();
647
+ } );
648
+ } );
547
649
  } );
@@ -1,9 +1,18 @@
1
- import type { BaseChartProps, DataPointDate, SeriesData } from '../../types';
1
+ import type {
2
+ BaseChartProps,
3
+ DataPointDate,
4
+ SeriesData,
5
+ SeriesChartLegendConfig,
6
+ } from '../../types';
2
7
  import type { RenderTooltipParams } from '../../visx/types';
3
8
  import type { CurveType } from '../line-chart/types';
4
9
  import type { ReactNode } from 'react';
5
10
 
6
11
  export interface AreaChartProps extends BaseChartProps< SeriesData[] > {
12
+ /**
13
+ * Legend configuration. Supports `collapseGroups` on top of the shared options.
14
+ */
15
+ legend?: SeriesChartLegendConfig;
7
16
  /**
8
17
  * Whether series should be stacked on top of each other.
9
18
  * When false, series are rendered as overlapping filled areas.
@@ -54,13 +63,18 @@ export interface AreaChartProps extends BaseChartProps< SeriesData[] > {
54
63
  */
55
64
  zoomable?: boolean;
56
65
  /**
57
- * When using an interactive legend, controls whether the Y axis rescales
58
- * to fit only the visible series. Defaults to `true`, matching the
59
- * intuitive default for LineChart and BarChart. Set to `false` to pin
60
- * the Y axis to the full data extent so toggling legend items off does
61
- * not move the chart's baseline.
66
+ * Whether the Y axis rescales to fit only the visible series when series are
67
+ * hidden or shown through the interactive legend.
68
+ * Defaults to `true`, matching LineChart. Set to `false` to pin the Y axis to
69
+ * the full data extent so hiding series does not move the chart's baseline.
62
70
  * @default true
63
71
  */
72
+ rescaleYOnVisibilityChange?: boolean;
73
+ /**
74
+ * @deprecated Use `rescaleYOnVisibilityChange`. The behaviour keys off series
75
+ * visibility changing, not specifically a legend toggle. Still honoured when
76
+ * `rescaleYOnVisibilityChange` is not set.
77
+ */
64
78
  rescaleYOnLegendToggle?: boolean;
65
79
  children?: ReactNode;
66
80
  }
@@ -37,12 +37,22 @@ import {
37
37
  BASE_BAND_PADDING_INNER,
38
38
  } from './private';
39
39
  import type { ComparisonSeriesEntry } from './private';
40
- import type { BaseChartProps, DataPointDate, SeriesData, Optional } from '../../types';
40
+ import type {
41
+ BaseChartProps,
42
+ DataPointDate,
43
+ SeriesData,
44
+ SeriesChartLegendConfig,
45
+ Optional,
46
+ } from '../../types';
41
47
  import type { RenderTooltipParams } from '../../visx/types';
42
48
  import type { ResponsiveConfig } from '../private/with-responsive';
43
49
  import type { FC, ReactNode, ComponentType } from 'react';
44
50
 
45
51
  export interface BarChartProps extends BaseChartProps< SeriesData[] > {
52
+ /**
53
+ * Legend configuration. Supports `collapseGroups` on top of the shared options.
54
+ */
55
+ legend?: SeriesChartLegendConfig;
46
56
  renderTooltip?: ( params: RenderTooltipParams< DataPointDate > ) => ReactNode;
47
57
  orientation?: 'horizontal' | 'vertical';
48
58
  withPatterns?: boolean;
@@ -117,6 +127,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
117
127
  gap = 'md',
118
128
  } ) => {
119
129
  const legendInteractive = legend.interactive ?? false;
130
+ const legendCollapseGroups = legend.collapseGroups ?? false;
120
131
  const horizontal = orientation === 'horizontal';
121
132
  const chartId = useChartId( providedChartId );
122
133
  const theme = useXYChartTheme( data );
@@ -131,7 +142,11 @@ const BarChartInternal: FC< BarChartProps > = ( {
131
142
  } );
132
143
 
133
144
  // Create legend items using the reusable hook
134
- const legendItems = useChartLegendItems( dataSorted );
145
+ const legendOptions = useMemo(
146
+ () => ( { collapseGroups: legendCollapseGroups } ),
147
+ [ legendCollapseGroups ]
148
+ );
149
+ const legendItems = useChartLegendItems( dataSorted, legendOptions );
135
150
  const chartOptions = useBarChartOptions( dataWithVisibleZeros, horizontal, options );
136
151
  const defaultMargin = useChartMargin( height, chartOptions, dataSorted, theme, horizontal );
137
152
  const chartRef = useRef< HTMLDivElement >( null );
@@ -544,11 +559,13 @@ const BarChartInternal: FC< BarChartProps > = ( {
544
559
  horizontal={ horizontal }
545
560
  pointerEventsDataKey="nearest"
546
561
  >
547
- <Grid
548
- columns={ gridVisibility.includes( 'y' ) }
549
- rows={ gridVisibility.includes( 'x' ) }
550
- numTicks={ 4 }
551
- />
562
+ { ! allSeriesHidden && (
563
+ <Grid
564
+ columns={ gridVisibility.includes( 'y' ) }
565
+ rows={ gridVisibility.includes( 'x' ) }
566
+ numTicks={ 4 }
567
+ />
568
+ ) }
552
569
 
553
570
  { withPatterns && (
554
571
  <>
@@ -615,8 +632,15 @@ const BarChartInternal: FC< BarChartProps > = ( {
615
632
  ) ) }
616
633
  </BarGroup>
617
634
 
618
- <Axis { ...chartOptions.axis.x } />
619
- <Axis { ...chartOptions.axis.y } />
635
+ { /* With every series hidden there is no data to build the value scale from, so
636
+ visx collapses the domain and the axes render squished at the top. Drop them
637
+ while the empty state stands in. */ }
638
+ { ! allSeriesHidden && (
639
+ <>
640
+ <Axis { ...chartOptions.axis.x } />
641
+ <Axis { ...chartOptions.axis.y } />
642
+ </>
643
+ ) }
620
644
 
621
645
  { withTooltips && (
622
646
  <AccessibleTooltip
@@ -1194,5 +1194,122 @@ describe( 'BarChart', () => {
1194
1194
  screen.getByText( /all series are hidden.*click legend items to show data/i )
1195
1195
  ).toBeInTheDocument();
1196
1196
  } );
1197
+
1198
+ it( 'drops the value-axis ticks when all series are hidden so the axes do not collapse', async () => {
1199
+ const user = userEvent.setup();
1200
+
1201
+ renderWithTheme( {
1202
+ showLegend: true,
1203
+ gridVisibility: 'both',
1204
+ legend: { interactive: true },
1205
+ chartId: 'test-hidden-axes-bar-chart',
1206
+ data: [
1207
+ {
1208
+ label: 'Series A',
1209
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
1210
+ options: {},
1211
+ },
1212
+ {
1213
+ label: 'Series B',
1214
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
1215
+ options: {},
1216
+ },
1217
+ ],
1218
+ } );
1219
+
1220
+ // The value axis renders numeric tick labels while there is data to scale against.
1221
+ // (Series labels carry no digits and the empty-state message is text-only, so a bare
1222
+ // number can only be an axis tick.)
1223
+ const numericTick = /^[\d,]+$/;
1224
+ expect( screen.getAllByText( numericTick ).length ).toBeGreaterThan( 0 );
1225
+
1226
+ const legendItems = screen.getAllByRole( 'button' );
1227
+ await user.click( legendItems[ 0 ] );
1228
+ await user.click( legendItems[ 1 ] );
1229
+
1230
+ // With no visible data the value scale would collapse, so the axes are removed rather
1231
+ // than rendered squished at the top — no tick labels remain.
1232
+ expect( screen.queryAllByText( numericTick ) ).toHaveLength( 0 );
1233
+ expect(
1234
+ screen.getByText( /all series are hidden.*click legend items to show data/i )
1235
+ ).toBeInTheDocument();
1236
+ } );
1237
+ } );
1238
+
1239
+ describe( 'Legend group collapsing', () => {
1240
+ const comparisonPair = [
1241
+ {
1242
+ label: 'Views',
1243
+ group: 'views',
1244
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
1245
+ },
1246
+ {
1247
+ label: 'Views — previous',
1248
+ group: 'views',
1249
+ options: { type: 'comparison' as const },
1250
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
1251
+ },
1252
+ ];
1253
+
1254
+ it( 'renders one legend item per series by default', () => {
1255
+ renderWithTheme( {
1256
+ showLegend: true,
1257
+ chartId: 'bar-legend-groups-default',
1258
+ data: comparisonPair,
1259
+ } );
1260
+
1261
+ expect( screen.getByText( 'Views' ) ).toBeInTheDocument();
1262
+ expect( screen.getByText( 'Views — previous' ) ).toBeInTheDocument();
1263
+ } );
1264
+
1265
+ it( 'collapses a group to one item when legend.collapseGroups is set', () => {
1266
+ renderWithTheme( {
1267
+ showLegend: true,
1268
+ legend: { collapseGroups: true },
1269
+ chartId: 'bar-legend-groups-collapsed',
1270
+ data: comparisonPair,
1271
+ } );
1272
+
1273
+ expect( screen.getByText( 'Views' ) ).toBeInTheDocument();
1274
+ expect( screen.queryByText( 'Views — previous' ) ).not.toBeInTheDocument();
1275
+ } );
1276
+
1277
+ it( 'toggles only the clicked series when interactive without collapseGroups', async () => {
1278
+ const user = userEvent.setup();
1279
+
1280
+ renderWithTheme( {
1281
+ showLegend: true,
1282
+ legend: { interactive: true },
1283
+ chartId: 'bar-legend-groups-interactive',
1284
+ data: comparisonPair,
1285
+ } );
1286
+
1287
+ const buttons = screen.getAllByRole( 'button' );
1288
+ await user.click( buttons[ 0 ] );
1289
+
1290
+ expect( buttons[ 0 ] ).toHaveAttribute( 'aria-pressed', 'false' );
1291
+ expect( buttons[ 1 ] ).toHaveAttribute( 'aria-pressed', 'true' );
1292
+ } );
1293
+
1294
+ it( 'toggles the whole group when interactive with collapseGroups', async () => {
1295
+ const user = userEvent.setup();
1296
+
1297
+ renderWithTheme( {
1298
+ showLegend: true,
1299
+ legend: { interactive: true, collapseGroups: true },
1300
+ chartId: 'bar-legend-groups-interactive-collapsed',
1301
+ data: comparisonPair,
1302
+ } );
1303
+
1304
+ const buttons = screen.getAllByRole( 'button' );
1305
+ expect( buttons ).toHaveLength( 1 );
1306
+
1307
+ await user.click( buttons[ 0 ] );
1308
+
1309
+ expect( buttons[ 0 ] ).toHaveAttribute( 'aria-pressed', 'false' );
1310
+ expect(
1311
+ screen.getByText( /all series are hidden.*click legend items to show data/i )
1312
+ ).toBeInTheDocument();
1313
+ } );
1197
1314
  } );
1198
1315
  } );
@@ -179,6 +179,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
179
179
  onPointerMove = undefined,
180
180
  onPointerOut = undefined,
181
181
  zoomable = false,
182
+ rescaleYOnVisibilityChange = true,
182
183
  children,
183
184
  gridVisibility,
184
185
  gap = 'md',
@@ -186,6 +187,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
186
187
  ref
187
188
  ) => {
188
189
  const legendInteractive = legend.interactive ?? false;
190
+ const legendCollapseGroups = legend.collapseGroups ?? false;
189
191
  const legendShape = legend.shape ?? 'line';
190
192
  const legendPosition = legend.position ?? 'bottom';
191
193
 
@@ -252,6 +254,28 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
252
254
  return seriesWithVisibility.every( ( { isVisible } ) => ! isVisible );
253
255
  }, [ seriesWithVisibility ] );
254
256
 
257
+ // When the interactive legend can hide series and rescaling is opted out, pin the value axis
258
+ // to the full data range so it stays put as series are toggled instead of visx rescaling the
259
+ // domain to whatever is currently visible and making the axis jump. Default is to rescale,
260
+ // matching the pre-existing behaviour and AreaChart's `rescaleYOnVisibilityChange`.
261
+ const stableYDomain = useMemo< [ number, number ] | undefined >( () => {
262
+ if ( ! legendInteractive || rescaleYOnVisibilityChange ) {
263
+ return undefined;
264
+ }
265
+ let min = Infinity;
266
+ let max = -Infinity;
267
+ for ( const series of dataSorted ) {
268
+ for ( const point of series.data ?? [] ) {
269
+ const value = point?.value;
270
+ if ( typeof value === 'number' && Number.isFinite( value ) ) {
271
+ min = Math.min( min, value );
272
+ max = Math.max( max, value );
273
+ }
274
+ }
275
+ }
276
+ return min < max ? [ min, max ] : undefined;
277
+ }, [ legendInteractive, rescaleYOnVisibilityChange, dataSorted ] );
278
+
255
279
  // Use the keyboard navigation hook
256
280
  const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
257
281
  selectedIndex,
@@ -291,10 +315,11 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
291
315
  type: 'linear' as const,
292
316
  nice: true,
293
317
  zero: false,
318
+ ...( stableYDomain ? { domain: stableYDomain } : {} ),
294
319
  ...options?.yScale,
295
320
  },
296
321
  };
297
- }, [ options, dataSorted, width, zoom.domain ] );
322
+ }, [ options, dataSorted, width, zoom.domain, stableYDomain ] );
298
323
 
299
324
  const tooltipRenderGlyph = useMemo( () => {
300
325
  return ( props: GlyphProps< DataPointDate > ) => {
@@ -328,9 +353,10 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
328
353
  () => ( {
329
354
  withGlyph: withLegendGlyph,
330
355
  glyphSize: Math.max( 0, toNumber( glyphStyle?.radius ) ?? 4 ),
356
+ collapseGroups: legendCollapseGroups,
331
357
  renderGlyph,
332
358
  } ),
333
- [ withLegendGlyph, glyphStyle?.radius, renderGlyph ]
359
+ [ withLegendGlyph, glyphStyle?.radius, legendCollapseGroups, renderGlyph ]
334
360
  );
335
361
 
336
362
  // Create legend items using the reusable hook
@@ -444,9 +470,18 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
444
470
  onPointerOut={ onPointerOut }
445
471
  pointerEventsDataKey="nearest"
446
472
  >
447
- { gridVisibility !== 'none' && <Grid columns={ false } numTicks={ 4 } /> }
448
- { chartOptions.axis.x.display && <Axis { ...chartOptions.axis.x } /> }
449
- { chartOptions.axis.y.display && <Axis { ...chartOptions.axis.y } /> }
473
+ { /* With every series hidden there is no data to scale against, so the grid and
474
+ axes are dropped while the empty state stands in — otherwise they render
475
+ squished at the top. */ }
476
+ { ! allSeriesHidden && gridVisibility !== 'none' && (
477
+ <Grid columns={ false } numTicks={ 4 } />
478
+ ) }
479
+ { ! allSeriesHidden && chartOptions.axis.x.display && (
480
+ <Axis { ...chartOptions.axis.x } />
481
+ ) }
482
+ { ! allSeriesHidden && chartOptions.axis.y.display && (
483
+ <Axis { ...chartOptions.axis.y } />
484
+ ) }
450
485
 
451
486
  { allSeriesHidden ? (
452
487
  <SvgEmptyState