@automattic/charts 3.0.0 → 3.1.1

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 (63) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/TOKENS.md +3 -3
  3. package/dist/index.cjs +250 -136
  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 +251 -138
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/area-chart/area-chart.tsx +26 -26
  12. package/src/charts/area-chart/private/overlays.tsx +3 -3
  13. package/src/charts/area-chart/test/area-chart.test.tsx +346 -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/private/use-bar-chart-options.ts +19 -12
  17. package/src/charts/bar-chart/test/bar-chart.test.tsx +277 -2
  18. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +6 -6
  19. package/src/charts/geo-chart/geo-chart.module.scss +8 -0
  20. package/src/charts/heatmap-chart/heatmap-chart.module.scss +9 -0
  21. package/src/charts/heatmap-chart/heatmap-chart.tsx +31 -8
  22. package/src/charts/heatmap-chart/private/build-calendar-data.ts +65 -5
  23. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +125 -0
  24. package/src/charts/heatmap-chart/types.ts +8 -0
  25. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +17 -22
  26. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +107 -1
  27. package/src/charts/line-chart/line-chart.module.scss +2 -2
  28. package/src/charts/line-chart/line-chart.tsx +33 -29
  29. package/src/charts/line-chart/private/line-chart-annotations-overlay.tsx +2 -2
  30. package/src/charts/line-chart/test/line-chart.test.tsx +313 -5
  31. package/src/charts/line-chart/types.ts +4 -3
  32. package/src/charts/pie-chart/pie-chart.tsx +8 -13
  33. package/src/charts/pie-chart/test/pie-chart.test.tsx +73 -1
  34. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +2 -2
  35. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +8 -13
  36. package/src/charts/private/{single-chart-context/single-chart-context.tsx → chart-instance-context/chart-instance-context.ts} +1 -5
  37. package/src/charts/private/chart-instance-context/index.ts +3 -0
  38. package/src/charts/private/{single-chart-context/use-single-chart-context.ts → chart-instance-context/use-chart-instance-context.ts} +1 -3
  39. package/src/charts/private/svg-empty-state/get-all-hidden-message.ts +41 -0
  40. package/src/charts/private/svg-empty-state/index.ts +1 -0
  41. package/src/charts/private/svg-empty-state/test/get-all-hidden-message.test.ts +15 -0
  42. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +2 -2
  43. package/src/charts/private/x-zoom/x-zoom.tsx +2 -2
  44. package/src/components/legend/legend.tsx +4 -4
  45. package/src/components/legend/private/base-legend.module.scss +1 -1
  46. package/src/components/legend/private/base-legend.tsx +67 -18
  47. package/src/components/legend/test/legend.test.tsx +100 -4
  48. package/src/components/tooltip/accessible-tooltip.tsx +13 -2
  49. package/src/components/trend-indicator/trend-indicator.module.scss +1 -1
  50. package/src/hooks/index.ts +1 -1
  51. package/src/hooks/{use-interactive-legend-data.ts → use-legend-visibility-data.ts} +29 -30
  52. package/src/index.ts +3 -1
  53. package/src/providers/chart-context/global-charts-provider.tsx +92 -18
  54. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +47 -0
  55. package/src/providers/chart-context/test/chart-context.test.tsx +142 -0
  56. package/src/providers/chart-context/test/use-default-hidden-series-paint.test.tsx +56 -0
  57. package/src/providers/chart-context/test/use-default-hidden-series.ssr.test.tsx +39 -0
  58. package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +222 -0
  59. package/src/providers/chart-context/types.ts +7 -1
  60. package/src/styles/chart-scope.scss +2 -2
  61. package/src/styles/test/wpds-fallbacks.test.ts +131 -0
  62. package/src/types.ts +12 -0
  63. package/src/charts/private/single-chart-context/index.ts +0 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "3.0.0",
3
+ "version": "3.1.1",
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": {
@@ -27,12 +27,13 @@ import {
27
27
  useGlobalChartsContext,
28
28
  useGlobalChartsTheme,
29
29
  } from '../../providers';
30
+ import { useDefaultHiddenSeries } from '../../providers/chart-context/hooks/use-default-hidden-series';
30
31
  import { attachSubComponents } from '../../utils';
