@automattic/charts 3.0.0 → 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 (62) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/TOKENS.md +3 -3
  3. package/dist/index.cjs +235 -123
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +23 -14
  6. package/dist/index.d.cts +51 -14
  7. package/dist/index.d.ts +51 -14
  8. package/dist/index.js +236 -125
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/area-chart/area-chart.tsx +24 -24
  12. package/src/charts/area-chart/private/overlays.tsx +3 -3
  13. package/src/charts/area-chart/test/area-chart.test.tsx +301 -11
  14. package/src/charts/area-chart/types.ts +4 -3
  15. package/src/charts/bar-chart/bar-chart.tsx +21 -15
  16. package/src/charts/bar-chart/test/bar-chart.test.tsx +255 -2
  17. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +6 -6
  18. package/src/charts/geo-chart/geo-chart.module.scss +8 -0
  19. package/src/charts/heatmap-chart/heatmap-chart.module.scss +9 -0
  20. package/src/charts/heatmap-chart/heatmap-chart.tsx +31 -8
  21. package/src/charts/heatmap-chart/private/build-calendar-data.ts +65 -5
  22. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +125 -0
  23. package/src/charts/heatmap-chart/types.ts +8 -0
  24. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +17 -22
  25. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +107 -1
  26. package/src/charts/line-chart/line-chart.module.scss +2 -2
  27. package/src/charts/line-chart/line-chart.tsx +31 -27
  28. package/src/charts/line-chart/private/line-chart-annotations-overlay.tsx +2 -2
  29. package/src/charts/line-chart/test/line-chart.test.tsx +262 -5
  30. package/src/charts/line-chart/types.ts +4 -3
  31. package/src/charts/pie-chart/pie-chart.tsx +8 -13
  32. package/src/charts/pie-chart/test/pie-chart.test.tsx +73 -1
  33. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +2 -2
  34. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +8 -13
  35. package/src/charts/private/{single-chart-context/single-chart-context.tsx → chart-instance-context/chart-instance-context.ts} +1 -5
  36. package/src/charts/private/chart-instance-context/index.ts +3 -0
  37. package/src/charts/private/{single-chart-context/use-single-chart-context.ts → chart-instance-context/use-chart-instance-context.ts} +1 -3
  38. package/src/charts/private/svg-empty-state/get-all-hidden-message.ts +41 -0
  39. package/src/charts/private/svg-empty-state/index.ts +1 -0
  40. package/src/charts/private/svg-empty-state/test/get-all-hidden-message.test.ts +15 -0
  41. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +2 -2
  42. package/src/charts/private/x-zoom/x-zoom.tsx +2 -2
  43. package/src/components/legend/legend.tsx +4 -4
  44. package/src/components/legend/private/base-legend.module.scss +1 -1
  45. package/src/components/legend/private/base-legend.tsx +67 -18
  46. package/src/components/legend/test/legend.test.tsx +100 -4
  47. package/src/components/tooltip/accessible-tooltip.tsx +13 -2
  48. package/src/components/trend-indicator/trend-indicator.module.scss +1 -1
  49. package/src/hooks/index.ts +1 -1
  50. package/src/hooks/{use-interactive-legend-data.ts → use-legend-visibility-data.ts} +29 -30
  51. package/src/index.ts +3 -1
  52. package/src/providers/chart-context/global-charts-provider.tsx +92 -18
  53. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +47 -0
  54. package/src/providers/chart-context/test/chart-context.test.tsx +142 -0
  55. package/src/providers/chart-context/test/use-default-hidden-series-paint.test.tsx +56 -0
  56. package/src/providers/chart-context/test/use-default-hidden-series.ssr.test.tsx +39 -0
  57. package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +222 -0
  58. package/src/providers/chart-context/types.ts +7 -1
  59. package/src/styles/chart-scope.scss +2 -2
  60. package/src/styles/test/wpds-fallbacks.test.ts +131 -0
  61. package/src/types.ts +12 -0
  62. package/src/charts/private/single-chart-context/index.ts +0 -8
@@ -142,3 +142,128 @@ describe( 'buildCalendarHeatmapData', () => {
142
142
  expect( rowLabels[ 4 ] ).toBe( 'Thu' );
143
143
  } );
144
144
  } );
