@automattic/charts 4.11.1 → 4.13.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 (73) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/index.cjs +1412 -787
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +59 -25
  5. package/dist/index.d.cts +64 -120
  6. package/dist/index.d.ts +64 -120
  7. package/dist/index.js +1400 -765
  8. package/dist/index.js.map +1 -1
  9. package/package.json +9 -9
  10. package/src/charts/area-chart/area-chart.tsx +16 -6
  11. package/src/charts/area-chart/test/area-chart.test.tsx +42 -9
  12. package/src/charts/bar-chart/bar-chart.tsx +143 -142
  13. package/src/charts/bar-chart/index.ts +3 -0
  14. package/src/charts/bar-chart/private/band-tooltip.tsx +41 -15
  15. package/src/charts/bar-chart/private/bar-layer.tsx +132 -0
  16. package/src/charts/bar-chart/private/comparison-bars-geometry.ts +22 -34
  17. package/src/charts/bar-chart/private/comparison-bars.tsx +9 -18
  18. package/src/charts/bar-chart/private/comparison-constants.ts +4 -10
  19. package/src/charts/bar-chart/private/grouped-bars.ts +68 -0
  20. package/src/charts/bar-chart/private/index.ts +7 -1
  21. package/src/charts/bar-chart/private/overlap-bars.ts +183 -0
  22. package/src/charts/bar-chart/private/selected-bar-ring.tsx +84 -0
  23. package/src/charts/bar-chart/private/slot-tooltip-members.tsx +28 -0
  24. package/src/charts/bar-chart/private/slotted-bar-series.tsx +84 -0
  25. package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +4 -11
  26. package/src/charts/bar-chart/private/test/comparison-bars.test.tsx +2 -3
  27. package/src/charts/bar-chart/private/test/grouped-bars.test.ts +87 -0
  28. package/src/charts/bar-chart/private/test/overlap-bars.test.ts +159 -0
  29. package/src/charts/bar-chart/private/use-bar-stops.ts +87 -0
  30. package/src/charts/bar-chart/private/use-overlap-slots.ts +213 -0
  31. package/src/charts/bar-chart/private/value-domain.ts +0 -10
  32. package/src/charts/bar-chart/test/bar-chart-overlap.test.tsx +347 -0
  33. package/src/charts/bar-chart/test/bar-chart.test.tsx +181 -19
  34. package/src/charts/bar-chart/test/use-bar-legend-items.test.tsx +81 -0
  35. package/src/charts/bar-chart/types.ts +3 -0
  36. package/src/charts/bar-chart/use-bar-colors.ts +46 -0
  37. package/src/charts/bar-chart/use-bar-legend-items.ts +87 -0
  38. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +24 -11
  39. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +235 -182
  40. package/src/charts/conversion-funnel-chart/index.ts +2 -0
  41. package/src/charts/conversion-funnel-chart/private/index.ts +1 -0
  42. package/src/charts/conversion-funnel-chart/private/use-funnel-selection.ts +9 -47
  43. package/src/charts/conversion-funnel-chart/private/use-roving-step-focus.ts +83 -0
  44. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +312 -92
  45. package/src/charts/conversion-funnel-chart/test/use-funnel-selection.test.ts +23 -188
  46. package/src/charts/conversion-funnel-chart/types.ts +22 -0
  47. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +41 -1
  48. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +101 -30
  49. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +47 -0
  50. package/src/charts/line-chart/line-chart.tsx +25 -18
  51. package/src/charts/line-chart/test/line-chart.test.tsx +240 -1
  52. package/src/charts/pie-chart/pie-chart.tsx +7 -2
  53. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +7 -2
  54. package/src/charts/private/pie-keyboard-navigation/index.ts +0 -1
  55. package/src/charts/private/{pie-keyboard-navigation/pie-keyboard-navigation.module.scss → selected-ring/selected-ring.module.scss} +11 -6
  56. package/src/charts/private/selected-ring/selected-ring.tsx +73 -0
  57. package/src/charts/private/{pie-keyboard-navigation/test/selected-segment-ring.test.ts → selected-ring/test/selected-ring.test.ts} +1 -1
  58. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +20 -0
  59. package/src/components/legend/hooks/use-chart-legend-items.ts +27 -3
  60. package/src/components/tooltip/private/at-nearest-date.ts +49 -0
  61. package/src/components/tooltip/private/keyboard-stops.ts +67 -0
  62. package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +10 -2
  63. package/src/components/tooltip/test/keyboard-stops.test.ts +36 -0
  64. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +135 -1
  65. package/src/components/tooltip/xy-chart-tooltip.tsx +11 -14
  66. package/src/index.ts +10 -2
  67. package/src/providers/chart-context/global-charts-provider.tsx +2 -0
  68. package/src/providers/chart-context/test/chart-context.test.tsx +0 -13
  69. package/src/providers/chart-context/themes.ts +0 -1
  70. package/src/providers/chart-context/types.ts +2 -0
  71. package/src/test-utils/selected-bar-ring.ts +14 -0
  72. package/src/types.ts +14 -5
  73. package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +0 -47
