@automattic/charts 4.11.0 → 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.
- package/CHANGELOG.md +18 -0
- package/dist/index.cjs +856 -236
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +19 -13
- package/dist/index.d.cts +43 -4
- package/dist/index.d.ts +43 -4
- package/dist/index.js +855 -237
- package/dist/index.js.map +1 -1
- package/package.json +10 -10
- package/src/charts/bar-chart/bar-chart.tsx +134 -138
- package/src/charts/bar-chart/index.ts +3 -0
- package/src/charts/bar-chart/private/band-tooltip.tsx +41 -15
- package/src/charts/bar-chart/private/bar-layer.tsx +132 -0
- package/src/charts/bar-chart/private/comparison-bars-geometry.ts +22 -34
- package/src/charts/bar-chart/private/comparison-bars.tsx +9 -18
- package/src/charts/bar-chart/private/comparison-constants.ts +4 -10
- package/src/charts/bar-chart/private/grouped-bars.ts +68 -0
- package/src/charts/bar-chart/private/index.ts +7 -1
- package/src/charts/bar-chart/private/overlap-bars.ts +183 -0
- package/src/charts/bar-chart/private/selected-bar-ring.tsx +84 -0
- package/src/charts/bar-chart/private/slot-tooltip-members.tsx +29 -0
- package/src/charts/bar-chart/private/slotted-bar-series.tsx +84 -0
- package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +4 -11
- package/src/charts/bar-chart/private/test/comparison-bars.test.tsx +2 -3
- package/src/charts/bar-chart/private/test/grouped-bars.test.ts +87 -0
- package/src/charts/bar-chart/private/test/overlap-bars.test.ts +159 -0
- package/src/charts/bar-chart/private/use-bar-stops.ts +89 -0
- package/src/charts/bar-chart/private/use-overlap-slots.ts +194 -0
- package/src/charts/bar-chart/private/value-domain.ts +0 -10
- package/src/charts/bar-chart/test/bar-chart-overlap.test.tsx +347 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +66 -19
- package/src/charts/bar-chart/test/use-bar-legend-items.test.tsx +81 -0
- package/src/charts/bar-chart/types.ts +3 -0
- package/src/charts/bar-chart/use-bar-colors.ts +46 -0
- package/src/charts/bar-chart/use-bar-legend-items.ts +87 -0
- package/src/charts/line-chart/line-chart.tsx +22 -17
- package/src/charts/line-chart/test/line-chart.test.tsx +165 -1
- package/src/charts/pie-chart/pie-chart.tsx +7 -2
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +7 -2
- package/src/charts/private/pie-keyboard-navigation/index.ts +0 -1
- package/src/charts/private/{pie-keyboard-navigation/pie-keyboard-navigation.module.scss → selected-ring/selected-ring.module.scss} +11 -6
- package/src/charts/private/selected-ring/selected-ring.tsx +73 -0
- package/src/charts/private/{pie-keyboard-navigation/test/selected-segment-ring.test.ts → selected-ring/test/selected-ring.test.ts} +1 -1
- package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +20 -0
- package/src/components/legend/hooks/use-chart-legend-items.ts +27 -3
- package/src/components/tooltip/xy-chart-tooltip.tsx +3 -2
- package/src/index.ts +8 -2
- package/src/providers/chart-context/global-charts-provider.tsx +2 -0
- package/src/providers/chart-context/test/chart-context.test.tsx +0 -13
- package/src/providers/chart-context/themes.ts +0 -1
- package/src/providers/chart-context/types.ts +2 -0
- package/src/test-utils/selected-bar-ring.ts +14 -0
- package/src/types.ts +10 -2
- 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
|
|
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
|
-
|
|
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-
|
|
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(
|
|
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(
|
|
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(
|
|
2709
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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( '
|
|
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
|
-
//
|
|
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 =
|
|
386
|
-
const datum = series?.data[ index ];
|
|
394
|
+
const series = visibleSeries.find( candidate => candidate.data[ index ] );
|
|
387
395
|
|
|
388
|
-
if ( series
|
|
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
|
-
[
|
|
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:
|
|
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(
|
|
428
|
-
[
|
|
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
|
-
|
|
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={
|
|
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>
|