145
+
146
+ describe( 'buildCalendarHeatmapData with a grid wider than the series', () => {
147
+ // Mon Jan 1 2024 through Sun Jan 7 — one whole week of data.
148
+ const oneWeek: DataPointDate[] = [
149
+ { dateString: '2024-01-01', value: 3 },
150
+ { dateString: '2024-01-07', value: 4 },
151
+ ];
152
+
153
+ test( 'draws the extra weeks and marks them placeholders, not blank cells', () => {
154
+ const { data } = buildCalendarHeatmapData( oneWeek, {
155
+ weekStartsOn: 1,
156
+ gridSpan: { start: '2023-12-04' }, // 4 weeks earlier
157
+ } );
158
+
159
+ expect( data ).toHaveLength( 5 );
160
+
161
+ // The filler weeks stand for days nothing was measured for.
162
+ expect( data[ 0 ].data.every( cell => cell.placeholder === true ) ).toBe( true );
163
+ expect( data[ 3 ].data.every( cell => cell.placeholder === true ) ).toBe( true );
164
+
165
+ // The data week is untouched: real values, no placeholder flag.
166
+ expect( data[ 4 ].data[ 0 ] ).toMatchObject( { value: 3 } );
167
+ expect( data[ 4 ].data[ 0 ].placeholder ).toBeUndefined();
168
+ expect( data[ 4 ].data[ 6 ] ).toMatchObject( { value: 4 } );
169
+ } );
170
+
171
+ test( 'labels the filler columns so the grid still reads as a calendar', () => {
172
+ const { data } = buildCalendarHeatmapData( oneWeek, {
173
+ weekStartsOn: 1,
174
+ gridSpan: { start: '2023-12-04' },
175
+ } );
176
+
177
+ expect( data.map( column => column.label ) ).toEqual( [ 'Dec', '', '', '', 'Jan' ] );
178
+ } );
179
+
180
+ test( 'ignores a bound that would cut into the series', () => {
181
+ const { data } = buildCalendarHeatmapData( oneWeek, {
182
+ weekStartsOn: 1,
183
+ gridSpan: { start: '2024-01-04', end: '2024-01-05' },
184
+ } );
185
+
186
+ expect( data ).toHaveLength( 1 );
187
+ expect( data[ 0 ].data[ 0 ].value ).toBe( 3 );
188
+ expect( data[ 0 ].data[ 6 ].value ).toBe( 4 );
189
+ } );
190
+
191
+ test( 'falls back to the series span for an unparseable bound', () => {
192
+ const { data } = buildCalendarHeatmapData( oneWeek, {
193
+ weekStartsOn: 1,
194
+ gridSpan: { start: 'not-a-date' },
195
+ } );
196
+
197
+ expect( data ).toHaveLength( 1 );
198
+ } );
199
+
200
+ test( 'separates the ragged edge from the filler', () => {
201
+ // The grid opens Mon Dec 25 and closes with the series on Wed Jan 3, so
202
+ // Thu Jan 4 onward only completes the last week.
203
+ const { data } = buildCalendarHeatmapData( [ { dateString: '2024-01-03', value: 5 } ], {
204
+ weekStartsOn: 1,
205
+ gridSpan: { start: '2023-12-25' },
206
+ } );
207
+
208
+ const lastColumn = data[ data.length - 1 ];
209
+
210
+ // Inside the requested grid, before the data: filler.
211
+ expect( lastColumn.data[ 0 ].placeholder ).toBe( true );
212
+ expect( lastColumn.data[ 0 ].hidden ).toBeUndefined();
213
+
214
+ expect( lastColumn.data[ 2 ].value ).toBe( 5 );
215
+
216
+ // Past the grid's end: the calendar's ragged edge, painted as nothing.
217
+ expect( lastColumn.data[ 3 ].hidden ).toBe( true );
218
+ expect( lastColumn.data[ 3 ].placeholder ).toBeUndefined();
219
+ } );
220
+
221
+ test( 'is a no-op when no grid span is given', () => {
222
+ expect( buildCalendarHeatmapData( oneWeek, { weekStartsOn: 1 } ) ).toEqual(
223
+ buildCalendarHeatmapData( oneWeek, { weekStartsOn: 1, gridSpan: {} } )
224
+ );
225
+ } );
226
+
227
+ // The caller sizes the grid in whole weeks back from the period's last day, so
228
+ // the bound lands on that weekday — a Monday only one year in seven.
229
+ test( 'opens on a whole column when the start bound falls mid-week', () => {
230
+ const { data } = buildCalendarHeatmapData( oneWeek, {
231
+ weekStartsOn: 1,
232
+ gridSpan: { start: '2023-12-07' }, // a Thursday
233
+ } );
234
+
235
+ expect( data ).toHaveLength( 5 );
236
+
237
+ // Mon Dec 4 through Wed Dec 6 precede the bound; they are as unmeasured as
238
+ // the rest of the filler, so they fill the column rather than notching it.
239
+ expect( data[ 0 ].data.every( cell => cell.placeholder === true ) ).toBe( true );
240
+ expect( data[ 0 ].data.some( cell => cell.hidden ) ).toBe( false );
241
+ } );
242
+
243
+ test( 'still draws the ragged edge past the grid end', () => {
244
+ // Wed Jan 3 ends the series, so Thu Jan 4 onward only completes the week.
245
+ const { data } = buildCalendarHeatmapData( [ { dateString: '2024-01-03', value: 5 } ], {
246
+ weekStartsOn: 1,
247
+ gridSpan: { start: '2023-12-20' }, // a Wednesday
248
+ } );
249
+
250
+ expect( data[ 0 ].data.every( cell => cell.placeholder === true ) ).toBe( true );
251
+
252
+ const lastColumn = data[ data.length - 1 ];
253
+ expect( lastColumn.data[ 2 ].value ).toBe( 5 );
254
+ expect( lastColumn.data[ 3 ].hidden ).toBe( true );
255
+ } );
256
+
257
+ test( 'keeps the ragged start edge when the bound is ignored', () => {
258
+ // A narrowing bound never applies, so the series' own first week stays
259
+ // ragged rather than being filled out.
260
+ const { data } = buildCalendarHeatmapData( [ { dateString: '2024-01-03', value: 5 } ], {
261
+ weekStartsOn: 1,
262
+ gridSpan: { start: '2024-01-05' },
263
+ } );
264
+
265
+ expect( data ).toHaveLength( 1 );
266
+ expect( data[ 0 ].data[ 0 ].hidden ).toBe( true );
267
+ expect( data[ 0 ].data[ 0 ].placeholder ).toBeUndefined();
268
+ } );
269
+ } );
@@ -13,6 +13,14 @@ export type HeatmapCell = {
13
13
  * days completing the first/last week fall outside the covered range.
14
14
  */
15
15
  hidden?: boolean;
16
+ /**
17
+ * Paint the cell as a faded empty slot that carries no claim about the day:
18
+ * it is set apart from a measured day that scored zero, and skipped by
19
+ * hover, tooltips, keyboard navigation and the accessibility tree. For grid
20
+ * filler — slots drawn only so a short range still fills its container,
21
+ * which were never measured.
22
+ */
23
+ placeholder?: boolean;
16
24
  };
