@automattic/charts 2.0.1 → 3.1.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 (102) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/TOKENS.md +109 -50
  3. package/dist/index.cjs +529 -233
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +118 -55
  6. package/dist/index.d.cts +90 -48
  7. package/dist/index.d.ts +90 -48
  8. package/dist/index.js +527 -235
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/area-chart/area-chart.module.scss +8 -3
  12. package/src/charts/area-chart/area-chart.tsx +25 -25
  13. package/src/charts/area-chart/private/overlays.tsx +3 -3
  14. package/src/charts/area-chart/test/area-chart.test.tsx +301 -11
  15. package/src/charts/area-chart/types.ts +4 -3
  16. package/src/charts/bar-chart/bar-chart.module.scss +14 -6
  17. package/src/charts/bar-chart/bar-chart.tsx +21 -15
  18. package/src/charts/bar-chart/test/bar-chart.test.tsx +255 -2
  19. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +23 -18
  20. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +107 -86
  21. package/src/charts/conversion-funnel-chart/test/chart-scope.test.tsx +36 -0
  22. package/src/charts/geo-chart/geo-chart.module.scss +8 -0
  23. package/src/charts/geo-chart/geo-chart.tsx +12 -4
  24. package/src/charts/heatmap-chart/heatmap-chart.module.scss +22 -9
  25. package/src/charts/heatmap-chart/heatmap-chart.tsx +43 -13
  26. package/src/charts/heatmap-chart/private/build-calendar-data.ts +65 -5
  27. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +125 -0
  28. package/src/charts/heatmap-chart/types.ts +8 -0
  29. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +13 -7
  30. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +17 -22
  31. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +107 -1
  32. package/src/charts/leaderboard-chart/types.ts +0 -2
  33. package/src/charts/line-chart/line-chart.module.scss +12 -7
  34. package/src/charts/line-chart/line-chart.tsx +32 -28
  35. package/src/charts/line-chart/private/line-chart-annotation.tsx +4 -4
  36. package/src/charts/line-chart/private/line-chart-annotations-overlay.tsx +2 -2
  37. package/src/charts/line-chart/test/line-chart.test.tsx +262 -5
  38. package/src/charts/line-chart/types.ts +4 -3
  39. package/src/charts/pie-chart/pie-chart.tsx +12 -14
  40. package/src/charts/pie-chart/test/pie-chart.test.tsx +73 -1
  41. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +2 -2
  42. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +12 -14
  43. package/src/charts/private/{single-chart-context/single-chart-context.tsx → chart-instance-context/chart-instance-context.ts} +1 -5
  44. package/src/charts/private/chart-instance-context/index.ts +3 -0
  45. package/src/charts/private/{single-chart-context/use-single-chart-context.ts → chart-instance-context/use-chart-instance-context.ts} +1 -3
  46. package/src/charts/private/svg-empty-state/get-all-hidden-message.ts +41 -0
  47. package/src/charts/private/svg-empty-state/index.ts +1 -0
  48. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  49. package/src/charts/private/svg-empty-state/test/get-all-hidden-message.test.ts +15 -0
  50. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +16 -0
  51. package/src/charts/private/with-responsive/with-responsive.tsx +18 -11
  52. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +2 -2
  53. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  54. package/src/charts/private/x-zoom/x-zoom.tsx +2 -2
  55. package/src/components/legend/legend.tsx +4 -4
  56. package/src/components/legend/private/base-legend.module.scss +1 -1
  57. package/src/components/legend/private/base-legend.tsx +70 -19
  58. package/src/components/legend/test/legend.test.tsx +100 -4
  59. package/src/components/tooltip/accessible-tooltip.tsx +17 -4
  60. package/src/components/tooltip/base-tooltip.tsx +8 -1
  61. package/src/components/trend-indicator/test/trend-indicator.test.tsx +20 -0
  62. package/src/components/trend-indicator/trend-indicator.module.scss +4 -7
  63. package/src/components/trend-indicator/trend-indicator.tsx +3 -0
  64. package/src/hooks/index.ts +1 -1
  65. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +28 -0
  66. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +19 -13
  67. package/src/hooks/test/use-xychart-theme.test.tsx +25 -0
  68. package/src/hooks/{use-interactive-legend-data.ts → use-legend-visibility-data.ts} +29 -30
  69. package/src/hooks/use-xychart-theme.ts +6 -4
  70. package/src/index.ts +12 -3
  71. package/src/providers/chart-context/global-charts-provider.tsx +127 -30
  72. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +47 -0
  73. package/src/providers/chart-context/private/theme-override-vars.ts +87 -0
  74. package/src/providers/chart-context/private/with-catalog-pointers.ts +50 -0
  75. package/src/providers/chart-context/test/chart-context.test.tsx +142 -40
  76. package/src/providers/chart-context/test/chart-scope-class.test.tsx +19 -0
  77. package/src/providers/chart-context/test/sparse-theme.test.ts +35 -0
  78. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +29 -0
  79. package/src/providers/chart-context/test/theme-override-vars.test.ts +118 -0
  80. package/src/providers/chart-context/test/use-default-hidden-series-paint.test.tsx +56 -0
  81. package/src/providers/chart-context/test/use-default-hidden-series.ssr.test.tsx +39 -0
  82. package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +222 -0
  83. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +47 -0
  84. package/src/providers/chart-context/themes.ts +18 -27
  85. package/src/providers/chart-context/types.ts +7 -4
  86. package/src/providers/chart-scope/chart-scope-context.ts +8 -0
  87. package/src/providers/chart-scope/index.ts +3 -0
  88. package/src/providers/chart-scope/test/chart-scope.test.tsx +24 -0
  89. package/src/providers/chart-scope/test/use-standalone-scope-class.test.tsx +20 -0
  90. package/src/providers/chart-scope/use-chart-scope-element.ts +9 -0
  91. package/src/providers/chart-scope/use-standalone-scope-class.ts +13 -0
  92. package/src/providers/index.ts +1 -0
  93. package/src/styles/chart-scope-class.ts +6 -0
  94. package/src/styles/chart-scope.scss +83 -0
  95. package/src/styles/test/chart-scope.test.ts +165 -0
  96. package/src/styles/test/wpds-fallbacks.test.ts +131 -0
  97. package/src/types.ts +12 -34
  98. package/src/charts/private/grid-control/grid-control.module.scss +0 -8
  99. package/src/charts/private/grid-control/grid-control.tsx +0 -26
  100. package/src/charts/private/grid-control/index.ts +0 -1
  101. package/src/charts/private/grid-control/test/grid-control.test.tsx +0 -56
  102. package/src/charts/private/single-chart-context/index.ts +0 -8