31
32
  import { renderDefaultTooltip } from '../line-chart';
32
33
  import { useChartChildren } from '../private/chart-composition';
34
+ import { ChartInstanceContext, type ChartInstanceRef } from '../private/chart-instance-context';
33
35
  import { ChartLayout } from '../private/chart-layout';
34
- import { SingleChartContext, type SingleChartRef } from '../private/single-chart-context';
35
- import { SvgEmptyState } from '../private/svg-empty-state';
36
+ import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
36
37
  import { getCurveType, getFormatter, guessOptimalNumTicks } from '../private/time-axis';
37
38
  import { withResponsive } from '../private/with-responsive';
38
39
  import { useXZoom, ZoomResetButton, ZoomSelectionRect, ZoomClip } from '../private/x-zoom';
@@ -43,7 +44,7 @@ import type { DataPointDate, Optional } from '../../types';
43
44
  import type { ResponsiveConfig } from '../private/with-responsive';
44
45
  import type { TickFormatter } from '@visx/axis';
45
46
 
46
- const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
47
+ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
47
48
  (
48
49
  {
49
50
  data,
@@ -72,6 +73,7 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
72
73
  zoomable = false,
73
74
  rescaleYOnVisibilityChange,
74
75
  rescaleYOnLegendToggle,
76
+ defaultHiddenSeries,
75
77
  children,
76
78
  gridVisibility,
77
79
  gap = 'md',
@@ -88,10 +90,15 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
88
90
  const providerTheme = useGlobalChartsTheme();
89
91
  const theme = useXYChartTheme( data );
90
92
  const chartId = useChartId( providedChartId );
93
+ const hiddenSeries = useDefaultHiddenSeries( chartId, defaultHiddenSeries );
94
+ const isSeriesVisible = useCallback(
95
+ ( seriesLabel: string ) => ! hiddenSeries.has( seriesLabel ),
96
+ [ hiddenSeries ]
97
+ );
91
98
  const chartRef = useRef< HTMLDivElement >( null );
92
99
  const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >( undefined );
93
100
  const [ isNavigating, setIsNavigating ] = useState( false );
94
- const internalChartRef = useRef< SingleChartRef >( null );
101
+ const internalChartRef = useRef< ChartInstanceRef >( null );
95
102
 
96
103
  const zoom = useXZoom< Date >( {
97
104
  enabled: zoomable,
@@ -121,18 +128,15 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
121
128
  );
122
129
 
123
130
  const dataSorted = useChartDataTransform( data );
124
- const { getElementStyles, isSeriesVisible } = useGlobalChartsContext();
131
+ const { getElementStyles } = useGlobalChartsContext();
125
132
 
126
133
  const seriesWithVisibility = useMemo( () => {
127
- if ( ! chartId || ! legendInteractive ) {
128
- return dataSorted.map( ( series, index ) => ( { series, index, isVisible: true } ) );
129
- }
130
134
  return dataSorted.map( ( series, index ) => ( {
131
135
  series,
132
136
  index,
133
- isVisible: isSeriesVisible( chartId, series.label ),
137
+ isVisible: ! hiddenSeries.has( series.label ),
134
138
  } ) );
135
- }, [ dataSorted, chartId, isSeriesVisible, legendInteractive ] );
139
+ }, [ dataSorted, hiddenSeries ] );
136
140
 
137
141
  const allSeriesHidden = useMemo(
138
142
  () => seriesWithVisibility.every( ( { isVisible } ) => ! isVisible ),
@@ -157,7 +161,6 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
157
161
  const fixedYDomain = useMemo< [ number, number ] | undefined >( () => {
158
162
  if (
159
163
  rescaleYOnVisibility ||
160
- ! legendInteractive ||
161
164
  ! dataSorted.length ||
162
165
  ! dataSorted[ 0 ].data.length ||
163
166
  ( stacked && stackOffset !== 'none' )
@@ -199,11 +202,11 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
199
202
  }
200
203
  if ( max === -Infinity ) return undefined;
201
204
  return [ Math.min( 0, min ), max ];
202
- }, [ dataSorted, stacked, stackOffset, legendInteractive, rescaleYOnVisibility ] );
205
+ }, [ dataSorted, stacked, stackOffset, rescaleYOnVisibility ] );
203
206
 
