@automattic/charts 4.11.1 → 4.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/index.cjs +856 -236
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +19 -13
  5. package/dist/index.d.cts +43 -4
  6. package/dist/index.d.ts +43 -4
  7. package/dist/index.js +855 -237
  8. package/dist/index.js.map +1 -1
  9. package/package.json +9 -9
  10. package/src/charts/bar-chart/bar-chart.tsx +134 -138
  11. package/src/charts/bar-chart/index.ts +3 -0
  12. package/src/charts/bar-chart/private/band-tooltip.tsx +41 -15
  13. package/src/charts/bar-chart/private/bar-layer.tsx +132 -0
  14. package/src/charts/bar-chart/private/comparison-bars-geometry.ts +22 -34
  15. package/src/charts/bar-chart/private/comparison-bars.tsx +9 -18
  16. package/src/charts/bar-chart/private/comparison-constants.ts +4 -10
  17. package/src/charts/bar-chart/private/grouped-bars.ts +68 -0
  18. package/src/charts/bar-chart/private/index.ts +7 -1
  19. package/src/charts/bar-chart/private/overlap-bars.ts +183 -0
  20. package/src/charts/bar-chart/private/selected-bar-ring.tsx +84 -0
  21. package/src/charts/bar-chart/private/slot-tooltip-members.tsx +29 -0
  22. package/src/charts/bar-chart/private/slotted-bar-series.tsx +84 -0
  23. package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +4 -11
  24. package/src/charts/bar-chart/private/test/comparison-bars.test.tsx +2 -3
  25. package/src/charts/bar-chart/private/test/grouped-bars.test.ts +87 -0
  26. package/src/charts/bar-chart/private/test/overlap-bars.test.ts +159 -0
  27. package/src/charts/bar-chart/private/use-bar-stops.ts +89 -0
  28. package/src/charts/bar-chart/private/use-overlap-slots.ts +194 -0
  29. package/src/charts/bar-chart/private/value-domain.ts +0 -10
  30. package/src/charts/bar-chart/test/bar-chart-overlap.test.tsx +347 -0
  31. package/src/charts/bar-chart/test/bar-chart.test.tsx +66 -19
  32. package/src/charts/bar-chart/test/use-bar-legend-items.test.tsx +81 -0
  33. package/src/charts/bar-chart/types.ts +3 -0
  34. package/src/charts/bar-chart/use-bar-colors.ts +46 -0
  35. package/src/charts/bar-chart/use-bar-legend-items.ts +87 -0
  36. package/src/charts/line-chart/line-chart.tsx +22 -17
  37. package/src/charts/line-chart/test/line-chart.test.tsx +165 -1
  38. package/src/charts/pie-chart/pie-chart.tsx +7 -2
  39. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +7 -2
  40. package/src/charts/private/pie-keyboard-navigation/index.ts +0 -1
  41. package/src/charts/private/{pie-keyboard-navigation/pie-keyboard-navigation.module.scss → selected-ring/selected-ring.module.scss} +11 -6
  42. package/src/charts/private/selected-ring/selected-ring.tsx +73 -0
  43. package/src/charts/private/{pie-keyboard-navigation/test/selected-segment-ring.test.ts → selected-ring/test/selected-ring.test.ts} +1 -1
  44. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +20 -0
  45. package/src/components/legend/hooks/use-chart-legend-items.ts +27 -3
  46. package/src/components/tooltip/xy-chart-tooltip.tsx +3 -2
  47. package/src/index.ts +8 -2
  48. package/src/providers/chart-context/global-charts-provider.tsx +2 -0
  49. package/src/providers/chart-context/test/chart-context.test.tsx +0 -13
  50. package/src/providers/chart-context/themes.ts +0 -1
  51. package/src/providers/chart-context/types.ts +2 -0
  52. package/src/test-utils/selected-bar-ring.ts +14 -0
  53. package/src/types.ts +10 -2
  54. package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +0 -47
@@ -11,8 +11,10 @@ import userEvent from '@testing-library/user-event';
11
11
  import { GlobalChartsProvider } from '../../../providers';
12
12
  import { useGlobalChartsContext } from '../../../providers/chart-context/hooks/use-global-charts-context';
13
13
  import { steadyTrafficData } from '../../../stories/sample-data';
14
+ import { selectedBarRingBounds } from '../../../test-utils/selected-bar-ring';
14
15
  import BarChart, { BarChartUnresponsive } from '../bar-chart';
15
16
  import { useBarChartOptions } from '../private';
17
+ import { useBarColors } from '../use-bar-colors';
16
18
  import type { GlobalChartsContextValue } from '../../../providers/chart-context/types';