@@ -11,10 +11,13 @@ 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';
20
+ import type { RenderTooltipParams } from '../../../visx/types';
18
21
 
19
22
  // Mock useElementSize to return non-zero dimensions in jsdom so charts render
20
23
  const mockRefCallback = jest.fn();
@@ -22,6 +25,17 @@ jest.mock( '../../../hooks/use-element-size', () => ( {
22
25
  useElementSize: () => [ mockRefCallback, 500, 300 ],
23
26
  } ) );
24
27
 
28
+ const geometryOf = ( element: Element ) =>
29
+ [ 'x', 'y', 'width', 'height' ].map( name =>
30
+ Number( element.getAttribute( name ) ).toFixed( 2 )
31
+ );
32
+
33
+ const selectedRingGeometry = () =>
34
+ Object.values( selectedBarRingBounds() ).map( value => value.toFixed( 2 ) );
35
+
36
+ // eslint-disable-next-line testing-library/no-node-access -- visx renders the bars and takes no test id.
37
+ const visxBars = () => document.querySelectorAll( '.visx-bar' );
38
+
25
39
  describe( 'BarChart', () => {
26
40
  const defaultProps = {
27
41
  width: 500,
@@ -1644,7 +1658,7 @@ describe( 'BarChart', () => {
1644
1658
  expect( screen.queryByTestId( 'bar-chart-patterns' ) ).not.toBeInTheDocument();
1645
1659
  } );
1646
1660
 
1647
- test( 'comparison shadow reuses the primary bar pattern when patterns are enabled', () => {
1661
+ test( 'fills the comparison bar with its own pattern when patterns are enabled', () => {
1648
1662
  renderWithTheme( {
1649
1663
  withPatterns: true,
1650
1664
  data: [
@@ -1670,9 +1684,7 @@ describe( 'BarChart', () => {
1670
1684
 
1671
1685
  const shadow = screen.getAllByTestId( /^bar-chart-comparison-\d+-\d+$/ )[ 0 ];
1672
1686
  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\)$/ );
1687
+ expect( shadowFill ).toMatch( /^url\(#bar-pattern-.+-1\)$/ );
1676
1688
  } );
1677
1689
  } );
1678
1690
 
@@ -2014,6 +2026,39 @@ describe( 'BarChart', () => {
2014
2026
  expect( tooltip ).toHaveTextContent( 'Previous period: 15' );
2015
2027
  } );
2016
2028
 
2029
+ test( 'pairs the comparison value by date when the bars have empty labels', async () => {
2030
+ const user = userEvent.setup();
2031
+ renderWithTheme( {
2032
+ withTooltips: true,
2033
+ data: [
2034
+ {
2035
+ label: 'This period',
2036
+ group: 'views',
2037
+ data: [
2038
+ { date: new Date( '2024-01-01' ), value: 10, label: '' },
2039
+ { date: new Date( '2024-01-02' ), value: 20, label: '' },
2040
+ ],
2041
+ },
2042
+ {
2043
+ label: 'Previous period',
2044
+ group: 'views',
2045
+ options: { type: 'comparison' as const },
2046
+ data: [
2047
+ { date: new Date( '2024-01-01' ), value: 15, label: '' },
2048
+ { date: new Date( '2024-01-02' ), value: 25, label: '' },
2049
+ ],
2050
+ },
2051
+ ],
2052
+ } );
2053
+
2054
+ screen.getByRole( 'application', { name: /bar chart/i } ).focus();
2055
+ await user.keyboard( '{ArrowRight}{ArrowRight}' );
2056
+
2057
+ const tooltip = screen.getByTestId( 'chart-tooltip-1' );
2058
+ expect( tooltip ).toHaveTextContent( 'This period: 20' );
2059
+ expect( tooltip ).toHaveTextContent( 'Previous period: 25' );
2060
+ } );
2061
+
2017
2062
  test( 'keyboard navigation past the first slot stays on the primary bar, not the comparison series', async () => {
2018
2063
  const user = userEvent.setup();
2019
2064
  renderWithTheme( {
@@ -2275,7 +2320,7 @@ describe( 'BarChart', () => {
2275
2320
  await user.keyboard( '{ArrowRight}' );
2276
2321
 
2277
2322
  expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'No data' );
2278
- expect( screen.queryByTestId( 'bar-chart-keyboard-highlight' ) ).not.toBeInTheDocument();
2323
+ expect( screen.queryByTestId( 'bar-chart-selected-ring' ) ).not.toBeInTheDocument();
2279
2324
  } );
2280
2325
 
2281
2326
  test( 'outlines the first rendered bar for the first bucket with a reading', async () => {
@@ -2286,9 +2331,7 @@ describe( 'BarChart', () => {
2286
2331
  await user.keyboard( '{ArrowRight}{ArrowRight}' );
2287
2332
 
2288
2333
  expect( screen.getByTestId( 'chart-tooltip-1' ) ).toHaveTextContent( '20' );
2289
- expect( screen.getByTestId( 'bar-chart-keyboard-highlight' ) ).toHaveTextContent(
2290
- /\.visx-bar:nth-child\(1\) \{/
2291
- );
2334
+ expect( selectedRingGeometry() ).toEqual( geometryOf( visxBars()[ 0 ] ) );
2292
2335
  } );
2293
2336
 
2294
2337
  test( 'skips the bars an earlier series left out when outlining a later series', async () => {
@@ -2319,9 +2362,7 @@ describe( 'BarChart', () => {
2319
2362
  await user.keyboard( '{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}' );
2320
2363
 
2321
2364
  expect( screen.getByTestId( 'chart-tooltip-3' ) ).toHaveTextContent( '15' );
2322
- expect( screen.getByTestId( 'bar-chart-keyboard-highlight' ) ).toHaveTextContent(
2323
- /\.visx-bar:nth-child\(4\) \{/
2324
- );
2365
+ expect( selectedRingGeometry() ).toEqual( geometryOf( visxBars()[ 3 ] ) );
2325
2366
  } );
2326
2367
  } );
2327
2368
  } );
@@ -2705,8 +2746,11 @@ describe( 'BarChart', () => {
2705
2746
  } );
2706
2747
 
2707
2748
  describe( 'Comparison Series', () => {
2708
- it( 'renders a translucent shadow behind the primary bar for a comparison series', () => {
2709
- const data = [
2749
+ it.each( [
2750
+ [ 'opaque, in the colors the charts context reports', undefined ],
2751
+ [ 'translucent, in the series colors, under the deprecated theme opacity', 0.5 ],
2752
+ ] )( 'draws a comparison bar behind the primary bar, %s', ( _case, opacity ) => {
2753
+ const data: SeriesData[] = [
2710
2754
  {
2711
2755
  label: 'This year',
2712
2756
  group: 'views',
@@ -2725,18 +2769,56 @@ describe( 'BarChart', () => {
2725
2769
  ],
2726
2770
  },
2727
2771
  ];
2728
- render( <BarChart data={ data } width={ 400 } height={ 300 } /> );
2772
+ let barColors: string[] = [];
2773
+ const CaptureBarColors = () => {
2774
+ barColors = useBarColors()( data );
2775
+ return null;
2776
+ };
2777
+ render(
2778
+ <GlobalChartsProvider
2779
+ theme={ opacity ? { barChart: { barStyles: { comparison: { opacity } } } } : undefined }
2780
+ >
2781
+ <CaptureBarColors />
2782
+ <BarChart data={ data } width={ 400 } height={ 300 } />
2783
+ </GlobalChartsProvider>
2784
+ );
2729
2785
 
2730
- // two comparison shadow rects (one per data point)
2731
- // Match individual rect testids like bar-chart-comparison-1-0 (not the group wrapper)
2786
+ const [ primaryColor, comparisonColor ] = barColors;
2732
2787
  const shadows = screen.getAllByTestId( /^bar-chart-comparison-\d+-\d+$/ );
2733
2788
  expect( shadows ).toHaveLength( 2 );
2734
- expect( shadows[ 0 ] ).toHaveAttribute( 'opacity', '0.5' );
2789
+ expect( shadows[ 0 ] ).toHaveAttribute( 'fill', comparisonColor );
2790
+ expect( shadows[ 0 ].attributes.getNamedItem( 'opacity' )?.value ).toBe(
2791
+ opacity ? String( opacity ) : undefined
2792
+ );
2793
+ expect( comparisonColor === primaryColor ).toBe( !! opacity );
2735
2794
 
2736
- // primary bars still render for the single primary series (visx .visx-bar)
2737
2795
  // eslint-disable-next-line testing-library/no-node-access
2738
2796
  const bars = document.querySelectorAll( '.visx-bar' );
2739
2797
  expect( bars.length ).toBeGreaterThanOrEqual( 2 );
2798
+ expect( bars[ 0 ] ).toHaveAttribute( 'fill', primaryColor );
2799
+ } );
2800
+
2801
+ it( "fills a comparison bar in its datum's own color, as a primary bar does", () => {
2802
+ render(
2803
+ <BarChart
2804
+ width={ 400 }
2805
+ height={ 300 }
2806
+ data={ [
2807
+ { label: 'This year', group: 'views', data: [ { label: 'Jan', value: 100 } ] },
2808
+ {
2809
+ label: 'Last year',
2810
+ group: 'views',
2811
+ options: { type: 'comparison' as const },
2812
+ data: [ { label: 'Jan', value: 80, color: '#123456' } ],
2813
+ },
2814
+ ] }
2815
+ />
2816
+ );
2817
+
2818
+ expect( screen.getByTestId( 'bar-chart-comparison-1-0' ) ).toHaveAttribute(
2819
+ 'fill',
2820
+ '#123456'
2821
+ );
2740
2822
  } );
2741
2823
 
2742
2824
  it( 'hides the comparison shadow group from assistive technology', () => {
@@ -2787,7 +2869,6 @@ describe( 'BarChart', () => {
2787
2869
 
2788
2870
  const shadows = screen.getAllByTestId( /^bar-chart-comparison-\d+-\d+$/ );
2789
2871
  expect( shadows ).toHaveLength( 2 );
2790
- expect( shadows[ 0 ] ).toHaveAttribute( 'opacity', '0.5' );
2791
2872
  } );
2792
2873
 
2793
2874
  it( 'expands value-axis domain to include comparison values exceeding the primary max', () => {
@@ -3507,6 +3588,87 @@ describe( 'BarChart', () => {
3507
3588
  expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
3508
3589
  } );
3509
3590
 
3591
+ const labelledViews = [
3592
+ { label: 'Apr', value: 29 },
3593
+ { label: 'May', value: 19 },
3594
+ ];
3595
+ const april = new Date( '2025-04-01T00:00:00Z' );
3596
+ const may = new Date( '2025-05-01T00:00:00Z' );
3597
+
3598
+ it.each( [
3599
+ {
3600
+ name: 'aligned series',
3601
+ views: labelledViews,
3602
+ visitors: [
3603
+ { label: 'Apr', value: 112 },
3604
+ { label: 'May', value: 80 },
3605
+ ],
3606
+ presses: 3,
3607
+ rows: [ 'Views=19@1', 'Visitors=80@1' ],
3608
+ nearest: 'Views',
3609
+ },
3610
+ {
3611
+ name: 'ragged series',
3612
+ views: labelledViews,
3613
+ visitors: [ { label: 'May', value: 80 } ],
3614
+ presses: 2,
3615
+ rows: [ 'Views=19@1', 'Visitors=80@0' ],
3616
+ nearest: 'Visitors',
3617
+ },
3618
+ {
3619
+ name: 'dated points with empty labels',
3620
+ views: [
3621
+ { date: april, label: '', value: 29 },
3622
+ { date: may, label: '', value: 19 },
3623
+ ],
3624
+ visitors: [
3625
+ { date: april, label: '', value: 112 },
3626
+ { date: may, label: '', value: 80 },
3627
+ ],
3628
+ presses: 3,
3629
+ rows: [ 'Views=19@1', 'Visitors=80@1' ],
3630
+ nearest: 'Views',
3631
+ },
3632
+ ] )(
3633
+ 'gives the keyboard tooltip only the bars at the selected category: $name',
3634
+ async ( { views, visitors, presses, rows, nearest } ) => {
3635
+ const user = userEvent.setup();
3636
+ const renderTooltip = ( { tooltipData }: RenderTooltipParams< DataPointDate > ) => (
3637
+ <div>
3638
+ <span data-testid="nearest">{ tooltipData?.nearestDatum?.key }</span>
3639
+ { Object.entries( tooltipData?.datumByKey ?? {} ).map(
3640
+ ( [ key, { datum, index } ] ) => (
3641
+ <span
3642
+ key={ key }
3643
+ data-testid="row"
3644
+ >{ `${ key }=${ datum.value }@${ index }` }</span>
3645
+ )
3646
+ ) }
3647
+ </div>
3648
+ );
3649
+
3650
+ renderWithTheme( {
3651
+ withTooltips: true,
3652
+ renderTooltip,
3653
+ data: [
3654
+ { label: 'Views', data: views, options: {} },
3655
+ { label: 'Visitors', data: visitors, options: {} },
3656
+ ],
3657
+ } );
3658
+
3659
+ screen.getByRole( 'application', { name: /bar chart/i } ).focus();
3660
+ for ( let i = 0; i < presses; i++ ) {
3661
+ await user.keyboard( '{ArrowRight}' );
3662
+ }
3663
+
3664
+ expect( screen.getAllByTestId( 'row' ).map( row => row.textContent ) ).toEqual(
3665
+ expect.arrayContaining( rows )
3666
+ );
3667
+ expect( screen.getAllByTestId( 'row' ) ).toHaveLength( rows.length );
3668
+ expect( screen.getByTestId( 'nearest' ) ).toHaveTextContent( nearest );
3669
+ }
3670
+ );
3671
+
3510
3672
  it( 'stops at the last visible slot on a standard chart', async () => {
3511
3673
  const user = userEvent.setup();
3512
3674
 
@@ -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
+ }
@@ -52,13 +52,28 @@
52
52
  height: 100%;
53
53
 
54
54
  &--animated {
55
- transition:
56
- opacity var(--wpds-motion-duration-lg)
57
- var(--wpds-motion-easing-subtle);
55
+
56
+ .step-text,
57
+ .funnel-bar,
58
+ .bar-container {
59
+ transition:
60
+ opacity var(--wpds-motion-duration-lg) var(--wpds-motion-easing-subtle),
61
+ background-color var(--wpds-motion-duration-lg) var(--wpds-motion-easing-subtle);
62
+ }
58
63
  }
59
64
 
65
+ // Dim the contents, not the step: opacity on an ancestor would also fade
66
+ // the bar's focus ring.
60
67
  &--blurred {
61
- opacity: 0.3;
68
+
69
+ .step-text,
70
+ .funnel-bar {
71
+ opacity: 0.3;
72
+ }
73
+
74
+ .bar-container {
75
+ background-color: color-mix(in sRGB, var(--a8c-charts-color-surface-secondary) 30%, transparent);
76
+ }
62
77
  }
63
78
  }
64
79
 
@@ -119,20 +134,18 @@
119
134
  }
120
135
  }
121
136
 
122
- .tooltip-title {
137
+ .tooltip-title,
138
+ .tooltip-label,
139
+ .tooltip-value {
123
140
  color: var(--a8c-charts-color-label);
124
141
  font-size: var(--wpds-typography-font-size-sm);
125
142
  font-style: normal;
126
143
  font-weight: var(--wpds-typography-font-weight-default);
127
- line-height: var(--wpds-typography-line-height-xs);
144
+ line-height: var(--wpds-typography-line-height-sm);
128
145
  }
129
146
 
130
- .tooltip-content {
131
- color: var(--a8c-charts-color-label);
132
- font-size: var(--wpds-typography-font-size-md);
133
- font-style: normal;
147
+ .tooltip-value {
134
148
  font-weight: var(--wpds-typography-font-weight-emphasis);
135
- line-height: var(--wpds-typography-line-height-sm);
136
149
  }
137
150
 
138
151
  .empty-state {