@automattic/charts 4.8.0 → 4.10.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 +28 -0
- package/dist/index.cjs +766 -277
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +72 -8
- package/dist/index.d.cts +54 -15
- package/dist/index.d.ts +54 -15
- package/dist/index.js +770 -281
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/area-chart/area-chart.tsx +7 -3
- package/src/charts/area-chart/test/area-chart.test.tsx +15 -14
- package/src/charts/area-chart/test/tooltip-resolution.test.tsx +2 -2
- package/src/charts/bar-chart/bar-chart.tsx +12 -4
- package/src/charts/bar-chart/test/bar-chart.test.tsx +74 -63
- package/src/charts/bar-list-chart/bar-list-chart.tsx +3 -0
- package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +3 -1
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +23 -7
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +22 -0
- package/src/charts/conversion-funnel-chart/types.ts +1 -1
- package/src/charts/geo-chart/geo-chart.tsx +17 -2
- package/src/charts/geo-chart/test/geo-chart.test.tsx +9 -4
- package/src/charts/geo-chart/types.ts +1 -1
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +10 -4
- package/src/charts/heatmap-chart/heatmap-chart.tsx +144 -37
- package/src/charts/heatmap-chart/private/column-groups.ts +79 -8
- package/src/charts/heatmap-chart/private/fit-compact-cells.ts +72 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +14 -2
- package/src/charts/heatmap-chart/private/index.ts +4 -2
- package/src/charts/heatmap-chart/test/column-groups.test.ts +41 -4
- package/src/charts/heatmap-chart/test/fit-compact-cells.test.ts +59 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +132 -7
- package/src/charts/heatmap-chart/types.ts +2 -2
- package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +52 -4
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +60 -9
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +80 -1
- package/src/charts/leaderboard-chart/types.ts +7 -1
- package/src/charts/line-chart/line-chart.tsx +8 -2
- package/src/charts/line-chart/private/line-chart-annotation-label-popover.tsx +19 -9
- package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +1 -1
- package/src/charts/line-chart/test/line-chart.test.tsx +37 -35
- package/src/charts/line-chart/test/tooltip-resolution.test.tsx +2 -2
- package/src/charts/line-chart/types.ts +3 -3
- package/src/charts/pie-chart/pie-chart.module.scss +3 -0
- package/src/charts/pie-chart/pie-chart.tsx +144 -139
- package/src/charts/pie-chart/test/pie-chart.test.tsx +167 -0
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +3 -0
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +117 -120
- package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +54 -0
- package/src/charts/private/chart-composition/use-chart-children.ts +11 -6
- package/src/charts/private/pie-keyboard-navigation/index.ts +4 -0
- package/src/charts/private/pie-keyboard-navigation/order-arcs-for-navigation.ts +10 -0
- package/src/charts/private/pie-keyboard-navigation/pie-keyboard-navigation.module.scss +16 -0
- package/src/charts/private/pie-keyboard-navigation/pie-selection-announcement.tsx +14 -0
- package/src/charts/private/pie-keyboard-navigation/pie-selection-output.tsx +82 -0
- package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +49 -0
- package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +47 -0
- package/src/charts/private/pie-keyboard-navigation/test/order-arcs-for-navigation.test.ts +34 -0
- package/src/charts/private/pie-keyboard-navigation/test/selected-segment-ring.test.ts +14 -0
- package/src/charts/private/pie-keyboard-navigation/use-pie-keyboard-navigation.ts +139 -0
- package/src/charts/private/x-zoom/test/x-zoom.test.tsx +3 -2
- package/src/charts/test/accessible-name.test.tsx +181 -0
- package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +63 -3
- package/src/components/legend/hooks/use-chart-legend-items.ts +63 -1
- package/src/components/legend/private/base-legend.tsx +27 -15
- package/src/components/legend/test/legend.test.tsx +42 -0
- package/src/components/legend/types.ts +2 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +20 -19
- package/src/components/tooltip/xy-chart-tooltip.tsx +3 -3
- package/src/hooks/test/use-xychart-theme.test.tsx +22 -0
- package/src/hooks/use-xychart-theme.ts +33 -2
- package/src/index.ts +1 -0
- package/src/types.ts +37 -3
- package/src/utils/resolve-font-size.ts +9 -10
- package/src/utils/test/resolve-font-size.test.ts +11 -2
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { orderArcsForNavigation } from '../order-arcs-for-navigation';
|
|
2
|
+
|
|
3
|
+
const half = Math.PI / 2;
|
|
4
|
+
|
|
5
|
+
describe( 'orderArcsForNavigation', () => {
|
|
6
|
+
it.each( [
|
|
7
|
+
[
|
|
8
|
+
'full pie, clockwise from 12 o’clock',
|
|
9
|
+
[
|
|
10
|
+
{ id: 'b', startAngle: Math.PI, endAngle: 2 * Math.PI },
|
|
11
|
+
{ id: 'a', startAngle: 0, endAngle: Math.PI },
|
|
12
|
+
],
|
|
13
|
+
[ 'a', 'b' ],
|
|
14
|
+
],
|
|
15
|
+
[
|
|
16
|
+
'clockwise semi-circle, left to right',
|
|
17
|
+
[
|
|
18
|
+
{ id: 'right', startAngle: 0, endAngle: half },
|
|
19
|
+
{ id: 'left', startAngle: -half, endAngle: 0 },
|
|
20
|
+
],
|
|
21
|
+
[ 'left', 'right' ],
|
|
22
|
+
],
|
|
23
|
+
[
|
|
24
|
+
'counter-clockwise semi-circle, left to right',
|
|
25
|
+
[
|
|
26
|
+
{ id: 'right', startAngle: half, endAngle: 0 },
|
|
27
|
+
{ id: 'left', startAngle: 0, endAngle: -half },
|
|
28
|
+
],
|
|
29
|
+
[ 'left', 'right' ],
|
|
30
|
+
],
|
|
31
|
+
] )( 'orders a %s', ( _, arcs, expected ) => {
|
|
32
|
+
expect( orderArcsForNavigation( arcs ).map( arc => arc.id ) ).toEqual( expected );
|
|
33
|
+
} );
|
|
34
|
+
} );
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { toSvgId } from '../selected-segment-ring';
|
|
2
|
+
|
|
3
|
+
describe( 'toSvgId', () => {
|
|
4
|
+
it.each( [
|
|
5
|
+
[ 'devices:1', 'devices/1' ],
|
|
6
|
+
[ 'a_3a_', 'a:' ],
|
|
7
|
+
[ ':r1:', '_r1_' ],
|
|
8
|
+
] )( 'keeps %s and %s apart', ( first, second ) => {
|
|
9
|
+
const ids = [ toSvgId( first ), toSvgId( second ) ];
|
|
10
|
+
|
|
11
|
+
expect( ids[ 0 ] ).not.toBe( ids[ 1 ] );
|
|
12
|
+
ids.forEach( id => expect( id ).toMatch( /^[A-Za-z0-9_-]+$/ ) );
|
|
13
|
+
} );
|
|
14
|
+
} );
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { useTooltip } from '@visx/tooltip';
|
|
2
|
+
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useKeyboardNavigation } from '../../../components/tooltip';
|
|
4
|
+
import type { DataPointPercentageCalculated } from '../../../types';
|
|
5
|
+
import type { MouseEvent } from 'react';
|
|
6
|
+
|
|
7
|
+
interface UsePieKeyboardNavigationProps< Arc > {
|
|
8
|
+
/** The visible arcs in `orderArcsForNavigation` order, which indexes run over. */
|
|
9
|
+
navigationArcs: Arc[];
|
|
10
|
+
withTooltips: boolean;
|
|
11
|
+
tooltipOffsetX: number;
|
|
12
|
+
tooltipOffsetY: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const usePieKeyboardNavigation = < Arc extends { data: DataPointPercentageCalculated } >( {
|
|
16
|
+
navigationArcs,
|
|
17
|
+
withTooltips,
|
|
18
|
+
tooltipOffsetX,
|
|
19
|
+
tooltipOffsetY,
|
|
20
|
+
}: UsePieKeyboardNavigationProps< Arc > ) => {
|
|
21
|
+
const chartRef = useRef< HTMLDivElement >( null );
|
|
22
|
+
const svgRef = useRef< SVGSVGElement >( null );
|
|
23
|
+
const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >( undefined );
|
|
24
|
+
const [ isNavigating, setIsNavigating ] = useState( false );
|
|
25
|
+
const { tooltipOpen, tooltipLeft, tooltipTop, tooltipData, hideTooltip, showTooltip } =
|
|
26
|
+
useTooltip< DataPointPercentageCalculated >();
|
|
27
|
+
|
|
28
|
+
const {
|
|
29
|
+
tooltipRef: focusTooltip,
|
|
30
|
+
onChartFocus,
|
|
31
|
+
onChartBlur,
|
|
32
|
+
onChartKeyDown,
|
|
33
|
+
onChartPointerMove,
|
|
34
|
+
} = useKeyboardNavigation( {
|
|
35
|
+
selectedIndex,
|
|
36
|
+
setSelectedIndex,
|
|
37
|
+
isNavigating,
|
|
38
|
+
setIsNavigating,
|
|
39
|
+
chartRef,
|
|
40
|
+
totalPoints: navigationArcs.length,
|
|
41
|
+
visibleSeriesKey: JSON.stringify( navigationArcs.map( arcDatum => arcDatum.data.label ) ),
|
|
42
|
+
} );
|
|
43
|
+
|
|
44
|
+
const selectedArc = selectedIndex === undefined ? undefined : navigationArcs[ selectedIndex ];
|
|
45
|
+
|
|
46
|
+
const keyboardTooltip = useRef< HTMLDivElement | null >( null );
|
|
47
|
+
const tooltipLostFocus = useRef( false );
|
|
48
|
+
|
|
49
|
+
// React detaches the ref before removing the node, so the tooltip still holds focus here.
|
|
50
|
+
const tooltipRef = useCallback(
|
|
51
|
+
( element: HTMLDivElement | null ) => {
|
|
52
|
+
if ( element ) {
|
|
53
|
+
keyboardTooltip.current = element;
|
|
54
|
+
focusTooltip( element );
|
|
55
|
+
} else if (
|
|
56
|
+
keyboardTooltip.current?.ownerDocument.activeElement === keyboardTooltip.current
|
|
57
|
+
) {
|
|
58
|
+
tooltipLostFocus.current = true;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
[ focusTooltip ]
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// Arrow keys mount the next tooltip in the same commit, so focus only reaches the body when the selection's tooltip went away.
|
|
65
|
+
useLayoutEffect( () => {
|
|
66
|
+
if ( ! tooltipLostFocus.current ) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
tooltipLostFocus.current = false;
|
|
70
|
+
const { activeElement, body } = chartRef.current?.ownerDocument ?? document;
|
|
71
|
+
if ( activeElement === null || activeElement === body ) {
|
|
72
|
+
chartRef.current?.focus();
|
|
73
|
+
}
|
|
74
|
+
} );
|
|
75
|
+
|
|
76
|
+
const getSegmentHandlers = ( data: DataPointPercentageCalculated, navigationIndex: number ) => ( {
|
|
77
|
+
onMouseMove: ( event: MouseEvent< SVGElement > ) => {
|
|
78
|
+
onChartPointerMove( navigationIndex );
|
|
79
|
+
if ( ! withTooltips ) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The tooltip renders inside `chartRef`, so pointer coordinates are taken relative to it.
|
|
84
|
+
const bounds = chartRef.current?.getBoundingClientRect();
|
|
85
|
+
if ( ! bounds ) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
showTooltip( {
|
|
90
|
+
tooltipData: data,
|
|
91
|
+
tooltipLeft: event.clientX - bounds.left + tooltipOffsetX,
|
|
92
|
+
tooltipTop: event.clientY - bounds.top + tooltipOffsetY,
|
|
93
|
+
} );
|
|
94
|
+
},
|
|
95
|
+
onMouseLeave: withTooltips ? hideTooltip : undefined,
|
|
96
|
+
} );
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Anchors the keyboard tooltip at `point`, an offset from the origin of the group the arcs are drawn in.
|
|
100
|
+
*
|
|
101
|
+
* @param originX - The group's x in SVG coordinates.
|
|
102
|
+
* @param originY - The group's y in SVG coordinates.
|
|
103
|
+
* @param point - The point relative to the group, such as an arc centroid.
|
|
104
|
+
* @return The anchor in SVG coordinates, or undefined without tooltips.
|
|
105
|
+
*/
|
|
106
|
+
const getKeyboardTooltipAnchor = useCallback(
|
|
107
|
+
( originX: number, originY: number, [ x, y ]: [ number, number ] ) =>
|
|
108
|
+
withTooltips
|
|
109
|
+
? { left: originX + x + tooltipOffsetX, top: originY + y + tooltipOffsetY }
|
|
110
|
+
: undefined,
|
|
111
|
+
[ withTooltips, tooltipOffsetX, tooltipOffsetY ]
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
chartRef,
|
|
116
|
+
svgRef,
|
|
117
|
+
selectedArc,
|
|
118
|
+
getSegmentHandlers,
|
|
119
|
+
getKeyboardTooltipAnchor,
|
|
120
|
+
selectionOutputProps: {
|
|
121
|
+
withTooltips,
|
|
122
|
+
selectedIndex,
|
|
123
|
+
selectedData: selectedArc?.data,
|
|
124
|
+
chartRef,
|
|
125
|
+
svgRef,
|
|
126
|
+
tooltipRef,
|
|
127
|
+
pointerTooltip: tooltipOpen
|
|
128
|
+
? { data: tooltipData, left: tooltipLeft || 0, top: tooltipTop || 0 }
|
|
129
|
+
: undefined,
|
|
130
|
+
},
|
|
131
|
+
chartProps: {
|
|
132
|
+
role: 'application',
|
|
133
|
+
tabIndex: 0,
|
|
134
|
+
onKeyDown: onChartKeyDown,
|
|
135
|
+
onFocus: onChartFocus,
|
|
136
|
+
onBlur: onChartBlur,
|
|
137
|
+
} as const,
|
|
138
|
+
};
|
|
139
|
+
};
|
|
@@ -212,11 +212,12 @@ describe( 'ZoomResetButton', () => {
|
|
|
212
212
|
|
|
213
213
|
test( 'keyboard activation survives the chart wrapper keydown handler', async () => {
|
|
214
214
|
const onClick = jest.fn();
|
|
215
|
-
// Mirrors the chart's
|
|
215
|
+
// Mirrors the chart's focusable container, whose keyboard-navigation handler
|
|
216
216
|
// calls preventDefault() on bubbled keydowns — which would cancel the
|
|
217
217
|
// native Enter/Space button activation.
|
|
218
218
|
render(
|
|
219
|
-
|
|
219
|
+
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- mirrors the charts' container.
|
|
220
|
+
<div role="application" tabIndex={ 0 } onKeyDown={ preventDefaultKeydown }>
|
|
220
221
|
<ZoomResetButton onClick={ onClick } />
|
|
221
222
|
</div>
|
|
222
223
|
);
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { GlobalChartsProvider } from '../../providers';
|
|
3
|
+
import { salesByProduct } from '../../stories/sample-data';
|
|
4
|
+
import { AreaChartUnresponsive } from '../area-chart';
|
|
5
|
+
import { BarChartUnresponsive } from '../bar-chart';
|
|
6
|
+
import { BarListChartUnresponsive } from '../bar-list-chart';
|
|
7
|
+
import { ConversionFunnelChart } from '../conversion-funnel-chart';
|
|
8
|
+
import { GeoChartUnresponsive } from '../geo-chart';
|
|
9
|
+
import { HeatmapChart } from '../heatmap-chart';
|
|
10
|
+
import { LeaderboardChartUnresponsive } from '../leaderboard-chart';
|
|
11
|
+
import { LineChartUnresponsive } from '../line-chart';
|
|
12
|
+
import { PieChartUnresponsive } from '../pie-chart';
|
|
13
|
+
import { PieSemiCircleChartUnresponsive } from '../pie-semi-circle-chart';
|
|
14
|
+
import type { GeoData } from '../../types';
|
|
15
|
+
import type { ReactElement } from 'react';
|
|
16
|
+
|
|
17
|
+
jest.mock( 'react-google-charts', () => ( { Chart: () => <div /> } ) );
|
|
18
|
+
|
|
19
|
+
const series = [
|
|
20
|
+
{
|
|
21
|
+
label: 'Series A',
|
|
22
|
+
data: [
|
|
23
|
+
{ date: new Date( '2024-01-01' ), value: 10 },
|
|
24
|
+
{ date: new Date( '2024-01-02' ), value: 20 },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const slices = [
|
|
30
|
+
{ label: 'MacOS', value: 30, valueDisplay: '30', percentage: 60 },
|
|
31
|
+
{ label: 'Linux', value: 20, valueDisplay: '20', percentage: 40 },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const geoData: GeoData = [
|
|
35
|
+
[ 'Country', 'Value' ],
|
|
36
|
+
[ 'US', 100 ],
|
|
37
|
+
[ 'CA', 50 ],
|
|
38
|
+
[ 'GB', 25 ],
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const funnelSteps = [
|
|
42
|
+
{ id: 'sessions', label: 'Sessions', rate: 100, count: 10000 },
|
|
43
|
+
{ id: 'cart', label: 'Cart', rate: 71.1, count: 7110 },
|
|
44
|
+
{ id: 'checkout', label: 'Checkout', rate: 52.5, count: 5250 },
|
|
45
|
+
{ id: 'purchase', label: 'Purchase', rate: 10.3, count: 1030 },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const heatmapData = [
|
|
49
|
+
{ label: 'W1', data: [ { value: 1 }, { value: 2 }, { value: null } ] },
|
|
50
|
+
{ label: 'W2', data: [ { value: 3 }, { value: 0 }, { value: 4 } ] },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const leaderboardData = [
|
|
54
|
+
{
|
|
55
|
+
id: 'direct',
|
|
56
|
+
label: 'Direct',
|
|
57
|
+
currentValue: 12500,
|
|
58
|
+
previousValue: 10000,
|
|
59
|
+
currentShare: 100,
|
|
60
|
+
previousShare: 80,
|
|
61
|
+
delta: 25,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'social',
|
|
65
|
+
label: 'Social Media',
|
|
66
|
+
currentValue: 8750,
|
|
67
|
+
previousValue: 9500,
|
|
68
|
+
currentShare: 70,
|
|
69
|
+
previousShare: 76,
|
|
70
|
+
delta: -8,
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const CHARTS: [ string, string, string, ( ariaLabel?: string ) => ReactElement ][] = [
|
|
75
|
+
[
|
|
76
|
+
'line',
|
|
77
|
+
'application',
|
|
78
|
+
'Line chart',
|
|
79
|
+
ariaLabel => (
|
|
80
|
+
<LineChartUnresponsive
|
|
81
|
+
data={ series }
|
|
82
|
+
width={ 400 }
|
|
83
|
+
height={ 300 }
|
|
84
|
+
withGradientFill={ false }
|
|
85
|
+
ariaLabel={ ariaLabel }
|
|
86
|
+
/>
|
|
87
|
+
),
|
|
88
|
+
],
|
|
89
|
+
[
|
|
90
|
+
'area',
|
|
91
|
+
'application',
|
|
92
|
+
'Area chart',
|
|
93
|
+
ariaLabel => (
|
|
94
|
+
<AreaChartUnresponsive data={ series } width={ 400 } height={ 300 } ariaLabel={ ariaLabel } />
|
|
95
|
+
),
|
|
96
|
+
],
|
|
97
|
+
[
|
|
98
|
+
'bar',
|
|
99
|
+
'application',
|
|
100
|
+
'Bar chart',
|
|
101
|
+
ariaLabel => (
|
|
102
|
+
<BarChartUnresponsive data={ series } width={ 400 } height={ 300 } ariaLabel={ ariaLabel } />
|
|
103
|
+
),
|
|
104
|
+
],
|
|
105
|
+
[
|
|
106
|
+
'bar list',
|
|
107
|
+
'application',
|
|
108
|
+
'Bar list chart',
|
|
109
|
+
ariaLabel => (
|
|
110
|
+
<BarListChartUnresponsive
|
|
111
|
+
data={ salesByProduct }
|
|
112
|
+
width={ 400 }
|
|
113
|
+
height={ 300 }
|
|
114
|
+
ariaLabel={ ariaLabel }
|
|
115
|
+
/>
|
|
116
|
+
),
|
|
117
|
+
],
|
|
118
|
+
[
|
|
119
|
+
'pie',
|
|
120
|
+
'application',
|
|
121
|
+
'Pie chart',
|
|
122
|
+
ariaLabel => <PieChartUnresponsive data={ slices } size={ 300 } ariaLabel={ ariaLabel } />,
|
|
123
|
+
],
|
|
124
|
+
[
|
|
125
|
+
'semi-circle',
|
|
126
|
+
'application',
|
|
127
|
+
'Semi-circle chart',
|
|
128
|
+
ariaLabel => (
|
|
129
|
+
<PieSemiCircleChartUnresponsive data={ slices } width={ 300 } ariaLabel={ ariaLabel } />
|
|
130
|
+
),
|
|
131
|
+
],
|
|
132
|
+
[
|
|
133
|
+
'geo',
|
|
134
|
+
'group',
|
|
135
|
+
'Geo chart',
|
|
136
|
+
ariaLabel => (
|
|
137
|
+
<GeoChartUnresponsive data={ geoData } width={ 800 } height={ 400 } ariaLabel={ ariaLabel } />
|
|
138
|
+
),
|
|
139
|
+
],
|
|
140
|
+
[
|
|
141
|
+
'conversion funnel',
|
|
142
|
+
'group',
|
|
143
|
+
'Conversion funnel chart',
|
|
144
|
+
ariaLabel => (
|
|
145
|
+
<ConversionFunnelChart mainRate={ 25 } steps={ funnelSteps } ariaLabel={ ariaLabel } />
|
|
146
|
+
),
|
|
147
|
+
],
|
|
148
|
+
[
|
|
149
|
+
'heatmap',
|
|
150
|
+
'grid',
|
|
151
|
+
'Heatmap chart',
|
|
152
|
+
ariaLabel => (
|
|
153
|
+
<HeatmapChart data={ heatmapData } width={ 500 } height={ 300 } ariaLabel={ ariaLabel } />
|
|
154
|
+
),
|
|
155
|
+
],
|
|
156
|
+
[
|
|
157
|
+
'leaderboard',
|
|
158
|
+
'list',
|
|
159
|
+
'Leaderboard chart',
|
|
160
|
+
ariaLabel => (
|
|
161
|
+
<LeaderboardChartUnresponsive
|
|
162
|
+
data={ leaderboardData }
|
|
163
|
+
width={ 400 }
|
|
164
|
+
height={ 300 }
|
|
165
|
+
ariaLabel={ ariaLabel }
|
|
166
|
+
/>
|
|
167
|
+
),
|
|
168
|
+
],
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
it.each( CHARTS )(
|
|
172
|
+
'names the %s chart from ariaLabel, or its default',
|
|
173
|
+
( _chart, role, defaultName, renderChart ) => {
|
|
174
|
+
const { unmount } = render( <GlobalChartsProvider>{ renderChart() }</GlobalChartsProvider> );
|
|
175
|
+
expect( screen.getByRole( role, { name: defaultName } ) ).toBeInTheDocument();
|
|
176
|
+
unmount();
|
|
177
|
+
|
|
178
|
+
render( <GlobalChartsProvider>{ renderChart( 'Views by day' ) }</GlobalChartsProvider> );
|
|
179
|
+
expect( screen.getByRole( role, { name: 'Views by day' } ) ).toBeInTheDocument();
|
|
180
|
+
}
|
|
181
|
+
);
|
|
@@ -257,6 +257,23 @@ describe( 'useChartLegendItems', () => {
|
|
|
257
257
|
data: [ { label: 'Mon', value: 90 } ],
|
|
258
258
|
},
|
|
259
259
|
];
|
|
260
|
+
const twoMetricData: SeriesData[] = [
|
|
261
|
+
{ label: 'Views', group: 'views', data: [ { label: 'Mon', value: 100 } ] },
|
|
262
|
+
{
|
|
263
|
+
label: 'Views — previous',
|
|
264
|
+
group: 'views',
|
|
265
|
+
options: { type: 'comparison' as const },
|
|
266
|
+
data: [ { label: 'Mon', value: 90 } ],
|
|
267
|
+
},
|
|
268
|
+
{ label: 'Visitors', group: 'visitors', data: [ { label: 'Mon', value: 50 } ] },
|
|
269
|
+
{
|
|
270
|
+
label: 'Visitors — previous',
|
|
271
|
+
group: 'visitors',
|
|
272
|
+
options: { type: 'comparison' as const },
|
|
273
|
+
data: [ { label: 'Mon', value: 40 } ],
|
|
274
|
+
},
|
|
275
|
+
];
|
|
276
|
+
const perMetricLabel = ( metric: string ) => `${ metric } (Prev. 7 days)`;
|
|
260
277
|
|
|
261
278
|
test( 'is off by default', () => {
|
|
262
279
|
const { result } = renderHook(
|
|
@@ -300,15 +317,58 @@ describe( 'useChartLegendItems', () => {
|
|
|
300
317
|
expect( result.current.map( item => item.label ) ).toEqual( [ 'Views', 'Previous period' ] );
|
|
301
318
|
} );
|
|
302
319
|
|
|
303
|
-
test( '
|
|
320
|
+
test( 'names one static item per compared metric with comparisonSeriesItems', () => {
|
|
304
321
|
const { result } = renderHook(
|
|
305
|
-
() =>
|
|
322
|
+
() =>
|
|
323
|
+
useChartLegendItems(
|
|
324
|
+
twoMetricData,
|
|
325
|
+
{ collapseGroups: true, comparisonSeriesItems: perMetricLabel },
|
|
326
|
+
'line'
|
|
327
|
+
),
|
|
306
328
|
{ wrapper }
|
|
307
329
|
);
|
|
308
330
|
|
|
309
|
-
expect( result.current.map( item => item.label ) ).toEqual( [
|
|
331
|
+
expect( result.current.map( item => item.label ) ).toEqual( [
|
|
332
|
+
'Views',
|
|
333
|
+
'Visitors',
|
|
334
|
+
'Views (Prev. 7 days)',
|
|
335
|
+
'Visitors (Prev. 7 days)',
|
|
336
|
+
] );
|
|
337
|
+
expect(
|
|
338
|
+
result.current.slice( 2 ).map( ( { interactive, describesSeries } ) => ( {
|
|
339
|
+
interactive,
|
|
340
|
+
describesSeries,
|
|
341
|
+
} ) )
|
|
342
|
+
).toEqual( [
|
|
343
|
+
{ interactive: false, describesSeries: 'Views — previous' },
|
|
344
|
+
{ interactive: false, describesSeries: 'Visitors — previous' },
|
|
345
|
+
] );
|
|
346
|
+
expect( result.current[ 2 ].color ).toBe( result.current[ 0 ].color );
|
|
347
|
+
expect( result.current[ 3 ].color ).toBe( result.current[ 1 ].color );
|
|
310
348
|
} );
|
|
311
349
|
|
|
350
|
+
test.each( [
|
|
351
|
+
[
|
|
352
|
+
'comparisonItem',
|
|
353
|
+
comparisonData,
|
|
354
|
+
{ comparisonItem: true },
|
|
355
|
+
[ 'Views', 'Views — previous' ],
|
|
356
|
+
],
|
|
357
|
+
[
|
|
358
|
+
'comparisonSeriesItems',
|
|
359
|
+
twoMetricData,
|
|
360
|
+
{ comparisonSeriesItems: perMetricLabel },
|
|
361
|
+
[ 'Views', 'Views — previous', 'Visitors', 'Visitors — previous' ],
|
|
362
|
+
],
|
|
363
|
+
] )(
|
|
364
|
+
'adds nothing for %s when the comparison series already has its own item',
|
|
365
|
+
( _name, data, options, expected ) => {
|
|
366
|
+
const { result } = renderHook( () => useChartLegendItems( data, options ), { wrapper } );
|
|
367
|
+
|
|
368
|
+
expect( result.current.map( item => item.label ) ).toEqual( expected );
|
|
369
|
+
}
|
|
370
|
+
);
|
|
371
|
+
|
|
312
372
|
test( 'styles a current-period series by its own line styles, not the theme index', () => {
|
|
313
373
|
const theme = {
|
|
314
374
|
seriesLineStyles: [ { strokeWidth: 2 } ],
|
|
@@ -8,10 +8,12 @@ import {
|
|
|
8
8
|
} from '../../../providers';
|
|
9
9
|
import { formatPercentage } from '../../../utils';
|
|
10
10
|
import type {
|
|
11
|
+
ComparisonItemLabel,
|
|
11
12
|
SeriesData,
|
|
12
13
|
DataPointDate,
|
|
13
14
|
DataPointPercentageCalculated,
|
|
14
15
|
LegendShape,
|
|
16
|
+
SeriesChartLegendConfig,
|
|
15
17
|
} from '../../../types';
|
|
16
18
|
import type { BaseLegendItem } from '../types';
|
|
17
19
|
import type { GlyphProps } from '@visx/xychart';
|
|
@@ -36,7 +38,9 @@ export interface ChartLegendOptions {
|
|
|
36
38
|
* `options.type === 'comparison'`. Skipped when that series already has its own
|
|
37
39
|
* item. Pass a string to replace the default label.
|
|
38
40
|
*/
|
|
39
|
-
comparisonItem?:
|
|
41
|
+
comparisonItem?: SeriesChartLegendConfig[ 'comparisonItem' ];
|
|
42
|
+
/** One static item per comparison series instead; see `SeriesChartLegendConfig`. */
|
|
43
|
+
comparisonSeriesItems?: SeriesChartLegendConfig[ 'comparisonSeriesItems' ];
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
/**
|
|
@@ -276,6 +280,48 @@ function buildComparisonLegendItem(
|
|
|
276
280
|
return { label, color, shapeStyle: shapeStyles, interactive: false };
|
|
277
281
|
}
|
|
278
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Builds one static item per comparison series folded into another series' item, named after
|
|
285
|
+
* that item and styled like the comparison series itself.
|
|
286
|
+
* @param seriesData - The series data to search for comparison series
|
|
287
|
+
* @param items - The legend items already built from the series
|
|
288
|
+
* @param formatLabel - Names an item after the label of the item it was folded into
|
|
289
|
+
* @param getElementStyles - Function to get element styles
|
|
290
|
+
* @param legendShape - The shape type for legend items (string literal or React component)
|
|
291
|
+
* @return The legend items, in series order
|
|
292
|
+
*/
|
|
293
|
+
function buildPerSeriesComparisonItems(
|
|
294
|
+
seriesData: SeriesData[],
|
|
295
|
+
items: BaseLegendItem[],
|
|
296
|
+
formatLabel: ComparisonItemLabel,
|
|
297
|
+
getElementStyles: ( params: GetElementStylesParams ) => ElementStyles,
|
|
298
|
+
legendShape?: LegendShape< SeriesData[], number >
|
|
299
|
+
): BaseLegendItem[] {
|
|
300
|
+
return seriesData.flatMap( ( series, index ) => {
|
|
301
|
+
const owner = items.find( item => item.seriesLabels?.includes( series.label ) );
|
|
302
|
+
|
|
303
|
+
if (
|
|
304
|
+
series.options?.type !== 'comparison' ||
|
|
305
|
+
! owner ||
|
|
306
|
+
owner.seriesLabels?.[ 0 ] === series.label
|
|
307
|
+
) {
|
|
308
|
+
return [];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const { color, shapeStyles } = getElementStyles( { data: series, index, legendShape } );
|
|
312
|
+
|
|
313
|
+
return [
|
|
314
|
+
{
|
|
315
|
+
label: formatLabel( owner.label ),
|
|
316
|
+
color,
|
|
317
|
+
shapeStyle: shapeStyles,
|
|
318
|
+
interactive: false,
|
|
319
|
+
describesSeries: series.label,
|
|
320
|
+
},
|
|
321
|
+
];
|
|
322
|
+
} );
|
|
323
|
+
}
|
|
324
|
+
|
|
279
325
|
/**
|
|
280
326
|
* Processes point data into legend items
|
|
281
327
|
* @param pointData - The point data to process
|
|
@@ -339,6 +385,7 @@ export function useChartLegendItems<
|
|
|
339
385
|
glyphSize = 8,
|
|
340
386
|
collapseGroups = false,
|
|
341
387
|
comparisonItem = false,
|
|
388
|
+
comparisonSeriesItems,
|
|
342
389
|
renderGlyph,
|
|
343
390
|
} = options;
|
|
344
391
|
const { getElementStyles } = useGlobalChartsContext();
|
|
@@ -361,6 +408,20 @@ export function useChartLegendItems<
|
|
|
361
408
|
renderGlyph,
|
|
362
409
|
legendShape
|
|
363
410
|
);
|
|
411
|
+
|
|
412
|
+
if ( comparisonSeriesItems ) {
|
|
413
|
+
return [
|
|
414
|
+
...items,
|
|
415
|
+
...buildPerSeriesComparisonItems(
|
|
416
|
+
seriesData,
|
|
417
|
+
items,
|
|
418
|
+
comparisonSeriesItems,
|
|
419
|
+
getElementStyles,
|
|
420
|
+
legendShape
|
|
421
|
+
),
|
|
422
|
+
];
|
|
423
|
+
}
|
|
424
|
+
|
|
364
425
|
const comparison = comparisonItem
|
|
365
426
|
? buildComparisonLegendItem(
|
|
366
427
|
seriesData,
|
|
@@ -396,6 +457,7 @@ export function useChartLegendItems<
|
|
|
396
457
|
glyphSize,
|
|
397
458
|
collapseGroups,
|
|
398
459
|
comparisonItem,
|
|
460
|
+
comparisonSeriesItems,
|
|
399
461
|
renderGlyph,
|
|
400
462
|
legendShape,
|
|
401
463
|
] );
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
forwardRef,
|
|
12
12
|
useCallback,
|
|
13
13
|
useContext,
|
|
14
|
+
useMemo,
|
|
14
15
|
} from 'react';
|
|
15
16
|
import { ChartInstanceContext } from '../../../charts/private/chart-instance-context';
|
|
16
17
|
import { useTextTruncation } from '../../../hooks';
|
|
@@ -115,7 +116,7 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
115
116
|
> = forwardRef< HTMLDivElement, BaseLegendProps >(
|
|
116
117
|
(
|
|
117
118
|
{
|
|
118
|
-
items,
|
|
119
|
+
items: allItems,
|
|
119
120
|
className,
|
|
120
121
|
orientation = 'horizontal',
|
|
121
122
|
alignment = 'center',
|
|
@@ -154,6 +155,31 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
154
155
|
const chartInstanceContext = useContext( ChartInstanceContext );
|
|
155
156
|
const standaloneScopeClass = useStandaloneScopeClass();
|
|
156
157
|
|
|
158
|
+
// Disabling interaction for the whole legend still preserves its series' visibility state.
|
|
159
|
+
const isSeriesVisible = useCallback(
|
|
160
|
+
( seriesLabel: string ) => {
|
|
161
|
+
if ( chartInstanceContext?.isSeriesVisible ) {
|
|
162
|
+
return chartInstanceContext.isSeriesVisible( seriesLabel );
|
|
163
|
+
}
|
|
164
|
+
if ( ! chartId || ! context ) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
return context.isSeriesVisible( chartId, seriesLabel );
|
|
168
|
+
},
|
|
169
|
+
[ chartId, chartInstanceContext, context ]
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const items = useMemo(
|
|
173
|
+
() =>
|
|
174
|
+
allItems.filter(
|
|
175
|
+
item =>
|
|
176
|
+
item.interactive !== false ||
|
|
177
|
+
! item.describesSeries ||
|
|
178
|
+
isSeriesVisible( item.describesSeries )
|
|
179
|
+
),
|
|
180
|
+
[ allItems, isSeriesVisible ]
|
|
181
|
+
);
|
|
182
|
+
|
|
157
183
|
// Keep duplicate labels: a static comparison item can share a metric's name.
|
|
158
184
|
const domain = items.map( item => item.label );
|
|
159
185
|
const legendScale = scaleOrdinal( {
|
|
@@ -189,20 +215,6 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
189
215
|
[ interactive, chartId, context ]
|
|
190
216
|
);
|
|
191
217
|
|
|
192
|
-
// Disabling interaction for the whole legend still preserves its series' visibility state.
|
|
193
|
-
const isSeriesVisible = useCallback(
|
|
194
|
-
( seriesLabel: string ) => {
|
|
195
|
-
if ( chartInstanceContext?.isSeriesVisible ) {
|
|
196
|
-
return chartInstanceContext.isSeriesVisible( seriesLabel );
|
|
197
|
-
}
|
|
198
|
-
if ( ! chartId || ! context ) {
|
|
199
|
-
return true;
|
|
200
|
-
}
|
|
201
|
-
return context.isSeriesVisible( chartId, seriesLabel );
|
|
202
|
-
},
|
|
203
|
-
[ chartId, chartInstanceContext, context ]
|
|
204
|
-
);
|
|
205
|
-
|
|
206
218
|
// Create event handlers to avoid inline arrow functions
|
|
207
219
|
const createClickHandler = useCallback(
|
|
208
220
|
( seriesLabels: string[], itemInteractive: boolean ) => {
|
|
@@ -801,6 +801,48 @@ describe( 'BaseLegend', () => {
|
|
|
801
801
|
expect( staticItem ).not.toHaveClass( 'legend-item--inactive' );
|
|
802
802
|
} );
|
|
803
803
|
|
|
804
|
+
it( 'leaves out a static item while its series is hidden, keeping later swatches aligned', async () => {
|
|
805
|
+
const user = userEvent.setup();
|
|
806
|
+
const items = [
|
|
807
|
+
{ label: 'Views', color: '#0000ff', seriesLabels: [ 'Views', 'Views · previous' ] },
|
|
808
|
+
{
|
|
809
|
+
label: 'Visitors',
|
|
810
|
+
color: '#00ff00',
|
|
811
|
+
seriesLabels: [ 'Visitors', 'Visitors · previous' ],
|
|
812
|
+
},
|
|
813
|
+
{
|
|
814
|
+
label: 'Views (Prev. 7 days)',
|
|
815
|
+
color: '#000088',
|
|
816
|
+
interactive: false,
|
|
817
|
+
describesSeries: 'Views · previous',
|
|
818
|
+
},
|
|
819
|
+
{
|
|
820
|
+
label: 'Visitors (Prev. 7 days)',
|
|
821
|
+
color: '#008800',
|
|
822
|
+
interactive: false,
|
|
823
|
+
describesSeries: 'Visitors · previous',
|
|
824
|
+
},
|
|
825
|
+
];
|
|
826
|
+
|
|
827
|
+
render(
|
|
828
|
+
<GlobalChartsProvider>
|
|
829
|
+
<BaseLegend items={ items } interactive chartId="test-chart" shape={ TestShape } />
|
|
830
|
+
</GlobalChartsProvider>
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
await user.click( screen.getByRole( 'button', { name: /^Views/ } ) );
|
|
834
|
+
|
|
835
|
+
expect( screen.getAllByTestId( 'legend-item' ).map( item => item.textContent ) ).toEqual( [
|
|
836
|
+
'Views',
|
|
837
|
+
'Visitors',
|
|
838
|
+
'Visitors (Prev. 7 days)',
|
|
839
|
+
] );
|
|
840
|
+
expect( screen.getAllByTestId( 'legend-marker' )[ 2 ] ).toHaveAttribute( 'fill', '#008800' );
|
|
841
|
+
|
|
842
|
+
await user.click( screen.getByRole( 'button', { name: /^Views/ } ) );
|
|
843
|
+
expect( screen.getAllByTestId( 'legend-item' ) ).toHaveLength( 4 );
|
|
844
|
+
} );
|
|
845
|
+
|
|
804
846
|
it( 'keeps a comparison item distinct when its label collides', async () => {
|
|
805
847
|
const user = userEvent.setup();
|
|
806
848
|
const Example = () => {
|