17
25
 
18
26
  /** A heatmap column (rendered left→right); its cells render top→bottom. */
@@ -17,8 +17,9 @@ import {
17
17
  } from '../../providers';
18
18
  import { formatMetricValue, attachSubComponents } from '../../utils';
19
19
  import { useChartChildren } from '../private/chart-composition';
20
+ import { ChartInstanceContext } from '../private/chart-instance-context';
20
21
  import { ChartLayout } from '../private/chart-layout';
21
- import { SingleChartContext } from '../private/single-chart-context';
22
+ import { getAllHiddenMessage } from '../private/svg-empty-state';
22
23
  import { withResponsive } from '../private/with-responsive';
23
24
  import { useFittedRowCount, useLeaderboardLegendItems } from './hooks';
24
25
  import styles from './leaderboard-chart.module.scss';
@@ -225,35 +226,28 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
225
226
  legendLabels,
226
227
  } );
227
228
 
228
- // Track visibility of primary and comparison series for interactive legends
229
+ // Track visibility of primary and comparison series from the shared legend state.
229
230
  const isPrimaryVisible = useMemo( () => {
230
- if ( ! chartId || ! legendInteractive || legendItems.length === 0 ) {
231
+ if ( legendItems.length === 0 ) {
231
232
  return true;
232
233
  }
233
234
  return isSeriesVisible( chartId, legendItems[ 0 ].label );
234
- }, [ chartId, legendInteractive, legendItems, isSeriesVisible ] );
235
+ }, [ chartId, legendItems, isSeriesVisible ] );
235
236
 
236
237
  const isComparisonVisible = useMemo( () => {
237
- if ( ! chartId || ! legendInteractive || legendItems.length < 2 ) {
238
+ if ( legendItems.length < 2 ) {
238
239
  return true;
239
240
  }
240
241
  return isSeriesVisible( chartId, legendItems[ 1 ].label );
241
- }, [ chartId, legendInteractive, legendItems, isSeriesVisible ] );
242
+ }, [ chartId, legendItems, isSeriesVisible ] );
242
243
 