204
207
  const chartOptions = useMemo( () => {
205
- const { tickResolution, ...xAxisOptions } = options?.axis?.x ?? {};
206
- const formatter = xAxisOptions.tickFormat || getFormatter( dataSorted, tickResolution );
208
+ const { tickResolution, tickFormat, ...xAxisOptions } = options?.axis?.x ?? {};
209
+ const formatter = tickFormat || getFormatter( dataSorted, tickResolution );
207
210
 
208
211
  return {
209
212
  axis: {
@@ -283,7 +286,6 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
283
286
  );
284
287
  const filteredRenderTooltip = useCallback(
285
288
  ( params: Parameters< typeof renderTooltip >[ 0 ] ) => {
286
- if ( ! legendInteractive ) return renderTooltip( params );
287
289
  const datumByKey = params?.tooltipData?.datumByKey;
288
290
  if ( ! datumByKey ) return renderTooltip( params );
289
291
  const filtered = Object.fromEntries(
@@ -307,7 +309,7 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
307
309
  } as typeof params.tooltipData,
308
310
  } );
309
311
  },
310
- [ renderTooltip, legendInteractive, visibleLabels ]
312
+ [ renderTooltip, visibleLabels ]
311
313
  );
312
314
 
313
315
  // Defaults that depend on stacked vs overlapping mode.
@@ -354,7 +356,7 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
354
356
  dataKey={ seriesData?.label }
355
357
  data={ seriesData.data as DataPointDate[] }
356
358
  xAccessor={ accessors.xAccessor }
357
- yAccessor={ isVisible || ! legendInteractive ? accessors.yAccessor : zeroYAccessor }
359
+ yAccessor={ isVisible ? accessors.yAccessor : zeroYAccessor }
358
360
  fill={ color }
359
361
  fillOpacity={ resolvedFillOpacity }
360
362
  { ...( stacked ? {} : { renderLine: resolvedWithStroke, curve } ) }
@@ -365,10 +367,11 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
365
367
  };
366
368
 
367
369
  return (
368
- <SingleChartContext.Provider
370
+ <ChartInstanceContext.Provider
369
371
  value={ {
370
372
  chartId,
371
373
  chartRef: internalChartRef,
374
+ isSeriesVisible,
372
375
  chartWidth: width,
373
376
  chartHeight: measuredChartHeight || 0,
374
377
  } }
@@ -437,10 +440,7 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
437
440
  width={ width }
438
441
  height={ chartHeight }
439
442
  >
440
- { __(
441
- 'All series are hidden. Click legend items to show data.',
442
- 'jetpack-charts'
443
- ) }
443
+ { getAllHiddenMessage( legendInteractive, 'series' ) }
444
444
  </SvgEmptyState>
445
445
  ) : null }
446
446
 
@@ -503,7 +503,7 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
503
503
  );
504
504
  } }
505
505
  </ChartLayout>
506
- </SingleChartContext.Provider>
506
+ </ChartInstanceContext.Provider>
507
507
  );
508
508
  }
509
509
  );
