@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.
- package/CHANGELOG.md +24 -0
- package/dist/index.cjs +1412 -787
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +59 -25
- package/dist/index.d.cts +64 -120
- package/dist/index.d.ts +64 -120
- package/dist/index.js +1400 -765
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
- package/src/charts/area-chart/area-chart.tsx +16 -6
- package/src/charts/area-chart/test/area-chart.test.tsx +42 -9
- package/src/charts/bar-chart/bar-chart.tsx +143 -142
- 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 +28 -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 +87 -0
- package/src/charts/bar-chart/private/use-overlap-slots.ts +213 -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 +181 -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/conversion-funnel-chart/conversion-funnel-chart.module.scss +24 -11
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +235 -182
- package/src/charts/conversion-funnel-chart/index.ts +2 -0
- package/src/charts/conversion-funnel-chart/private/index.ts +1 -0
- package/src/charts/conversion-funnel-chart/private/use-funnel-selection.ts +9 -47
- package/src/charts/conversion-funnel-chart/private/use-roving-step-focus.ts +83 -0
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +312 -92
- package/src/charts/conversion-funnel-chart/test/use-funnel-selection.test.ts +23 -188
- package/src/charts/conversion-funnel-chart/types.ts +22 -0
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +41 -1
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +101 -30
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +47 -0
- package/src/charts/line-chart/line-chart.tsx +25 -18
- package/src/charts/line-chart/test/line-chart.test.tsx +240 -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/private/at-nearest-date.ts +49 -0
- package/src/components/tooltip/private/keyboard-stops.ts +67 -0
- package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +10 -2
- package/src/components/tooltip/test/keyboard-stops.test.ts +36 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +135 -1
- package/src/components/tooltip/xy-chart-tooltip.tsx +11 -14
- package/src/index.ts +10 -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 +14 -5
- package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +0 -47
|
@@ -2,205 +2,40 @@ import { act, renderHook } from '@testing-library/react';
|
|
|
2
2
|
import { useFunnelSelection } from '../private/use-funnel-selection';
|
|
3
3
|
|
|
4
4
|
describe( 'useFunnelSelection', () => {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const { result } = renderHook( () => useFunnelSelection() );
|
|
5
|
+
it( 'starts with no step selected', () => {
|
|
6
|
+
const { result } = renderHook( () => useFunnelSelection() );
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
expect( result.current.getStepState( 'cart' ) ).toEqual( {
|
|
9
|
+
isSelected: false,
|
|
10
|
+
isBlurred: false,
|
|
12
11
|
} );
|
|
13
12
|
} );
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const { result } = renderHook( () => useFunnelSelection() );
|
|
14
|
+
it( 'selects one step and blurs the others', () => {
|
|
15
|
+
const { result } = renderHook( () => useFunnelSelection() );
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
} );
|
|
17
|
+
act( () => result.current.selectStep( 'sessions' ) );
|
|
18
|
+
act( () => result.current.selectStep( 'cart' ) );
|
|
22
19
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
expect( result.current.getStepState( 'cart' ) ).toEqual( {
|
|
21
|
+
isSelected: true,
|
|
22
|
+
isBlurred: false,
|
|
26
23
|
} );
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
act( () => {
|
|
32
|
-
result.current.handleBarClick( 'selected-step' );
|
|
33
|
-
} );
|
|
34
|
-
|
|
35
|
-
const selectedState = result.current.getStepState( 'selected-step' );
|
|
36
|
-
const otherState = result.current.getStepState( 'other-step' );
|
|
37
|
-
|
|
38
|
-
expect( selectedState.isClicked ).toBe( true );
|
|
39
|
-
expect( selectedState.isBlurred ).toBe( false );
|
|
40
|
-
expect( otherState.isClicked ).toBe( false );
|
|
41
|
-
expect( otherState.isBlurred ).toBe( true );
|
|
42
|
-
} );
|
|
43
|
-
|
|
44
|
-
it( 'deselects step when clicking the same bar again', () => {
|
|
45
|
-
const { result } = renderHook( () => useFunnelSelection() );
|
|
46
|
-
|
|
47
|
-
// First click to select
|
|
48
|
-
act( () => {
|
|
49
|
-
result.current.handleBarClick( 'test-step' );
|
|
50
|
-
} );
|
|
51
|
-
|
|
52
|
-
let stepState = result.current.getStepState( 'test-step' );
|
|
53
|
-
expect( stepState.isClicked ).toBe( true );
|
|
54
|
-
|
|
55
|
-
// Second click to deselect
|
|
56
|
-
act( () => {
|
|
57
|
-
result.current.handleBarClick( 'test-step' );
|
|
58
|
-
} );
|
|
59
|
-
|
|
60
|
-
stepState = result.current.getStepState( 'test-step' );
|
|
61
|
-
expect( stepState.isClicked ).toBe( false );
|
|
62
|
-
expect( stepState.isBlurred ).toBe( false );
|
|
63
|
-
} );
|
|
64
|
-
|
|
65
|
-
it( 'switches selection when clicking different bar', () => {
|
|
66
|
-
const { result } = renderHook( () => useFunnelSelection() );
|
|
67
|
-
|
|
68
|
-
// Select first step
|
|
69
|
-
act( () => {
|
|
70
|
-
result.current.handleBarClick( 'step1' );
|
|
71
|
-
} );
|
|
72
|
-
|
|
73
|
-
// Select second step
|
|
74
|
-
act( () => {
|
|
75
|
-
result.current.handleBarClick( 'step2' );
|
|
76
|
-
} );
|
|
77
|
-
|
|
78
|
-
const step1State = result.current.getStepState( 'step1' );
|
|
79
|
-
const step2State = result.current.getStepState( 'step2' );
|
|
80
|
-
|
|
81
|
-
expect( step1State.isClicked ).toBe( false );
|
|
82
|
-
expect( step1State.isBlurred ).toBe( true );
|
|
83
|
-
expect( step2State.isClicked ).toBe( true );
|
|
84
|
-
expect( step2State.isBlurred ).toBe( false );
|
|
85
|
-
} );
|
|
86
|
-
} );
|
|
87
|
-
|
|
88
|
-
describe( 'Keyboard Handling', () => {
|
|
89
|
-
it( 'selects step on Enter key', () => {
|
|
90
|
-
const { result } = renderHook( () => useFunnelSelection() );
|
|
91
|
-
const mockEvent = {
|
|
92
|
-
key: 'Enter',
|
|
93
|
-
preventDefault: jest.fn(),
|
|
94
|
-
stopPropagation: jest.fn(),
|
|
95
|
-
} as unknown as React.KeyboardEvent;
|
|
96
|
-
|
|
97
|
-
act( () => {
|
|
98
|
-
result.current.handleBarKeyDown( 'test-step', mockEvent );
|
|
99
|
-
} );
|
|
100
|
-
|
|
101
|
-
const stepState = result.current.getStepState( 'test-step' );
|
|
102
|
-
expect( stepState.isClicked ).toBe( true );
|
|
103
|
-
expect( mockEvent.preventDefault ).toHaveBeenCalled();
|
|
104
|
-
// Note: stopPropagation is not called in keyboard handler
|
|
105
|
-
} );
|
|
106
|
-
|
|
107
|
-
it( 'selects step on Space key', () => {
|
|
108
|
-
const { result } = renderHook( () => useFunnelSelection() );
|
|
109
|
-
const mockEvent = {
|
|
110
|
-
key: ' ',
|
|
111
|
-
preventDefault: jest.fn(),
|
|
112
|
-
stopPropagation: jest.fn(),
|
|
113
|
-
} as unknown as React.KeyboardEvent;
|
|
114
|
-
|
|
115
|
-
act( () => {
|
|
116
|
-
result.current.handleBarKeyDown( 'test-step', mockEvent );
|
|
117
|
-
} );
|
|
118
|
-
|
|
119
|
-
const stepState = result.current.getStepState( 'test-step' );
|
|
120
|
-
expect( stepState.isClicked ).toBe( true );
|
|
121
|
-
expect( mockEvent.preventDefault ).toHaveBeenCalled();
|
|
122
|
-
// Note: stopPropagation is not called in keyboard handler
|
|
123
|
-
} );
|
|
124
|
-
|
|
125
|
-
it( 'ignores other keys', () => {
|
|
126
|
-
const { result } = renderHook( () => useFunnelSelection() );
|
|
127
|
-
const mockEvent = {
|
|
128
|
-
key: 'Tab',
|
|
129
|
-
preventDefault: jest.fn(),
|
|
130
|
-
stopPropagation: jest.fn(),
|
|
131
|
-
} as unknown as React.KeyboardEvent;
|
|
132
|
-
|
|
133
|
-
act( () => {
|
|
134
|
-
result.current.handleBarKeyDown( 'test-step', mockEvent );
|
|
135
|
-
} );
|
|
136
|
-
|
|
137
|
-
const stepState = result.current.getStepState( 'test-step' );
|
|
138
|
-
expect( stepState.isClicked ).toBe( false );
|
|
139
|
-
expect( mockEvent.preventDefault ).not.toHaveBeenCalled();
|
|
140
|
-
expect( mockEvent.stopPropagation ).not.toHaveBeenCalled();
|
|
141
|
-
} );
|
|
142
|
-
} );
|
|
143
|
-
|
|
144
|
-
describe( 'Clear Selection', () => {
|
|
145
|
-
it( 'clears selection when clearSelection is called', () => {
|
|
146
|
-
const { result } = renderHook( () => useFunnelSelection() );
|
|
147
|
-
|
|
148
|
-
// First select a step
|
|
149
|
-
act( () => {
|
|
150
|
-
result.current.handleBarClick( 'test-step' );
|
|
151
|
-
} );
|
|
152
|
-
|
|
153
|
-
let stepState = result.current.getStepState( 'test-step' );
|
|
154
|
-
expect( stepState.isClicked ).toBe( true );
|
|
155
|
-
|
|
156
|
-
// Clear selection
|
|
157
|
-
act( () => {
|
|
158
|
-
result.current.clearSelection();
|
|
159
|
-
} );
|
|
160
|
-
|
|
161
|
-
stepState = result.current.getStepState( 'test-step' );
|
|
162
|
-
expect( stepState.isClicked ).toBe( false );
|
|
163
|
-
expect( stepState.isBlurred ).toBe( false );
|
|
24
|
+
expect( result.current.getStepState( 'sessions' ) ).toEqual( {
|
|
25
|
+
isSelected: false,
|
|
26
|
+
isBlurred: true,
|
|
164
27
|
} );
|
|
165
28
|
} );
|
|
166
29
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const stepState = result.current.getStepState( 'test-step' );
|
|
172
|
-
expect( stepState ).toEqual( {
|
|
173
|
-
isClicked: false,
|
|
174
|
-
isBlurred: false,
|
|
175
|
-
} );
|
|
176
|
-
} );
|
|
177
|
-
|
|
178
|
-
it( 'returns correct state for selected step', () => {
|
|
179
|
-
const { result } = renderHook( () => useFunnelSelection() );
|
|
30
|
+
it( 'clears the selection and hides the tooltip', () => {
|
|
31
|
+
const hideTooltip = jest.fn();
|
|
32
|
+
const { result } = renderHook( () => useFunnelSelection( hideTooltip ) );
|
|
180
33
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
} );
|
|
34
|
+
act( () => result.current.selectStep( 'cart' ) );
|
|
35
|
+
act( () => result.current.clearSelection() );
|
|
184
36
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
isBlurred: false,
|
|
189
|
-
} );
|
|
190
|
-
} );
|
|
191
|
-
|
|
192
|
-
it( 'returns correct state for blurred steps', () => {
|
|
193
|
-
const { result } = renderHook( () => useFunnelSelection() );
|
|
194
|
-
|
|
195
|
-
act( () => {
|
|
196
|
-
result.current.handleBarClick( 'selected-step' );
|
|
197
|
-
} );
|
|
198
|
-
|
|
199
|
-
const blurredStepState = result.current.getStepState( 'other-step' );
|
|
200
|
-
expect( blurredStepState ).toEqual( {
|
|
201
|
-
isClicked: false,
|
|
202
|
-
isBlurred: true,
|
|
203
|
-
} );
|
|
204
|
-
} );
|
|
37
|
+
expect( result.current.selectedStepId ).toBeNull();
|
|
38
|
+
expect( result.current.getStepState( 'sessions' ).isBlurred ).toBe( false );
|
|
39
|
+
expect( hideTooltip ).toHaveBeenCalled();
|
|
205
40
|
} );
|
|
206
41
|
} );
|
|
@@ -42,12 +42,32 @@ export interface MainMetricRenderProps {
|
|
|
42
42
|
changeColor?: string;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* A labeled line in a funnel step's tooltip
|
|
47
|
+
*/
|
|
48
|
+
export interface FunnelTooltipRow {
|
|
49
|
+
label: string;
|
|
50
|
+
value: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What a funnel step's tooltip shows below or beside the step's name. Screen readers read the same content after the name.
|
|
55
|
+
*/
|
|
56
|
+
export interface FunnelTooltipContent {
|
|
57
|
+
/** Shown on the name's line, e.g. the step's count */
|
|
58
|
+
value?: string;
|
|
59
|
+
/** Labeled lines below the name */
|
|
60
|
+
rows?: FunnelTooltipRow[];
|
|
61
|
+
}
|
|
62
|
+
|
|
45
63
|
/**
|
|
46
64
|
* Render prop for customizing tooltip content
|
|
47
65
|
*/
|
|
48
66
|
export interface TooltipRenderProps {
|
|
49
67
|
step: FunnelStep;
|
|
50
68
|
index: number;
|
|
69
|
+
/** The step's tooltip content. Screen readers read the whole rendered tooltip as the step's description. */
|
|
70
|
+
content: FunnelTooltipContent;
|
|
51
71
|
top: number;
|
|
52
72
|
left: number;
|
|
53
73
|
/** @deprecated The tooltip box draws the package surface; this is no longer set. */
|
|
@@ -81,6 +101,8 @@ export interface ConversionFunnelChartProps extends Pick<
|
|
|
81
101
|
renderMainMetric?: ( props: MainMetricRenderProps ) => React.ReactNode;
|
|
82
102
|
/** Custom render function for tooltip content */
|
|
83
103
|
renderTooltip?: ( props: TooltipRenderProps ) => React.ReactNode;
|
|
104
|
+
/** What each step's tooltip shows, which screen readers also read after the step's name. Defaults to the count and the conversion rate. */
|
|
105
|
+
getTooltipContent?: ( step: FunnelStep, index: number ) => FunnelTooltipContent;
|
|
84
106
|
/** Inline styles merged over the tooltip box defaults. */
|
|
85
107
|
tooltipStyle?: React.CSSProperties;
|
|
86
108
|
}
|
|
@@ -51,6 +51,12 @@
|
|
|
51
51
|
.label {
|
|
52
52
|
padding-left: var(--wpds-dimension-padding-sm);
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
// The bar's 100% height needs a track that fills the row,
|
|
56
|
+
// not one sized to its content.
|
|
57
|
+
.barTrack {
|
|
58
|
+
align-self: stretch;
|
|
59
|
+
}
|
|
54
60
|
}
|
|
55
61
|
|
|
56
62
|
.bar {
|
|
@@ -74,6 +80,20 @@
|
|
|
74
80
|
var(--a8c-charts-motion-easing-entrance) both;
|
|
75
81
|
}
|
|
76
82
|
|
|
83
|
+
// Grows from zero, which is its inline-end edge.
|
|
84
|
+
&--negative {
|
|
85
|
+
transform-origin: right;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// transform-origin has no logical keywords, so mirror both origins in RTL.
|
|
89
|
+
[dir="rtl"] &--animated {
|
|
90
|
+
transform-origin: right;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
[dir="rtl"] &--negative {
|
|
94
|
+
transform-origin: left;
|
|
95
|
+
}
|
|
96
|
+
|
|
77
97
|
@keyframes stretch {
|
|
78
98
|
|
|
79
99
|
from {
|
|
@@ -85,11 +105,31 @@
|
|
|
85
105
|
}
|
|
86
106
|
}
|
|
87
107
|
}
|
|
108
|
+
|
|
109
|
+
.barTrack {
|
|
110
|
+
display: grid;
|
|
111
|
+
|
|
112
|
+
.bar {
|
|
113
|
+
grid-column: 2;
|
|
114
|
+
justify-self: start;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.bar--negative {
|
|
118
|
+
grid-column: 1;
|
|
119
|
+
justify-self: end;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
88
122
|
}
|
|
89
123
|
|
|
124
|
+
// Spans the row's value and delta tracks and lays its cells out on them, so
|
|
125
|
+
// the values and the deltas each end on their own column edge whatever width
|
|
126
|
+
// the other takes.
|
|
90
127
|
.valueContainer {
|
|
128
|
+
display: grid;
|
|
129
|
+
grid-column: 2 / -1;
|
|
130
|
+
grid-template-columns: subgrid;
|
|
91
131
|
align-items: center;
|
|
92
|
-
justify-
|
|
132
|
+
justify-items: end;
|
|
93
133
|
}
|
|
94
134
|
|
|
95
135
|
.deltaValue {
|
|
@@ -107,6 +107,55 @@ const BarLabel = ( { label }: { label: LeaderboardEntry[ 'label' ] } ) => (
|
|
|
107
107
|
<>{ typeof label === 'string' ? <Text className={ styles.label }>{ label }</Text> : label }</>
|
|
108
108
|
);
|
|
109
109
|
|
|
110
|
+
/** The largest negative and positive share magnitudes, which size each side of zero. */
|
|
111
|
+
type DivergingScale = { negative: number; positive: number };
|
|
112
|
+
|
|
113
|
+
const Bar = ( {
|
|
114
|
+
share,
|
|
115
|
+
color,
|
|
116
|
+
animation,
|
|
117
|
+
divergingScale,
|
|
118
|
+
}: {
|
|
119
|
+
share: number;
|
|
120
|
+
color: string;
|
|
121
|
+
animation?: boolean;
|
|
122
|
+
divergingScale?: DivergingScale;
|
|
123
|
+
} ) => {
|
|
124
|
+
const isNegative = share < 0;
|
|
125
|
+
const sideMax = divergingScale?.[ isNegative ? 'negative' : 'positive' ];
|
|
126
|
+
// A diverging bar is measured against the largest share on its own side of zero.
|
|
127
|
+
const width = sideMax ? ( Math.abs( share ) / sideMax ) * 100 : Math.abs( share );
|
|
128
|
+
|
|
129
|
+
const bar = (
|
|
130
|
+
<div
|
|
131
|
+
className={ clsx( styles.bar, {
|
|
132
|
+
[ styles[ 'bar--animated' ] ]: animation,
|
|
133
|
+
[ styles[ 'bar--negative' ] ]: divergingScale && isNegative,
|
|
134
|
+
} ) }
|
|
135
|
+
style={ {
|
|
136
|
+
width: getBarWidth( width ),
|
|
137
|
+
backgroundColor: color,
|
|
138
|
+
} }
|
|
139
|
+
></div>
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
if ( ! divergingScale ) {
|
|
143
|
+
return bar;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Each side is as wide as its share of the full range, so zero sits where the range crosses it.
|
|
147
|
+
return (
|
|
148
|
+
<div
|
|
149
|
+
className={ styles.barTrack }
|
|
150
|
+
style={ {
|
|
151
|
+
gridTemplateColumns: `${ divergingScale.negative }fr ${ divergingScale.positive }fr`,
|
|
152
|
+
} }
|
|
153
|
+
>
|
|
154
|
+
{ bar }
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
};
|
|
158
|
+
|
|
110
159
|
const BarWithLabel = ( {
|
|
111
160
|
entry,
|
|
112
161
|
withComparison,
|
|
@@ -114,6 +163,7 @@ const BarWithLabel = ( {
|
|
|
114
163
|
primaryColor,
|
|
115
164
|
secondaryColor,
|
|
116
165
|
animation,
|
|
166
|
+
divergingScale,
|
|
117
167
|
isPrimaryVisible = true,
|
|
118
168
|
isComparisonVisible = true,
|
|
119
169
|
}: {
|
|
@@ -122,43 +172,44 @@ const BarWithLabel = ( {
|
|
|
122
172
|
withOverlayLabel?: boolean;
|
|
123
173
|
primaryColor: string;
|
|
124
174
|
secondaryColor: string;
|
|
175
|
+
divergingScale?: DivergingScale;
|
|
125
176
|
isPrimaryVisible?: boolean;
|
|
126
177
|
isComparisonVisible?: boolean;
|
|
127
178
|
animation?: boolean;
|
|
128
179
|
} ) => {
|
|
129
180
|
const showComparisonBar = withComparison && ! withOverlayLabel && isComparisonVisible;
|
|
130
181
|
|
|
182
|
+
const bars = (
|
|
183
|
+
<>
|
|
184
|
+
{ isPrimaryVisible && (
|
|
185
|
+
<Bar
|
|
186
|
+
share={ entry.currentShare }
|
|
187
|
+
color={ primaryColor }
|
|
188
|
+
animation={ animation }
|
|
189
|
+
divergingScale={ divergingScale }
|
|
190
|
+
/>
|
|
191
|
+
) }
|
|
192
|
+
|
|
193
|
+
{ showComparisonBar && hasPreviousValue( entry ) && (
|
|
194
|
+
<Bar
|
|
195
|
+
share={ entry.previousShare }
|
|
196
|
+
color={ secondaryColor }
|
|
197
|
+
animation={ animation }
|
|
198
|
+
divergingScale={ divergingScale }
|
|
199
|
+
/>
|
|
200
|
+
) }
|
|
201
|
+
</>
|
|
202
|
+
);
|
|
203
|
+
|
|
131
204
|
return (
|
|
132
205
|
<div
|
|
133
206
|
className={ clsx( styles.barWithLabelContainer, {
|
|
134
207
|
[ styles[ 'is-overlay' ] ]: withOverlayLabel,
|
|
135
208
|
} ) }
|
|
209
|
+
data-testid={ divergingScale ? 'leaderboard-chart-diverging-row' : undefined }
|
|
136
210
|
>
|
|
137
211
|
<BarLabel label={ entry.label } />
|
|
138
|
-
|
|
139
|
-
{ isPrimaryVisible && (
|
|
140
|
-
<div
|
|
141
|
-
className={ clsx( styles.bar, {
|
|
142
|
-
[ styles[ 'bar--animated' ] ]: animation,
|
|
143
|
-
} ) }
|
|
144
|
-
style={ {
|
|
145
|
-
width: getBarWidth( entry.currentShare ),
|
|
146
|
-
backgroundColor: primaryColor,
|
|
147
|
-
} }
|
|
148
|
-
></div>
|
|
149
|
-
) }
|
|
150
|
-
|
|
151
|
-
{ showComparisonBar && hasPreviousValue( entry ) && (
|
|
152
|
-
<div
|
|
153
|
-
className={ clsx( styles.bar, {
|
|
154
|
-
[ styles[ 'bar--animated' ] ]: animation,
|
|
155
|
-
} ) }
|
|
156
|
-
style={ {
|
|
157
|
-
width: getBarWidth( entry.previousShare ),
|
|
158
|
-
backgroundColor: secondaryColor,
|
|
159
|
-
} }
|
|
160
|
-
></div>
|
|
161
|
-
) }
|
|
212
|
+
{ bars }
|
|
162
213
|
</div>
|
|
163
214
|
);
|
|
164
215
|
};
|
|
@@ -267,6 +318,23 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
267
318
|
return ! isPrimaryVisible;
|
|
268
319
|
}, [ isPrimaryVisible, isComparisonVisible, withComparison, withOverlayLabel ] );
|
|
269
320
|
|
|
321
|
+
// One negative share puts every row on a shared zero point, so the bars stay comparable.
|
|
322
|
+
const divergingScale = useMemo( (): DivergingScale | undefined => {
|
|
323
|
+
const withComparisonBars = withComparison && ! withOverlayLabel;
|
|
324
|
+
const scale = { negative: 0, positive: 0 };
|
|
325
|
+
for ( const entry of data ?? [] ) {
|
|
326
|
+
const shares = [ entry.currentShare ];
|
|
327
|
+
if ( withComparisonBars && hasPreviousValue( entry ) ) {
|
|
328
|
+
shares.push( entry.previousShare );
|
|
329
|
+
}
|
|
330
|
+
for ( const share of shares ) {
|
|
331
|
+
const side = share < 0 ? 'negative' : 'positive';
|
|
332
|
+
scale[ side ] = Math.max( scale[ side ], Math.abs( share ) );
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return scale.negative > 0 ? scale : undefined;
|
|
336
|
+
}, [ data, withComparison, withOverlayLabel ] );
|
|
337
|
+
|
|
270
338
|
// Validate data
|
|
271
339
|
const isDataValid = Boolean( data && data.length > 0 );
|
|
272
340
|
|
|
@@ -300,6 +368,11 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
300
368
|
data
|
|
301
369
|
);
|
|
302
370
|
const shouldFitRows = fitRows && isMeasurable;
|
|
371
|
+
// Comparing adds a delta track so the values and the deltas each line up in a column
|
|
372
|
+
// of their own; with either series hidden the grid keeps its two tracks.
|
|
373
|
+
const showComparisonColumn = withComparison && isComparisonVisible;
|
|
374
|
+
const templateColumns =
|
|
375
|
+
showComparisonColumn && isPrimaryVisible ? 'minmax(0, 1fr) auto auto' : 'minmax(0, 1fr) auto';
|
|
303
376
|
|
|
304
377
|
// Handle empty or undefined data
|
|
305
378
|
if ( ! data || data.length === 0 ) {
|
|
@@ -393,7 +466,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
393
466
|
<Grid
|
|
394
467
|
role="list"
|
|
395
468
|
aria-label={ ariaLabel ?? __( 'Leaderboard chart', 'jetpack-charts' ) }
|
|
396
|
-
templateColumns=
|
|
469
|
+
templateColumns={ templateColumns }
|
|
397
470
|
rowGap={ ROW_GAP }
|
|
398
471
|
columnGap={ COLUMN_GAP }
|
|
399
472
|
data-leaderboard-grid
|
|
@@ -406,7 +479,6 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
406
479
|
shouldFitRows && rowIndex >= fittedCount
|
|
407
480
|
? ( { visibility: 'hidden' } as const )
|
|
408
481
|
: undefined;
|
|
409
|
-
const showComparisonColumn = withComparison && isComparisonVisible;
|
|
410
482
|
const hasPreviousPeriodValue = hasPreviousValue( entry );
|
|
411
483
|
const hasDelta = hasDeltaValue( entry );
|
|
412
484
|
const showComparisonValue = showComparisonColumn && hasDelta;
|
|
@@ -426,15 +498,14 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
426
498
|
withOverlayLabel={ withOverlayLabel }
|
|
427
499
|
primaryColor={ resolvedPrimaryColor }
|
|
428
500
|
secondaryColor={ resolvedSecondaryColor }
|
|
501
|
+
divergingScale={ divergingScale }
|
|
429
502
|
isPrimaryVisible={ isPrimaryVisible }
|
|
430
503
|
isComparisonVisible={ isComparisonVisible }
|
|
431
504
|
animation={ animation && ! loading && ! prefersReducedMotion }
|
|
432
505
|
/>
|
|
433
506
|
</Stack>
|
|
434
507
|
|
|
435
|
-
<
|
|
436
|
-
direction="row"
|
|
437
|
-
gap="xs"
|
|
508
|
+
<div
|
|
438
509
|
className={ clsx( styles.valueContainer, {
|
|
439
510
|
[ styles.overlayLabel ]: withOverlayLabel,
|
|
440
511
|
} ) }
|
|
@@ -463,7 +534,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
463
534
|
</VisuallyHidden>
|
|
464
535
|
</Text>
|
|
465
536
|
) }
|
|
466
|
-
</
|
|
537
|
+
</div>
|
|
467
538
|
</>
|
|
468
539
|
);
|
|
469
540
|
|
|
@@ -152,6 +152,24 @@ describe( 'LeaderboardChart', () => {
|
|
|
152
152
|
expect( container.querySelectorAll( '.bar' ) ).toHaveLength( 2 );
|
|
153
153
|
} );
|
|
154
154
|
|
|
155
|
+
it.each( [
|
|
156
|
+
[ 'a current share is negative', { currentShare: -40 }, true, 2 ],
|
|
157
|
+
[ 'a comparison share is negative', { previousShare: -40 }, true, 2 ],
|
|
158
|
+
[ 'only a hidden comparison share is negative', { previousShare: -40 }, false, 0 ],
|
|
159
|
+
[
|
|
160
|
+
'only a share without a comparison value is negative',
|
|
161
|
+
{ previousShare: -40, previousValue: undefined },
|
|
162
|
+
true,
|
|
163
|
+
0,
|
|
164
|
+
],
|
|
165
|
+
[ 'every share is positive', {}, true, 0 ],
|
|
166
|
+
] )( 'diverges the bars from zero when %s', ( _, override, withComparison, expected ) => {
|
|
167
|
+
const data = [ { ...mockData[ 0 ], ...override }, mockData[ 1 ] ];
|
|
168
|
+
render( <LeaderboardChart data={ data } withComparison={ withComparison } /> );
|
|
169
|
+
|
|
170
|
+
expect( screen.queryAllByTestId( 'leaderboard-chart-diverging-row' ) ).toHaveLength( expected );
|
|
171
|
+
} );
|
|
172
|
+
|
|
155
173
|
it( 'shows custom label when provided', () => {
|
|
156
174
|
render(
|
|
157
175
|
<LeaderboardChart
|
|
@@ -748,6 +766,35 @@ describe( 'LeaderboardChart', () => {
|
|
|
748
766
|
describe( 'grid gaps', () => {
|
|
749
767
|
// eslint-disable-next-line testing-library/no-node-access -- The grid is rendered by `Grid` from @wordpress/components, which takes no test id.
|
|
750
768
|
const grid = () => document.querySelector< HTMLElement >( '[data-leaderboard-grid]' );
|
|
769
|
+
const templateColumns = () =>
|
|
770
|
+
grid().style.getPropertyValue( '--wp-components-grid-template-columns' );
|
|
771
|
+
|
|
772
|
+
it( 'lays the rows out without a delta track when not comparing', () => {
|
|
773
|
+
render( <LeaderboardChart data={ mockData } withComparison={ false } /> );
|
|
774
|
+
|
|
775
|
+
expect( templateColumns() ).toBe( 'minmax(0, 1fr) auto' );
|
|
776
|
+
} );
|
|
777
|
+
|
|
778
|
+
it.each( [ [ 'Previous period' ], [ 'Current period' ] ] )(
|
|
779
|
+
'adds a delta track when comparing and drops it while "%s" is hidden',
|
|
780
|
+
async legendItem => {
|
|
781
|
+
const user = userEvent.setup();
|
|
782
|
+
render(
|
|
783
|
+
<LeaderboardChart
|
|
784
|
+
data={ mockData }
|
|
785
|
+
withComparison={ true }
|
|
786
|
+
showLegend={ true }
|
|
787
|
+
legend={ { interactive: true } }
|
|
788
|
+
/>
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
expect( templateColumns() ).toBe( 'minmax(0, 1fr) auto auto' );
|
|
792
|
+
|
|
793
|
+
await user.click( screen.getByRole( 'button', { name: new RegExp( legendItem ) } ) );
|
|
794
|
+
|
|
795
|
+
expect( templateColumns() ).toBe( 'minmax(0, 1fr) auto' );
|
|
796
|
+
}
|
|
797
|
+
);
|
|
751
798
|
|
|
752
799
|
it( 'hands the catalog role to the grid', () => {
|
|
753
800
|
render( <LeaderboardChart data={ mockData } /> );
|