243
244
  // Check if all series are hidden
244
245
  const allSeriesHidden = useMemo( () => {
245
- if ( ! legendInteractive ) return false;
246
246
  if ( withComparison && ! withOverlayLabel ) {
247
247
  return ! isPrimaryVisible && ! isComparisonVisible;
248
248
  }
249
249
  return ! isPrimaryVisible;
250
- }, [
251
- legendInteractive,
252
- isPrimaryVisible,
253
- isComparisonVisible,
254
- withComparison,
255
- withOverlayLabel,
256
- ] );
250
+ }, [ isPrimaryVisible, isComparisonVisible, withComparison, withOverlayLabel ] );
257
251
 
258
252
  // Validate data
259
253
  const isDataValid = Boolean( data && data.length > 0 );
@@ -278,9 +272,10 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
278
272
 
279
273
  const prefersReducedMotion = usePrefersReducedMotion();
280
274
 
281
- // There are no rows to measure while an interactive legend has hidden every
282
- // series. Pausing fitting restores the full row count and, when a series is shown
283
- // again, re-runs the effect against the newly mounted grid.
275
+ // There are no rows to measure while every series is hidden, whether from an
276
+ // interactive legend click or a programmatic toggle. Pausing fitting restores
277
+ // the full row count and, when a series is shown again, re-runs the effect
278
+ // against the newly mounted grid.
284
279
  const { contentRef, fittedCount, isMeasurable } = useFittedRowCount(
285
280
  fitRows && ! allSeriesHidden,
286
281
  data?.length ?? 0,
@@ -291,7 +286,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
291
286
  // Handle empty or undefined data
292
287
  if ( ! data || data.length === 0 ) {
293
288
  return (
294
- <SingleChartContext.Provider value={ { chartId } }>
289
+ <ChartInstanceContext.Provider value={ { chartId } }>
295
290
  <ChartLayout
296
291
  legendPosition={ legendPosition }
297
292
  legendElement={ false }
@@ -315,7 +310,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
315
310
  : __( 'No data available', 'jetpack-charts' ) }
316
311
  </div>
317
312
  </ChartLayout>
318
- </SingleChartContext.Provider>
313
+ </ChartInstanceContext.Provider>
319
314
  );
320
315
  }
321
316
 
@@ -335,7 +330,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
335
330
  );
336
331
 
337
332
  return (
338
- <SingleChartContext.Provider value={ { chartId } }>
333
+ <ChartInstanceContext.Provider value={ { chartId } }>
339
334
  <ChartLayout
340
335
  legendPosition={ legendPosition }
341
336
  legendElement={ legendElement }
@@ -372,7 +367,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
372
367
  ) }
373
368
  { allSeriesHidden ? (
374
369
  <div className={ styles.emptyState }>
375
- { __( 'All series are hidden. Click legend items to show data.', 'jetpack-charts' ) }
370
+ { getAllHiddenMessage( legendInteractive, 'series' ) }
376
371
  </div>
377
372
  ) : (
378
373
  <Grid
@@ -476,7 +471,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
476
471
  ) }
477
472
  </div>
478
473
  </ChartLayout>
479
- </SingleChartContext.Provider>
474
+ </ChartInstanceContext.Provider>
480
475
  );
481
476
  };
482
477
 
@@ -1,6 +1,9 @@
1
- import { render, screen } from '@testing-library/react';
1
+ import { render, screen, act } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
+ import { GlobalChartsProvider } from '../../../providers';
4
+ import { useGlobalChartsContext } from '../../../providers/chart-context/hooks/use-global-charts-context';
3
5
  import LeaderboardChart from '../leaderboard-chart';
6
+ import type { GlobalChartsContextValue } from '../../../providers/chart-context/types';
4
7
  import type { LeaderboardEntry } from '../../../types';
5
8
 
6
9
  const mockDefaultParentSize = () => ( {
@@ -418,6 +421,109 @@ describe( 'LeaderboardChart', () => {
418
421
  } );
419
422
  } );
420
423
 