17
19
  import type { DataPointDate, SeriesData } from '../../../types';
18
20
 
@@ -22,6 +24,17 @@ jest.mock( '../../../hooks/use-element-size', () => ( {
22
24
  useElementSize: () => [ mockRefCallback, 500, 300 ],
23
25
  } ) );
24
26
 
27
+ const geometryOf = ( element: Element ) =>
28
+ [ 'x', 'y', 'width', 'height' ].map( name =>
29
+ Number( element.getAttribute( name ) ).toFixed( 2 )
30
+ );
31
+
32
+ const selectedRingGeometry = () =>
33
+ Object.values( selectedBarRingBounds() ).map( value => value.toFixed( 2 ) );
34
+
35
+ // eslint-disable-next-line testing-library/no-node-access -- visx renders the bars and takes no test id.
36
+ const visxBars = () => document.querySelectorAll( '.visx-bar' );
37
+
25
38
  describe( 'BarChart', () => {
26
39
  const defaultProps = {
27
40
  width: 500,
@@ -1644,7 +1657,7 @@ describe( 'BarChart', () => {
1644
1657
  expect( screen.queryByTestId( 'bar-chart-patterns' ) ).not.toBeInTheDocument();
1645
1658
  } );
1646
1659
 
1647
- test( 'comparison shadow reuses the primary bar pattern when patterns are enabled', () => {
1660
+ test( 'fills the comparison bar with its own pattern when patterns are enabled', () => {
1648
1661
  renderWithTheme( {
1649
1662
  withPatterns: true,
1650
1663
  data: [
@@ -1670,9 +1683,7 @@ describe( 'BarChart', () => {
1670
1683
 
1671
1684
  const shadow = screen.getAllByTestId( /^bar-chart-comparison-\d+-\d+$/ )[ 0 ];
1672
1685
  const shadowFill = shadow.getAttribute( 'fill' );
1673
- // Shadow is filled with a pattern, not a solid color, and it references the PRIMARY
1674
- // series' pattern (index 0) rather than the comparison series' own index.
1675
- expect( shadowFill ).toMatch( /^url\(#bar-pattern-.+-0\)$/ );
1686
+ expect( shadowFill ).toMatch( /^url\(#bar-pattern-.+-1\)$/ );
1676
1687
  } );
1677
1688
  } );
1678
1689
 
@@ -2275,7 +2286,7 @@ describe( 'BarChart', () => {
2275
2286
  await user.keyboard( '{ArrowRight}' );
2276
2287
 
2277
2288
  expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'No data' );
2278
- expect( screen.queryByTestId( 'bar-chart-keyboard-highlight' ) ).not.toBeInTheDocument();
2289
+ expect( screen.queryByTestId( 'bar-chart-selected-ring' ) ).not.toBeInTheDocument();
2279
2290
  } );
2280
2291
 
2281
2292
  test( 'outlines the first rendered bar for the first bucket with a reading', async () => {
@@ -2286,9 +2297,7 @@ describe( 'BarChart', () => {
2286
2297
  await user.keyboard( '{ArrowRight}{ArrowRight}' );
2287
2298
 
2288
2299
  expect( screen.getByTestId( 'chart-tooltip-1' ) ).toHaveTextContent( '20' );
2289
- expect( screen.getByTestId( 'bar-chart-keyboard-highlight' ) ).toHaveTextContent(
2290
- /\.visx-bar:nth-child\(1\) \{/
2291
- );
2300
+ expect( selectedRingGeometry() ).toEqual( geometryOf( visxBars()[ 0 ] ) );
2292
2301
  } );
2293
2302
 
2294
2303
  test( 'skips the bars an earlier series left out when outlining a later series', async () => {
@@ -2319,9 +2328,7 @@ describe( 'BarChart', () => {
2319
2328
  await user.keyboard( '{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}' );
2320
2329
 
2321
2330
  expect( screen.getByTestId( 'chart-tooltip-3' ) ).toHaveTextContent( '15' );
2322
- expect( screen.getByTestId( 'bar-chart-keyboard-highlight' ) ).toHaveTextContent(
2323
- /\.visx-bar:nth-child\(4\) \{/
2324
- );
2331
+ expect( selectedRingGeometry() ).toEqual( geometryOf( visxBars()[ 3 ] ) );
2325
2332
  } );
2326
2333
  } );
2327
2334
  } );
@@ -2705,8 +2712,11 @@ describe( 'BarChart', () => {
2705
2712
  } );
2706
2713
 
2707
2714
  describe( 'Comparison Series', () => {
2708
- it( 'renders a translucent shadow behind the primary bar for a comparison series', () => {
2709
- const data = [
2715
+ it.each( [
2716
+ [ 'opaque, in the colors the charts context reports', undefined ],
2717
+ [ 'translucent, in the series colors, under the deprecated theme opacity', 0.5 ],
2718
+ ] )( 'draws a comparison bar behind the primary bar, %s', ( _case, opacity ) => {
2719
+ const data: SeriesData[] = [
2710
2720
  {
2711
2721
  label: 'This year',
2712
2722
  group: 'views',
@@ -2725,18 +2735,56 @@ describe( 'BarChart', () => {
2725
2735
  ],
2726
2736
  },
2727
2737
  ];
2728
- render( <BarChart data={ data } width={ 400 } height={ 300 } /> );
2738
+ let barColors: string[] = [];
2739
+ const CaptureBarColors = () => {
2740
+ barColors = useBarColors()( data );
2741
+ return null;
2742
+ };
2743
+ render(
2744
+ <GlobalChartsProvider
2745
+ theme={ opacity ? { barChart: { barStyles: { comparison: { opacity } } } } : undefined }
2746
+ >
2747
+ <CaptureBarColors />
2748
+ <BarChart data={ data } width={ 400 } height={ 300 } />
2749
+ </GlobalChartsProvider>
2750
+ );
2729
2751
 
2730
- // two comparison shadow rects (one per data point)
2731
- // Match individual rect testids like bar-chart-comparison-1-0 (not the group wrapper)
2752
+ const [ primaryColor, comparisonColor ] = barColors;
2732
2753
  const shadows = screen.getAllByTestId( /^bar-chart-comparison-\d+-\d+$/ );
2733
2754
  expect( shadows ).toHaveLength( 2 );
2734
- expect( shadows[ 0 ] ).toHaveAttribute( 'opacity', '0.5' );
2755
+ expect( shadows[ 0 ] ).toHaveAttribute( 'fill', comparisonColor );
2756
+ expect( shadows[ 0 ].attributes.getNamedItem( 'opacity' )?.value ).toBe(
2757
+ opacity ? String( opacity ) : undefined
2758
+ );
2759
+ expect( comparisonColor === primaryColor ).toBe( !! opacity );
2735
2760
 
2736
- // primary bars still render for the single primary series (visx .visx-bar)
2737
2761
  // eslint-disable-next-line testing-library/no-node-access
2738
2762
  const bars = document.querySelectorAll( '.visx-bar' );
2739
2763
  expect( bars.length ).toBeGreaterThanOrEqual( 2 );
2764
+ expect( bars[ 0 ] ).toHaveAttribute( 'fill', primaryColor );
2765
+ } );
2766
+
2767
+ it( "fills a comparison bar in its datum's own color, as a primary bar does", () => {
2768
+ render(
2769
+ <BarChart
2770
+ width={ 400 }
2771
+ height={ 300 }
2772
+ data={ [
2773
+ { label: 'This year', group: 'views', data: [ { label: 'Jan', value: 100 } ] },
2774
+ {
2775
+ label: 'Last year',
2776
+ group: 'views',
2777
+ options: { type: 'comparison' as const },
2778
+ data: [ { label: 'Jan', value: 80, color: '#123456' } ],
2779
+ },
2780
+ ] }
2781
+ />
2782
+ );
2783
+
2784
+ expect( screen.getByTestId( 'bar-chart-comparison-1-0' ) ).toHaveAttribute(
2785
+ 'fill',
2786
+ '#123456'
2787
+ );
2740
2788
  } );
2741
2789
 
2742
2790
  it( 'hides the comparison shadow group from assistive technology', () => {
@@ -2787,7 +2835,6 @@ describe( 'BarChart', () => {
2787
2835
 
2788
2836
  const shadows = screen.getAllByTestId( /^bar-chart-comparison-\d+-\d+$/ );
2789
2837
  expect( shadows ).toHaveLength( 2 );
2790
- expect( shadows[ 0 ] ).toHaveAttribute( 'opacity', '0.5' );
2791
2838
  } );
2792
2839
 
2793
2840
  it( 'expands value-axis domain to include comparison values exceeding the primary max', () => {
@@ -0,0 +1,81 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { useChartLegendItems } from '../../../components/legend';
3
+ import { GlobalChartsProvider } from '../../../providers';
4
+ import { useBarColors } from '../use-bar-colors';
5
+ import { useBarLegendItems } from '../use-bar-legend-items';
6
+ import type { SeriesData } from '../../../types';
7
+ import type { ReactNode } from 'react';
8
+
9
+ const wrapper = ( { children }: { children: ReactNode } ) => (
10
+ <GlobalChartsProvider>{ children }</GlobalChartsProvider>
11
+ );
12
+
13
+ const twoMetricData: SeriesData[] = [
14
+ { label: 'Views', group: 'views', data: [ { label: 'Mon', value: 100 } ] },
15
+ {
16
+ label: 'Views — previous',
17
+ group: 'views',
18
+ options: { type: 'comparison' },
19
+ data: [ { label: 'Mon', value: 90 } ],
20
+ },
21
+ { label: 'Visitors', group: 'visitors', data: [ { label: 'Mon', value: 50 } ] },
22
+ {
23
+ label: 'Visitors — previous',
24
+ group: 'visitors',
25
+ options: { type: 'comparison' },
26
+ data: [ { label: 'Mon', value: 40 } ],
27
+ },
28
+ ];
29
+ const perMetricLabel = ( metric: string ) => `${ metric } (Prev. 7 days)`;
30
+
31
+ describe( 'useBarLegendItems', () => {
32
+ test.each( [
33
+ [
34
+ 'takes the grouped comparison tints, a collapsed group in its comparison tint,',
35
+ 'grouped',
36
+ [ 1, 3, 1, 3 ],
37
+ ],
38
+ [ 'takes the opaque overlap bar colors', 'overlap', [ 0, 2, 1, 3 ] ],
39
+ ] as const )( '%s with layout %s', ( _case, layout, colorOrder ) => {
40
+ const { result } = renderHook(
41
+ () => ( {
42
+ items: useBarLegendItems( twoMetricData, {
43
+ collapseGroups: true,
44
+ comparisonSeriesItems: perMetricLabel,
45
+ layout,
46
+ } ),
47
+ getBarColors: useBarColors(),
48
+ } ),
49
+ { wrapper }
50
+ );
51
+ const colors = result.current.getBarColors( twoMetricData, { layout } );
52
+
53
+ expect( result.current.items.map( item => item.color ) ).toEqual(
54
+ colorOrder.map( index => colors[ index ] )
55
+ );
56
+ } );
57
+
58
+ test( 'lists one item per series in slot order, primaries first, with layout overlap', () => {
59
+ const { result } = renderHook(
60
+ () => useBarLegendItems( twoMetricData, { layout: 'overlap' } ),
61
+ { wrapper }
62
+ );
63
+
64
+ expect( result.current.map( item => item.label ) ).toEqual(
65
+ [ 0, 2, 1, 3 ].map( index => twoMetricData[ index ].label )
66
+ );
67
+ } );
68
+
69
+ test( "returns the chart legend's own items without a layout", () => {
70
+ const options = { collapseGroups: true };
71
+ const { result } = renderHook(
72
+ () => ( {
73
+ bar: useBarLegendItems( twoMetricData, options ),
74
+ chart: useChartLegendItems( twoMetricData, options ),
75
+ } ),
76
+ { wrapper }
77
+ );
78
+
79
+ expect( result.current.bar ).toEqual( result.current.chart );
80
+ } );
81
+ } );
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ BarChartLayout,
2
3
  BaseChartProps,
3
4
  DataPointDate,
4
5
  SeriesData,
@@ -33,6 +34,8 @@ export interface BarChartProps extends BaseChartProps< SeriesData[] >, SeriesVis
33
34
  /** CSS class for each primary bar, in addition to visx-bar. */
34
35
  barClassName?: ( datum: DataPointDate ) => string | undefined;
35
36
  orientation?: 'horizontal' | 'vertical';
37
+ /** Defaults to `grouped`. */
38
+ layout?: BarChartLayout;
36
39
  withPatterns?: boolean;
37
40
  showZeroValues?: boolean;
38
41
  /** Highlight the active band across the plot when tooltips are enabled. */
@@ -0,0 +1,46 @@
1
+ import { useCallback } from 'react';
2
+ import { useGlobalChartsContext } from '../../providers/chart-context/hooks/use-global-charts-context';
3
+ import { BACKGROUND_FALLBACK } from '../../providers/chart-context/private/catalog-pointers';
4
+ import { getGroupedBarColors } from './private/grouped-bars';
5
+ import { getOverlapBarColors } from './private/overlap-bars';
6
+ import type { BarChartLayout, SeriesData } from '../../types';
7
+
8
+ export type GetBarColors = (
9
+ series: SeriesData[],
10
+ options?: { layout?: BarChartLayout }
11
+ ) => string[];
12
+
13
+ /**
14
+ * Reads the colors `BarChart` fills series with, so a custom tooltip or legend can match the bars.
15
+ *
16
+ * @return A function from series and a layout to each series' bar color, by index.
17
+ */
18
+ export function useBarColors(): GetBarColors {
19
+ const {
20
+ getElementStyles,
21
+ backgroundColor = BACKGROUND_FALLBACK,
22
+ theme,
23
+ } = useGlobalChartsContext();
24
+ const comparisonOpacity = theme.barChart?.barStyles?.comparison?.opacity;
25
+
26
+ return useCallback< GetBarColors >(
27
+ ( series, { layout = 'grouped' } = {} ) => {
28
+ const colors = series.map( ( data, index ) => getElementStyles( { data, index } ).color );
29
+
30
+ if ( layout !== 'overlap' ) {
31
+ // The deprecated comparison opacity keeps the translucent bars in their series colors.
32
+ return comparisonOpacity === undefined
33
+ ? getGroupedBarColors( series, colors, backgroundColor )
34
+ : colors;
35
+ }
36
+
37
+ return getOverlapBarColors(
38
+ series,
39
+ colors,
40
+ [ getElementStyles( { index: 0 } ).color, getElementStyles( { index: 1 } ).color ],
41
+ backgroundColor
42
+ );
43
+ },
44
+ [ getElementStyles, backgroundColor, comparisonOpacity ]
45
+ );
46
+ }
@@ -0,0 +1,87 @@
1
+ import { useMemo } from 'react';
2
+ import { useChartLegendItems } from '../../components/legend/hooks/use-chart-legend-items';
3
+ import { useBarColors } from './use-bar-colors';
4
+ import type { ChartLegendOptions } from '../../components/legend/hooks/use-chart-legend-items';
5
+ import type { BaseLegendItem } from '../../components/legend/types';
6
+ import type { BarChartLayout, LegendShape, SeriesData } from '../../types';
7
+
8
+ export type BarLegendOptions = Omit< ChartLegendOptions, 'seriesColors' > & {
9
+ /** The layout the bars are drawn in. Unset, the items are the chart legend's own. */
10
+ layout?: BarChartLayout;
11
+ };
12
+
13
+ const isComparison = ( data: SeriesData[], label: string | undefined ) =>
14
+ data.some( series => series.label === label && series.options?.type === 'comparison' );
15
+
16
+ /**
17
+ * Applies what a bar layout changes about a legend: overlap lists every primary before every comparison, as it draws their slots, with every swatch opaque like its bar; a collapsed grouped item takes its comparison's tint, the wider bar that frames the group.
18
+ *
19
+ * @param data - The series the items were built from.
20
+ * @param items - The legend items.
21
+ * @param layout - The layout the bars are drawn in.
22
+ * @param seriesColors - Each series' bar color, by index.
23
+ * @return The legend items.
24
+ */
25
+ function forBarLayout(
26
+ data: SeriesData[],
27
+ items: BaseLegendItem[],
28
+ layout: BarChartLayout,
29
+ seriesColors: string[]
30
+ ): BaseLegendItem[] {
31
+ if ( layout === 'overlap' ) {
32
+ const opaque = items.map( item =>
33
+ item.shapeStyle ? { ...item, shapeStyle: { ...item.shapeStyle, opacity: undefined } } : item
34
+ );
35
+ // Static items, such as a comparison item, follow the series items and keep their place.
36
+ const seriesItems = opaque.filter( item => item.seriesLabels );
37
+ const firstLabel = ( item: BaseLegendItem ) => item.seriesLabels?.[ 0 ];
38
+
39
+ return [
40
+ ...seriesItems.filter( item => ! isComparison( data, firstLabel( item ) ) ),
41
+ ...seriesItems.filter( item => isComparison( data, firstLabel( item ) ) ),
42
+ ...opaque.filter( item => ! item.seriesLabels ),
43
+ ];
44
+ }
45
+
46
+ return items.map( item => {
47
+ if ( ( item.seriesLabels?.length ?? 0 ) < 2 ) {
48
+ return item;
49
+ }
50
+ const comparisonIndex = data.findIndex(
51
+ series => series.options?.type === 'comparison' && item.seriesLabels?.includes( series.label )
52
+ );
53
+
54
+ return comparisonIndex === -1 ? item : { ...item, color: seriesColors[ comparisonIndex ] };
55
+ } );
56
+ }
57
+
58
+ /**
59
+ * Builds legend items that match `BarChart`'s bars in a layout, for a custom legend or a chart's own.
60
+ *
61
+ * @param data - The series the chart draws.
62
+ * @param options - The legend options, and the layout the bars are drawn in.
63
+ * @param legendShape - The shape of the legend swatches.
64
+ * @return The legend items.
65
+ */
66
+ export function useBarLegendItems(
67
+ data: SeriesData[],
68
+ options: BarLegendOptions = {},
69
+ legendShape?: LegendShape< SeriesData[], number >
70
+ ): BaseLegendItem[] {
71
+ const { layout } = options;
72
+ const getBarColors = useBarColors();
73
+ const seriesColors = useMemo(
74
+ () => ( layout ? getBarColors( data, { layout } ) : undefined ),
75
+ [ data, layout, getBarColors ]
76
+ );
77
+ const chartLegendOptions = useMemo(
78
+ (): ChartLegendOptions => ( { ...options, seriesColors } ),
79
+ [ options, seriesColors ]
80
+ );
81
+ const items = useChartLegendItems( data, chartLegendOptions, legendShape );
82
+
83
+ return useMemo(
84
+ () => ( layout && seriesColors ? forBarLayout( data, items, layout, seriesColors ) : items ),
85
+ [ data, items, layout, seriesColors ]
86
+ );
87
+ }
@@ -336,6 +336,15 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
336
336
  } ) );
337
337
  }, [ dataSorted, hiddenSeries ] );
338
338
 
339
+ const visibleSeries = useMemo(
340
+ () => dataSorted.filter( series => isSeriesVisible( series.label ) ),
341
+ [ dataSorted, isSeriesVisible ]
342
+ );
343
+ const visibleSeriesKey = useMemo(
344
+ () => JSON.stringify( visibleSeries.map( series => series.label ) ),
345
+ [ visibleSeries ]
346
+ );
347
+
339
348
  // Check if all series are hidden
340
349
  const allSeriesHidden = useMemo( () => {
341
350
  return seriesWithVisibility.every( ( { isVisible } ) => ! isVisible );
@@ -378,18 +387,17 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
378
387
  return min < max ? [ min, max ] : undefined;
379
388
  }, [ rescaleYOnVisibilityChange, dataSorted, isSeriesVisible, options?.yScale ] );
380
389
 
381
- // Keyboard navigation steps through x positions, and the grouped tooltip
382
- // reads every series at that position; the first series names the point.
390
+ // Keyboard navigation steps through x positions, and the grouped tooltip reads every
391
+ // visible series at that position; the first visible series with a point there names it.
383
392
  const activateSelectedPoint = useCallback(
384
393
  ( index: number ) => {
385
- const series = dataSorted[ 0 ];
386
- const datum = series?.data[ index ];
394
+ const series = visibleSeries.find( candidate => candidate.data[ index ] );
387
395
 
388
- if ( series && datum ) {
389
- onDatumActivate?.( { datum, index, key: series.label } );
396
+ if ( series ) {
397
+ onDatumActivate?.( { datum: series.data[ index ], index, key: series.label } );
390
398
  }
391
399
  },
392
- [ dataSorted, onDatumActivate ]
400
+ [ visibleSeries, onDatumActivate ]
393
401
  );
394
402
 
395
403
  const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
@@ -398,7 +406,8 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
398
406
  isNavigating,
399
407
  setIsNavigating,
400
408
  chartRef,
401
- totalPoints: dataSorted[ 0 ]?.data.length || 0,
409
+ totalPoints: Math.max( 0, ...visibleSeries.map( series => series.data.length ) ),
410
+ visibleSeriesKey,
402
411
  onActivate: activateSelectedPoint,
403
412
  preventTooltipScroll: tooltipPlacement === 'below-axis',
404
413
  } );
@@ -424,8 +433,8 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
424
433
  }, [ dataSorted, isSeriesVisible ] );
425
434
 
426
435
  const hasWholeNumberValues = useMemo(
427
- () => hasOnlyWholeNumbers( dataSorted.filter( series => isSeriesVisible( series.label ) ) ),
428
- [ dataSorted, isSeriesVisible ]
436
+ () => hasOnlyWholeNumbers( visibleSeries ),
437
+ [ visibleSeries ]
429
438
  );
430
439
 
431
440
  const chartOptions = useMemo( () => {
@@ -482,12 +491,8 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
482
491
  // Classified from the rendered series, like the axis above: a hidden
483
492
  // hourly line must not leave the tooltip naming an hour the axis dropped.
484
493
  const bucketInfo = useMemo(
485
- () =>
486
- getBucketInfo(
487
- dataSorted.filter( series => isSeriesVisible( series.label ) ),
488
- options?.axis?.x?.tickResolution
489
- ),
490
- [ dataSorted, isSeriesVisible, options?.axis?.x?.tickResolution ]
494
+ () => getBucketInfo( visibleSeries, options?.axis?.x?.tickResolution ),
495
+ [ visibleSeries, options?.axis?.x?.tickResolution ]
491
496
  );
492
497
 
493
498
  const tooltipRenderGlyph = useMemo( () => {
@@ -809,7 +814,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
809
814
  keyboardFocusedClassName={
810
815
  styles[ 'line-chart__tooltip--keyboard-focused' ]
811
816
  }
812
- series={ dataSorted }
817
+ series={ visibleSeries }
813
818
  />
814
819
  ) }
815
820
 
@@ -6,7 +6,7 @@ import { GlyphDiamond } from '@visx/glyph';
6
6
  import { createElement, createRef, type ComponentProps } from 'react';
7
7
  import { GlobalChartsProvider, defaultTheme } from '../../../providers';
8
8
  import { useGlobalChartsContext } from '../../../providers/chart-context/hooks/use-global-charts-context';
9
- import LineChart, { LineChartUnresponsive } from '../line-chart';
9
+ import LineChart, { LineChartUnresponsive, renderDefaultTooltip } from '../line-chart';
10
10
  import type { GlobalChartsContextValue } from '../../../providers/chart-context/types';
11
11
  import type { DataPointDate, SeriesData } from '../../../types';
12
12
  import type { ChartInstanceRef } from '../../private/chart-instance-context';
@@ -1658,6 +1658,32 @@ describe( 'LineChart', () => {
1658
1658
  } );
1659
1659
  } );
1660
1660
 
1661
+ test.each( [
1662
+ [ 'the first is hidden', [ { ...SERIES_A, data: SERIES_A.data.slice( 0, 1 ) }, SERIES_B ] ],
1663
+ [
1664
+ 'the first visible series is empty',
1665
+ [ SERIES_A, { ...SERIES_A, label: 'Empty', data: [] }, SERIES_B ],
1666
+ ],
1667
+ ] )( 'Enter reaches every visible point when %s', async ( _, data ) => {
1668
+ const user = userEvent.setup();
1669
+ const onDatumActivate = jest.fn();
1670
+ renderWithTheme( {
1671
+ data,
1672
+ defaultHiddenSeries: [ 'Series A' ],
1673
+ chartId: 'activation-hidden-first',
1674
+ onDatumActivate,
1675
+ } );
1676
+
1677
+ screen.getByRole( 'application', { name: /line chart/i } ).focus();
1678
+ await user.keyboard( '{ArrowRight}{ArrowRight}{Enter}' );
1679
+
1680
+ expect( onDatumActivate ).toHaveBeenCalledWith( {
1681
+ datum: SERIES_B.data[ 1 ],
1682
+ index: 1,
1683
+ key: 'Series B',
1684
+ } );
1685
+ } );
1686
+
1661
1687
  test( 'Enter with no point selected activates nothing', async () => {
1662
1688
  const user = userEvent.setup();
1663
1689
  const onDatumActivate = jest.fn();
@@ -1859,6 +1885,144 @@ describe( 'LineChart', () => {
1859
1885
  expect( legendItem ).toHaveAttribute( 'aria-pressed', 'false' );
1860
1886
  } );
1861
1887
 
1888
+ it.each( [ 'Series A', 'Series B' ] )(
1889
+ 'leaves %s out of the keyboard tooltip once it is hidden',
1890
+ async hiddenLabel => {
1891
+ const user = userEvent.setup();
1892
+ const visibleLabel = hiddenLabel === 'Series A' ? 'Series B' : 'Series A';
1893
+ const renderTooltip = jest.fn( renderDefaultTooltip );
1894
+
1895
+ render(
1896
+ <GlobalChartsProvider>
1897
+ <LineChartUnresponsive
1898
+ width={ 500 }
1899
+ height={ 300 }
1900
+ data={ [
1901
+ defaultProps.data[ 0 ],
1902
+ {
1903
+ label: 'Series B',
1904
+ data: [
1905
+ { date: new Date( '2024-01-01' ), value: 15 },
1906
+ { date: new Date( '2024-01-02' ), value: 25 },
1907
+ ],
1908
+ },
1909
+ ] }
1910
+ withGradientFill={ false }
1911
+ showLegend={ true }
1912
+ legend={ { interactive: true } }
1913
+ renderTooltip={ renderTooltip }
1914
+ chartId={ `test-keyboard-tooltip-hidden-${ hiddenLabel }` }
1915
+ />
1916
+ </GlobalChartsProvider>
1917
+ );
1918
+
1919
+ await user.click( screen.getByRole( 'button', { name: new RegExp( hiddenLabel ) } ) );
1920
+ screen.getByRole( 'application', { name: /line chart/i } ).focus();
1921
+ await user.keyboard( '{ArrowRight}' );
1922
+
1923
+ const tooltip = await screen.findByTestId( 'line-chart-tooltip-content' );
1924
+ expect( tooltip ).toHaveTextContent( visibleLabel );
1925
+ expect( tooltip ).not.toHaveTextContent( hiddenLabel );
1926
+ expect( renderTooltip ).toHaveBeenLastCalledWith(
1927
+ expect.objectContaining( {
1928
+ tooltipData: expect.objectContaining( {
1929
+ nearestDatum: expect.objectContaining( { key: visibleLabel } ),
1930
+ } ),
1931
+ } )
1932
+ );
1933
+ }
1934
+ );
1935
+
1936
+ it( 'does not leave a series hidden mid-navigation in the keyboard tooltip', async () => {
1937
+ const user = userEvent.setup();
1938
+
1939
+ render(
1940
+ <GlobalChartsProvider>
1941
+ <LineChartUnresponsive
1942
+ width={ 500 }
1943
+ height={ 300 }
1944
+ data={ [
1945
+ defaultProps.data[ 0 ],
1946
+ {
1947
+ label: 'Series B',
1948
+ data: [
1949
+ { date: new Date( '2024-01-01' ), value: 15 },
1950
+ { date: new Date( '2024-01-02' ), value: 25 },
1951
+ ],
1952
+ },
1953
+ ] }
1954
+ withGradientFill={ false }
1955
+ showLegend={ true }
1956
+ legend={ { interactive: true } }
1957
+ chartId="test-keyboard-tooltip-hide-mid-navigation"
1958
+ />
1959
+ </GlobalChartsProvider>
1960
+ );
1961
+
1962
+ screen.getByRole( 'application', { name: /line chart/i } ).focus();
1963
+ await user.keyboard( '{ArrowRight}' );
1964
+ await expect(
1965
+ screen.findByTestId( 'line-chart-tooltip-content' )
1966
+ ).resolves.toHaveTextContent( 'Series A' );
1967
+
1968
+ await user.click( screen.getByRole( 'button', { name: /Series A/ } ) );
1969
+
1970
+ await waitFor( () =>
1971
+ expect( screen.queryByTestId( 'line-chart-tooltip-content' ) ).not.toBeInTheDocument()
1972
+ );
1973
+ } );
1974
+
1975
+ it( 'drops a series hidden programmatically from an open keyboard tooltip', async () => {
1976
+ const user = userEvent.setup();
1977
+ let context: GlobalChartsContextValue;
1978
+ const Grab = () => {
1979
+ context = useGlobalChartsContext();
1980
+ return null;
1981
+ };
1982
+
1983
+ render(
1984
+ <GlobalChartsProvider>
1985
+ <Grab />
1986
+ <LineChartUnresponsive
1987
+ width={ 500 }
1988
+ height={ 300 }
1989
+ withGradientFill={ false }
1990
+ chartId="test-keyboard-tooltip-programmatic-hide"
1991
+ data={ [
1992
+ {
1993
+ label: 'Series A',
1994
+ data: [
1995
+ { date: new Date( '2024-01-01' ), value: 10 },
1996
+ { date: new Date( '2024-01-02' ), value: 20 },
1997
+ { date: new Date( '2024-01-03' ), value: 30 },
1998
+ ],
1999
+ },
2000
+ {
2001
+ label: 'Series B',
2002
+ data: [
2003
+ { date: new Date( '2024-01-01' ), value: 15 },
2004
+ { date: new Date( '2024-01-02' ), value: 25 },
2005
+ ],
2006
+ },
2007
+ ] }
2008
+ />
2009
+ </GlobalChartsProvider>
2010
+ );
2011
+
2012
+ screen.getByRole( 'application', { name: /line chart/i } ).focus();
2013
+ await user.keyboard( '{ArrowRight}{ArrowRight}' );
2014
+ await expect(
2015
+ screen.findByTestId( 'line-chart-tooltip-content' )
2016
+ ).resolves.toHaveTextContent( 'Series A' );
2017
+
2018
+ act( () => {
2019
+ context.toggleSeriesVisibility( 'test-keyboard-tooltip-programmatic-hide', 'Series A' );
2020
+ } );
2021
+
2022
+ await waitFor( () => expect( screen.queryByText( 'Series A:' ) ).not.toBeInTheDocument() );
2023
+ expect( screen.getByTestId( 'line-chart-tooltip-content' ) ).toHaveTextContent( 'Series B' );
2024
+ } );
2025
+
1862
2026
  it( 'does not filter series when legendInteractive is false', () => {
1863
2027
  render(
1864
2028
  <GlobalChartsProvider>