@@ -29,12 +29,13 @@ import {
29
29
  useGlobalChartsContext,
30
30
  useGlobalChartsTheme,
31
31
  } from '../../providers';
32
+ import { useDefaultHiddenSeries } from '../../providers/chart-context/hooks/use-default-hidden-series';
32
33
  import { attachSubComponents } from '../../utils';
33
34
  import { useChartChildren } from '../private/chart-composition';
35
+ import { ChartInstanceContext, type ChartInstanceRef } from '../private/chart-instance-context';
34
36
  import { ChartLayout } from '../private/chart-layout';
35
37
  import { DefaultGlyph } from '../private/default-glyph';
36
- import { SingleChartContext, type SingleChartRef } from '../private/single-chart-context';
37
- import { SvgEmptyState } from '../private/svg-empty-state';
38
+ import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
38
39
  import { getCurveType, getFormatter, guessOptimalNumTicks } from '../private/time-axis';
39
40
  import { withResponsive } from '../private/with-responsive';
40
41
  import { useXZoom, ZoomResetButton, ZoomSelectionRect, ZoomClip } from '../private/x-zoom';
@@ -119,7 +120,7 @@ const validateData = ( data: SeriesData[] ) => {
119
120
 
120
121
  // Inner component to access DataContext and provide scale data to ref
121
122
  const LineChartScalesRef: FC< {
122
- chartRef?: Ref< SingleChartRef >;
123
+ chartRef?: Ref< ChartInstanceRef >;
123
124
  width: number;
124
125
  height: number;
125
126
  margin?: { top?: number; right?: number; bottom?: number; left?: number };
@@ -150,7 +151,7 @@ const LineChartScalesRef: FC< {
150
151
  return null; // This component only provides the ref interface
151
152
  };
152
153
 
153
- const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
154
+ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
154
155
  (
155
156
  {
156
157
  data,
@@ -180,6 +181,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
180
181
  onPointerOut = undefined,
181
182
  zoomable = false,
182
183
  rescaleYOnVisibilityChange = true,
184
+ defaultHiddenSeries,
183
185
  children,
184
186
  gridVisibility,
185
187
  gap = 'md',
@@ -193,13 +195,18 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
193
195
 
194
196
  const providerTheme = useGlobalChartsTheme();
195
197
  const theme = useXYChartTheme( data );
196
- // Gradient stops apply this as an SVG attribute, where CSS var() cannot resolve. useXYChartTheme has already resolved the same role inside its memo, so read it back rather than paying another getComputedStyle on every render.
198
+ // Gradient stops apply this as an SVG attribute, where CSS var() cannot resolve. useXYChartTheme has already resolved the same role inside its memo, against the chart's scope element, so read it back rather than paying another getComputedStyle on every render.
197
199
  const resolvedBackgroundColor = theme.backgroundColor ?? providerTheme.backgroundColor;
198
200
  const chartId = useChartId( providedChartId );
201
+ const hiddenSeries = useDefaultHiddenSeries( chartId, defaultHiddenSeries );
202
+ const isSeriesVisible = useCallback(
203
+ ( seriesLabel: string ) => ! hiddenSeries.has( seriesLabel ),
204
+ [ hiddenSeries ]
205
+ );
199
206
  const chartRef = useRef< HTMLDivElement >( null );
200
207
  const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >( undefined );
201
208
  const [ isNavigating, setIsNavigating ] = useState( false );
202
- const internalChartRef = useRef< SingleChartRef >( null );
209
+ const internalChartRef = useRef< ChartInstanceRef >( null );
203
210
 
204
211
  const zoom = useXZoom< Date >( {
205
212
  enabled: zoomable,
@@ -233,31 +240,30 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
233
240
  );
234
241
 
235
242
  const dataSorted = useChartDataTransform( data );
236
- const { getElementStyles, isSeriesVisible } = useGlobalChartsContext();
243
+ const { getElementStyles } = useGlobalChartsContext();
237
244
 
238
- // Add visibility information to series when using interactive legends
245
+ // Series visibility is owned by the provider, so it applies whether it changed
246
+ // through the interactive legend or programmatically.
239
247
  const seriesWithVisibility = useMemo( () => {
240
- if ( ! chartId || ! legendInteractive ) {
241
- return dataSorted.map( ( series, index ) => ( { series, index, isVisible: true } ) );
242
- }
243
248
  return dataSorted.map( ( series, index ) => ( {
244
249
  series,
245
250
  index,
246
- isVisible: isSeriesVisible( chartId, series.label ),
251
+ isVisible: ! hiddenSeries.has( series.label ),
247
252
  } ) );
248
- }, [ dataSorted, chartId, isSeriesVisible, legendInteractive ] );
253
+ }, [ dataSorted, hiddenSeries ] );
249
254
 
250
255
  // Check if all series are hidden
251
256
  const allSeriesHidden = useMemo( () => {
252
257
  return seriesWithVisibility.every( ( { isVisible } ) => ! isVisible );
253
258
  }, [ seriesWithVisibility ] );
254
259
 
255
- // When the interactive legend can hide series and rescaling is opted out, pin the value axis
256
- // to the full data range so it stays put as series are toggled instead of visx rescaling the
257
- // domain to whatever is currently visible and making the axis jump. Default is to rescale,
258
- // matching the pre-existing behaviour and AreaChart's `rescaleYOnVisibilityChange`.
260
+ // When series visibility changes — via the interactive legend or programmatically —
261
+ // and rescaling is opted out, pin the value axis to the full data range so it stays
262
+ // put instead of visx rescaling the domain to whatever is currently visible and
263
+ // making the axis jump. Default is to rescale, matching the pre-existing behaviour
264
+ // and AreaChart's `rescaleYOnVisibilityChange`.
259
265
  const stableYDomain = useMemo< [ number, number ] | undefined >( () => {
260
- if ( ! legendInteractive || rescaleYOnVisibilityChange ) {
266
+ if ( rescaleYOnVisibilityChange ) {
261
267
  return undefined;
262
268
  }
263
269
  let min = Infinity;
@@ -272,7 +278,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
272
278
  }
273
279
  }
274
280
  return min < max ? [ min, max ] : undefined;
275
- }, [ legendInteractive, rescaleYOnVisibilityChange, dataSorted ] );
281
+ }, [ rescaleYOnVisibilityChange, dataSorted ] );
276
282
 