424
+ describe( 'Programmatic visibility', () => {
425
+ it( 'hides the primary series programmatically when the legend is not interactive', () => {
426
+ let context: GlobalChartsContextValue;
427
+ const Grab = () => {
428
+ context = useGlobalChartsContext();
429
+ return null;
430
+ };
431
+
432
+ render(
433
+ <GlobalChartsProvider>
434
+ <Grab />
435
+ <LeaderboardChart
436
+ width={ 500 }
437
+ height={ 300 }
438
+ showLegend={ true }
439
+ legend={ { interactive: false } }
440
+ chartId="test-programmatic-leaderboard"
441
+ data={ mockData }
442
+ />
443
+ </GlobalChartsProvider>
444
+ );
445
+
446
+ const primaryLabel = screen.getAllByTestId( 'legend-item' )[ 0 ].textContent;
447
+
448
+ act( () => {
449
+ context.toggleSeriesVisibility( 'test-programmatic-leaderboard', primaryLabel );
450
+ } );
451
+
452
+ expect( screen.getByText( /all series are hidden/i ) ).toBeInTheDocument();
453
+ } );
454
+
455
+ it( 'omits the click instruction when the legend cannot be clicked', () => {
456
+ let context: GlobalChartsContextValue;
457
+ const Grab = () => {
458
+ context = useGlobalChartsContext();
459
+ return null;
460
+ };
461
+
462
+ render(
463
+ <GlobalChartsProvider>
464
+ <Grab />
465
+ <LeaderboardChart
466
+ width={ 500 }
467
+ height={ 300 }
468
+ showLegend={ true }
469
+ legend={ { interactive: false } }
470
+ chartId="test-empty-copy-leaderboard"
471
+ data={ mockData }
472
+ />
473
+ </GlobalChartsProvider>
474
+ );
475
+
476
+ const primaryLabel = screen.getAllByTestId( 'legend-item' )[ 0 ].textContent;
477
+
478
+ act( () => {
479
+ context.toggleSeriesVisibility( 'test-empty-copy-leaderboard', primaryLabel );
480
+ } );
481
+
482
+ expect( screen.getByText( 'All series are hidden.' ) ).toBeInTheDocument();
483
+ expect( screen.queryByText( /click legend items/i ) ).not.toBeInTheDocument();
484
+ } );
485
+
486
+ it( 'still renders the primary series when only the comparison is hidden programmatically', () => {
487
+ // Hiding every series routes through allSeriesHidden to the empty state and
488
+ // never exercises the per-series render path. Hiding only the comparison
489
+ // series exercises that path for the leaderboard's primary/comparison split.
490
+ let context: GlobalChartsContextValue;
491
+ const Grab = () => {
492
+ context = useGlobalChartsContext();
493
+ return null;
494
+ };
495
+
496
+ render(
497
+ <GlobalChartsProvider>
498
+ <Grab />
499
+ <LeaderboardChart
500
+ width={ 500 }
501
+ height={ 300 }
502
+ showLegend={ true }
503
+ withComparison={ true }
504
+ legend={ { interactive: false } }
505
+ chartId="test-programmatic-comparison-leaderboard"
506
+ data={ mockData }
507
+ />
508
+ </GlobalChartsProvider>
509
+ );
510
+
511
+ const comparisonLabel = screen.getAllByTestId( 'legend-item' )[ 1 ].textContent;
512
+
513
+ act( () => {
514
+ context.toggleSeriesVisibility(
515
+ 'test-programmatic-comparison-leaderboard',
516
+ comparisonLabel
517
+ );
518
+ } );
519
+
520
+ expect( screen.queryByText( /all series are hidden/i ) ).not.toBeInTheDocument();
521
+ expect( screen.getByText( 'Direct' ) ).toBeInTheDocument();
522
+ expect( screen.getByText( '12.5K' ) ).toBeInTheDocument();
523
+ expect( screen.queryByText( '+25%' ) ).not.toBeInTheDocument();
524
+ } );
525
+ } );
526
+
421
527
  describe( 'Responsive wrapper', () => {
422
528
  it( 'applies explicit width and height to chart container', () => {
423
529
  const { useParentSize } = jest.requireMock( '@visx/responsive' );
@@ -25,7 +25,7 @@
25
25
  }
26
26
 
27
27
  &__tooltip-date {
28
- font-weight: var(--wpds-typography-font-weight-emphasis, 499);
28
+ font-weight: var(--wpds-typography-font-weight-emphasis, 600);
29
29
  padding-bottom: var(--wpds-dimension-padding-md, 12px);
30
30
  }
31
31
 
@@ -34,7 +34,7 @@
34
34
  }
35
35
 
36
36
  &__tooltip-label {
37
- font-weight: var(--wpds-typography-font-weight-emphasis, 499);
37
+ font-weight: var(--wpds-typography-font-weight-emphasis, 600);
38
38
  padding-right: var(--wpds-dimension-padding-lg, 16px);
39
39
  }
40
40
 
@@ -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',
@@ -196,10 +198,15 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
196
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
@@ -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 );