@@ -515,16 +515,16 @@ type AreaChartSubComponents = {
515
515
  type AreaChartBaseProps = Optional< AreaChartProps, 'width' | 'height' | 'size' >;
516
516
 
517
517
  type AreaChartComponent = React.ForwardRefExoticComponent<
518
- AreaChartBaseProps & React.RefAttributes< SingleChartRef >
518
+ AreaChartBaseProps & React.RefAttributes< ChartInstanceRef >
519
519
  > &
520
520
  AreaChartSubComponents;
521
521
 
522
522
  type AreaChartResponsiveComponent = React.ForwardRefExoticComponent<
523
- AreaChartBaseProps & ResponsiveConfig & React.RefAttributes< SingleChartRef >
523
+ AreaChartBaseProps & ResponsiveConfig & React.RefAttributes< ChartInstanceRef >
524
524
  > &
525
525
  AreaChartSubComponents;
526
526
 
527
- const AreaChartWithProvider = forwardRef< SingleChartRef, AreaChartProps >( ( props, ref ) => {
527
+ const AreaChartWithProvider = forwardRef< ChartInstanceRef, AreaChartProps >( ( props, ref ) => {
528
528
  const existingContext = useContext( GlobalChartsContext );
529
529
 
530
530
  if ( existingContext ) {
@@ -2,7 +2,7 @@ import { DataContext, TooltipContext } from '@visx/xychart';
2
2
  import { useContext, useImperativeHandle } from 'react';
3
3
  import type { ElementStyles, GetElementStylesParams } from '../../../providers';
4
4
  import type { DataPointDate, SeriesData } from '../../../types';
5
- import type { SingleChartRef } from '../../private/single-chart-context';
5
+ import type { ChartInstanceRef } from '../../private/chart-instance-context';
6
6
  import type { FC, ReactNode, Ref } from 'react';
7
7
 
8
8
  export type VisibleSeriesEntry = { series: SeriesData; index: number; isVisible: boolean };
@@ -11,10 +11,10 @@ export type VisibleSeriesEntry = { series: SeriesData; index: number; isVisible:
11
11
  // instead of spreading `any`. `Number(...)` + `isFinite` guards every call.
12
12
  type ScaleFn = ( input: Date | number ) => number;
13
13
 
14
- // Bridges visx's `DataContext` to the chart's `SingleChartRef` so consumers
14
+ // Bridges visx's `DataContext` to the chart's `ChartInstanceRef` so consumers
15
15
  // can read scales and dimensions imperatively. Must be inside `<XYChart>`.
16
16
  export const AreaChartScalesRef: FC< {
17
- chartRef?: Ref< SingleChartRef >;
17
+ chartRef?: Ref< ChartInstanceRef >;
18
18
  width: number;
19
19
  height: number;
20
20
  margin?: { top?: number; right?: number; bottom?: number; left?: number };
@@ -1,9 +1,11 @@
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
3
  import { createRef } from 'react';
4
4
  import { GlobalChartsProvider } from '../../../providers';
5
+ import { useGlobalChartsContext } from '../../../providers/chart-context/hooks/use-global-charts-context';
5
6
  import AreaChart, { AreaChartUnresponsive } from '../area-chart';
6
- import type { SingleChartRef } from '../../private/single-chart-context';
7
+ import type { GlobalChartsContextValue } from '../../../providers/chart-context/types';
8
+ import type { ChartInstanceRef } from '../../private/chart-instance-context';
7
9
 
8
10
  const mockRefCallback = jest.fn();
9
11
  jest.mock( '../../../hooks/use-element-size', () => ( {
@@ -42,7 +44,7 @@ describe( 'AreaChart', () => {
42
44
  );
43
45
  };
44
46
 
45
- const renderUnresponsive = ( props = {}, ref?: React.Ref< SingleChartRef > ) => {
47
+ const renderUnresponsive = ( props = {}, ref?: React.Ref< ChartInstanceRef > ) => {
46
48
  return render(
47
49
  <GlobalChartsProvider>
48
50
  <AreaChartUnresponsive { ...defaultProps } { ...props } ref={ ref } />
@@ -110,6 +112,51 @@ describe( 'AreaChart', () => {
110
112
  } );
111
113
  } );
112
114
 
115
+ describe( 'X-Axis Ticks', () => {
116
+ const monthlySeries = [
117
+ {
118
+ label: 'Series A',
119
+ data: [
120
+ { date: new Date( '2024-01-01' ), value: 10 },
121
+ { date: new Date( '2024-04-01' ), value: 20 },
122
+ { date: new Date( '2024-07-01' ), value: 30 },
123
+ { date: new Date( '2024-10-01' ), value: 40 },
124
+ { date: new Date( '2025-03-01' ), value: 50 },
125
+ ],
126
+ },
127
+ ];
128
+
129
+ test( 'keeps the derived month formatter when tickFormat is passed as undefined', () => {
130
+ renderWithProvider( {
131
+ options: { axis: { x: { tickFormat: undefined } } },
132
+ data: monthlySeries,
133
+ } );
134
+
135
+ // January is absent: formatMonthOrYearTick renders it as the year instead.
136
+ const ticks = screen.getAllByText( /^(Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/ );
137
+ expect( ticks.length ).toBeGreaterThan( 1 );
138
+ expect(
139
+ screen.queryByText( /^(February|March|April|June|July|August|September|October)$/ )
140
+ ).not.toBeInTheDocument();
141
+ } );
142
+
143
+ test( 'honors an explicit tickFormat over the derived formatter', () => {
144
+ renderWithProvider( {
145
+ options: {
146
+ axis: {
147
+ x: {
148
+ tickFormat: ( date: Date | number ) =>
149
+ `tick-${ new Date( Number( date ) ).getUTCMonth() }`,
150
+ },
151
+ },
152
+ },
153
+ data: monthlySeries,
154
+ } );
155
+
156
+ expect( screen.getAllByText( /^tick-\d+$/ ).length ).toBeGreaterThan( 0 );
157
+ } );
158
+ } );
159
+
113
160
  describe( 'Stacking', () => {
114
161
  test( 'is stacked by default', () => {
115
162
  renderWithProvider();
@@ -242,7 +289,7 @@ describe( 'AreaChart', () => {
242
289
 
243
290
  test( 'y-axis rescales across legend toggles by default', async () => {
244
291
  const user = userEvent.setup();
245
- const ref = createRef< SingleChartRef >();
292
+ const ref = createRef< ChartInstanceRef >();
246
293
  render(
247
294
  <GlobalChartsProvider>
248
295
  <AreaChartUnresponsive
@@ -274,7 +321,7 @@ describe( 'AreaChart', () => {
274
321
  // Exercises the non-stacked branch of fixedYDomain, which scans the
275
322
  // raw min/max across all series rather than summing stack columns.
276
323
  const user = userEvent.setup();
277
- const ref = createRef< SingleChartRef >();
324
+ const ref = createRef< ChartInstanceRef >();
278
325
  render(
279
326
  <GlobalChartsProvider>
280
327
  <AreaChartUnresponsive
@@ -306,7 +353,7 @@ describe( 'AreaChart', () => {
306
353
 
307
354
  test( 'y-axis stays pinned when rescaleYOnLegendToggle is false', async () => {
308
355
  const user = userEvent.setup();
309
- const ref = createRef< SingleChartRef >();
356
+ const ref = createRef< ChartInstanceRef >();
310
357
  render(
311
358
  <GlobalChartsProvider>
312
359
  <AreaChartUnresponsive
@@ -335,7 +382,7 @@ describe( 'AreaChart', () => {
335
382
 
336
383
  test( 'y-axis stays pinned when rescaleYOnVisibilityChange is false', async () => {
337
384
  const user = userEvent.setup();
338
- const ref = createRef< SingleChartRef >();
385
+ const ref = createRef< ChartInstanceRef >();
339
386
  render(
340
387
  <GlobalChartsProvider>
341
388
  <AreaChartUnresponsive
@@ -362,12 +409,196 @@ describe( 'AreaChart', () => {
362
409
  expect( afterToggleDomain ).toEqual( initialDomain );
363
410
  } );
364
411
 
412
+ it( 'hides a series programmatically when the legend is not interactive', () => {
413
+ let context: GlobalChartsContextValue;
414
+ const Grab = () => {
415
+ context = useGlobalChartsContext();
416
+ return null;
417
+ };
418
+
419
+ render(
420
+ <GlobalChartsProvider>
421
+ <Grab />
422
+ <AreaChartUnresponsive
423
+ width={ 500 }
424
+ height={ 300 }
425
+ showLegend={ true }
426
+ legend={ { interactive: false } }
427
+ chartId="test-programmatic-area"
428
+ data={ [
429
+ {
430
+ label: 'Series A',
431
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
432
+ options: {},
433
+ },
434
+ {
435
+ label: 'Series B',
436
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
437
+ options: {},
438
+ },
439
+ ] }
440
+ />
441
+ </GlobalChartsProvider>
442
+ );
443
+
444
+ expect( screen.queryByText( /all series are hidden/i ) ).not.toBeInTheDocument();
445
+
446
+ act( () => {
447
+ context.toggleSeriesVisibility( 'test-programmatic-area', 'Series A' );
448
+ context.toggleSeriesVisibility( 'test-programmatic-area', 'Series B' );
449
+ } );
450
+
451
+ expect( screen.getByText( /all series are hidden/i ) ).toBeInTheDocument();
452
+ } );
453
+
454
+ it( 'omits the click instruction when the legend cannot be clicked', () => {
455
+ let context: GlobalChartsContextValue;
456
+ const Grab = () => {
457
+ context = useGlobalChartsContext();
458
+ return null;
459
+ };
460
+
461
+ render(
462
+ <GlobalChartsProvider>
463
+ <Grab />
464
+ <AreaChartUnresponsive
465
+ width={ 500 }
466
+ height={ 300 }
467
+ showLegend={ true }
468
+ legend={ { interactive: false } }
469
+ chartId="test-empty-copy-area"
470
+ data={ [
471
+ {
472
+ label: 'Series A',
473
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
474
+ options: {},
475
+ },
476
+ ] }
477
+ />
478
+ </GlobalChartsProvider>
479
+ );
480
+
481
+ act( () => {
482
+ context.toggleSeriesVisibility( 'test-empty-copy-area', 'Series A' );
483
+ } );
484
+
485
+ expect( screen.getByText( 'All series are hidden.' ) ).toBeInTheDocument();
486
+ expect( screen.queryByText( /click legend items/i ) ).not.toBeInTheDocument();
487
+ } );
488
+
489
+ it( 'pins the value axis across a programmatic hide when rescaleYOnVisibilityChange is false', () => {
490
+ let context: GlobalChartsContextValue;
491
+ const Grab = () => {
492
+ context = useGlobalChartsContext();
493
+ return null;
494
+ };
495
+ const ref = createRef< ChartInstanceRef >();
496
+
497
+ render(
498
+ <GlobalChartsProvider>
499
+ <Grab />
500
+ <AreaChartUnresponsive
501
+ width={ 500 }
502
+ height={ 300 }
503
+ showLegend={ true }
504
+ legend={ { interactive: false } }
505
+ chartId="test-programmatic-pin-area"
506
+ rescaleYOnVisibilityChange={ false }
507
+ ref={ ref }
508
+ data={ [
509
+ {
510
+ label: 'Series A',
511
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
512
+ options: {},
513
+ },
514
+ {
515
+ label: 'Series B',
516
+ data: [ { date: new Date( '2024-01-01' ), value: 200, label: 'Jan 1' } ],
517
+ options: {},
518
+ },
519
+ ] }
520
+ />
521
+ </GlobalChartsProvider>
522
+ );
523
+
524
+ const before = (
525
+ ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
526
+ )?.domain();
527
+ expect( before ).toBeDefined();
528
+
529
+ act( () => {
530
+ context.toggleSeriesVisibility( 'test-programmatic-pin-area', 'Series B' );
531
+ } );
532
+
533
+ const after = (
534
+ ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
535
+ )?.domain();
536
+ expect( after ).toEqual( before );
537
+ } );
538
+
539
+ it( 'collapses only the hidden series when the legend is not interactive', () => {
540
+ // With a non-interactive legend, `renderSeries` must still zero out a
541
+ // programmatically-hidden series via `yAccessor` — the same collapse the
542
+ // interactive legend gets — rather than rendering its real data. With the
543
+ // default `rescaleYOnVisibilityChange` (true), visx derives the y-scale
544
+ // domain from what's actually rendered, so hiding the taller of two
545
+ // series must shrink the domain if the collapse took effect.
546
+ let context: GlobalChartsContextValue;
547
+ const Grab = () => {
548
+ context = useGlobalChartsContext();
549
+ return null;
550
+ };
551
+ const ref = createRef< ChartInstanceRef >();
552
+
553
+ render(
554
+ <GlobalChartsProvider>
555
+ <Grab />
556
+ <AreaChartUnresponsive
557
+ width={ 500 }
558
+ height={ 300 }
559
+ showLegend={ true }
560
+ legend={ { interactive: false } }
561
+ chartId="test-programmatic-collapse-area"
562
+ ref={ ref }
563
+ data={ [
564
+ {
565
+ label: 'Series A',
566
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
567
+ options: {},
568
+ },
569
+ {
570
+ label: 'Series B',
571
+ data: [ { date: new Date( '2024-01-01' ), value: 200, label: 'Jan 1' } ],
572
+ options: {},
573
+ },
574
+ ] }
575
+ />
576
+ </GlobalChartsProvider>
577
+ );
578
+
579
+ const before = (
580
+ ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
581
+ )?.domain();
582
+ expect( before ).toBeDefined();
583
+ expect( before![ 1 ] ).toBeGreaterThanOrEqual( 200 );
584
+
585
+ act( () => {
586
+ context.toggleSeriesVisibility( 'test-programmatic-collapse-area', 'Series B' );
587
+ } );
588
+
589
+ const after = (
590
+ ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
591
+ )?.domain();
592
+ expect( after ).toBeDefined();
593
+ expect( after![ 1 ] ).toBeLessThan( before![ 1 ] );
594
+ } );
595
+
365
596
  test( 'supports negative stacked values without clipping (with pinned Y)', () => {
366
597
  // The mixed-sign full-extent pin only kicks in when the consumer
367
598
  // opts into pinned-Y behavior; visx's natural domain derivation for
368
599
  // a `stackOffset: 'none'` stack does not extend below zero for
369
600
  // purely-negative series, which is what this test guards against.
370
- const ref = createRef< SingleChartRef >();
601
+ const ref = createRef< ChartInstanceRef >();
371
602
  render(
372
603
  <GlobalChartsProvider>
373
604
  <AreaChartUnresponsive
@@ -406,7 +637,7 @@ describe( 'AreaChart', () => {
406
637
  } );
407
638
 
408
639
  test( 'does not pin domain for non-default stack offsets', () => {
409
- const ref = createRef< SingleChartRef >();
640
+ const ref = createRef< ChartInstanceRef >();
410
641
  render(
411
642
  <GlobalChartsProvider>
412
643
  <AreaChartUnresponsive
@@ -430,6 +661,44 @@ describe( 'AreaChart', () => {
430
661
  } );
431
662
  } );
432
663
 
664
+ describe( 'Programmatic visibility (non-interactive legend)', () => {
665
+ test( 'tooltip omits a series hidden programmatically even though the legend is not clickable', async () => {
666
+ const user = userEvent.setup();
667
+ const chartId = 'test-noninteractive-tooltip';
668
+ let context: GlobalChartsContextValue | undefined;
669
+
670
+ const Harness = () => {
671
+ context = useGlobalChartsContext();
672
+ return (
673
+ <AreaChart
674
+ { ...defaultProps }
675
+ chartId={ chartId }
676
+ showLegend
677
+ legend={ { interactive: false } }
678
+ />
679
+ );
680
+ };
681
+
682
+ render(
683
+ <GlobalChartsProvider>
684
+ <Harness />
685
+ </GlobalChartsProvider>
686
+ );
687
+
688
+ act( () => {
689
+ context?.toggleSeriesVisibility( chartId, 'Series A' );
690
+ } );
691
+
692
+ const chart = screen.getByRole( 'grid', { name: /area chart/i } );
693
+ chart.focus();
694
+ await user.keyboard( '{ArrowRight}' );
695
+
696
+ const tooltip = await screen.findByRole( 'tooltip' );
697
+ expect( tooltip ).not.toHaveTextContent( 'Series A' );
698
+ expect( tooltip ).toHaveTextContent( 'Series B' );
699
+ } );
700
+ } );
701
+
433
702
  describe( 'Without GlobalChartsProvider', () => {
434
703
  test( 'self-wraps in a provider when none is present', () => {
435
704
  render( <AreaChartUnresponsive { ...defaultProps } /> );
@@ -448,7 +717,7 @@ describe( 'AreaChart', () => {
448
717
 
449
718
  describe( 'Chart Ref Interface', () => {
450
719
  test( 'exposes getScales via ref', () => {
451
- const ref = createRef< SingleChartRef >();
720
+ const ref = createRef< ChartInstanceRef >();
452
721
  renderUnresponsive( {}, ref );
453
722
 
454
723
  expect( ref.current?.getScales() ).toBeDefined();
@@ -457,7 +726,7 @@ describe( 'AreaChart', () => {
457
726
  } );
458
727
 
459
728
  test( 'exposes getChartDimensions via ref', () => {
460
- const ref = createRef< SingleChartRef >();
729
+ const ref = createRef< ChartInstanceRef >();
461
730
  renderUnresponsive( { width: 800, height: 400 }, ref );
462
731
 
463
732
  const dimensions = ref.current?.getChartDimensions();
@@ -646,4 +915,70 @@ describe( 'AreaChart', () => {
646
915
  ).toBeInTheDocument();
647
916
  } );
648
917
  } );
918
+
919
+ describe( 'defaultHiddenSeries', () => {
920
+ it( 'renders a series hidden when named in defaultHiddenSeries', () => {
921
+ render(
922
+ <GlobalChartsProvider>
923
+ <AreaChartUnresponsive
924
+ width={ 500 }
925
+ height={ 300 }
926
+ showLegend={ true }
927
+ legend={ { interactive: true } }
928
+ chartId="test-default-hidden-area"
929
+ defaultHiddenSeries={ [ 'Series B' ] }
930
+ data={ [
931
+ {
932
+ label: 'Series A',
933
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
934
+ options: {},
935
+ },
936
+ {
937
+ label: 'Series B',
938
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
939
+ options: {},
940
+ },
941
+ ] }
942
+ />
943
+ </GlobalChartsProvider>
944
+ );
945
+
946
+ const items = screen.getAllByRole( 'button' );
947
+ expect( items[ 0 ] ).toHaveAttribute( 'aria-pressed', 'true' );
948
+ expect( items[ 1 ] ).toHaveAttribute( 'aria-pressed', 'false' );
949
+ } );
950
+
951
+ it( 'lets the user reveal a series seeded hidden', async () => {
952
+ const user = userEvent.setup();
953
+
954
+ render(
955
+ <GlobalChartsProvider>
956
+ <AreaChartUnresponsive
957
+ width={ 500 }
958
+ height={ 300 }
959
+ showLegend={ true }
960
+ legend={ { interactive: true } }
961
+ chartId="test-default-hidden-reveal-area"
962
+ defaultHiddenSeries={ [ 'Series B' ] }
963
+ data={ [
964
+ {
965
+ label: 'Series A',
966
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
967
+ options: {},
968
+ },
969
+ {
970
+ label: 'Series B',
971
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
972
+ options: {},
973
+ },
974
+ ] }
975
+ />
976
+ </GlobalChartsProvider>
977
+ );
978
+
979
+ await user.click( screen.getAllByRole( 'button' )[ 1 ] );
980
+
981
+ expect( screen.getAllByRole( 'button' )[ 1 ] ).toHaveAttribute( 'aria-pressed', 'true' );
982
+ } );
983
+ } );
649
984
  } );
@@ -3,12 +3,13 @@ import type {
3
3
  DataPointDate,
4
4
  SeriesData,
5
5
  SeriesChartLegendConfig,
6
+ SeriesVisibilityProps,
6
7
  } from '../../types';
7
8
  import type { RenderTooltipParams } from '../../visx/types';
8
9
  import type { CurveType } from '../line-chart/types';
9
10
  import type { ReactNode } from 'react';
10
11
 
11
- export interface AreaChartProps extends BaseChartProps< SeriesData[] > {
12
+ export interface AreaChartProps extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps {
12
13
  /**
13
14
  * Legend configuration. Supports `collapseGroups` on top of the shared options.
14
15
  */
@@ -63,8 +64,8 @@ export interface AreaChartProps extends BaseChartProps< SeriesData[] > {
63
64
  */
64
65
  zoomable?: boolean;
65
66
  /**
66
- * Whether the Y axis rescales to fit only the visible series when series are
67
- * hidden or shown through the interactive legend.
67
+ * Whether the Y axis rescales to fit only the visible series when the set of
68
+ * visible series changes — via the interactive legend or otherwise.
68
69
  * Defaults to `true`, matching LineChart. Set to `false` to pin the Y axis to
69
70
  * the full data extent so hiding series does not move the chart's baseline.
70
71
  * @default true