277
283
  // Use the keyboard navigation hook
278
284
  const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
@@ -412,10 +418,11 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
412
418
  );
413
419
 
414
420
  return (
415
- <SingleChartContext.Provider
421
+ <ChartInstanceContext.Provider
416
422
  value={ {
417
423
  chartId,
418
424
  chartRef: internalChartRef,
425
+ isSeriesVisible,
419
426
  chartWidth: width,
420
427
  chartHeight: measuredChartHeight || 0,
421
428
  } }
@@ -489,10 +496,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
489
496
  width={ width }
490
497
  height={ chartHeight }
491
498
  >
492
- { __(
493
- 'All series are hidden. Click legend items to show data.',
494
- 'jetpack-charts'
495
- ) }
499
+ { getAllHiddenMessage( legendInteractive, 'series' ) }
496
500
  </SvgEmptyState>
497
501
  ) : null }
498
502
 
@@ -619,7 +623,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
619
623
  );
620
624
  } }
621
625
  </ChartLayout>
622
- </SingleChartContext.Provider>
626
+ </ChartInstanceContext.Provider>
623
627
  );
624
628
  }
625
629
  );
@@ -634,16 +638,16 @@ type LineChartAnnotationComponents = {
634
638
  type LineChartBaseProps = Optional< LineChartProps, 'width' | 'height' | 'size' >;
635
639
 
636
640
  type LineChartComponent = React.ForwardRefExoticComponent<
637
- LineChartBaseProps & React.RefAttributes< SingleChartRef >
641
+ LineChartBaseProps & React.RefAttributes< ChartInstanceRef >
638
642
  > &
639
643
  LineChartAnnotationComponents;
640
644
 
641
645
  type LineChartResponsiveComponent = React.ForwardRefExoticComponent<
642
- LineChartBaseProps & ResponsiveConfig & React.RefAttributes< SingleChartRef >
646
+ LineChartBaseProps & ResponsiveConfig & React.RefAttributes< ChartInstanceRef >
643
647
  > &
644
648
  LineChartAnnotationComponents;
645
649
 
646
- const LineChartWithProvider = forwardRef< SingleChartRef, LineChartProps >( ( props, ref ) => {
650
+ const LineChartWithProvider = forwardRef< ChartInstanceRef, LineChartProps >( ( props, ref ) => {
647
651
  const existingContext = useContext( GlobalChartsContext );
648
652
 
649
653
  // If we're already in a GlobalChartsProvider context, render the core component directly
@@ -9,7 +9,7 @@ import {
9
9
  import { DataContext } from '@visx/xychart';
10
10
  import merge from 'deepmerge';
11
11
  import { useContext, useRef, useEffect, useState, useMemo } from 'react';
12
- import { useGlobalChartsTheme } from '../../../providers';
12
+ import { useChartScopeElement, useGlobalChartsTheme } from '../../../providers';
13
13
  import { isSafari, resolveCssVariable } from '../../../utils';
14
14
  import LineChartAnnotationLabelWithPopover, {
15
15
  POPOVER_BUTTON_SIZE,
@@ -152,6 +152,7 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
152
152
  renderLabelPopover,
153
153
  } ) => {
154
154
  const providerTheme = useGlobalChartsTheme();
155
+ const scopeElement = useChartScopeElement();
155
156
  const { xScale, yScale } = useContext( DataContext ) || {};
156
157
  const labelRef = useRef< SVGGElement >( null );
157
158
  const [ height, setHeight ] = useState< number | null >( null );
@@ -159,10 +160,9 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
159
160
  // Deep merge styles to preserve nested object properties
160
161
  const styles = merge( providerTheme.annotationStyles ?? {}, datumStyles ?? {} );
161
162
 
162
- // visx annotation parts apply these colors as SVG presentation attributes,
163
- // where CSS var() cannot resolve. Resolve WPDS tokens to concrete values first.
163
+ // visx annotation parts apply these colours as SVG presentation attributes, where CSS var() cannot resolve. Resolve against the chart's scope element, never :root.
164
164
  const resolveColor = ( value?: string ): string | undefined =>
165
- value ? resolveCssVariable( value ) ?? value : value;
165
+ value ? resolveCssVariable( value, scopeElement ) ?? value : value;
166
166
 
167
167
  // Measure the label height once after initial render
168
168
  useEffect( () => {
@@ -1,6 +1,6 @@
1
1
  import { DataContext } from '@visx/xychart';
2
2
  import { useEffect, useState, useCallback } from 'react';
3
- import { useSingleChartContext } from '../../private/single-chart-context';
3
+ import { useChartInstanceContext } from '../../private/chart-instance-context';
4
4
  import styles from '../line-chart.module.scss';
5
5
  import type { AxisScale } from '@visx/axis';
6
6
  import type { FC, ReactNode } from 'react';
@@ -15,7 +15,7 @@ interface ScaleData {
15
15
  }
16
16
 
17
17
  const LineChartAnnotationsOverlay: FC< LineChartAnnotationsProps > = ( { children } ) => {
18
- const { chartRef, chartWidth, chartHeight } = useSingleChartContext();
18
+ const { chartRef, chartWidth, chartHeight } = useChartInstanceContext();
19
19
 
20
20
  const [ scales, setScales ] = useState< ScaleData | null >( null );
21
21
  const [ scalesStable, setScalesStable ] = useState< boolean >( false );
@@ -1,12 +1,14 @@
1
1
  /* eslint-disable react/jsx-no-bind */
2
2
 
3
- import { render, screen, waitFor, within } from '@testing-library/react';
3
+ import { render, screen, waitFor, within, act } from '@testing-library/react';
4
4
  import userEvent from '@testing-library/user-event';
5
5
  import { GlyphDiamond } from '@visx/glyph';
6
6
  import { createElement, createRef } from 'react';
7
7
  import { GlobalChartsProvider, defaultTheme } from '../../../providers';
8
+ import { useGlobalChartsContext } from '../../../providers/chart-context/hooks/use-global-charts-context';
8
9
  import LineChart, { LineChartUnresponsive } from '../line-chart';
9
- import type { SingleChartRef } from '../../private/single-chart-context';
10
+ import type { GlobalChartsContextValue } from '../../../providers/chart-context/types';
11
+ import type { ChartInstanceRef } from '../../private/chart-instance-context';
10
12
 
11
13
  // Mock useElementSize to return non-zero dimensions in jsdom so charts render
12
14
  const mockRefCallback = jest.fn();
@@ -902,7 +904,7 @@ describe( 'LineChart', () => {
902
904
 
903
905
  describe( 'Chart Ref Interface', () => {
904
906
  test( 'exposes getScales method via ref', () => {
905
- const ref = createRef< SingleChartRef >();
907
+ const ref = createRef< ChartInstanceRef >();
906
908
  renderUnwrappedWithTheme( {}, 'default', ref );
907
909
 
908
910
  expect( ref.current?.getScales() ).toBeDefined();
@@ -911,7 +913,7 @@ describe( 'LineChart', () => {
911
913
  } );
912
914
 
913
915
  test( 'exposes getChartDimensions method via ref', () => {
914
- const ref = createRef< SingleChartRef >();
916
+ const ref = createRef< ChartInstanceRef >();
915
917
  renderUnwrappedWithTheme( { width: 800, height: 400 }, 'default', ref );
916
918
 
917
919
  const dimensions = ref.current?.getChartDimensions();
@@ -1298,7 +1300,7 @@ describe( 'LineChart', () => {
1298
1300
  expect( buttons ).toHaveLength( 0 );
1299
1301
  } );
1300
1302
 
1301
- it( 'shows all series when chartId is missing even if legendInteractive is true', () => {
1303
+ it( 'shows all series when nothing has been hidden', () => {
1302
1304
  render(
1303
1305
  <GlobalChartsProvider>
1304
1306
  <LineChartUnresponsive
@@ -1317,6 +1319,136 @@ describe( 'LineChart', () => {
1317
1319
  expect( item ).toHaveAttribute( 'aria-pressed', 'true' );
1318
1320
  } );
1319
1321
  } );
1322
+
1323
+ it( 'hides a series programmatically when the legend is not interactive', () => {
1324
+ let context: GlobalChartsContextValue;
1325
+ const Grab = () => {
1326
+ context = useGlobalChartsContext();
1327
+ return null;
1328
+ };
1329
+
1330
+ render(
1331
+ <GlobalChartsProvider>
1332
+ <Grab />
1333
+ <LineChartUnresponsive
1334
+ width={ 500 }
1335
+ height={ 300 }
1336
+ withGradientFill={ false }
1337
+ showLegend={ true }
1338
+ legend={ { interactive: false } }
1339
+ chartId="test-programmatic-line"
1340
+ data={ [
1341
+ {
1342
+ label: 'Series A',
1343
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
1344
+ options: {},
1345
+ },
1346
+ {
1347
+ label: 'Series B',
1348
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
1349
+ options: {},
1350
+ },
1351
+ ] }
1352
+ />
1353
+ </GlobalChartsProvider>
1354
+ );
1355
+
1356
+ expect( screen.queryByText( /all series are hidden/i ) ).not.toBeInTheDocument();
1357
+
1358
+ act( () => {
1359
+ context.toggleSeriesVisibility( 'test-programmatic-line', 'Series A' );
1360
+ context.toggleSeriesVisibility( 'test-programmatic-line', 'Series B' );
1361
+ } );
1362
+
1363
+ expect( screen.getByText( /all series are hidden/i ) ).toBeInTheDocument();
1364
+ } );
1365
+
1366
+ it( 'omits the click instruction when the legend cannot be clicked', () => {
1367
+ let context: GlobalChartsContextValue;
1368
+ const Grab = () => {
1369
+ context = useGlobalChartsContext();
1370
+ return null;
1371
+ };
1372
+
1373
+ render(
1374
+ <GlobalChartsProvider>
1375
+ <Grab />
1376
+ <LineChartUnresponsive
1377
+ width={ 500 }
1378
+ height={ 300 }
1379
+ withGradientFill={ false }
1380
+ showLegend={ true }
1381
+ legend={ { interactive: false } }
1382
+ chartId="test-empty-copy-line"
1383
+ data={ [
1384
+ {
1385
+ label: 'Series A',
1386
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
1387
+ options: {},
1388
+ },
1389
+ ] }
1390
+ />
1391
+ </GlobalChartsProvider>
1392
+ );
1393
+
1394
+ act( () => {
1395
+ context.toggleSeriesVisibility( 'test-empty-copy-line', 'Series A' );
1396
+ } );
1397
+
1398
+ expect( screen.getByText( 'All series are hidden.' ) ).toBeInTheDocument();
1399
+ expect( screen.queryByText( /click legend items/i ) ).not.toBeInTheDocument();
1400
+ } );
1401
+
1402
+ it( 'pins the value axis across a programmatic hide when rescaleYOnVisibilityChange is false', () => {
1403
+ let context: GlobalChartsContextValue;
1404
+ const Grab = () => {
1405
+ context = useGlobalChartsContext();
1406
+ return null;
1407
+ };
1408
+ const ref = createRef< ChartInstanceRef >();
1409
+
1410
+ render(
1411
+ <GlobalChartsProvider>
1412
+ <Grab />
1413
+ <LineChartUnresponsive
1414
+ width={ 500 }
1415
+ height={ 300 }
1416
+ withGradientFill={ false }
1417
+ showLegend={ true }
1418
+ legend={ { interactive: false } }
1419
+ chartId="test-programmatic-pin-line"
1420
+ rescaleYOnVisibilityChange={ false }
1421
+ ref={ ref }
1422
+ data={ [
1423
+ {
1424
+ label: 'Series A',
1425
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
1426
+ options: {},
1427
+ },
1428
+ {
1429
+ label: 'Series B',
1430
+ data: [ { date: new Date( '2024-01-01' ), value: 200, label: 'Jan 1' } ],
1431
+ options: {},
1432
+ },
1433
+ ] }
1434
+ />
1435
+ </GlobalChartsProvider>
1436
+ );
1437
+
1438
+ const before = (
1439
+ ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
1440
+ )?.domain();
1441
+ expect( before ).toBeDefined();
1442
+
1443
+ act( () => {
1444
+ context.toggleSeriesVisibility( 'test-programmatic-pin-line', 'Series B' );
1445
+ } );
1446
+
1447
+ const after = (
1448
+ ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
1449
+ )?.domain();
1450
+ expect( after ).toEqual( before );
1451
+ } );
1320
1452
  } );
1321
1453
 
1322
1454
  // The line is not animated, so it clips only while actually zoomed.
@@ -1523,4 +1655,129 @@ describe( 'LineChart', () => {
1523
1655
  ).toBeInTheDocument();
1524
1656
  } );
1525
1657
  } );
1658
+
1659
+ describe( 'defaultHiddenSeries', () => {
1660
+ it( 'renders a series hidden when named in defaultHiddenSeries', () => {
1661
+ render(
1662
+ <GlobalChartsProvider>
1663
+ <LineChartUnresponsive
1664
+ width={ 500 }
1665
+ height={ 300 }
1666
+ withGradientFill={ false }
1667
+ showLegend={ true }
1668
+ legend={ { interactive: true } }
1669
+ chartId="test-default-hidden-line"
1670
+ defaultHiddenSeries={ [ 'Series B' ] }
1671
+ data={ [
1672
+ {
1673
+ label: 'Series A',
1674
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
1675
+ options: {},
1676
+ },
1677
+ {
1678
+ label: 'Series B',
1679
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
1680
+ options: {},
1681
+ },
1682
+ ] }
1683
+ />
1684
+ </GlobalChartsProvider>
1685
+ );
1686
+
1687
+ const items = screen.getAllByRole( 'button' );
1688
+ expect( items[ 0 ] ).toHaveAttribute( 'aria-pressed', 'true' );
1689
+ expect( items[ 1 ] ).toHaveAttribute( 'aria-pressed', 'false' );
1690
+ } );
1691
+
1692
+ it( 'lets the user reveal a series seeded hidden', async () => {
1693
+ const user = userEvent.setup();
1694
+
1695
+ render(
1696
+ <GlobalChartsProvider>
1697
+ <LineChartUnresponsive
1698
+ width={ 500 }
1699
+ height={ 300 }
1700
+ withGradientFill={ false }
1701
+ showLegend={ true }
1702
+ legend={ { interactive: true } }
1703
+ chartId="test-default-hidden-reveal-line"
1704
+ defaultHiddenSeries={ [ 'Series B' ] }
1705
+ data={ [
1706
+ {
1707
+ label: 'Series A',
1708
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
1709
+ options: {},
1710
+ },
1711
+ {
1712
+ label: 'Series B',
1713
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
1714
+ options: {},
1715
+ },
1716
+ ] }
1717
+ />
1718
+ </GlobalChartsProvider>
1719
+ );
1720
+
1721
+ await user.click( screen.getAllByRole( 'button' )[ 1 ] );
1722
+
1723
+ expect( screen.getAllByRole( 'button' )[ 1 ] ).toHaveAttribute( 'aria-pressed', 'true' );
1724
+ } );
1725
+
1726
+ it( 'keeps a revealed series visible after a data change', async () => {
1727
+ // Matters specifically at chart level: useChartRegistration unregisters and
1728
+ // re-registers whenever legendItems change, which a data prop change triggers.
1729
+ // A hook-level harness has no registration at all, so it can't catch a
1730
+ // regression where re-registration re-triggers the seeding effect.
1731
+ const user = userEvent.setup();
1732
+ const chartId = 'test-default-hidden-data-change-line';
1733
+
1734
+ const makeData = ( seriesAValue: number ) => [
1735
+ {
1736
+ label: 'Series A',
1737
+ data: [ { date: new Date( '2024-01-01' ), value: seriesAValue, label: 'Jan 1' } ],
1738
+ options: {},
1739
+ },
1740
+ {
1741
+ label: 'Series B',
1742
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
1743
+ options: {},
1744
+ },
1745
+ ];
1746
+
1747
+ const { rerender } = render(
1748
+ <GlobalChartsProvider>
1749
+ <LineChartUnresponsive
1750
+ width={ 500 }
1751
+ height={ 300 }
1752
+ withGradientFill={ false }
1753
+ showLegend={ true }
1754
+ legend={ { interactive: true } }
1755
+ chartId={ chartId }
1756
+ defaultHiddenSeries={ [ 'Series B' ] }
1757
+ data={ makeData( 10 ) }
1758
+ />
1759
+ </GlobalChartsProvider>
1760
+ );
1761
+
1762
+ await user.click( screen.getAllByRole( 'button' )[ 1 ] );
1763
+ expect( screen.getAllByRole( 'button' )[ 1 ] ).toHaveAttribute( 'aria-pressed', 'true' );
1764
+
1765
+ rerender(
1766
+ <GlobalChartsProvider>
1767
+ <LineChartUnresponsive
1768
+ width={ 500 }
1769
+ height={ 300 }
1770
+ withGradientFill={ false }
1771
+ showLegend={ true }
1772
+ legend={ { interactive: true } }
1773
+ chartId={ chartId }
1774
+ defaultHiddenSeries={ [ 'Series B' ] }
1775
+ data={ makeData( 30 ) }
1776
+ />
1777
+ </GlobalChartsProvider>
1778
+ );
1779
+
1780
+ expect( screen.getAllByRole( 'button' )[ 1 ] ).toHaveAttribute( 'aria-pressed', 'true' );
1781
+ } );
1782
+ } );
1526
1783
  } );
@@ -3,6 +3,7 @@ import type {
3
3
  DataPointDate,
4
4
  SeriesData,
5
5
  SeriesChartLegendConfig,
6
+ SeriesVisibilityProps,
6
7
  AnnotationStyles,
7
8
  DataPoint,
8
9
  } from '../../types';
@@ -28,7 +29,7 @@ export type RenderLineGlyphProps< Datum extends object > = GlyphProps< Datum > &
28
29
  position?: 'start' | 'end';
29
30
  };
30
31
 
31
- export interface LineChartProps extends BaseChartProps< SeriesData[] > {
32
+ export interface LineChartProps extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps {
32
33
  /**
33
34
  * Legend configuration. Supports `collapseGroups` on top of the shared options.
34
35
  */
@@ -53,8 +54,8 @@ export interface LineChartProps extends BaseChartProps< SeriesData[] > {
53
54
  */
54
55
  zoomable?: boolean;
55
56
  /**
56
- * Whether the Y axis rescales to fit only the visible series when series are
57
- * hidden or shown through the interactive legend.
57
+ * Whether the Y axis rescales to fit only the visible series when the set of
58
+ * visible series changes — via the interactive legend or otherwise.
58
59
  * Defaults to `true` (the pre-existing behaviour). Set to `false` to pin the Y
59
60
  * axis to the full data extent so hiding series does not move the chart's
60
61
  * baseline — useful for comparison charts. Matches `AreaChart`.
@@ -1,14 +1,13 @@
1
1
  import { Group } from '@visx/group';
2
2
  import { Pie } from '@visx/shape';
3
3
  import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
4
- import { __ } from '@wordpress/i18n';
5
4
  import clsx from 'clsx';
6
5
  import { useCallback, useContext, useMemo } from 'react';
7
6
  import { Legend, useChartLegendItems } from '../../components/legend';
8
7
  import { BaseTooltip } from '../../components/tooltip';
9
8
  import {
10
9
  useDataWithPercentages,
11
- useInteractiveLegendData,
10
+ useLegendVisibilityData,
12
11
  usePrefersReducedMotion,
13
12
  } from '../../hooks';
14
13
  import {
@@ -19,14 +18,15 @@ import {
19
18
  useGlobalChartsTheme,
20
19
  GlobalChartsContext,
21
20
  } from '../../providers';
21
+ import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
22
22
  import { attachSubComponents, resolveFontSize } from '../../utils';
23
23
  import { getStringWidth } from '../../visx/text';
24
24
  import { Center } from '../private/center';
25
25
  import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composition';
26
+ import { ChartInstanceContext } from '../private/chart-instance-context';
26
27
  import { ChartLayout } from '../private/chart-layout';
27
28
  import { RadialWipeAnimation } from '../private/radial-wipe-animation/';
28
- import { SingleChartContext } from '../private/single-chart-context';
29
- import { SvgEmptyState } from '../private/svg-empty-state';
29
+ import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
30
30
  import { withResponsive, ResponsiveConfig } from '../private/with-responsive';
31
31
  import styles from './pie-chart.module.scss';
32
32
  import type { LegendValueDisplay } from '../../components/legend';
@@ -217,11 +217,10 @@ const PieChartInternal = ( {
217
217
  // Calculate percentages from values (single source of truth)
218
218
  const dataWithPercentages = useDataWithPercentages( data );
219
219
 
220
- // Filter and recalculate data for interactive legends
221
- const { visibleData, allSegmentsHidden, legendData } = useInteractiveLegendData( {
220
+ // Filter and recalculate data from the shared legend visibility state.
221
+ const { visibleData, allSegmentsHidden, legendData } = useLegendVisibilityData( {
222
222
  data: dataWithPercentages,
223
223
  chartId,
224
- legendInteractive,
225
224
  isSeriesVisible,
226
225
  } );
227
226
 
@@ -307,7 +306,7 @@ const PieChartInternal = ( {
307
306
  );
308
307
 
309
308
  return (
310
- <SingleChartContext.Provider value={ { chartId } }>
309
+ <ChartInstanceContext.Provider value={ { chartId } }>
311
310
  <ChartLayout
312
311
  legendPosition={ legendPosition }
313
312
  legendElement={ legendElement }
@@ -328,7 +327,9 @@ const PieChartInternal = ( {
328
327
  <>
329
328
  { withTooltips && tooltipOpen && tooltipData && (
330
329
  <TooltipInPortal top={ tooltipTop || 0 } left={ tooltipLeft || 0 }>
331
- <div role="tooltip">{ renderTooltip( { tooltipData } ) }</div>
330
+ <div className={ CHART_SCOPE_CLASS } role="tooltip">
331
+ { renderTooltip( { tooltipData } ) }
332
+ </div>
332
333
  </TooltipInPortal>
333
334
  ) }
334
335
  { htmlChildren }
@@ -382,10 +383,7 @@ const PieChartInternal = ( {
382
383
  >
383
384
  { allSegmentsHidden ? (
384
385
  <SvgEmptyState x={ 0 } y={ 0 } width={ width } height={ height }>
385
- { __(
386
- 'All segments are hidden. Click legend items to show data.',
387
- 'jetpack-charts'
388
- ) }
386
+ { getAllHiddenMessage( legendInteractive, 'segments' ) }
389
387
  </SvgEmptyState>
390
388
  ) : (
391
389
  <Pie< DataPointPercentageCalculated & { index: number } >
@@ -492,7 +490,7 @@ const PieChartInternal = ( {
492
490
  );
493
491
  } }
494
492
  </ChartLayout>
495
- </SingleChartContext.Provider>
493
+ </ChartInstanceContext.Provider>
496
494
  );
497
495
  };
498
496